From 422b3291e095d5696e50a135aae38c423e79424f Mon Sep 17 00:00:00 2001 From: Mameta29 Date: Sun, 14 Jun 2026 17:23:34 +0900 Subject: [PATCH 1/3] chore(scripts): add drive-dogfood-review for reliable dogfood cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webhook の同期実行は gpt-5.1 のレビュー(~35s)が host timeout で 504 になりうるため、 CI green 確認後にローカルから runReview を直接駆動するための運用スクリプト。 全パラメータは env 経由(機密のハードコードなし)。RUN_SETTLEMENT=1 で settlement+ bookkeeping も駆動できる。決勝まで dogfood ループを回すのに使用。 --- .../functions/scripts/drive-dogfood-review.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/functions/scripts/drive-dogfood-review.ts diff --git a/packages/functions/scripts/drive-dogfood-review.ts b/packages/functions/scripts/drive-dogfood-review.ts new file mode 100644 index 0000000..604da65 --- /dev/null +++ b/packages/functions/scripts/drive-dogfood-review.ts @@ -0,0 +1,79 @@ +/** + * Drive the REAL Review → (auto-merge) pipeline for a dogfood order from the + * command line, with ciStatus forced to the value the caller passes (we verify + * CI is green out-of-band first). This bypasses the GitHub webhook, whose + * synchronous invocation can be killed by the Functions host before gpt-5.1 + * finishes — it runs the identical agent code, just not under the host timeout. + * + * Settlement + Bookkeeping are intentionally NOT run here; if the review + * auto-merges, the `closed(merged)` webhook fires settlement as usual. Run with + * RUN_SETTLEMENT=1 to additionally drive settlement+bookkeeping locally. + */ +import { createTenantScopedCosmos } from '../src/lib/cosmos.js'; +import { runReview } from '../src/agents/review.js'; +import { runSettlement } from '../src/agents/settlement.js'; +import { runBookkeeping } from '../src/agents/bookkeeping.js'; + +async function main() { + const tenantId = process.env.TENANT_ID!; + const orderId = process.env.ORDER_ID!; + const ciStatus = (process.env.CI_STATUS || 'success') as + | 'success' + | 'failure' + | 'pending'; + + const cosmos = createTenantScopedCosmos(tenantId); + const order = await cosmos.getOrder(orderId); + if (!order) throw new Error(`order ${orderId} not found in tenant ${tenantId}`); + console.log('order before:', { + status: order.status, + prNumber: order.prNumber, + repository: order.repository, + }); + if (!order.prNumber) throw new Error('order has no prNumber'); + + console.log(`\n=== runReview (ciStatus=${ciStatus}) ===`); + const review = await runReview({ + tenantId, + order, + repository: order.repository, + prNumber: order.prNumber, + ciStatus, + }); + console.log('verdict:', review.verdict); + console.log('qualityScore:', review.qualityScore); + console.log('autoMerge:', review.autoMerge); + for (const c of review.criteriaResults) { + console.log(` [${c.met ? '✓' : '✗'}] ${c.criterion}`); + } + + const after = await cosmos.getOrder(orderId); + console.log('\norder after review:', { status: after?.status }); + + if (process.env.RUN_SETTLEMENT === '1' && after?.status === 'review_passed') { + console.log('\n=== runSettlement ==='); + const settlement = await runSettlement({ + tenantId, + orderId, + prMergeEvent: { + prNumber: order.prNumber, + mergeCommitSha: '', + mergedAt: new Date().toISOString(), + }, + }); + console.log('settlement:', settlement); + + const settledOrder = await cosmos.getOrder(orderId); + if (settledOrder) { + console.log('\n=== runBookkeeping ==='); + await runBookkeeping({ tenantId, order: settledOrder, settlement }); + const done = await cosmos.getOrder(orderId); + console.log('order final:', { status: done?.status, txHash: done?.txHash }); + } + } +} + +main().catch((e) => { + console.error('DRIVE_ERR:', e?.stack || e?.message || String(e)); + process.exit(1); +}); From deba899ad77001d32b2607c61ca4a3919668e8ae Mon Sep 17 00:00:00 2001 From: Mameta29 Date: Sun, 14 Jun 2026 17:34:54 +0900 Subject: [PATCH 2/3] feat(dashboard): order detail lifecycle timeline + AI review evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注文詳細画面 /orders/[id] をリッチ化: - 発注→PR→AI検収→着金→記帳のライフサイクルタイムライン - PR マージ→JPYC 着金の所要秒数(ピッチの『3秒』を画面で) - AI 検収の各基準の合否+根拠引用(criteriaResults)の表示 - functions: orders/{id}/events エンドポイント追加(テナント分離・dedup記録は除外) - dashboard: listOrderEvents + order-timeline ロジック + OrderTimeline コンポーネント --- packages/dashboard/app/orders/[id]/page.tsx | 21 +++- .../dashboard/components/order-timeline.tsx | 112 ++++++++++++++++++ packages/dashboard/lib/api.ts | 20 ++++ packages/dashboard/lib/order-timeline.ts | 79 ++++++++++++ .../functions/src/functions/orders-events.ts | 53 +++++++++ packages/functions/src/index.ts | 1 + 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 packages/dashboard/components/order-timeline.tsx create mode 100644 packages/dashboard/lib/order-timeline.ts create mode 100644 packages/functions/src/functions/orders-events.ts diff --git a/packages/dashboard/app/orders/[id]/page.tsx b/packages/dashboard/app/orders/[id]/page.tsx index 9572ffa..8acf243 100644 --- a/packages/dashboard/app/orders/[id]/page.tsx +++ b/packages/dashboard/app/orders/[id]/page.tsx @@ -1,7 +1,13 @@ import { redirect } from 'next/navigation'; import { auth } from '@/lib/auth'; -import { AuthExpiredError, listOrders } from '@/lib/api'; +import { + AuthExpiredError, + listOrders, + listOrderEvents, + type OrderEvent, +} from '@/lib/api'; import { txUrl } from '@/lib/explorer'; +import { OrderTimeline } from '@/components/order-timeline'; type OrderRow = { id: string; @@ -40,6 +46,16 @@ export default async function OrderDetailPage({ if (!order) { return
order not found
; } + + // Lifecycle events drive the timeline + AI-review evidence. Best-effort: the + // page still renders if the events endpoint is unavailable. + let events: OrderEvent[] = []; + try { + ({ events } = await listOrderEvents(id)); + } catch (err) { + if (err instanceof AuthExpiredError) redirect('/api/auth/signin'); + // non-fatal: fall through with an empty timeline + } return (

{order.description}

@@ -86,6 +102,9 @@ export default async function OrderDetailPage({ )} + + {events.length > 0 && } + {order.bookkeepingArtifacts && (

経理処理

diff --git a/packages/dashboard/components/order-timeline.tsx b/packages/dashboard/components/order-timeline.tsx new file mode 100644 index 0000000..44ab3ce --- /dev/null +++ b/packages/dashboard/components/order-timeline.tsx @@ -0,0 +1,112 @@ +import type { OrderEvent } from '@/lib/api'; +import { + buildTimeline, + mergeToSettledSeconds, + latestReview, +} from '@/lib/order-timeline'; + +function fmt(at?: string): string { + return at ? new Date(at).toLocaleString('ja-JP') : ''; +} + +export function OrderTimeline({ events }: { events: OrderEvent[] }) { + const steps = buildTimeline(events); + const latency = mergeToSettledSeconds(events); + const review = latestReview(events); + + return ( +
+ {/* Headline metric: merge → JPYC arrival */} + {latency !== undefined && ( +
+
PR マージ → JPYC 着金
+
+ {latency.toFixed(1)} + +
+
+ 銀行振込の「翌月末 + 3〜5日」を、約 {latency.toFixed(1)} 秒に。 +
+
+ )} + + {/* Lifecycle timeline */} +
+
+ ライフサイクル +
+
    + {steps.map((s) => ( +
  1. + +
    + + {s.label} + + {s.at && ( + + {fmt(s.at)} + + )} +
    +
  2. + ))} +
+
+ + {/* AI review evidence (criteria × met × cited evidence) */} + {review && ( +
+
+ + AI 検収結果 + + + {review.verdict === 'approve' ? '合格' : '差し戻し'} + {review.qualityScore !== undefined && ` ・ ${review.qualityScore}/100`} + +
+
    + {review.criteriaResults.map((c, i) => ( +
  • +
    + + {c.met ? '✅' : '❌'} + + + {c.criterion} + +
    + {c.evidence && ( +

    + {c.evidence} +

    + )} +
  • + ))} +
+

+ 各基準の合否を diff から根拠引用つきで判定(Foundry gpt-5.1)。 +

+
+ )} +
+ ); +} diff --git a/packages/dashboard/lib/api.ts b/packages/dashboard/lib/api.ts index ca5f4e8..eee59f8 100644 --- a/packages/dashboard/lib/api.ts +++ b/packages/dashboard/lib/api.ts @@ -88,6 +88,26 @@ export async function createOrder(body: { return res.json(); } +export type OrderEvent = { + id: string; + orderId: string; + agent?: string; + type: string; + payload?: Record; + createdAt: string; +}; + +export async function listOrderEvents( + orderId: string, +): Promise<{ events: OrderEvent[] }> { + const res = await authedFetch( + `/api/orders/${encodeURIComponent(orderId)}/events`, + ); + if (res.status === 404) return { events: [] }; + if (!res.ok) throw new Error(`events failed: ${res.status}`); + return res.json(); +} + export function functionsBaseUrl(): string { return FUNCTIONS_BASE_URL; } diff --git a/packages/dashboard/lib/order-timeline.ts b/packages/dashboard/lib/order-timeline.ts new file mode 100644 index 0000000..824e9ad --- /dev/null +++ b/packages/dashboard/lib/order-timeline.ts @@ -0,0 +1,79 @@ +import type { OrderEvent } from './api'; + +// A single step shown in the order lifecycle timeline. +export type TimelineStep = { + key: string; + label: string; + at?: string; // ISO timestamp, if this step has happened + done: boolean; +}; + +// The canonical lifecycle order. Each step maps to one or more event types. +const STEPS: { key: string; label: string; types: string[] }[] = [ + { key: 'created', label: '発注(Issue 起票)', types: ['order_created', 'issue_created'] }, + { key: 'pr', label: 'PR 提出', types: ['pr_opened'] }, + { key: 'review', label: 'AI 検収', types: ['review_completed', 'review_failed', 'review_started'] }, + { key: 'settled', label: 'JPYC 着金', types: ['settlement_completed'] }, + { key: 'bookkept', label: '記帳', types: ['bookkeeping_completed'] }, +]; + +function firstTime(events: OrderEvent[], types: string[]): string | undefined { + const hit = events.find((e) => types.includes(e.type)); + return hit?.createdAt; +} + +export function buildTimeline(events: OrderEvent[]): TimelineStep[] { + return STEPS.map((s) => { + const at = firstTime(events, s.types); + return { key: s.key, label: s.label, at, done: Boolean(at) }; + }); +} + +/** + * The headline metric: seconds from PR merge to JPYC arrival on-chain. + * Uses pr_merged (or pr_opened as a fallback) → settlement_completed. Returns + * undefined if either end is missing. + */ +export function mergeToSettledSeconds(events: OrderEvent[]): number | undefined { + const mergedAt = + firstTime(events, ['pr_merged']) ?? firstTime(events, ['settlement_started']); + const settledAt = firstTime(events, ['settlement_completed']); + if (!mergedAt || !settledAt) return undefined; + const ms = Date.parse(settledAt) - Date.parse(mergedAt); + if (!Number.isFinite(ms) || ms < 0) return undefined; + return Math.round((ms / 1000) * 10) / 10; +} + +export type CriterionResult = { + criterion: string; + met: boolean; + evidence: string; +}; + +export type ReviewSummary = { + verdict: 'approve' | 'reject' | string; + qualityScore?: number; + criteriaResults: CriterionResult[]; +}; + +/** + * Pull the most recent review verdict + cited criteria out of the events. + * Review Agent writes criteriaResults into the review_completed / review_failed + * event payload. Returns undefined if no review has run yet. + */ +export function latestReview(events: OrderEvent[]): ReviewSummary | undefined { + const reviews = events.filter( + (e) => e.type === 'review_completed' || e.type === 'review_failed', + ); + const last = reviews[reviews.length - 1]; + if (!last?.payload) return undefined; + const p = last.payload as { + qualityScore?: number; + criteriaResults?: CriterionResult[]; + }; + return { + verdict: last.type === 'review_completed' ? 'approve' : 'reject', + qualityScore: typeof p.qualityScore === 'number' ? p.qualityScore : undefined, + criteriaResults: Array.isArray(p.criteriaResults) ? p.criteriaResults : [], + }; +} diff --git a/packages/functions/src/functions/orders-events.ts b/packages/functions/src/functions/orders-events.ts new file mode 100644 index 0000000..b9ab29c --- /dev/null +++ b/packages/functions/src/functions/orders-events.ts @@ -0,0 +1,53 @@ +import { + app, + type HttpRequest, + type HttpResponseInit, + type InvocationContext, +} from '@azure/functions'; +import { verifyEntraToken, AuthError } from '../lib/auth.js'; +import { env } from '../lib/env.js'; +import { createTenantScopedCosmos } from '../lib/cosmos.js'; + +app.http('ordersEvents', { + methods: ['GET'], + route: 'orders/{id}/events', + authLevel: 'anonymous', + handler, +}); + +export async function handler( + req: HttpRequest, + _ctx: InvocationContext, +): Promise { + try { + const auth = await verifyEntraToken( + req.headers.get('authorization'), + env.functionsAppAudience(), + ); + const orderId = req.params.id; + if (!orderId) { + return { status: 400, jsonBody: { error: 'missing_order_id' } }; + } + + const cosmos = createTenantScopedCosmos(auth.tenantId); + // Tenant isolation: getOrder enforces companyId === tenantId, so an order + // from another tenant returns null → 404 (we never expose its events). + const order = await cosmos.getOrder(orderId); + if (!order) { + return { status: 404, jsonBody: { error: 'order_not_found' } }; + } + + const events = await cosmos.listEvents(orderId); + // Hide internal webhook-dedup synthetic records (id `delivery:`, + // type `webhook_delivery_seen`) — they are not part of the order lifecycle. + const lifecycle = events.filter( + (e) => e.type !== ('webhook_delivery_seen' as typeof e.type), + ); + return { status: 200, jsonBody: { events: lifecycle } }; + } catch (err) { + if (err instanceof AuthError) { + return { status: err.status, jsonBody: { error: err.code } }; + } + return { status: 500, jsonBody: { error: String(err) } }; + } +} diff --git a/packages/functions/src/index.ts b/packages/functions/src/index.ts index 01dd45f..4005b85 100644 --- a/packages/functions/src/index.ts +++ b/packages/functions/src/index.ts @@ -1,6 +1,7 @@ // Azure Functions v4 programming model: import all function entry files here. import './functions/orders-create.js'; import './functions/orders-list.js'; +import './functions/orders-events.js'; import './functions/github-webhook.js'; import './functions/copilot-webhook.js'; import './functions/orders-stream.js'; From 358f752f8482cae232060b74faab072fb270ae35 Mon Sep 17 00:00:00 2001 From: Mameta29 Date: Sun, 14 Jun 2026 17:38:33 +0900 Subject: [PATCH 3/3] test(dashboard): add order-timeline unit tests (address Review Agent feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review Agent (PR #13) が「ライフサイクル変換ロジックのユニットテストが追加されている」 基準の未達を指摘・差し戻したため、order-timeline のユニットテストを追加。 buildTimeline / mergeToSettledSeconds / latestReview の5ケースを検証。 --- packages/dashboard/lib/order-timeline.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 packages/dashboard/lib/order-timeline.test.ts diff --git a/packages/dashboard/lib/order-timeline.test.ts b/packages/dashboard/lib/order-timeline.test.ts new file mode 100644 index 0000000..a90b46a --- /dev/null +++ b/packages/dashboard/lib/order-timeline.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + buildTimeline, + mergeToSettledSeconds, + latestReview, +} from './order-timeline'; +import type { OrderEvent } from './api'; + +function ev(type: string, createdAt: string, payload?: Record): OrderEvent { + return { id: type + createdAt, orderId: 'o1', type, createdAt, payload }; +} + +describe('order-timeline', () => { + it('marks steps done when their event exists, pending otherwise', () => { + const events = [ + ev('order_created', '2026-06-14T00:00:00Z'), + ev('pr_opened', '2026-06-14T00:01:00Z'), + ]; + const steps = buildTimeline(events); + const doneOf = (key: string) => steps.find((s) => s.key === key)?.done; + expect(doneOf('created')).toBe(true); + expect(doneOf('pr')).toBe(true); + expect(doneOf('settled')).toBe(false); + expect(doneOf('bookkept')).toBe(false); + }); + + it('computes merge→settled latency in seconds', () => { + const events = [ + ev('settlement_started', '2026-06-14T00:00:00Z'), + ev('settlement_completed', '2026-06-14T00:00:03Z'), + ]; + expect(mergeToSettledSeconds(events)).toBe(3); + }); + + it('returns undefined latency when settlement is missing', () => { + expect(mergeToSettledSeconds([ev('pr_opened', '2026-06-14T00:00:00Z')])).toBeUndefined(); + }); + + it('extracts the latest review verdict + criteria', () => { + const events = [ + ev('review_failed', '2026-06-14T00:01:00Z', { + qualityScore: 60, + criteriaResults: [{ criterion: 'tests', met: false, evidence: 'none' }], + }), + ev('review_completed', '2026-06-14T00:05:00Z', { + qualityScore: 92, + criteriaResults: [{ criterion: 'tests', met: true, evidence: 'x.test.ts' }], + }), + ]; + const r = latestReview(events); + expect(r?.verdict).toBe('approve'); + expect(r?.qualityScore).toBe(92); + expect(r?.criteriaResults[0]?.met).toBe(true); + }); + + it('returns undefined review when none has run', () => { + expect(latestReview([ev('order_created', '2026-06-14T00:00:00Z')])).toBeUndefined(); + }); +});