diff --git a/packages/integration/examples/cloudflare-agents/approval-trace/scripts/run-worker-proof.ts b/packages/integration/examples/cloudflare-agents/approval-trace/scripts/run-worker-proof.ts index 7b1a4106..9049a350 100644 --- a/packages/integration/examples/cloudflare-agents/approval-trace/scripts/run-worker-proof.ts +++ b/packages/integration/examples/cloudflare-agents/approval-trace/scripts/run-worker-proof.ts @@ -233,6 +233,7 @@ async function verifyTrace( const push = (name: string, ok: boolean, detail?: string) => checks.push({ name, ok, detail }) const byLabel = new Map(trace.records.map((record) => [record.label, record])) const trigger = byLabel.get('trigger') + const triage = byLabel.get('triage') const proposal = byLabel.get('proposal') const approval = byLabel.get('approval') ?? byLabel.get('rejection') const execution = byLabel.get('execution') @@ -254,6 +255,7 @@ async function verifyTrace( } push(`${trace.run_id}: trigger exists`, Boolean(trigger)) + push(`${trace.run_id}: triage exists`, Boolean(triage)) push(`${trace.run_id}: proposal exists`, Boolean(proposal)) push(`${trace.run_id}: decision exists`, Boolean(approval)) push( @@ -267,15 +269,27 @@ async function verifyTrace( .every((node) => node.verification_state === 'signature_valid'), ) push( - `${trace.run_id}: proposal points at trigger`, - Boolean(trigger && proposal && refsEqual(proposal.record.informed_by, [trigger.record_hash])), + `${trace.run_id}: triage points at trigger`, + Boolean(trigger && triage && refsEqual(triage.record.informed_by, [trigger.record_hash])), ) push( - `${trace.run_id}: graph trigger edge`, + `${trace.run_id}: graph trigger-to-triage edge`, Boolean( trigger && + triage && + hasGraphEdge(graph, 'INFORMED_BY', triage.record_hash, trigger.record_hash), + ), + ) + push( + `${trace.run_id}: proposal points at triage`, + Boolean(triage && proposal && refsEqual(proposal.record.informed_by, [triage.record_hash])), + ) + push( + `${trace.run_id}: graph triage-to-proposal edge`, + Boolean( + triage && proposal && - hasGraphEdge(graph, 'INFORMED_BY', proposal.record_hash, trigger.record_hash), + hasGraphEdge(graph, 'INFORMED_BY', proposal.record_hash, triage.record_hash), ), ) push( @@ -403,9 +417,10 @@ async function main() { page.includes('data-testid="approval-trace-app"') && page.includes('Cloudflare Agent Trace') && page.includes('Human review halted') && - page.includes('Trigger and progress') && + page.includes('Trigger & progress') && page.includes('write_file') && - page.includes('Signed records will appear here as the workflow runs.') && + page.includes('Record timeline') && + page.includes('Receipt inspector') && page.includes('Approve and resume'), }, ] diff --git a/packages/integration/examples/cloudflare-agents/approval-trace/src/index.ts b/packages/integration/examples/cloudflare-agents/approval-trace/src/index.ts index a7e3875b..2b358b3b 100644 --- a/packages/integration/examples/cloudflare-agents/approval-trace/src/index.ts +++ b/packages/integration/examples/cloudflare-agents/approval-trace/src/index.ts @@ -31,10 +31,11 @@ type WorkflowStatus = | 'pending_approval' | 'approved' | 'rejected' + | 'changes_requested' | 'executing' | 'succeeded' | 'failed' -type Decision = 'approved' | 'rejected' | null +type Decision = 'approved' | 'rejected' | 'changes_requested' | null type SignerRole = 'agent' | 'human' | 'action_mcp' interface Env { @@ -182,6 +183,9 @@ const TRACE_RECORD_OFFSETS_MS = { proposal: 650, approval: 950, rejection: 950, + changesRequested: 950, + revision: 1_450, + reviewDecisionDelay: 1_600, handoff: 1_700, postApprovalPause: 125, } @@ -221,6 +225,11 @@ function html(value: string): Response { }) } +function requestColo(request: Request): string { + const cf = (request as Request & { cf?: { colo?: unknown } }).cf + return typeof cf?.colo === 'string' && cf.colo ? cf.colo : 'IAD' +} + function jsonText(value: unknown): string { return JSON.stringify(value, null, 2) } @@ -357,10 +366,14 @@ function emitNativeEvent(input: { } function fixturePlan(prompt: string): PlannedAction { - const diff = `@@ -1,6 +1,16 @@ + const diff = `@@ -1,17 +1,27 @@ import { NextFunction, Request, Response } from 'express'; import { getConfig } from '../config'; + import { logRequest } from '../observability/logging'; + import { reportMetrics } from '../observability/metrics'; + import { resolveTenant } from '../tenant'; + +import rateLimit from 'express-rate-limit'; + +const limiter = rateLimit({ @@ -377,13 +390,24 @@ function fixturePlan(prompt: string): PlannedAction { // existing logic next(); -} -+}];` ++}]; + + export function reportHealth(req: Request, res: Response) { + const tenant = resolveTenant(req); + reportMetrics('report.health', { tenant }); + res.json({ ok: true, tenant }); + } + + export function reportAudit(req: Request, res: Response) { + logRequest(req, 'report.audit'); + res.status(204).end(); + }` return { planner: 'fixture', - action: 'Update rate-limit middleware for the /v1/report route', + action: 'Update file in repository', summary: 'Respond to a GitHub issue webhook by preparing a small repository file update that adds request limiting to the reported route.', - risk: 'Introduces rate limiting that changes production request handling.', + risk: 'Introduces rate limiting which may impact client traffic if misconfigured.', payload: { operation: 'write_file', issue_id: 'workers-issue-4821', @@ -415,6 +439,72 @@ function fixturePlan(prompt: string): PlannedAction { } } +function revisedPlanFromFeedback( + priorPayload: PlannedAction['payload'], + feedback: string, +): PlannedAction { + const diff = `@@ -1,17 +1,30 @@ + import { NextFunction, Request, Response } from 'express'; + import { getConfig } from '../config'; + + import { logRequest } from '../observability/logging'; + import { reportMetrics } from '../observability/metrics'; + import { resolveTenant } from '../tenant'; + ++import rateLimit from 'express-rate-limit'; ++ ++const reportLimiter = rateLimit({ ++ windowMs: 60 * 1000, ++ max: 60, ++ standardHeaders: true, ++ legacyHeaders: false, ++ skip: (req) => req.path !== '/v1/report', ++}); + + const config = getConfig(); + +-export function reportHandler(req: Request, res: Response, next: NextFunction) { ++export const reportHandler = [reportLimiter, (req: Request, res: Response, next: NextFunction) => { + // existing logic + next(); +-} ++}]; + + export function reportHealth(req: Request, res: Response) { + const tenant = resolveTenant(req); + reportMetrics('report.health', { tenant }); + res.json({ ok: true, tenant }); + } + + export function reportAudit(req: Request, res: Response) { + logRequest(req, 'report.audit'); + res.status(204).end(); + }` + return { + planner: 'fixture', + action: 'Update file in repository', + summary: + 'Revise the repository file update after human feedback by keeping the guard scoped to the reported route.', + risk: 'Narrows the limiter to /v1/report and lowers the default cap before any MCP write runs.', + payload: { + ...priorPayload, + version: priorPayload.version + 1, + after: { + ...priorPayload.after, + rate_limit: { + window_ms: 60_000, + max: 60, + standard_headers: true, + legacy_headers: false, + scope: '/v1/report', + }, + note: `Revised after human feedback: ${feedback.slice(0, 140)}`, + }, + diff, + }, + } +} + async function planAction(env: Env, prompt: string): Promise { if (!env.OPENAI_API_KEY) return fixturePlan(prompt) const baseUrl = env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1' @@ -512,7 +602,8 @@ function tracePacket( const get = (label: string) => parsed.find((entry) => entry.label === label) const trigger = get('trigger') const triage = get('triage') - const decision = get('approval') ?? get('rejection') + const latestProposal = + [...parsed].reverse().find((entry) => entry.label === 'revision') ?? get('proposal') const execution = get('execution') const outcome = get('outcome') const handoff = get('handoff') @@ -535,7 +626,9 @@ function tracePacket( decision: workflow?.decision ?? null, executed: Boolean(execution), outcome: - workflow?.status === 'rejected' + workflow?.status === 'changes_requested' + ? 'revision_requested' + : workflow?.status === 'rejected' ? 'not_run' : outcomeBody?.status === 'error' ? 'error' @@ -558,7 +651,7 @@ function tracePacket( name: 'Decision context', evidence: 'The proposal signs the exact Cloudflare-shaped payload before the reviewer approves.', - evidence_labels: ['proposal'], + evidence_labels: [latestProposal?.label ?? 'proposal'], }, { name: 'Semantic causal chain', @@ -865,7 +958,11 @@ export class ApprovalTraceAgent extends Agent { if (workflow.status !== 'pending_approval') { throw new Error(`run is not pending approval: ${workflow.status}`) } - const approvalTimestamp = runTimestamp(workflow.created_at, TRACE_RECORD_OFFSETS_MS.approval) + const proposalTimestamp = this.currentProposalTimestamp(workflow) + const approvalTimestamp = Math.max( + runTimestamp(workflow.created_at, TRACE_RECORD_OFFSETS_MS.approval), + proposalTimestamp + TRACE_RECORD_OFFSETS_MS.reviewDecisionDelay, + ) const body = { kind: 'human_approval', reviewer_id: 'browser-demo-human', @@ -939,7 +1036,11 @@ export class ApprovalTraceAgent extends Agent { if (workflow.status !== 'pending_approval') { throw new Error(`run is not pending approval: ${workflow.status}`) } - const rejectionTimestamp = runTimestamp(workflow.created_at, TRACE_RECORD_OFFSETS_MS.rejection) + const proposalTimestamp = this.currentProposalTimestamp(workflow) + const rejectionTimestamp = Math.max( + runTimestamp(workflow.created_at, TRACE_RECORD_OFFSETS_MS.rejection), + proposalTimestamp + TRACE_RECORD_OFFSETS_MS.reviewDecisionDelay, + ) const body = { kind: 'human_approval', reviewer_id: 'browser-demo-human', @@ -995,6 +1096,148 @@ export class ApprovalTraceAgent extends Agent { return this.getRun(input.runId) } + async requestChanges(input: { runId: string; feedback: string }): Promise { + this.ensureSchema() + const workflow = this.getWorkflowRow(input.runId) + if (!workflow) throw new Error(`run not found: ${input.runId}`) + if (workflow.status !== 'pending_approval') { + throw new Error(`run is not pending approval: ${workflow.status}`) + } + const feedbackTimestamp = runTimestamp( + workflow.created_at, + TRACE_RECORD_OFFSETS_MS.changesRequested, + ) + const proposalTimestamp = this.currentProposalTimestamp(workflow) + const reviewFeedbackTimestamp = Math.max( + feedbackTimestamp, + proposalTimestamp + TRACE_RECORD_OFFSETS_MS.reviewDecisionDelay, + ) + const feedback = input.feedback.trim() || 'Request a narrower repository file update.' + const body = { + kind: 'human_review_feedback', + reviewer_id: 'browser-demo-human', + decision: 'changes_requested', + feedback, + requested_changes: [ + 'Keep the rate-limit guard, but narrow the change to /v1/report only.', + 'Return a revised proposal before the action MCP writes repository files.', + ], + approved_payload_hash: workflow.payload_hash, + stable_connector_id: STABLE_CONNECTOR_ID, + next_step: 'agent_revision', + } + const record = await signObservation({ + env: this.env, + role: 'human', + key: privateKey(this.env.ATRIB_HUMAN_APPROVER_PRIVATE_KEY), + contextId: workflow.context_id, + chainRoot: workflow.proposal_record_hash, + toolName: 'change_request', + body, + informedBy: [workflow.proposal_record_hash], + timestamp: reviewFeedbackTimestamp, + }) + const feedbackHash = recordHash(record) + await this.saveTraceRecord({ + runId: input.runId, + label: 'change_request', + signer: 'human', + record, + body, + proof: await submitRecord(this.env, record), + }) + this.saveNativeEvent( + input.runId, + emitNativeEvent({ + channel: 'agents:message', + type: 'tool:review_feedback', + agent: 'ApprovalTraceAgent', + name: input.runId, + payload: { + approved: false, + changesRequested: true, + feedbackRecordHash: feedbackHash, + feedback, + }, + timestamp: reviewFeedbackTimestamp, + }), + ) + const priorPayload = JSON.parse(workflow.payload_json) as PlannedAction['payload'] + const revision = revisedPlanFromFeedback(priorPayload, feedback) + const revisionPayloadHash = hashUnknown(revision.payload) + const revisionTimestamp = Math.max( + Date.now(), + workflow.created_at + TRACE_RECORD_OFFSETS_MS.revision, + reviewFeedbackTimestamp + 650, + ) + const revisionBody = { + kind: 'agent_revised_proposal', + prompt: workflow.prompt, + revision_number: 2, + prior_proposal_record_hash: workflow.proposal_record_hash, + feedback_record_hash: feedbackHash, + reviewer_feedback: feedback, + planner: revision.planner, + action: revision.action, + summary: revision.summary, + risk: revision.risk, + stable_connector_id: STABLE_CONNECTOR_ID, + proposed_payload_hash: revisionPayloadHash, + proposed_payload: revision.payload, + approval_question: + 'Should the agent write this revised repository file update and resume MCP execution?', + } + const revisionRecord = await signObservation({ + env: this.env, + role: 'agent', + key: privateKey(this.env.ATRIB_AGENT_PRIVATE_KEY), + contextId: workflow.context_id, + chainRoot: feedbackHash, + toolName: 'revised_proposal', + body: revisionBody, + informedBy: [workflow.proposal_record_hash, feedbackHash], + timestamp: revisionTimestamp, + }) + const revisionHash = recordHash(revisionRecord) + await this.saveTraceRecord({ + runId: input.runId, + label: 'revision', + signer: 'agent', + record: revisionRecord, + body: revisionBody, + proof: await submitRecord(this.env, revisionRecord), + }) + this.sql` + UPDATE workflows + SET status = ${'pending_approval'}, + proposal_record_hash = ${revisionHash}, + payload_hash = ${revisionPayloadHash}, + payload_json = ${JSON.stringify(revision.payload)}, + planner = ${revision.planner}, + decision = ${null}, + decision_reason = ${feedback}, + decision_record_hash = ${null}, + updated_at = ${revisionTimestamp} + WHERE run_id = ${input.runId} + ` + this.saveNativeEvent( + input.runId, + emitNativeEvent({ + channel: 'agents:message', + type: 'message:revision', + agent: 'ApprovalTraceAgent', + name: input.runId, + payload: { + revisionRecordHash: revisionHash, + feedbackRecordHash: feedbackHash, + payloadHash: revisionPayloadHash, + }, + timestamp: revisionTimestamp, + }), + ) + return this.getRun(input.runId) + } + async markExecuting(runId: string): Promise { this.ensureSchema() this.sql` @@ -1180,12 +1423,14 @@ export class ApprovalTraceAgent extends Agent { WHEN 'trigger' THEN 1 WHEN 'triage' THEN 2 WHEN 'proposal' THEN 3 - WHEN 'approval' THEN 4 - WHEN 'rejection' THEN 4 - WHEN 'preview' THEN 5 - WHEN 'execution' THEN 6 - WHEN 'outcome' THEN 7 - WHEN 'handoff' THEN 8 + WHEN 'change_request' THEN 4 + WHEN 'revision' THEN 5 + WHEN 'approval' THEN 6 + WHEN 'rejection' THEN 6 + WHEN 'preview' THEN 7 + WHEN 'execution' THEN 8 + WHEN 'outcome' THEN 9 + WHEN 'handoff' THEN 10 ELSE 99 END ASC, created_at ASC @@ -1193,6 +1438,14 @@ export class ApprovalTraceAgent extends Agent { ] } + private currentProposalTimestamp(workflow: WorkflowRow): number { + const row = this + .getRecordRows(workflow.run_id) + .find((record) => record.record_hash === workflow.proposal_record_hash) + if (!row) return workflow.updated_at + return (JSON.parse(row.record_json) as AtribRecord).timestamp + } + private getNativeRows(runId: string): NativeObservabilityRow[] { return [ ...this.sql` @@ -1725,7 +1978,9 @@ export default { async fetch(request: Request, env: Env, ctx: globalThis.ExecutionContext): Promise { try { const url = new URL(request.url) - if (url.pathname === '/' || url.pathname === '/demo') return html(renderApp()) + if (url.pathname === '/' || url.pathname === '/demo') { + return html(renderApp({ colo: requestColo(request) })) + } if (url.pathname.startsWith('/action-mcp')) return actionMcpHandler.fetch(request, env, ctx) if (url.pathname === '/api/runs' && request.method === 'POST') { @@ -1805,6 +2060,24 @@ export default { ) } + const requestChangesMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/request-changes$/u) + if (requestChangesMatch && request.method === 'POST') { + const runId = decodeURIComponent(requestChangesMatch[1]!) + const body = (await request.json()) as { feedback?: string } + const agent = await getTraceAgent(env, runId) + const current = (await agent.getRun(runId)) as { status: WorkflowStatus | null } + if (current.status === null) return errorJson(new Error(`run not found: ${runId}`)) + if (current.status !== 'pending_approval') { + return errorJson(new Error(`run is not pending approval: ${current.status}`)) + } + return json( + await agent.requestChanges({ + runId, + feedback: body.feedback ?? 'Request a narrower repository file update.', + }), + ) + } + return json({ ok: true, endpoints: [ @@ -1814,6 +2087,7 @@ export default { '/api/runs/:runId', '/api/runs/:runId/approve', '/api/runs/:runId/reject', + '/api/runs/:runId/request-changes', '/action-mcp', ], }) diff --git a/packages/integration/examples/cloudflare-agents/approval-trace/src/ui.ts b/packages/integration/examples/cloudflare-agents/approval-trace/src/ui.ts index dc5990aa..f70f3032 100644 --- a/packages/integration/examples/cloudflare-agents/approval-trace/src/ui.ts +++ b/packages/integration/examples/cloudflare-agents/approval-trace/src/ui.ts @@ -1,4 +1,6 @@ -export function renderApp(): string { +export function renderApp(options: { colo?: string } = {}): string { + const colo = (options.colo ?? 'IAD').replace(/[^A-Za-z0-9-]/gu, '').slice(0, 12) || 'IAD' + return ` @@ -37,7 +39,9 @@ export function renderApp(): string { linear-gradient(180deg, #f8fafc 0, #eef2f7 320px, #e8edf4 100%), var(--bg); color: var(--text); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", sans-serif; + max-width: 100%; + overflow-x: hidden; } button, @@ -603,7 +607,6 @@ export function renderApp(): string { } .cloud-mark { - color: var(--orange); flex: 0 0 auto; height: 32px; width: 38px; @@ -736,8 +739,9 @@ export function renderApp(): string { color: #111827; display: block; flex: 0 0 auto; - height: 24px; - width: 24px; + height: 25px; + overflow: visible; + width: 25px; } .trigger-details { @@ -762,7 +766,7 @@ export function renderApp(): string { .progress-item, .event { - animation: itemIn 360ms ease both; + transition: background-color 160ms ease, color 160ms ease; } .progress-item { @@ -776,7 +780,12 @@ export function renderApp(): string { } .diff { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; grid-template-columns: 1fr; + width: 100%; } .diff pre { @@ -955,8 +964,10 @@ export function renderApp(): string { } .shell { - max-width: none; + margin: 0 auto; + max-width: 1536px; padding: 0 0 0; + width: 100%; } .hero { @@ -968,7 +979,10 @@ export function renderApp(): string { justify-content: flex-start; margin-bottom: 8px; min-height: 62px; + overflow: visible; padding: 10px 28px; + position: relative; + z-index: 30; } .hero::after { @@ -997,8 +1011,9 @@ export function renderApp(): string { .header-meta { flex: 1 1 auto; - gap: 12px; + gap: 18px; justify-content: flex-start; + position: relative; } .header-meta > span:nth-child(3) { @@ -1018,10 +1033,179 @@ export function renderApp(): string { white-space: nowrap; } + .run-id-meta { + align-items: center; + display: inline-flex; + gap: 7px; + } + + .run-id-meta .copy-icon { + height: 16px; + width: 16px; + } + + .run-mode-wrap { + display: inline-flex; + position: relative; + white-space: nowrap; + } + .meta-pill { gap: 8px; } + button.meta-pill { + cursor: pointer; + font: inherit; + } + + button.meta-pill:hover, + button.meta-pill:focus-visible, + button.meta-pill[aria-expanded="true"] { + border-color: #b8c8f6; + color: var(--blue); + outline: 0; + } + + .meta-pill.live-run .menu-chevron { + color: #475569; + height: 12px; + margin-left: -2px; + transition: transform 160ms ease; + width: 12px; + } + + .meta-pill.live-run[aria-expanded="true"] .menu-chevron { + transform: rotate(180deg); + } + + .colo-status-dot { + background: var(--green); + border-radius: 999px; + display: inline-block; + height: 8px; + margin-left: 6px; + vertical-align: 1px; + width: 8px; + } + + .header-menu { + align-items: center; + background: #fff; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--ink); + display: inline-flex; + height: 32px; + justify-content: center; + padding: 0; + width: 32px; + } + + .header-menu:hover, + .header-menu:focus-visible, + .header-menu[aria-expanded="true"] { + border-color: #b8c8f6; + color: var(--blue); + outline: 0; + } + + .header-menu svg { + height: 16px; + width: 16px; + } + + .header-actions-menu { + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 14px 32px rgba(18, 27, 42, 0.16); + display: grid; + min-width: 178px; + padding: 5px; + position: absolute; + right: 0; + top: 42px; + z-index: 1000; + } + + .run-mode-menu { + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 14px 32px rgba(18, 27, 42, 0.16); + display: grid; + left: 0; + max-width: calc(100vw - 40px); + min-width: 224px; + overflow: hidden; + padding: 5px; + position: absolute; + top: 40px; + width: 224px; + z-index: 1200; + } + + .header-actions-menu[hidden] { + display: none; + } + + .run-mode-menu[hidden] { + display: none; + } + + .header-actions-menu button, + .header-actions-menu a, + .run-mode-menu button { + align-items: center; + background: transparent; + border-radius: 6px; + color: var(--ink); + display: flex; + font-size: 12px; + font-weight: 700; + min-height: 30px; + padding: 7px 9px; + text-align: left; + text-decoration: none; + white-space: nowrap; + } + + .run-mode-menu button { + display: grid; + gap: 7px; + grid-template-columns: 12px minmax(0, 1fr); + overflow: hidden; + text-overflow: ellipsis; + } + + .run-mode-menu button::before { + color: transparent; + content: ""; + font-size: 11px; + font-weight: 800; + line-height: 1; + } + + .run-mode-menu button[aria-checked="true"]::before { + color: var(--green); + content: "✓"; + } + + .header-actions-menu button:hover, + .header-actions-menu button:focus-visible, + .header-actions-menu a:hover, + .header-actions-menu a:focus-visible, + .run-mode-menu button:hover, + .run-mode-menu button:focus-visible { + background: #f4f7fb; + outline: 0; + } + + .run-mode-menu button[aria-checked="true"] { + color: var(--green); + } + .meta-pill, .meta-code { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); @@ -1034,7 +1218,7 @@ export function renderApp(): string { gap: 16px; grid-template-columns: 1fr; margin-bottom: 6px; - min-height: 64px; + min-height: 72px; padding: 0 30px 0; } @@ -1065,8 +1249,8 @@ export function renderApp(): string { .rail-stepper { align-items: center; display: grid; - gap: 12px; - grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 16px; + grid-template-columns: minmax(126px, 1fr) minmax(196px, 1.18fr) minmax(236px, 1.12fr) minmax(242px, 1.36fr) minmax(142px, 0.9fr); position: relative; width: 100%; } @@ -1076,47 +1260,59 @@ export function renderApp(): string { background: transparent; border: 1px solid transparent; color: var(--ink); - display: flex; - min-height: 52px; + display: grid; + gap: 10px; + grid-template-columns: 38px minmax(0, 1fr); + min-height: 64px; min-width: 0; - padding: 6px 9px; + padding: 6px 0; position: relative; } .step:not(:last-child)::after { - border-top: 2px solid #c5cfdb; + background-image: repeating-linear-gradient( + to right, + #c5cfdb 0, + #c5cfdb 4px, + transparent 4px, + transparent 7px + ); content: ""; - left: 168px; + height: 2px; + left: 54px; position: absolute; - top: 26px; - width: calc(100% - 178px); + right: -16px; + top: 31px; + width: auto; z-index: 0; } .step.done:not(:last-child)::after { - border-color: var(--green); + background: var(--green); } .step.active, .step.halted, .step.error { - background: #fff; - border-color: #ccd7e5; - box-shadow: var(--shadow-tight); + background: transparent; + border-color: transparent; + box-shadow: none; } .step.halted { background: #fff8ed; border-color: #f3a64e; - justify-self: center; - width: 332px; + border-radius: 8px; + color: var(--ink); + min-height: 58px; + padding: 6px 10px; + width: auto; } .step.halted:not(:last-child)::after { - border-color: #c5cfdb; - border-top-style: dashed; - left: calc(100% + 8px); - width: 78px; + left: calc(100% + 1px); + right: auto; + width: var(--halt-connector-width, 56px); } .step.done { @@ -1134,29 +1330,33 @@ export function renderApp(): string { flex: 0 0 auto; font-size: 14px; font-weight: 800; - height: 34px; - width: 34px; + height: 38px; + width: 38px; z-index: 1; } .step.done .step-index { + color: #fff; font-size: 0; position: relative; } .step.done .step-index::after { content: ""; - border-bottom: 3px solid #fff; - border-right: 3px solid #fff; - height: 14px; + background: currentColor; + height: 22px; left: 50%; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; position: absolute; top: 50%; - transform: translate(-50%, -58%) rotate(45deg); - width: 7px; + transform: translate(-50%, -50%); + width: 22px; } .step.halted .step-index { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #f97316 100%); + border-color: #f59e0b; font-size: 0; position: relative; } @@ -1164,16 +1364,17 @@ export function renderApp(): string { .step.halted .step-index::before, .step.halted .step-index::after { background: #fff; - border-radius: 2px; + border-radius: 3px; content: ""; - height: 15px; + height: 14px; position: absolute; - top: 9px; - width: 4px; + top: 50%; + transform: translateY(-50%); + width: 3px; } .step.halted .step-index::before { - left: 12px; + left: 13px; } .step.halted .step-index::after { @@ -1182,6 +1383,7 @@ export function renderApp(): string { .step-copy strong { font-size: 14px; + font-weight: 850; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; @@ -1194,30 +1396,96 @@ export function renderApp(): string { } .step-copy { - background: inherit; + background: #fff; + display: grid; + gap: 3px; + justify-self: start; + max-width: 100%; min-width: 0; + padding: 0 4px; position: relative; + width: max-content; z-index: 1; } + .step.halted .step-copy { + background: #fff8ed; + } + + .step.done .step-copy, + .step.active .step-copy, + .step.error .step-copy { + background: #fff; + } + + .step[data-step="halt"] .step-copy strong { + display: block; + font-size: 14px; + font-weight: 850; + line-height: 1.1; + } + + .step[data-step="halt"] .step-copy .step-number-label, + .step[data-step="halt"] .step-copy [data-step-title] { + display: inline; + margin-top: 0; + } + + .step-copy strong .step-number-label, + .step-copy strong [data-step-title] { + font-weight: inherit; + } + + .step[data-step="halt"] .step-copy .step-meta-line { + align-items: center; + display: flex; + gap: 4px; + line-height: 1; + margin-top: 0; + min-width: 0; + } + + .step[data-step="halt"] [data-step-time="halt"] { + white-space: nowrap; + } + .step-badge { background: #fff0dc; border: 1px solid #ffd09a; border-radius: 999px; color: #a44900; display: inline-flex; - font-size: 10px; + flex: 0 0 auto; + font-size: 8px; font-weight: 850; line-height: 1; - margin-left: 7px; + margin-left: 0; padding: 3px 6px; + text-transform: uppercase; vertical-align: 1px; + white-space: nowrap; + } + + .step[data-step="halt"] .step-copy .step-badge { + font-size: 8px; + font-weight: 850; + line-height: 1; + margin-top: 0; + } + + .step-badge[hidden] { + display: none; } .step-copy strong [data-step-title] { display: inline; } + .step.halted:not(.review-rejected):not(.review-requested) + .step .step-index { + border-color: var(--blue); + color: var(--blue); + } + .step-badge.approved { background: #e9f8ef; border-color: #b9e5cb; @@ -1230,10 +1498,70 @@ export function renderApp(): string { color: #be123c; } + .step-badge.requested { + background: #fff8ed; + border-color: #ffd09a; + color: #a44900; + } + + .step[data-step="halt"] .step-copy .step-badge.approved { + color: #047857; + } + + .step[data-step="halt"] .step-copy .step-badge.rejected { + color: #be123c; + } + + .step[data-step="halt"] .step-copy .step-badge.requested { + color: #a44900; + } + + .step.branch-ready .step-index { + background: var(--green); + border-color: var(--green); + color: #fff; + font-size: 0; + } + + .step.branch-ready .step-index::after { + content: ""; + background: currentColor; + height: 18px; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; + position: absolute; + width: 18px; + } + + @media (min-width: 1451px) { + .workflow-rail { + padding: 0 9px 0 51px; + } + + .rail-stepper { + grid-template-columns: 272px 244px 370px minmax(250px, 1fr) 200px; + } + + .step.halted { + width: 331px; + } + + .step[data-step="halt"] .step-copy .step-meta-line { + gap: 16px; + } + + .step-badge, + .step[data-step="halt"] .step-copy .step-badge { + font-size: 10px; + padding: 3px 4px; + } + } + .grid { gap: 10px; align-items: start; - grid-template-columns: minmax(336px, 363px) minmax(620px, 1fr) minmax(438px, 524px); + grid-template-columns: minmax(318px, 363px) minmax(500px, 610px) minmax(340px, 523px); + justify-content: center; margin: 0 10px; } @@ -1256,16 +1584,21 @@ export function renderApp(): string { .panel h2 { align-items: center; + background: rgba(255, 255, 255, 0.98); border-bottom: 1px solid var(--line); color: var(--ink); display: flex; - font-size: 12px; - font-weight: 850; - justify-content: space-between; - letter-spacing: 0.05em; - margin: 0; - min-height: 38px; - padding: 0 14px; + font-size: 11.5px; + font-weight: 800; + gap: 7px; + justify-content: flex-start; + letter-spacing: 0.035em; + margin: 0; + min-height: 38px; + padding: 0 14px; + position: sticky; + top: 0; + z-index: 3; } .grid > .panel:first-child > h2 { @@ -1273,10 +1606,10 @@ export function renderApp(): string { } .heading-pill { - background: #fff0dc; - border: 1px solid #ffd09a; + background: #f4f7fb; + border: 1px solid var(--line); border-radius: 999px; - color: #a44900; + color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: 0; @@ -1284,10 +1617,29 @@ export function renderApp(): string { text-transform: none; } - .heading-pill.green { - background: #e8f7ef; + .heading-pill.green, + .heading-pill.approved { + background: #e9f8ef; border-color: #b9e5cb; - color: var(--green); + color: #047857; + } + + .heading-pill.rejected { + background: #fff1f2; + border-color: #fecdd3; + color: #be123c; + } + + .heading-pill.requested { + background: #fff8ed; + border-color: #ffd09a; + color: #a44900; + } + + .heading-pill.running { + background: #edf4ff; + border-color: #c7dcff; + color: #0969da; } .trigger-card { @@ -1303,7 +1655,12 @@ export function renderApp(): string { } .trigger-source { - font-size: 15px; + font-size: 14px; + } + + .trigger-card .detail-row strong { + font-size: 13px; + font-weight: 400; } .trigger-details { @@ -1323,9 +1680,17 @@ export function renderApp(): string { padding: 14px 16px; } + .proposal, + .timeline { + padding-left: 12px; + padding-right: 12px; + } + .proposal { display: grid; gap: 10px; + min-width: 0; + overflow: hidden; } .prompt { @@ -1368,6 +1733,14 @@ export function renderApp(): string { text-transform: uppercase; } + #answer > .section-label { + font-size: 14px; + font-weight: 700; + letter-spacing: 0; + margin-bottom: 7px; + text-transform: none; + } + textarea { min-height: 86px; } @@ -1389,7 +1762,7 @@ export function renderApp(): string { border: 0; border-radius: 0; column-gap: 10px; - grid-template-columns: 120px minmax(0, 1fr); + grid-template-columns: max-content minmax(0, 1fr); padding: 0; } @@ -1408,6 +1781,27 @@ export function renderApp(): string { min-width: 0; } + .proposal .metric .pill { + font-size: 12px; + line-height: 1.25; + min-height: 22px; + padding: 2px 7px; + } + + .proposal .metric .meta-code { + font-size: 12px; + line-height: 1.25; + padding: 2px 7px; + } + + .proposal .diff-head .label { + color: var(--ink); + font-size: 13px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + } + .label { letter-spacing: 0.04em; } @@ -1439,12 +1833,18 @@ export function renderApp(): string { } .progress-item .dot { - border: 2px solid #fff; - box-shadow: 0 0 0 1px #c7d2df; - height: 14px; - margin-left: 2px; + align-items: center; + border: 0; + border-radius: 999px; + box-shadow: none; + box-sizing: border-box; + display: inline-flex; + height: 16px; + justify-content: center; + justify-self: center; + margin-left: 0; position: relative; - width: 14px; + width: 16px; z-index: 1; } @@ -1452,37 +1852,56 @@ export function renderApp(): string { background: #fff; } + .progress-item .dot.future { + border: 1.5px solid #a8b4c3; + box-shadow: none; + } + .progress-item.halted { background: transparent; } - .progress-item.halted strong { - color: #9a4a00; + .progress-item.skipped strong { + color: #5d687a; + font-weight: 700; } - .progress-item.proposal .dot.ok { - background: var(--blue); - box-shadow: 0 0 0 1px #b8c8f6; + .progress-item strong { + font-size: 13px; + font-weight: 400; + } + + .progress-item.halted strong { + color: #9a4a00; + font-weight: 700; } - .progress-item:not(.proposal) .dot.ok { + .progress-item .dot.ok { background: var(--green); + color: #fff; font-size: 0; } - .progress-item:not(.proposal) .dot.ok::after { - border-bottom: 2px solid #fff; - border-right: 2px solid #fff; + .progress-item .dot.ok::after { content: ""; - height: 6px; - left: 50%; + background: currentColor; + height: 10px; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; position: absolute; - top: 50%; - transform: translate(-50%, -58%) rotate(45deg); - width: 3px; + width: 10px; + } + + .progress-item.proposal.pending-proposal .dot.ok { + background: var(--blue); + } + + .progress-item.proposal.pending-proposal .dot.ok::after { + content: none; } .progress-item.halted .dot.pending { + background: var(--orange); font-size: 0; } @@ -1491,18 +1910,35 @@ export function renderApp(): string { background: #fff; border-radius: 1px; content: ""; - height: 7px; + height: 8px; position: absolute; - top: 2px; + top: 50%; + transform: translateY(-50%); width: 2px; } .progress-item.halted .dot.pending::before { - left: 4px; + left: 5px; } .progress-item.halted .dot.pending::after { - right: 4px; + right: 5px; + } + + .progress-item .dot.skipped { + background: #f8fafc; + border: 1.5px solid #a8b4c3; + color: #5d687a; + font-size: 0; + } + + .progress-item .dot.skipped::after { + background: currentColor; + border-radius: 999px; + content: ""; + height: 2px; + position: absolute; + width: 8px; } .run-state { @@ -1526,10 +1962,13 @@ export function renderApp(): string { gap: 10px; justify-content: space-between; margin-bottom: 6px; + max-width: 100%; min-width: 0; + width: 100%; } .diff-tools { + align-items: center; color: var(--muted); display: flex; font-size: 12px; @@ -1538,29 +1977,94 @@ export function renderApp(): string { white-space: nowrap; } - .diff pre { + .diff-tools select, + .diff-tools button { + background: transparent; + border: 0; + border-radius: 6px; + color: var(--muted); font-size: 12px; - line-height: 1.55; - max-height: 300px; + font-weight: 650; + min-height: 24px; + padding: 2px 4px; + } + + .diff-tools select:hover, + .diff-tools select:focus-visible, + .diff-tools button:hover, + .diff-tools button:focus-visible, + .diff-tools button[aria-pressed="true"] { + background: #f4f7fb; + color: var(--ink); + outline: 0; + } + + .diff-tools .diff-copy-button { + align-items: center; + display: inline-flex; + height: 24px; + justify-content: center; + padding: 0; + width: 24px; + } + + .diff-tools .diff-copy-button svg { + height: 14px; + width: 14px; + } + + .diff-tools .diff-copy-button[data-copy-state="copied"] { + color: var(--green); + } + + .diff pre { + font-size: 10px; + line-height: 14.2px; + max-height: 315px; } .diff-code { background: #fbfdff; border: 1px solid var(--line); border-radius: 8px; + box-sizing: border-box; color: #102033; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.55; - max-height: 302px; + font-size: 10px; + line-height: 14.2px; + max-height: 315px; + max-width: 100%; + height: 315px; overflow: auto; padding: 8px 0; + width: 100%; } .diff-line { - display: block; - min-height: 18px; + align-items: start; + column-gap: 8px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + min-height: 14.2px; + min-width: 0; padding: 0 12px; + width: auto; + } + + .diff-line-no { + color: #94a3b8; + font-variant-numeric: tabular-nums; + text-align: right; + user-select: none; + } + + .diff-line-text { + min-width: 0; + white-space: pre; + } + + .diff-code.wrap .diff-line-text { + overflow-wrap: anywhere; white-space: pre-wrap; } @@ -1580,11 +2084,11 @@ export function renderApp(): string { .risk-bar { align-items: center; - background: #fff8ed; + background: #fff; border: 1px solid #ffd09a; border-radius: 8px; display: grid; - gap: 8px; + gap: 7px; grid-template-columns: auto auto minmax(0, 1fr) minmax(42px, auto); min-width: 0; padding: 8px 10px; @@ -1657,63 +2161,161 @@ export function renderApp(): string { display: none; } + .feedback-summary, + .review-feedback-drawer { + background: #f8fafc; + border: 1px solid var(--line); + border-radius: 8px; + display: grid; + gap: 5px; + padding: 8px 10px; + } + + .feedback-summary .label, + .review-feedback-drawer .label { + color: #5f6f86; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 1; + text-transform: uppercase; + } + + .feedback-summary .value { + color: var(--ink); + font-size: 12px; + line-height: 1.35; + } + + .review-feedback-drawer[hidden] { + display: none; + } + + .review-feedback-drawer textarea { + background: #fff; + border: 1px solid #cfd8e6; + border-radius: 7px; + color: var(--ink); + font: 12px/1.35 var(--mono); + min-height: 58px; + padding: 8px 9px; + resize: vertical; + width: 100%; + } + + .review-feedback-drawer textarea:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.12); + outline: 0; + } + .primary, .secondary, .danger { align-items: center; - display: grid; - gap: 8px; - grid-template-columns: 20px minmax(0, 1fr); - justify-content: stretch; + display: flex; + justify-content: center; min-height: 58px; - padding: 9px 10px; + padding: 10px 14px; + position: relative; } .actions { display: grid; - grid-template-columns: minmax(186px, 1.05fr) minmax(156px, 0.9fr) minmax(168px, 0.96fr); - gap: 14px; - margin-top: 4px; + gap: 0; + grid-template-columns: 190px 22px 168px 28px 166px; + justify-content: start; + margin-top: 6px; min-width: 0; } + .actions .primary { + grid-column: 1; + } + + .actions .danger { + grid-column: 3; + } + + .actions .secondary { + grid-column: 5; + } + + .button-content { + align-items: center; + display: grid; + flex: 0 1 auto; + height: 100%; + justify-items: center; + max-width: 100%; + min-width: 0; + width: 100%; + } + + .button-content::after { + content: none; + } + .action-copy { + align-items: center; + box-sizing: border-box; display: grid; + flex: 0 0 auto; gap: 2px; + justify-content: center; + max-width: calc(100% - 42px); + min-width: 0; + padding: 0; + text-align: center; + width: 100%; + } + + .action-heading { + align-items: center; + display: inline-flex; + gap: 8px; + justify-content: center; + max-width: 100%; min-width: 0; - text-align: left; } .action-copy small { color: inherit; - font-size: 9.5px; - font-weight: 650; - line-height: 1.12; + display: block; + font-size: 9px; + font-weight: 600; + line-height: 1.15; opacity: 0.78; - overflow: hidden; - overflow-wrap: normal; - text-overflow: ellipsis; - text-wrap: normal; + overflow: visible; + overflow-wrap: anywhere; + text-align: center; + text-overflow: clip; + white-space: normal; + width: auto; } .button-label { display: block; - font-size: 12.5px; - font-weight: 850; + font-size: 13px; + font-weight: 800; line-height: 1.12; + max-width: 100%; + text-align: center; white-space: nowrap; + width: auto; } .primary .button-label { - font-size: 12.5px; + font-size: 13px; } .primary .action-copy small { - font-size: 8px; + font-size: 9px; + font-weight: 500; letter-spacing: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible; + text-overflow: clip; + white-space: normal; } .primary { @@ -1729,32 +2331,63 @@ export function renderApp(): string { background: #fff; } + .danger .action-copy small, + .secondary .action-copy small { + color: #475569; + font-weight: 500; + opacity: 1; + } + .button-icon { align-items: center; - border: 1px solid currentColor; border-radius: 999px; display: inline-flex; - height: 18px; + flex: 0 0 16px; + height: 16px; justify-content: center; - justify-self: start; line-height: 0; - margin-left: 0; - width: 18px; + margin: 0; + width: 16px; } .button-icon svg { display: block; - height: 11px; - width: 11px; + height: 14px; + width: 14px; } .primary .button-icon { - border-color: rgba(255, 255, 255, 0.78); + border: 0; + color: #fff; + } + + .danger .button-icon { + border: 1.5px solid currentColor; + color: var(--red); } - .danger .button-icon, .secondary .button-icon { - margin-top: -1px; + border: 1px solid #cbd5e1; + border-radius: 6px; + color: #334155; + } + + @media (min-width: 1451px) { + .primary, + .secondary, + .danger { + padding-left: 14px; + padding-right: 14px; + } + + .action-copy { + max-width: 100%; + } + + .action-copy small, + .primary .action-copy small { + white-space: nowrap; + } } .primary .action-copy, @@ -1773,7 +2406,7 @@ export function renderApp(): string { background: #cfd8e5; bottom: 26px; content: ""; - left: 68px; + left: 17px; position: absolute; top: 24px; width: 2px; @@ -1781,28 +2414,52 @@ export function renderApp(): string { .event, .event-future { + align-items: center; animation-duration: 180ms; background: transparent; border: 0; border-radius: 0; + box-sizing: border-box; display: grid; - gap: 7px; - grid-template-columns: 54px 20px minmax(0, 1fr) minmax(82px, 120px); + gap: 10px; + grid-template-columns: 20px 94px minmax(0, 1fr) minmax(82px, 120px); + min-height: 44px; min-width: 0; - padding: 3px 0; + padding: 4px 0 4px 8px; } - .event:hover, - .event:focus-visible, - .event.selected { + .event { + grid-template-columns: 20px 94px minmax(0, 1fr) minmax(82px, 112px) 14px; + } + + #timeline .record-timeline .event, + #timeline .record-timeline .event-future { + transition: color 160ms ease; + } + + #timeline .record-timeline .event:hover, + #timeline .record-timeline .event:focus-visible { background: #fff7ec; border-color: transparent; box-shadow: none; outline: 0; } - .event-future.selected { + #timeline .record-timeline .event.current, + #timeline .record-timeline .event-future.current { background: #fff7ec; + box-shadow: inset 3px 0 0 #f59e0b; + } + + #timeline .record-timeline .event.selected { + background: #eef6ff; + box-shadow: inset 3px 0 0 #0969da; + outline: 0; + } + + #timeline .record-timeline .event.selected .event-cue { + color: #0969da; + opacity: 1; } .event-time { @@ -1814,35 +2471,34 @@ export function renderApp(): string { .event-marker { align-items: center; background: var(--green); - border: 2px solid #fff; + border: 0; border-radius: 999px; - box-shadow: 0 0 0 1px #b7d7c8; + box-shadow: none; + box-sizing: border-box; color: #fff; display: inline-flex; font-size: 10px; - height: 17px; + height: 18px; justify-content: center; + justify-self: center; margin-top: 3px; position: relative; - width: 17px; + width: 18px; z-index: 1; } .event-marker.done::after { - border-bottom: 2px solid #fff; - border-right: 2px solid #fff; content: ""; - height: 7px; - left: 50%; + background: currentColor; + height: 11px; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12 4.8' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.1'/%3E%3C/svg%3E") center / contain no-repeat; position: absolute; - top: 50%; - transform: translate(-50%, -58%) rotate(45deg); - width: 4px; + width: 11px; } .event-marker.pending { background: var(--orange); - box-shadow: 0 0 0 1px #ffd09a; font-size: 0; } @@ -1853,22 +2509,37 @@ export function renderApp(): string { content: ""; height: 8px; position: absolute; - top: 3px; + top: 50%; + transform: translateY(-50%); width: 2px; } .event-marker.pending::before { - left: 5px; + left: 6px; } .event-marker.pending::after { - right: 5px; + right: 6px; } .event-marker.future { background: #fff; - box-shadow: 0 0 0 1px #a8b4c3; - border-color: #fff; + border: 1.5px solid #a8b4c3; + box-shadow: none; + color: #5d687a; + font-size: 12px; + font-weight: 700; + line-height: 1; + } + + .event-marker.skipped { + background: #f8fafc; + border: 1.5px solid #a8b4c3; + box-shadow: none; + color: #5d687a; + font-size: 12px; + font-weight: 700; + line-height: 1; } .event-copy { @@ -1877,7 +2548,50 @@ export function renderApp(): string { min-width: 0; } + .event-title-line { + align-items: center; + display: flex; + gap: 5px; + min-width: 0; + } + + .event-signer-icon { + align-items: center; + border: 1px solid transparent; + border-radius: 6px; + display: inline-flex; + flex: 0 0 18px; + height: 18px; + justify-content: center; + width: 18px; + } + + .event-signer-icon svg { + display: block; + height: 13px; + width: 13px; + } + + .event-signer-icon.agent { + background: #edf5ff; + border-color: #c7dcff; + color: #0969da; + } + + .event-signer-icon.human { + background: #fff1dc; + border-color: #ffd09a; + color: #c76a00; + } + + .event-signer-icon.mcp { + background: #e9f8ef; + border-color: #b9e5cb; + color: #078861; + } + .event-copy strong { + min-width: 0; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; @@ -1899,11 +2613,16 @@ export function renderApp(): string { color: #7a8497; } + .event-future .event-hash { + text-align: left; + } + .event-hash { align-self: center; color: #44536a; font-size: 11px; max-width: 132px; + min-width: 0; overflow: hidden; overflow-wrap: normal; text-align: right; @@ -1912,6 +2631,22 @@ export function renderApp(): string { word-break: normal; } + .event-cue { + align-self: center; + color: #6b778c; + display: inline-flex; + height: 14px; + justify-content: center; + opacity: 0.85; + width: 14px; + } + + .event-cue svg { + display: block; + height: 14px; + width: 14px; + } + .trace-section-label { color: var(--ink); display: block; @@ -1950,7 +2685,7 @@ export function renderApp(): string { .signer-list { gap: 0; - margin-top: 12px; + margin-top: 14px; } .signer-list .trace-section-label { @@ -1960,10 +2695,10 @@ export function renderApp(): string { .signer-row { background: #fff; border-radius: 0; - gap: 7px; - grid-template-columns: 24px 74px minmax(0, 1fr) 56px minmax(62px, 82px) 14px; - min-height: 30px; - padding: 4px 8px; + gap: 2px 7px; + grid-template-columns: 24px minmax(76px, 1fr) minmax(52px, auto) minmax(124px, 138px) 16px; + min-height: 42px; + padding: 5px 8px; box-shadow: none; } @@ -1990,11 +2725,17 @@ export function renderApp(): string { .signer-row strong { font-size: 12px; + grid-column: 2; + grid-row: 1; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .signer-row .empty { font-size: 12px; + grid-column: 2 / 6; + grid-row: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -2007,36 +2748,39 @@ export function renderApp(): string { .signer-icon { align-items: center; - border-radius: 7px; + background: transparent; + border-radius: 0; display: inline-flex; + grid-column: 1; + grid-row: 1 / 3; height: 22px; justify-content: center; width: 22px; } .signer-icon svg { - height: 15px; - width: 15px; + display: block; + height: 20px; + width: 20px; } .signer-icon.agent { - background: #e8f2ff; color: #0969da; } .signer-icon.human { - background: #fff3df; color: #c76a00; } .signer-icon.mcp { - background: #e9f7ef; color: #078861; } .signer-status { border-color: #c7d6e8; font-size: 11px; + grid-column: 3; + grid-row: 1; min-height: 20px; padding: 2px 6px; justify-self: end; @@ -2059,17 +2803,49 @@ export function renderApp(): string { color: #078861; } + .signer-status.blocked { + background: #eef2f8; + border-color: #cfd8e6; + color: #475569; + } + + .signer-status.skipped { + background: #f8fafc; + border-color: #d7e0ec; + color: #69758a; + } + .signature-slot { align-self: center; color: var(--muted); font-size: 11px; + grid-column: 4; + grid-row: 1; + justify-self: end; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .signature-label, + .signature-separator { + display: inline-block; + } + .signature-slot .hash { + display: inline-block; font-size: 10px; + max-width: calc(100% - 38px); + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; + } + + .signer-row .copy-icon { + grid-column: 5; + grid-row: 1; } .copy-icon { @@ -2111,7 +2887,8 @@ export function renderApp(): string { display: grid; font-size: 12px; gap: 8px; - grid-template-columns: 94px minmax(0, 1fr) auto; + grid-template-columns: 94px minmax(0, 1fr) 16px; + min-width: 0; } .integrity-row strong { @@ -2125,6 +2902,60 @@ export function renderApp(): string { white-space: nowrap; } + .integrity-row .value { + font-size: 12px; + line-height: 1.3; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .proof-status-value { + align-items: center; + display: flex; + gap: 6px; + } + + .integrity-proof-dot { + align-items: center; + color: var(--green); + display: inline-flex; + flex: 0 0 12px; + height: 12px; + justify-content: center; + width: 12px; + } + + .integrity-proof-dot svg { + display: block; + height: 12px; + width: 12px; + } + + .proof-status-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .integrity-row.proof-row { + grid-template-columns: 94px minmax(0, 1fr) auto; + } + + .trace-integrity .event-action { + font-size: 12px; + gap: 4px; + justify-self: end; + white-space: nowrap; + } + + .trace-integrity .event-action svg { + height: 12px; + width: 12px; + } + .verify-row { background: #fff; } @@ -2132,27 +2963,31 @@ export function renderApp(): string { .receipt-grid { display: block; margin-top: 10px; - padding: 0 10px; + padding: 0; } .receipt-panel { grid-column: auto; + min-height: 248px; scroll-margin-top: 12px; } .receipt-toolbar { align-items: center; display: flex; - gap: 12px; + gap: 8px; justify-content: flex-start; min-height: 38px; - padding: 0 14px; + padding: 0 14px 0 24px; } .receipt-toolbar h2 { border-bottom: 0; + font-size: 11px; + letter-spacing: 0.025em; min-height: auto; padding: 0; + white-space: nowrap; } .receipt-controls { @@ -2163,11 +2998,27 @@ export function renderApp(): string { .receipt-controls .label { color: var(--ink); - font-size: 12px; + font-size: 11px; letter-spacing: 0; + line-height: 1; text-transform: none; } + .receipt-format { + appearance: auto; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + font: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + height: 26px; + max-width: 156px; + padding: 0 6px; + width: 121px; + } + .icon-button { align-items: center; background: #fff; @@ -2175,12 +3026,12 @@ export function renderApp(): string { border-radius: 6px; color: var(--ink); display: inline-flex; - font-size: 12px; + font-size: 11px; font-weight: 700; - gap: 7px; + gap: 6px; height: 26px; justify-content: center; - padding: 0 9px; + padding: 0 8px; } .icon-button:disabled { @@ -2210,35 +3061,84 @@ export function renderApp(): string { padding: 0; } + #downloadReceipt { + gap: 5px; + padding: 0 5px; + } + .icon-button svg { height: 14px; width: 14px; } + #downloadReceipt svg { + height: 13px; + width: 13px; + } + .receipt-shell { - grid-template-columns: minmax(420px, 1.05fr) minmax(360px, 0.9fr) minmax(420px, 1fr); + grid-template-columns: minmax(360px, 520px) minmax(320px, 458px) minmax(360px, 1fr); } .receipt-section { - padding: 12px 14px; + padding: 10px 14px; } .json pre { background: #fff; - border: 1px solid var(--line); + border: 0; color: #102033; font-size: 12px; - line-height: 1.5; - max-height: 210px; - padding: 10px 12px 10px 36px; + line-height: 1.38; + max-height: 188px; + overflow: auto; + padding: 6px 10px; position: relative; } + .json-line { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + } + + .json-line-number { + color: #94a3b8; + padding-right: 10px; + text-align: right; + user-select: none; + } + + .json-line-code { + min-width: 0; + white-space: pre; + } + + .json-token.key { + color: #c33a65; + font-weight: 650; + } + + .json-token.string { + color: #284f93; + } + + .json-token.number { + color: #1d7665; + } + + .json-token.literal { + color: #8b4c9b; + } + + .json-token.punctuation { + color: #6b7280; + } + .receipt-tabs { border-bottom: 1px solid var(--line); display: flex; gap: 34px; - margin: -12px -14px 10px; + margin: -10px -14px 8px; padding: 0 26px; } @@ -2249,7 +3149,7 @@ export function renderApp(): string { cursor: pointer; font-size: 12px; font-weight: 700; - min-height: 34px; + min-height: 30px; padding: 0; position: relative; } @@ -2282,14 +3182,19 @@ export function renderApp(): string { .receipt-summary-grid, .verification-list { display: grid; - gap: 8px; + gap: 6px; + } + + .verify-list { + gap: 5px; } .summary-row { display: grid; font-size: 12px; - gap: 8px; + gap: 6px; grid-template-columns: 130px minmax(0, 1fr) auto; + line-height: 1.18; } .summary-row .hash, @@ -2300,16 +3205,32 @@ export function renderApp(): string { } .verify-row { - border-radius: 7px; + background: transparent; + border: 0; + border-radius: 0; grid-template-columns: 34px minmax(0, 1fr) minmax(72px, auto); - min-height: 44px; - padding: 7px 9px; + min-height: 42px; + padding: 6px 0; } .verify-row > div { min-width: 0; } + .verify-row strong { + display: block; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + } + + .verify-row .empty { + color: var(--muted); + font-size: 11px; + line-height: 1.25; + margin-top: 2px; + } + .verify-icon { align-items: center; border: 1px solid; @@ -2326,21 +3247,21 @@ export function renderApp(): string { } .verify-icon.log { - background: #f3f8ff; - border-color: #d4e5fb; - color: #0969da; + background: #fff; + border-color: var(--line); + color: #4b5563; } .verify-icon.sig { - background: #fff7ed; - border-color: #fed7aa; - color: #b35a00; + background: #fff; + border-color: var(--line); + color: #4b5563; } .verify-icon.get { - background: #eefbf4; - border-color: #c7efd6; - color: #078861; + background: #fff; + border-color: var(--line); + color: #4b5563; } .verify-row .event-action { @@ -2378,39 +3299,52 @@ export function renderApp(): string { } .verification-result { - background: #fbfdff; - border: 1px solid var(--line); - border-radius: 8px; + background: transparent; + border: 0; + border-top: 1px solid var(--line); + border-radius: 0; display: grid; - gap: 7px; - margin-top: 10px; - padding: 9px 10px; + gap: 8px; + margin-top: 8px; + padding: 8px 0 0; } .verification-result.checking { - background: #f7fbff; + background: transparent; border-color: #cfe0f8; } .verification-result.failed { - background: #fff7f7; + background: transparent; border-color: #ffc9c9; } .verification-step { - align-items: center; + align-items: start; display: grid; font-size: 12px; - gap: 7px; + gap: 8px; grid-template-columns: 16px minmax(0, 1fr); + min-height: 31px; + } + + .verification-step > div { + display: grid; + gap: 3px; + min-width: 0; } .verification-step strong { + display: block; font-size: 12px; + font-weight: 750; + line-height: 1.25; } .verification-step span:last-child { color: var(--muted); + display: block; + line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -2423,6 +3357,7 @@ export function renderApp(): string { display: inline-flex; height: 14px; justify-content: center; + margin-top: 1px; width: 14px; } @@ -2469,7 +3404,7 @@ export function renderApp(): string { font-size: 12px; font-weight: 750; justify-content: space-between; - margin: 2px 0 -4px; + margin: 0 0 -4px; } .risk-details-toggle svg { @@ -2490,23 +3425,58 @@ export function renderApp(): string { } @media (max-width: 1450px) and (min-width: 1101px) { + .rail-stepper { + grid-template-columns: minmax(126px, 0.9fr) minmax(190px, 1.05fr) minmax(252px, 1.2fr) minmax(222px, 1.2fr) minmax(136px, 0.8fr); + } + .grid { grid-template-columns: minmax(318px, 0.82fr) minmax(560px, 1.2fr) minmax(340px, 0.94fr); } .actions { gap: 10px; - grid-template-columns: minmax(170px, 1fr) minmax(138px, 0.88fr) minmax(150px, 0.94fr); + grid-template-columns: minmax(0, 1.05fr) repeat(2, minmax(0, 0.95fr)); + } + + .actions .primary, + .actions .danger, + .actions .secondary { + grid-column: auto; + } + + .action-copy small, + .primary .action-copy small { + font-size: 9px; + white-space: nowrap; } .event, .event-future { - gap: 6px; - grid-template-columns: 48px 18px minmax(0, 1fr) minmax(66px, 92px); + gap: 10px; + grid-template-columns: 18px 82px minmax(0, 1fr) minmax(66px, 92px); + } + + .event { + grid-template-columns: 18px 82px minmax(0, 1fr) minmax(58px, 82px) 12px; } .record-timeline::before { - left: 62px; + left: 16px; + } + + .integrity-row { + gap: 7px; + grid-template-columns: 88px minmax(0, 1fr) 16px; + } + + .integrity-row.proof-row { + gap: 2px 7px; + grid-template-columns: 88px minmax(0, 1fr); + } + + .integrity-row.proof-row .event-action { + grid-column: 2; + justify-self: start; } .event-hash { @@ -2514,19 +3484,22 @@ export function renderApp(): string { } .signer-row { - gap: 5px; - grid-template-columns: 22px 62px minmax(0, 1fr) 50px minmax(42px, 58px) 12px; - padding: 4px 6px; + gap: 2px 7px; + grid-template-columns: 22px minmax(54px, 1fr) minmax(48px, auto) minmax(108px, 116px) 14px; + min-height: 42px; + padding: 5px 7px; } .signer-icon { - height: 20px; - width: 20px; + grid-column: 1; + grid-row: 1 / 3; + height: 22px; + width: 22px; } .signer-icon svg { - height: 13px; - width: 13px; + height: 20px; + width: 20px; } .signer-row strong, @@ -2534,8 +3507,48 @@ export function renderApp(): string { font-size: 11px; } + .signer-row strong { + grid-column: 2; + grid-row: 1; + } + + .signer-row .empty { + grid-column: 2 / 6; + grid-row: 2; + max-width: 100%; + } + + .signer-status { + grid-column: 3; + grid-row: 1; + } + .signature-slot { font-size: 10px; + grid-column: 4; + grid-row: 1; + justify-self: end; + max-width: 100%; + } + + .signature-slot .signature-label, + .signature-slot .signature-separator { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + + .signature-slot .hash { + max-width: 100%; + } + + .signer-row .copy-icon { + grid-column: 5; + grid-row: 1; } } @@ -2554,6 +3567,12 @@ export function renderApp(): string { .rail-stepper { gap: 8px; } + + .actions .primary, + .actions .danger, + .actions .secondary { + grid-column: auto; + } } @media (max-width: 720px) { @@ -2573,13 +3592,27 @@ export function renderApp(): string { } .event { - grid-template-columns: 48px 20px minmax(0, 1fr); + grid-template-columns: 20px 48px minmax(0, 1fr); + } + + .actions { + grid-template-columns: 1fr; + } + + .actions .primary, + .actions .danger, + .actions .secondary { + grid-column: auto; } .event-hash { grid-column: 3; justify-self: start; } + + .event-cue { + display: none; + } } @@ -2587,8 +3620,9 @@ export function renderApp(): string {
-

Cloudflare Agent Trace

@@ -2596,10 +3630,22 @@ export function renderApp(): string {
- Live run - Run ID pending - Region IAD + + + + + Run ID pending + Colo ${colo} Started waiting + +
@@ -2612,11 +3658,11 @@ export function renderApp(): string {
- 1TriggerPending - 2Autonomous triagePending - 3Human review halted Awaiting reviewPending - 4MCP execution resumedPending - 5Audit readyPending + 11. TriggerPending + 22. Autonomous triagePending + 33. Human review haltedPendingAwaiting review + 44. MCP execution resumedPending + 55. Audit readyPending
@@ -2626,7 +3672,7 @@ export function renderApp(): string {
- GitHub issue webhook @@ -2682,7 +3728,10 @@ export function renderApp(): string {

Receipt inspector

Format - JSON (pretty) + @@ -2700,6 +3749,7 @@ export function renderApp(): string {

Summary appears after a signed record is selected.

+
Verify in Cloudflare Integrity LogCheck inclusion and consistency proof
Verify receipt signatureValidate signer and record hashes
@@ -2719,6 +3769,8 @@ export function renderApp(): string { let autoFollow = true; let stageDisplayTimes = {}; let selectedReceiptRecord = null; + let selectedReceiptView = 'record'; + let selectedReceiptFormat = 'pretty'; const statusDot = document.querySelector('#statusDot'); const statusTitle = document.querySelector('#statusTitle'); @@ -2741,6 +3793,11 @@ export function renderApp(): string { const traceIdLabel = document.querySelector('#traceIdLabel'); const copyReceiptButton = document.querySelector('#copyReceipt'); const downloadReceiptButton = document.querySelector('#downloadReceipt'); + const receiptFormatSelect = document.querySelector('#receiptFormat'); + const headerMenuButton = document.querySelector('#headerMenu'); + const headerActionsMenu = document.querySelector('#headerActions'); + const runModeButton = document.querySelector('#runModeMenu'); + const runModeActionsMenu = document.querySelector('#runModeActions'); const bootStages = [ { @@ -2757,7 +3814,7 @@ export function renderApp(): string { }, { key: 'policy', - title: 'Policy and intent analysis', + title: 'Policy & intent analysis', detail: 'The agent classified the request as a repository write that must stop for review.', step: 'autonomous', }, @@ -2787,14 +3844,27 @@ export function renderApp(): string { trigger: 0, triage: 1200, proposal: 4800, - approval: 6200, - rejection: 6200, + approval: 7400, + rejection: 7400, + change_request: 6200, + revision: 7400, preview: 7400, execution: 8600, outcome: 9800, handoff: 11200, }; + const progressDisplayOffsets = { + trigger: 0, + context: 1200, + policy: 2600, + proposal: 4800, + halt: 6200, + revision: 7400, + resume: 8600, + audit: 11200, + }; + function renderSteps(step, kind = 'pending') { currentStep = step; const order = ['trigger', 'autonomous', 'halt', 'resume', 'audit']; @@ -2802,34 +3872,57 @@ export function renderApp(): string { workflowSteps.querySelectorAll('.step').forEach((item) => { const itemIndex = order.indexOf(item.dataset.step); item.className = 'step'; - if (itemIndex < activeIndex) item.classList.add('done'); - if (item.dataset.step === step) { + const activeDone = kind === 'ok' && itemIndex === activeIndex; + if (itemIndex < activeIndex || activeDone) item.classList.add('done'); + if (item.dataset.step === step && !activeDone) { item.classList.add(step === 'halt' ? 'halted' : kind === 'error' ? 'error' : 'active'); } }); updateHaltStepState(); updateStepTimes(); + syncRailConnectors(); + } + + function syncRailConnectors() { + const halted = workflowSteps.querySelector('[data-step="halt"]'); + const resumeMarker = workflowSteps.querySelector('[data-step="resume"] .step-index'); + if (!halted || !resumeMarker) return; + requestAnimationFrame(() => { + const haltedRect = halted.getBoundingClientRect(); + const resumeRect = resumeMarker.getBoundingClientRect(); + const width = Math.max(18, Math.round(resumeRect.left - haltedRect.right)); + halted.style.setProperty('--halt-connector-width', width + 'px'); + }); } function updateStepTimes(run = currentRun) { const trigger = run?.records.find((record) => record.label === 'trigger'); const triage = run?.records.find((record) => record.label === 'triage'); - const proposal = run?.records.find((record) => record.label === 'proposal'); - const decision = run?.records.find((record) => record.label === 'approval' || record.label === 'rejection'); + const proposal = latestProposalRecord(run); + const decision = latestHumanDecisionRecord(run); const triggerTime = stageDisplayTimes.trigger ?? (trigger ? displayRecordTime(trigger, 'trigger') + ' UTC' : 'Pending'); const triageTime = stageDisplayTimes.context ?? (triage ? displayRecordTime(triage, 'triage') + ' UTC' : 'Pending'); - const proposalTime = stageDisplayTimes.proposal ?? (proposal ? displayRecordTime(proposal, 'proposal') + ' UTC' : 'Pending'); - const haltRecord = decision ?? proposal; - const haltLabel = decision?.label ?? 'approval'; + const proposalTime = stageDisplayTimes.proposal ?? (proposal ? displayRecordTime(proposal, proposal.label) + ' UTC' : 'Pending'); + const haltRecord = run?.status === 'pending_approval' ? proposal : decision ?? proposal; + const haltLabel = run?.status === 'pending_approval' ? proposal?.label ?? 'approval' : decision?.label ?? 'approval'; const haltTime = stageDisplayTimes.halt ?? (haltRecord ? displayRecordTime(haltRecord, haltLabel) + ' UTC' : proposalTime); const execution = run?.records.find((record) => record.label === 'execution'); const handoff = run?.records.find((record) => record.label === 'handoff'); + const terminalDecisionTime = decision ? displayRecordTime(decision, decision.label) + ' UTC' : 'Pending'; const stepTimes = { trigger: triggerTime, autonomous: triageTime, halt: haltTime, - resume: execution ? displayRecordTime(execution, 'execution') + ' UTC' : 'Pending', - audit: handoff ? displayRecordTime(handoff, 'handoff') + ' UTC' : 'Pending', + resume: run?.status === 'rejected' + ? 'Skipped' + : run?.status === 'changes_requested' + ? terminalDecisionTime + : execution ? displayRecordTime(execution, 'execution') + ' UTC' : 'Pending', + audit: run?.status === 'rejected' + ? terminalDecisionTime + : run?.status === 'changes_requested' + ? 'Awaiting revision' + : handoff ? displayRecordTime(handoff, 'handoff') + ' UTC' : 'Pending', }; Object.entries(stepTimes).forEach(([key, value]) => { const target = workflowSteps.querySelector('[data-step-time="' + key + '"]'); @@ -2839,18 +3932,53 @@ export function renderApp(): string { function updateHaltStepState(run = currentRun) { const title = workflowSteps.querySelector('[data-step-title="halt"]'); + const resumeTitle = workflowSteps.querySelector('[data-step-title="resume"]'); + const auditTitle = workflowSteps.querySelector('[data-step-title="audit"]'); const badge = workflowSteps.querySelector('[data-step-badge="halt"]'); + const haltStep = workflowSteps.querySelector('[data-step="halt"]'); + const resumeStep = workflowSteps.querySelector('[data-step="resume"]'); + const auditStep = workflowSteps.querySelector('[data-step="audit"]'); if (!title || !badge) return; - badge.classList.remove('approved', 'rejected'); - if (!run || run.status === 'pending_approval') { + badge.classList.remove('approved', 'rejected', 'requested'); + haltStep?.classList.remove('review-rejected', 'review-requested'); + resumeStep?.classList.remove('branch-skipped', 'branch-requested', 'branch-ready'); + auditStep?.classList.remove('branch-skipped', 'branch-requested', 'branch-ready'); + if (resumeTitle) resumeTitle.textContent = 'MCP execution resumed'; + if (auditTitle) auditTitle.textContent = 'Audit ready'; + badge.hidden = false; + if (!run) { title.textContent = 'Human review halted'; badge.textContent = 'Awaiting review'; + badge.classList.add('requested'); + badge.hidden = !haltStep?.classList.contains('halted'); + return; + } + if (run.status === 'pending_approval') { + title.textContent = hasRevisedProposal(run) ? 'Revised proposal halted' : 'Human review halted'; + badge.textContent = 'Awaiting review'; + badge.classList.add('requested'); return; } if (run.status === 'rejected') { title.textContent = 'Human review rejected'; badge.textContent = 'Rejected'; badge.classList.add('rejected'); + haltStep?.classList.add('review-rejected'); + resumeStep?.classList.add('branch-skipped'); + auditStep?.classList.add('branch-ready'); + if (resumeTitle) resumeTitle.textContent = 'MCP execution skipped'; + if (auditTitle) auditTitle.textContent = 'Decision audit ready'; + return; + } + if (run.status === 'changes_requested') { + title.textContent = 'Changes requested'; + badge.textContent = 'Needs revision'; + badge.classList.add('requested'); + haltStep?.classList.add('review-requested'); + resumeStep?.classList.add('branch-ready'); + auditStep?.classList.add('branch-requested'); + if (resumeTitle) resumeTitle.textContent = 'Feedback returned to agent'; + if (auditTitle) auditTitle.textContent = 'Revision pending'; return; } if (run.status === 'approved' || run.status === 'executing') { @@ -2870,12 +3998,24 @@ export function renderApp(): string { statusDetail.textContent = detail || 'The workflow is waiting for the next action.'; renderSteps(step, kind); if (reviewStatePill) { - reviewStatePill.textContent = step === 'halt' ? 'Paused' : step === 'resume' ? 'Running' : step === 'audit' && kind === 'error' ? 'Needs review' : step === 'audit' ? 'Ready' : 'Waiting'; - reviewStatePill.textContent = reviewStatePill.textContent.toUpperCase(); - reviewStatePill.classList.toggle('green', step === 'audit' && kind === 'ok'); + const state = reviewPillState(currentRun, step, kind); + reviewStatePill.textContent = state.text.toUpperCase(); + reviewStatePill.classList.remove('green', 'approved', 'rejected', 'requested', 'running', 'waiting'); + reviewStatePill.classList.add(state.className); + if (state.className === 'approved') reviewStatePill.classList.add('green'); } } + function reviewPillState(run, step, kind) { + if (run?.status === 'rejected') return { text: 'Rejected', className: 'rejected' }; + if (run?.status === 'changes_requested') return { text: 'Needs revision', className: 'requested' }; + if (step === 'halt') return { text: 'Paused', className: 'requested' }; + if (step === 'resume') return { text: 'Running', className: 'running' }; + if (step === 'audit' && kind === 'error') return { text: 'Needs review', className: 'rejected' }; + if (step === 'audit') return { text: 'Ready', className: 'approved' }; + return { text: 'Waiting', className: 'waiting' }; + } + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -2892,6 +4032,65 @@ export function renderApp(): string { }); } + function resetPanelScroll(target) { + const panel = target?.closest?.('.panel'); + if (panel) panel.scrollTop = 0; + } + + function followPanelElement(target) { + if (!target || !autoFollow) return; + const panel = target.closest?.('.panel'); + if (!panel) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const alignTarget = (force = false) => { + const targetRect = target.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const stickyHeader = panel.querySelector('h2')?.getBoundingClientRect(); + const topGuard = stickyHeader ? stickyHeader.bottom + 8 : panelRect.top + 8; + const bottomGuard = panelRect.bottom - 10; + const bottomDelta = targetRect.bottom - bottomGuard; + const topDelta = targetRect.top - topGuard; + if (bottomDelta > 0) { + const top = panel.scrollTop + bottomDelta; + if (force) { + panel.scrollTop = top; + } else { + panel.scrollTo({ + top, + behavior: reduceMotion ? 'auto' : 'smooth', + }); + } + return; + } + if (topDelta < 0) { + const top = Math.max(0, panel.scrollTop + topDelta); + if (force) { + panel.scrollTop = top; + } else { + panel.scrollTo({ + top, + behavior: reduceMotion ? 'auto' : 'smooth', + }); + } + } + }; + requestAnimationFrame(() => { + alignTarget(); + window.setTimeout(() => alignTarget(true), reduceMotion ? 0 : 220); + }); + } + + function followWorkflowOverview() { + if (!autoFollow) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + requestAnimationFrame(() => { + window.scrollTo({ + top: 0, + behavior: reduceMotion ? 'auto' : 'smooth', + }); + }); + } + function escapeHtml(value) { return String(value) .replaceAll('&', '&') @@ -2905,28 +4104,48 @@ export function renderApp(): string { return new Date(Date.now() + offsetMs).toISOString().slice(11, 19) + ' UTC'; } + function formatHeaderDate(value) { + const date = value ? new Date(value) : new Date(); + const month = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); + const day = date.getUTCDate(); + const year = date.getUTCFullYear(); + const time = date.toISOString().slice(11, 19); + return month + ' ' + day + ', ' + year + ' ' + time + ' UTC'; + } + function displayRecordTime(record, label = record?.label, fallbackIndex = 0) { const offset = recordDisplayOffsets[label] ?? fallbackIndex * 1000; return formatRecordTime(record, offset); } + function progressTimeLabel(value) { + return String(value ?? '-').replace(/ UTC$/, ''); + } + function progressKeyForTitle(title) { if (title === 'Trigger received') return 'trigger'; if (title === 'Context gathered') return 'context'; - if (title === 'Policy and intent analysis') return 'policy'; - if (title === 'Proposed action generated') return 'proposal'; - if (title === 'Human review halted' || title === 'Human review recorded') return 'halt'; - if (title === 'Agent resumed through MCP') return 'resume'; - if (title === 'Audit ready') return 'audit'; + if (title === 'Policy & intent analysis') return 'policy'; + if (title === 'Proposed action generated' || title === 'Initial proposal generated') return 'proposal'; + if (title === 'Revised proposal generated' || title === 'Revised proposal halted') return 'revision'; + if (title === 'Human review halted' || title === 'Human review recorded' || title === 'Human review feedback sent' || title === 'Human review rejected') return 'halt'; + if (title === 'Agent resumed through MCP' || title === 'MCP execution skipped' || title === 'Feedback returned to agent') return 'resume'; + if (title === 'Audit ready' || title === 'Decision audit ready' || title === 'Revised proposal pending') return 'audit'; return ''; } function progressDisplayTime(run, title, active) { const key = progressKeyForTitle(title); - if (key && stageDisplayTimes[key]) return stageDisplayTimes[key]; + if (key && stageDisplayTimes[key]) return progressTimeLabel(stageDisplayTimes[key]); if (!active) return '-'; const record = progressRecordFor(run, title); - return displayRecordTime(record, record?.label ?? key) + ' UTC'; + const alignToRecord = ['Human review recorded', 'Human review rejected'].includes(title); + return formatRecordTime( + record, + alignToRecord + ? recordDisplayOffsets[record?.label ?? key] ?? 0 + : progressDisplayOffsets[key] ?? recordDisplayOffsets[record?.label ?? key] ?? 0, + ); } function formatRecordTime(record, offsetMs = 0) { @@ -2935,7 +4154,15 @@ export function renderApp(): string { return new Date(Date.now() + offsetMs).toISOString().slice(11, 19); } - function renderBootTimeline(activeIndex) { + function bootRecordFor(key, run = currentRun) { + if (!run) return null; + if (key === 'trigger') return run.records.find((record) => record.label === 'trigger'); + if (key === 'context') return run.records.find((record) => record.label === 'triage'); + if (key === 'proposal' || key === 'halt') return run.records.find((record) => record.label === 'proposal'); + return null; + } + + function renderBootTimeline(activeIndex, run = currentRun) { const activeStage = bootStages[activeIndex] ?? bootStages[bootStages.length - 1]; const reached = (key) => bootStages.findIndex((stage) => stage.key === key) <= activeIndex; const bootRows = [ @@ -2989,24 +4216,31 @@ export function renderApp(): string { }, ]; const bootSigners = [ - { kind: 'agent', name: 'Agent', detail: 'agents/triage@1.4.2', status: reached('proposal') ? 'Signed' : 'Pending', className: reached('proposal') ? 'signed' : 'pending mcp', sig: reached('proposal') ? '2fb06b13...' : '-' }, + { kind: 'agent', name: 'Agent', detail: 'agents/triage@1.4.2', signer: 'agent', status: reached('proposal') ? 'Signed' : 'Pending', className: reached('proposal') ? 'signed' : 'pending mcp', sig: reached('proposal') && run ? signerLatestRecordDigest(run, 'agent') : '-' }, { kind: 'human', name: 'Human', detail: 'alice@example.com', status: 'Pending', className: 'pending', sig: '-' }, { kind: 'mcp', name: 'Action MCP', detail: 'github.write@2.3.1', status: 'Pending', className: 'pending mcp', sig: '-' }, ]; + const merkleRoot = reached('trigger') ? run?.records[0]?.record_hash ?? '' : ''; + const logHash = reached('context') ? run?.records[1]?.record_hash ?? '' : ''; timelineEl.innerHTML = \`
- \${bootRows.map((row) => \` -
- \${row.time ? row.time.slice(0, 8) : '-'} + \${bootRows.map((row) => { + const record = bootRecordFor(row.key, run); + const time = row.time ?? (record ? displayRecordTime(record, record.label) + ' UTC' : ''); + const hash = row.marker === 'future' ? '-' : record ? recordDisplayId(record.record_hash) : 'pending'; + return \` +
+ \${time ? time.slice(0, 8) : '-'} - \${row.name} + \${row.marker === 'future' || row.key === 'halt' ? '' : timelineSignerIcon(record)}\${row.name} \${row.detail} - \${row.marker === 'future' ? '-' : 'pending'} + \${hash}
- \`).join('')} + \`; + }).join('')}
@@ -3016,22 +4250,22 @@ export function renderApp(): string { \${signer.name} \${signer.detail} \${signer.status} - Sig: \${signer.sig}\${copyIcon('', signer.name + ' signature')} + Latest: \${signer.sig}\${copyIcon(run && signer.signer ? signerRecordHash(run, signer.signer) : '', signer.name + ' latest record')}
\`).join('')}
-
Merkle rootpending\${copyIcon('', 'Merkle root')}
-
Log hashpending\${copyIcon('', 'log hash')}
-
Proof statusWaiting for first signed record
+
Merkle root\${merkleRoot || 'pending'}\${copyIcon(merkleRoot, 'Merkle root')}
+
Log hash\${logHash || 'pending'}\${copyIcon(logHash, 'log hash')}
+
Proof status\${merkleRoot ? 'Signed records available' : 'Waiting for first signed record'}
\`; } - function renderBootProgress(activeIndex) { + function renderBootProgress(activeIndex, run = currentRun) { const rows = bootStages.map((stage, index) => { const done = index < activeIndex; const active = index === activeIndex; @@ -3044,7 +4278,7 @@ export function renderApp(): string { \${stage.title} \${stage.detail}
- \${done || active ? stageDisplayTimes[stage.key] : '-'} + \${done || active ? progressTimeLabel(stageDisplayTimes[stage.key]) : '-'}
\`; }).join(''); @@ -3059,9 +4293,12 @@ export function renderApp(): string {
\`; } - renderBootTimeline(activeIndex); - followElement(answerEl.querySelectorAll('.progress-item')[activeIndex], 'nearest'); - if (activeStage.key === 'halt') followElement(proposalEl, 'nearest'); + renderBootTimeline(activeIndex, run); + if (activeStage.key === 'halt') { + followWorkflowOverview(); + } else { + followElement(answerEl.querySelectorAll('.progress-item')[activeIndex], 'nearest'); + } } function updateControls(activeLabel = '') { @@ -3075,16 +4312,29 @@ export function renderApp(): string { simulateErrorInput.disabled = busy || !canSetFailureMode; const approve = document.querySelector('#approve'); const reject = document.querySelector('#reject'); + const requestChanges = document.querySelector('#requestChanges'); + const reviewFeedback = document.querySelector('#reviewFeedback'); + const reviewFeedbackDrawer = document.querySelector('#reviewFeedbackDrawer'); if (approve) { approve.disabled = busy || !hasPendingApproval; const label = approve.querySelector('.button-label'); - if (label) label.textContent = busy && activeLabel === 'approve' ? 'Resuming agent...' : 'Approve and resume'; + if (label) label.textContent = busy && activeLabel === 'approve' ? 'Resuming agent...' : 'Approve & resume'; } if (reject) { reject.disabled = busy || !hasPendingApproval; const label = reject.querySelector('.button-label'); if (label) label.textContent = busy && activeLabel === 'reject' ? 'Rejecting...' : 'Reject'; } + if (requestChanges) { + requestChanges.disabled = busy || !hasPendingApproval; + const drawerOpen = Boolean(reviewFeedbackDrawer && !reviewFeedbackDrawer.hidden); + requestChanges.setAttribute('aria-expanded', String(drawerOpen)); + const label = requestChanges.querySelector('.button-label'); + if (label) label.textContent = busy && activeLabel === 'request' ? 'Requesting...' : drawerOpen ? 'Sign feedback' : 'Request changes'; + } + if (reviewFeedback) reviewFeedback.disabled = busy || !hasPendingApproval; + updateHeaderMenuControls(); + updateRunModeControls(); } function setBusy(next, activeLabel = '') { @@ -3106,7 +4356,8 @@ export function renderApp(): string { function shortHash(hash) { if (!hash) return 'missing'; - return hash.slice(0, 18) + '...' + hash.slice(-8); + const normalized = String(hash).replace(/^sha256:/, ''); + return normalized.slice(0, 18) + '...' + normalized.slice(-8); } function recordDisplayId(hash) { @@ -3118,8 +4369,10 @@ export function renderApp(): string { if (entry.label === 'trigger') return 'GitHub issue webhook'; if (entry.label === 'triage') return 'Intent: add rate limiting'; if (entry.label === 'proposal') return 'write_file proposal'; + if (entry.label === 'revision') return 'Revised write_file proposal'; if (entry.label === 'approval') return 'Human approved payload'; if (entry.label === 'rejection') return 'Human rejected payload'; + if (entry.label === 'change_request') return 'Human requested revision'; if (entry.label === 'preview') return 'MCP preview completed'; if (entry.label === 'execution') return run.status === 'failed' ? 'MCP execution attempted' : 'MCP execution resumed'; if (entry.label === 'outcome') return run.status === 'failed' ? 'Diagnostic outcome signed' : 'Repository update signed'; @@ -3131,8 +4384,10 @@ export function renderApp(): string { if (entry.label === 'trigger') return 'trigger.received'; if (entry.label === 'triage') return 'triage.completed'; if (entry.label === 'proposal') return 'proposal.generated'; + if (entry.label === 'revision') return 'proposal.revised'; if (entry.label === 'approval') return 'human.approval.signed'; if (entry.label === 'rejection') return 'human.rejection.signed'; + if (entry.label === 'change_request') return 'human.change_request.signed'; if (entry.label === 'preview') return 'mcp.preview.completed'; if (entry.label === 'execution') return 'mcp.execution.resumed'; if (entry.label === 'outcome') return run.status === 'failed' ? 'diagnostic.signed' : 'repository.update.signed'; @@ -3143,22 +4398,33 @@ export function renderApp(): string { function futureTraceRows(run) { const labels = new Set(run.trace_packet.timeline.map((entry) => entry.label)); const rows = []; - if (!labels.has('approval') && !labels.has('rejection')) { - const proposal = run.records.find((record) => record.label === 'proposal'); + const rejected = run.status === 'rejected'; + const changesRequested = run.status === 'changes_requested'; + if (run.status === 'pending_approval' || (!labels.has('approval') && !labels.has('rejection') && !labels.has('change_request'))) { + const proposal = latestProposalRecord(run); rows.push({ name: 'human.review.halted', - detail: 'Awaiting human decision', + detail: hasRevisedProposal(run) ? 'Awaiting decision on revised proposal' : 'Awaiting human decision', marker: 'pending', record: proposal, displayLabel: 'approval', + timeLabel: proposal?.label ?? 'proposal', hash: proposal?.record_hash, }); } if (!labels.has('execution')) { - rows.push({ name: 'mcp.execution.resumed', detail: 'Pending approval', marker: 'future' }); + rows.push(rejected + ? { name: 'mcp.execution.skipped', detail: 'Blocked by signed rejection', marker: 'skipped', markerLabel: '4' } + : changesRequested + ? { name: 'agent.feedback.returned', detail: 'Feedback queued revision', marker: 'done' } + : { name: 'mcp.execution.resumed', detail: 'Pending approval', marker: 'future', markerLabel: '4' }); } if (!labels.has('handoff')) { - rows.push({ name: 'audit.ready', detail: 'Pending', marker: 'future' }); + rows.push(rejected + ? { name: 'decision.audit.ready', detail: 'Rejection receipt ready', marker: 'done' } + : changesRequested + ? { name: 'revised.proposal.pending', detail: 'Awaiting revised proposal', marker: 'future', markerLabel: '5' } + : { name: 'audit.ready', detail: 'Pending', marker: 'future', markerLabel: '5' }); } return rows; } @@ -3168,20 +4434,92 @@ export function renderApp(): string { return ''; } if (kind === 'mcp') { - return ''; + return ''; + } + return ''; + } + + function signerKindForRecord(record) { + if (!record) return ''; + if (record.signer === 'human') return 'human'; + if (record.signer === 'action_mcp') return 'mcp'; + return 'agent'; + } + + function signerLabelForKind(kind) { + if (kind === 'human') return 'Human'; + if (kind === 'mcp') return 'Action MCP'; + return 'Agent'; + } + + function timelineSignerIcon(record) { + const kind = signerKindForRecord(record); + if (!kind) return ''; + const label = signerLabelForKind(kind); + if (kind === 'human') { + return ''; + } + if (kind === 'mcp') { + return ''; } - return ''; + return ''; + } + + function recordDetailsIcon() { + return ''; + } + + function recordsForSigner(run, signer) { + return run.records.filter((item) => item.signer === signer); + } + + function latestRecordByLabel(run, labels) { + if (!run) return null; + const wanted = new Set(labels); + return [...run.records].reverse().find((record) => wanted.has(record.label)) ?? null; + } + + function latestProposalRecord(run = currentRun) { + return latestRecordByLabel(run, ['revision', 'proposal']); + } + + function latestChangeRequestRecord(run = currentRun) { + return latestRecordByLabel(run, ['change_request']); + } + + function latestHumanDecisionRecord(run = currentRun) { + return latestRecordByLabel(run, ['approval', 'rejection', 'change_request']); + } + + function hasRevisedProposal(run = currentRun) { + return Boolean(latestRecordByLabel(run, ['revision'])); + } + + function signerLatestRecord(run, signer) { + const records = recordsForSigner(run, signer); + return records[records.length - 1]; + } + + function recordCountLabel(count) { + return count === 1 ? '1 record' : count + ' records'; } - function signerSignature(run, signer) { - const record = run.records.find((item) => item.signer === signer); + function signerLatestRecordDigest(run, signer) { + const record = signerLatestRecord(run, signer); if (!record) return '-'; const hash = record.record_hash.replace('sha256:', ''); - return hash.slice(0, 10) + '...' + hash.slice(-4); + return hash.slice(0, 6) + '...' + hash.slice(-4); } function signerRecordHash(run, signer) { - return run.records.find((item) => item.signer === signer)?.record_hash ?? ''; + return signerLatestRecord(run, signer)?.record_hash ?? ''; + } + + function signerStatusForRun(run, signer, recordCount) { + if (recordCount > 0) return 'Signed'; + if (signer.signer === 'action_mcp' && run.status === 'rejected') return 'Skipped'; + if (signer.signer === 'action_mcp' && (run.status === 'changes_requested' || hasRevisedProposal(run))) return 'Blocked'; + return 'Pending'; } function signerStatusClass(signer) { @@ -3189,8 +4527,26 @@ export function renderApp(): string { return status + (signer.kind === 'mcp' ? ' mcp' : ''); } + function traceIdFromRunId(runId) { + return 'trc_' + String(runId).replaceAll('-', '').toUpperCase().slice(0, 18); + } + function traceIdForRun(run) { - return run.trace_packet.trace_id ?? 'trc_' + run.run_id.replaceAll('-', '').toUpperCase().slice(0, 18); + return run.trace_packet?.trace_id ?? traceIdFromRunId(run.run_id); + } + + function runDisplayId(runId) { + const normalized = String(runId ?? '').trim(); + if (!normalized) return 'pending'; + if (normalized.startsWith('run_')) return normalized; + return 'run_' + normalized.replaceAll('-', '').toUpperCase().slice(0, 22); + } + + function setRunId(rawRunId) { + const normalized = String(rawRunId ?? '').trim(); + runIdLabel.textContent = runDisplayId(normalized); + runIdLabel.dataset.runId = normalized; + runIdLabel.title = normalized; } function copyIcon(value = '', label = 'value') { @@ -3199,8 +4555,33 @@ export function renderApp(): string { return ''; } - function renderDiff(diff) { - return String(diff).split('\\n').map((line) => { + function visibleDiffLines(diff, context = '3') { + const lines = String(diff).split('\\n'); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + if (context === 'all') return lines; + const contextLines = Number.parseInt(context, 10); + if (!Number.isFinite(contextLines)) return lines; + const isChangedLine = (line) => (line.startsWith('+') && !line.startsWith('+++')) + || (line.startsWith('-') && !line.startsWith('---')) + || line.startsWith('@@'); + const lastChangedIndex = lines.reduce((lastIndex, line, index) => ( + isChangedLine(line) ? index : lastIndex + ), -1); + let shownContextAfterChange = 0; + return lines.filter((line, index) => { + const changed = isChangedLine(line); + if (changed) { + shownContextAfterChange = 0; + return true; + } + if (contextLines <= 3 && index > lastChangedIndex) return false; + shownContextAfterChange += 1; + return shownContextAfterChange <= contextLines; + }); + } + + function renderDiff(diff, context = '3') { + return visibleDiffLines(diff, context).map((line, index) => { const kind = line.startsWith('+') && !line.startsWith('+++') ? 'add' : line.startsWith('-') && !line.startsWith('---') @@ -3208,21 +4589,39 @@ export function renderApp(): string { : line.startsWith('@@') ? 'meta' : ''; - return '' + escapeHtml(line) + ''; + return '' + String(index + 1) + '' + escapeHtml(line) + ''; }).join(''); } + function currentVisibleDiffText() { + const lines = Array.from(document.querySelectorAll('.diff-code .diff-line-text')) + .map((line) => line.textContent ?? ''); + return lines.join('\\n'); + } + function progressRecordFor(run, rowTitle) { if (rowTitle === 'Trigger received') return run.records.find((record) => record.label === 'trigger'); - if (rowTitle === 'Context gathered' || rowTitle === 'Policy and intent analysis') { + if (rowTitle === 'Context gathered' || rowTitle === 'Policy & intent analysis') { return run.records.find((record) => record.label === 'triage'); } - if (rowTitle === 'Proposed action generated') return run.records.find((record) => record.label === 'proposal'); - if (rowTitle === 'Human review halted' || rowTitle === 'Human review recorded') { - return run.records.find((record) => record.label === 'approval' || record.label === 'rejection') - ?? run.records.find((record) => record.label === 'proposal'); + if (rowTitle === 'Proposed action generated' || rowTitle === 'Initial proposal generated') return run.records.find((record) => record.label === 'proposal'); + if (rowTitle === 'Revised proposal generated') return latestProposalRecord(run); + if (rowTitle === 'Human review halted' || rowTitle === 'Revised proposal halted') { + return latestProposalRecord(run); + } + if (rowTitle === 'Human review feedback sent') { + return latestChangeRequestRecord(run) ?? latestProposalRecord(run); + } + if (rowTitle === 'Human review recorded' || rowTitle === 'Human review rejected') { + return latestHumanDecisionRecord(run) ?? latestProposalRecord(run); } if (rowTitle === 'Agent resumed through MCP') return run.records.find((record) => record.label === 'execution'); + if (rowTitle === 'MCP execution skipped' || rowTitle === 'Decision audit ready') { + return run.records.find((record) => record.label === 'rejection'); + } + if (rowTitle === 'Feedback returned to agent' || rowTitle === 'Revised proposal pending') { + return run.records.find((record) => record.label === 'change_request'); + } if (rowTitle === 'Audit ready') { return run.records.find((record) => record.label === 'handoff') ?? run.records.find((record) => record.label === 'outcome') @@ -3231,11 +4630,13 @@ export function renderApp(): string { return run.records.find((record) => record.label === 'proposal'); } - function progressRowClass(row) { + function progressRowClass(row, run) { const key = progressKeyForTitle(row.title); return [ row.halted ? 'halted' : '', + row.skipped ? 'skipped' : '', key === 'proposal' ? 'proposal' : '', + key === 'proposal' && run?.status === 'pending_approval' ? 'pending-proposal' : '', ].filter(Boolean).join(' '); } @@ -3243,6 +4644,62 @@ export function renderApp(): string { return JSON.stringify(value, null, 2); } + function formatReceiptJson(value, format = selectedReceiptFormat) { + return format === 'compact' ? JSON.stringify(value) : pretty(value); + } + + function highlightJsonValueFragment(fragment) { + const token = fragment.match(/^(\\s*)("(?:\\\\.|[^"\\\\])*"|-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?|true|false|null)(.*)$/); + if (!token) return escapeHtml(fragment); + const type = token[2].startsWith('"') + ? 'string' + : token[2] === 'true' || token[2] === 'false' || token[2] === 'null' + ? 'literal' + : 'number'; + return escapeHtml(token[1]) + + '' + escapeHtml(token[2]) + '' + + escapeHtml(token[3]); + } + + function highlightJsonLine(line) { + const key = line.match(/^(\\s*)("(?:\\\\.|[^"\\\\])*"):(.*)$/); + if (!key) return highlightJsonValueFragment(line); + return escapeHtml(key[1]) + + '' + escapeHtml(key[2]) + '' + + ':' + + highlightJsonValueFragment(key[3]); + } + + function renderReceiptJson(value) { + return '
' + formatReceiptJson(value).split('\\n').map((line, index) => (
+          '' + String(index + 1) + '' + highlightJsonLine(line) + ''
+        )).join('') + '
'; + } + + function traceReceiptPayload(run = currentRun) { + if (!run) return null; + const createdAt = run.records[0]?.record?.timestamp + ? new Date(run.records[0].record.timestamp).toISOString() + : null; + return { + trace_id: traceIdForRun(run), + run_id: run.run_id, + status: run.status === 'pending_approval' ? 'human_review_halted' : run.status, + current_step: ['pending_approval', 'changes_requested', 'rejected'].includes(run.status) ? 3 : ['succeeded', 'failed'].includes(run.status) ? 5 : 4, + created_at: createdAt, + records: run.trace_packet.timeline.map((entry) => { + const record = run.records.find((item) => item.record_hash === entry.record_hash); + return { + record_id: recordDisplayId(entry.record_hash), + timestamp: record?.record?.timestamp ? new Date(record.record.timestamp).toISOString() : null, + event: entry.event, + label: entry.label, + record_hash: entry.record_hash, + }; + }), + }; + } + async function writeClipboard(text) { const value = String(text ?? ''); if (!value) return false; @@ -3271,7 +4728,7 @@ export function renderApp(): string { } function downloadJson(filename, value) { - const blob = new Blob([pretty(value)], { type: 'application/json' }); + const blob = new Blob([formatReceiptJson(value)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -3290,6 +4747,7 @@ export function renderApp(): string { function selectedReceiptPayload(record = selectedReceiptRecord) { if (!record || !currentRun) return null; + if (selectedReceiptView === 'trace') return traceReceiptPayload(currentRun); return { trace_id: traceIdForRun(currentRun), run_id: currentRun.run_id, @@ -3320,14 +4778,14 @@ export function renderApp(): string { function verificationRowsMarkup(run = currentRun, record = selectedReceiptRecord) { if (!run || !record) { - return '
' + return '
' + '
' + verifyIcon('log') + '
Verify in Cloudflare Integrity LogCheck inclusion and consistency proof
' + '
' + verifyIcon('sig') + '
Verify receipt signatureValidate signer and record hashes
' + '
' + verifyIcon('get') + '
Download transparency proofCT-style proof for this receipt
' + '
'; } const proofUrl = proofTargetForRun(run); - return '
' + return '
' + '
' + verifyIcon('log') + '
Verify in Cloudflare Integrity LogCheck inclusion and consistency proof
View proof ' + actionGlyph('external') + '
' + '
' + verifyIcon('sig') + '
Verify receipt signatureValidate signer and record hashes
' + '
' + verifyIcon('get') + '
Download transparency proofCT-style proof for this receipt
' @@ -3354,9 +4812,35 @@ export function renderApp(): string { } function updateTraceHeaderCopy() { - const button = document.querySelector('[data-copy-source="#traceIdLabel"]'); - if (!button) return; - button.disabled = !traceIdLabel.textContent || traceIdLabel.textContent === 'pending'; + const traceButton = document.querySelector('[data-copy-source="#traceIdLabel"]'); + if (traceButton) traceButton.disabled = !traceIdLabel.textContent || traceIdLabel.textContent === 'pending'; + const runButton = document.querySelector('[data-copy-source="#runIdLabel"]'); + if (runButton) runButton.disabled = !runIdLabel.textContent || runIdLabel.textContent === 'pending'; + } + + function setHeaderMenuOpen(open) { + if (!headerMenuButton || !headerActionsMenu) return; + headerMenuButton.setAttribute('aria-expanded', String(open)); + headerActionsMenu.hidden = !open; + } + + function setRunModeMenuOpen(open) { + if (!runModeButton || !runModeActionsMenu) return; + runModeButton.setAttribute('aria-expanded', String(open)); + updateRunModeControls(); + runModeActionsMenu.hidden = !open; + } + + function updateHeaderMenuControls() { + if (!headerActionsMenu) return; + document.querySelectorAll('[data-header-action="open-json"], [data-header-action="reset"]').forEach((button) => { + button.disabled = !currentRun || busy; + }); + } + + function updateRunModeControls() { + runModeActionsMenu?.querySelector('[data-run-mode-action="live"]')?.setAttribute('aria-checked', 'true'); + runModeActionsMenu?.querySelector('[data-run-mode-action="toggle-follow"]')?.setAttribute('aria-checked', String(autoFollow)); } function renderVerificationActions(run = currentRun, record = selectedReceiptRecord) { @@ -3372,8 +4856,9 @@ export function renderApp(): string { const existingResult = verificationEl.querySelector('#verificationResult'); if (existingResult) existingResult.remove(); verificationEl.insertAdjacentHTML('beforeend', verificationCheckingMarkup(record)); + followElement(verificationEl.querySelector('#verificationResult'), 'nearest'); try { - await sleep(250); + await sleep(850); const result = await post('/api/verify-record', { record: record.record, expected_hash: record.record_hash, @@ -3381,6 +4866,7 @@ export function renderApp(): string { const ok = Boolean(result?.ok); const resultEl = verificationEl.querySelector('#verificationResult'); if (resultEl) resultEl.outerHTML = verificationResultMarkup(result, record); + followElement(verificationEl.querySelector('#verificationResult'), 'nearest'); button.innerHTML = (ok ? 'Verified ' : 'Check failed ') + actionGlyph(ok ? 'check' : 'external'); button.classList.toggle('verified', ok); button.classList.toggle('failed', !ok); @@ -3409,8 +4895,21 @@ export function renderApp(): string { if (downloadReceiptButton) downloadReceiptButton.disabled = !hasRecord; } + function selectedReceiptJsonPayload() { + if (!selectedReceiptRecord || !currentRun) return null; + return selectedReceiptView === 'trace' + ? traceReceiptPayload(currentRun) + : selectedReceiptRecord; + } + + function rerenderSelectedReceiptJson() { + const payload = selectedReceiptJsonPayload(); + if (payload) receiptsEl.innerHTML = renderReceiptJson(payload); + } + function clearReceiptInspector() { selectedReceiptRecord = null; + selectedReceiptView = 'record'; receiptsEl.innerHTML = '

View signed record and proof after the first trace record is selected.

'; receiptSummaryEl.innerHTML = '

Summary appears after a signed record is selected.

'; renderVerificationActions(null, null); @@ -3420,6 +4919,12 @@ export function renderApp(): string { function runStateCopy(run) { switch (run.status) { case 'pending_approval': + if (hasRevisedProposal(run)) { + return { + title: 'Revised proposal ready for review', + detail: 'The agent incorporated signed feedback and halted again before MCP execution.', + }; + } return { title: 'Halted for human review', detail: 'The agent has stopped before publishing. Approval resumes execution through the action MCP.', @@ -3439,6 +4944,11 @@ export function renderApp(): string { title: 'Rejected before execution', detail: 'The human decision is signed. The agent did not run the MCP action.', }; + case 'changes_requested': + return { + title: 'Revision requested before execution', + detail: 'The human feedback is signed. The agent must revise the payload before MCP execution can resume.', + }; default: return { title: run.status.replaceAll('_', ' '), @@ -3449,7 +4959,14 @@ export function renderApp(): string { function setStatusForRun(run) { if (run.status === 'pending_approval') { - setStatus('Halted for human review', 'pending', 'Autonomous triage is complete. Review the payload before the agent can resume.', 'halt'); + setStatus( + hasRevisedProposal(run) ? 'Revised proposal ready for review' : 'Halted for human review', + 'pending', + hasRevisedProposal(run) + ? 'The agent signed a revised payload from the requested changes. Review it before MCP execution can resume.' + : 'Autonomous triage is complete. Review the payload before the agent can resume.', + 'halt', + ); return; } if (run.status === 'succeeded') { @@ -3461,14 +4978,19 @@ export function renderApp(): string { return; } if (run.status === 'rejected') { - setStatus('Rejected', 'error', 'The human decision is signed. No execution ran.', 'audit'); + setStatus('Rejected', 'error', 'The human decision is signed. No execution ran.', 'halt'); + return; + } + if (run.status === 'changes_requested') { + setStatus('Changes requested', 'pending', 'The human feedback is signed. MCP execution remains blocked until the agent revises the proposal.', 'halt'); return; } setStatus(run.status.replaceAll('_', ' '), 'pending', 'The workflow is still running.', 'resume'); } function renderProposal(run) { - const proposal = run.records.find((record) => record.label === 'proposal'); + const proposal = latestProposalRecord(run); + const feedbackRecord = latestChangeRequestRecord(run); const body = proposal?.body ?? {}; const payload = body.proposed_payload ?? {}; const before = payload.before ?? {}; @@ -3489,13 +5011,17 @@ export function renderApp(): string { Target \${payload.target_file ?? 'missing'}
-
+
Diff (unified) - Context3 linesWrap + + + + +
-
\${renderDiff(diff)}
+
\${renderDiff(diff, '3')}
Risk assessment
@@ -3510,11 +5036,23 @@ export function renderApp(): string { This proposal changes repository code for a production Workers route. The agent must halt before the action MCP writes the file. Approval signs the exact payload hash, connector id, and target file before execution resumes.
+ \${feedbackRecord ? \` + + \` : ''}
- - - + + +
+ \${run.status === 'pending_approval' ? \` + + \` : ''} \`; document.querySelector('#riskDetailsToggle')?.addEventListener('click', (event) => { const button = event.currentTarget; @@ -3524,6 +5062,24 @@ export function renderApp(): string { button.setAttribute('aria-expanded', String(!expanded)); details.hidden = expanded; }); + document.querySelector('#diffWrapToggle')?.addEventListener('click', (event) => { + const button = event.currentTarget; + const code = document.querySelector('.diff-code'); + const pressed = button.getAttribute('aria-pressed') === 'true'; + button.setAttribute('aria-pressed', String(!pressed)); + code?.classList.toggle('wrap', !pressed); + }); + document.querySelector('#diffContext')?.addEventListener('change', (event) => { + const context = event.currentTarget.value; + const diffRoot = document.querySelector('.diff'); + const code = document.querySelector('.diff-code'); + const wrap = document.querySelector('#diffWrapToggle')?.getAttribute('aria-pressed') === 'true'; + diffRoot?.setAttribute('data-context-lines', context); + if (code) { + code.innerHTML = renderDiff(diff, context); + code.classList.toggle('wrap', wrap); + } + }); document.querySelector('#approve')?.addEventListener('click', async () => { await transition({ title: 'Agent resumed', @@ -3548,13 +5104,24 @@ export function renderApp(): string { }); }); document.querySelector('#requestChanges')?.addEventListener('click', async () => { + const drawer = document.querySelector('#reviewFeedbackDrawer'); + const input = document.querySelector('#reviewFeedback'); + if (drawer?.hidden) { + drawer.hidden = false; + updateControls(); + followPanelElement(drawer); + input?.focus(); + return; + } + const feedback = document.querySelector('#reviewFeedback')?.value?.trim() + || 'Please narrow this to the /v1/report route only and return a revised proposal before any MCP write.'; await transition({ title: 'Requesting changes', - detail: 'The human decision is being signed as a no-execute review outcome.', + detail: 'The reviewer feedback is being signed. The agent will revise and return to human review.', step: 'halt', - activeLabel: 'reject', - fn: async () => post('/api/runs/' + run.run_id + '/reject', { - reason: 'The reviewer requested a smaller repository file update.', + activeLabel: 'request', + fn: async () => post('/api/runs/' + run.run_id + '/request-changes', { + feedback, }), }); }); @@ -3564,8 +5131,14 @@ export function renderApp(): string { function renderAnswer(run) { const answer = run.trace_packet.answer; const publicUrl = run.trace_packet.handoff?.public_context_url; - const auditReady = ['succeeded', 'failed', 'rejected'].includes(run.status); + const auditReady = ['succeeded', 'failed'].includes(run.status); + const rejected = run.status === 'rejected'; + const changesRequested = run.status === 'changes_requested'; const labels = new Set(run.records.map((record) => record.label)); + const revised = hasRevisedProposal(run); + const feedback = latestChangeRequestRecord(run); + const reviewLoopActive = Boolean(feedback) && !auditReady && !rejected && !changesRequested; + const showReviewResult = auditReady || rejected || changesRequested || Boolean(feedback); const stageRows = [ { title: 'Trigger received', @@ -3578,57 +5151,80 @@ export function renderApp(): string { done: labels.has('triage'), }, { - title: 'Policy and intent analysis', + title: 'Policy & intent analysis', detail: labels.has('triage') ? 'Repository writes require human review before MCP execution.' : 'Waiting for policy analysis.', done: labels.has('triage'), }, { - title: 'Proposed action generated', + title: revised ? 'Initial proposal generated' : 'Proposed action generated', detail: labels.has('proposal') ? 'Agent prepared a write_file payload, diff, risk note, and payload hash.' : 'Agent has not planned yet.', done: labels.has('proposal'), }, + ...(feedback ? [{ + title: 'Human review feedback sent', + detail: 'Reviewer feedback was signed and returned to the agent.', + done: true, + }] : []), + ...(revised ? [{ + title: 'Revised proposal generated', + detail: 'The agent narrowed the file update and signed a new payload hash.', + done: true, + }] : []), { - title: run.status === 'pending_approval' ? 'Human review halted' : 'Human review recorded', - detail: answer.decision ? 'Decision: ' + answer.decision : 'Execution is stopped until a human signs approval or rejection.', + title: run.status === 'pending_approval' ? revised ? 'Revised proposal halted' : 'Human review halted' : rejected ? 'Human review rejected' : changesRequested ? 'Human review feedback sent' : 'Human review recorded', + detail: answer.decision ? 'Decision: ' + answer.decision : revised ? 'Execution is stopped again until a human signs the revised proposal.' : 'Execution is stopped until a human signs approval, rejection, or feedback.', done: Boolean(answer.decision), halted: run.status === 'pending_approval', }, { - title: answer.executed ? 'Agent resumed through MCP' : 'Resume not started', - detail: answer.executed ? 'The action MCP ran only after approval.' : 'Rejected or waiting for approval.', - done: answer.executed, + title: answer.executed ? 'Agent resumed through MCP' : rejected ? 'MCP execution skipped' : changesRequested ? 'Feedback returned to agent' : 'Resume not started', + detail: answer.executed + ? 'The action MCP ran only after approval.' + : rejected + ? 'The signed rejection closed the gate before any MCP write.' + : changesRequested + ? 'Signed feedback has been returned to the agent for revision.' + : revised + ? 'Waiting on approval for the revised proposal.' + : 'Rejected or waiting for approval.', + done: answer.executed || changesRequested, + skipped: rejected, }, { - title: auditReady ? 'Audit ready' : 'Audit assembling', + title: auditReady ? 'Audit ready' : rejected ? 'Decision audit ready' : changesRequested ? 'Revised proposal pending' : 'Audit assembling', detail: auditReady ? 'Public log context and trace JSON are ready.' + : rejected + ? 'Signed rejection receipt and public log proof are ready.' + : changesRequested + ? 'Signed feedback is in the trace; the next agent pass must produce a revised proposal.' : 'Receipts appear as the run progresses; terminal audit waits for a decision.', - done: auditReady, + done: auditReady || rejected, }, ]; answerEl.innerHTML = \`
\${stageRows.map((row) => \` -
- +
+
\${row.title === 'Resume not started' ? 'MCP execution (pending)' : row.title === 'Audit assembling' ? 'Audit ready (pending)' : row.title} \${row.detail}
- \${progressDisplayTime(run, row.title, row.done || row.halted)} + \${progressDisplayTime(run, row.title, row.done || row.halted || row.skipped)}
\`).join('')}
- \${auditReady ? \` -
+ \${showReviewResult ? \` +
- Execution result - \${answer.executed ? answer.outcome : 'not run'} + \${changesRequested || reviewLoopActive ? 'Review result' : 'Execution result'} + \${changesRequested || reviewLoopActive ? 'changes requested' : answer.executed ? answer.outcome : 'not run'}
- Changed rows - \${answer.changed.length ? answer.changed.join(', ') : 'none'} + \${changesRequested || reviewLoopActive ? 'Next step' : 'Changed rows'} + \${changesRequested ? 'agent revision' : reviewLoopActive && run.status === 'pending_approval' ? 'review revised proposal' : answer.changed.length ? answer.changed.join(', ') : 'none'}
\` : ''} @@ -3638,39 +5234,66 @@ export function renderApp(): string { function renderTimeline(run) { const signers = [ - { kind: 'agent', name: 'Agent', detail: 'agents/triage@1.4.2', signer: 'agent', status: run.records.some((record) => record.signer === 'agent') ? 'Signed' : 'Pending' }, - { kind: 'human', name: 'Human', detail: 'alice@example.com', signer: 'human', status: run.records.some((record) => record.signer === 'human') ? 'Signed' : 'Pending' }, - { kind: 'mcp', name: 'Action MCP', detail: 'github.write@2.3.1', signer: 'action_mcp', status: run.records.some((record) => record.signer === 'action_mcp') ? 'Signed' : 'Pending' }, - ]; + { kind: 'agent', name: 'Agent', detail: 'agents/triage@1.4.2', signer: 'agent' }, + { kind: 'human', name: 'Human', detail: 'alice@example.com', signer: 'human' }, + { kind: 'mcp', name: 'Action MCP', detail: 'github.write@2.3.1', signer: 'action_mcp' }, + ].map((signer) => { + const records = recordsForSigner(run, signer.signer); + const recordCount = records.length; + return { + ...signer, + recordCount, + status: signerStatusForRun(run, signer, recordCount), + }; + }); timelineEl.innerHTML = run.trace_packet.timeline.length ? \` +
\${run.trace_packet.timeline.map((entry, index) => { const record = run.records.find((item) => item.record_hash === entry.record_hash); const isPendingHuman = false; return \` - \`; }).join('')} - \${futureTraceRows(run).map((row) => \` -
+ \${futureTraceRows(run).map((row) => { + const isCurrent = row.marker === 'pending'; + if (isCurrent && row.record && row.hash) { + return \` + + \`; + } + return \` +
+ \${row.markerLabel ?? ''} \${row.record ? displayRecordTime(row.record, row.displayLabel) : '-'} - - \${row.name} + \${timelineSignerIcon(row.record)}\${row.name} \${row.detail} \${row.hash ? recordDisplayId(row.hash) : '-'}
- \`).join('')} + \`; + }).join('')}
@@ -3678,9 +5301,9 @@ export function renderApp(): string {
\${signerIcon(signer.kind)} \${signer.name} - \${signer.detail} + \${signer.detail}\${signer.recordCount ? ' (' + recordCountLabel(signer.recordCount) + ')' : ''} \${signer.status} - Sig: \${signerSignature(run, signer.signer)}\${copyIcon(signerRecordHash(run, signer.signer), signer.name + ' signature')} + Latest: \${signerLatestRecordDigest(run, signer.signer)}\${copyIcon(signerRecordHash(run, signer.signer), signer.name + ' latest record')}
\`).join('')}
@@ -3689,7 +5312,7 @@ export function renderApp(): string {
Merkle root\${run.records[0]?.record_hash ?? 'pending'}\${copyIcon(run.records[0]?.record_hash ?? '', 'Merkle root')}
Log hash\${run.records[1]?.record_hash ?? 'pending'}\${copyIcon(run.records[1]?.record_hash ?? '', 'log hash')}
-
Proof statusIncluded in Cloudflare Integrity LogView proof
+
Proof statusIncluded in Cloudflare Integrity LogView proof \${actionGlyph('external')}
\` @@ -3702,13 +5325,15 @@ export function renderApp(): string { }); }; const renderReceiptSummary = (record, activeTab = 'summary') => { - const signedRecords = signers.filter((signer) => signer.status === 'Signed').length; - const pendingSignatures = signers.filter((signer) => signer.status === 'Pending').length; + const signedSigners = signers.filter((signer) => signer.status === 'Signed').length; + const pendingSigners = signers.filter((signer) => signer.status === 'Pending').length; + const blockedSigners = signers.filter((signer) => signer.status === 'Blocked').length; + const skippedSigners = signers.filter((signer) => signer.status === 'Skipped').length; const logEntry = run.trace_packet.handoff?.public_context_url ?? '/api/runs/' + run.run_id; const tabMarkup = \` -
- - +
+ +
\`; if (activeTab === 'details') { @@ -3731,8 +5356,10 @@ export function renderApp(): string { \${tabMarkup}
Total records\${run.records.length}
-
Signed records\${signedRecords}
-
Pending signatures\${pendingSignatures}
+
Signed signers\${signedSigners}
+
Pending signers\${pendingSigners}
+ \${blockedSigners ? '
Blocked signers' + blockedSigners + '
' : ''} + \${skippedSigners ? '
Skipped signers' + skippedSigners + '
' : ''}
Merkle root\${shortHash(run.records[0]?.record_hash)}\${copyIcon(run.records[0]?.record_hash ?? '', 'Merkle root')}
Log timestamp\${displayRecordTime(record, record.label) + ' UTC'}
@@ -3741,9 +5368,10 @@ export function renderApp(): string { \`; bindReceiptTabs(record); }; - const selectRecord = (record) => { + const selectRecord = (record, options = {}) => { selectedReceiptRecord = record; - receiptsEl.innerHTML = '
' + pretty(record) + '
'; + selectedReceiptView = options.showTrace ? 'trace' : 'record'; + receiptsEl.innerHTML = renderReceiptJson(selectedReceiptJsonPayload()); renderReceiptSummary(record); renderVerificationActions(run, record); updateReceiptControls(record); @@ -3751,26 +5379,38 @@ export function renderApp(): string { timelineEl.querySelectorAll('.event').forEach((button) => { button.addEventListener('click', () => { const record = run.records.find((item) => item.record_hash === button.dataset.hash); + if (!record) return; timelineEl.querySelectorAll('.event').forEach((item) => item.classList.remove('selected')); button.classList.add('selected'); selectRecord(record); }); }); - const preferredLabel = run.status === 'pending_approval' ? 'proposal' : run.status === 'rejected' ? 'rejection' : run.status === 'failed' ? 'outcome' : 'handoff'; + const preferredLabel = run.status === 'pending_approval' ? 'approval' : run.status === 'changes_requested' ? 'change_request' : run.status === 'rejected' ? 'rejection' : run.status === 'failed' ? 'outcome' : 'handoff'; const preferredButton = timelineEl.querySelector(\`.event[data-label="\${preferredLabel}"]\`) ?? timelineEl.querySelector('.event'); - preferredButton?.click(); + if (preferredButton) { + timelineEl.querySelectorAll('.event').forEach((item) => item.classList.remove('selected')); + preferredButton.classList.add('selected'); + const preferredRecord = run.records.find((item) => item.record_hash === preferredButton.dataset.hash); + if (preferredRecord) selectRecord(preferredRecord, { showTrace: true }); + } } - function render(run) { + function applyRunHeader(run) { currentRun = run; - runIdLabel.textContent = run.run_id; + setRunId(run.run_id); traceIdLabel.textContent = traceIdForRun(run); updateTraceHeaderCopy(); const started = run.records[0]?.record?.timestamp - ? new Date(run.records[0].record.timestamp).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' + ? formatHeaderDate(run.records[0].record.timestamp) : 'pending'; startedLabel.textContent = started; receivedLabel.textContent = started; + updateControls(); + } + + function render(run) { + stageDisplayTimes = {}; + applyRunHeader(run); renderProposal(run); renderAnswer(run); renderTimeline(run); @@ -3778,9 +5418,12 @@ export function renderApp(): string { updateStepTimes(run); updateControls(); if (run.status === 'pending_approval') { - followElement(document.querySelector('#approve'), 'nearest'); + followWorkflowOverview(); } else if (['succeeded', 'failed', 'rejected'].includes(run.status)) { + followPanelElement(answerEl.querySelector('.review-result')); followElement(document.querySelector('.receipt-panel'), 'start'); + } else if (run.status === 'changes_requested') { + followPanelElement(answerEl.querySelector('.review-result')); } } @@ -3801,7 +5444,59 @@ export function renderApp(): string { document.addEventListener('click', async (event) => { const target = event.target; - const copyButton = target.closest?.('[data-copy-value], [data-copy-source], #copyReceipt'); + const menuButton = target.closest?.('#headerMenu'); + if (menuButton) { + setRunModeMenuOpen(false); + setHeaderMenuOpen(menuButton.getAttribute('aria-expanded') !== 'true'); + return; + } + + const runModeTrigger = target.closest?.('#runModeMenu'); + if (runModeTrigger) { + setHeaderMenuOpen(false); + setRunModeMenuOpen(runModeTrigger.getAttribute('aria-expanded') !== 'true'); + return; + } + + const runModeAction = target.closest?.('[data-run-mode-action]'); + if (runModeAction && !runModeAction.disabled) { + const action = runModeAction.dataset.runModeAction; + setRunModeMenuOpen(false); + if (action === 'toggle-follow') { + autoFollow = !autoFollow; + updateRunModeControls(); + return; + } + return; + } + + const menuAction = target.closest?.('[data-header-action]'); + if (menuAction && !menuAction.disabled) { + const action = menuAction.dataset.headerAction; + setHeaderMenuOpen(false); + if (action === 'copy-link') { + if (await writeClipboard(window.location.href)) markCopied(menuAction); + return; + } + if (action === 'open-json' && currentRun) { + window.open('/api/runs/' + currentRun.run_id, '_blank', 'noreferrer'); + return; + } + if (action === 'reset') { + resetButton.click(); + return; + } + } + + if (headerActionsMenu && !target.closest?.('#headerActions')) { + setHeaderMenuOpen(false); + } + + if (runModeActionsMenu && !target.closest?.('#runModeActions')) { + setRunModeMenuOpen(false); + } + + const copyButton = target.closest?.('[data-copy-value], [data-copy-source], [data-copy-diff], #copyReceipt'); if (copyButton && !copyButton.disabled) { const source = copyButton.dataset.copySource ? document.querySelector(copyButton.dataset.copySource)?.textContent @@ -3809,7 +5504,8 @@ export function renderApp(): string { const payload = copyButton.id === 'copyReceipt' ? selectedReceiptPayload() : null; - const value = payload ? pretty(payload) : source ?? copyButton.dataset.copyValue ?? ''; + const diff = copyButton.dataset.copyDiff !== undefined ? currentVisibleDiffText() : null; + const value = payload ? formatReceiptJson(payload) : diff ?? source ?? copyButton.dataset.copyValue ?? ''; if (await writeClipboard(value)) markCopied(copyButton); return; } @@ -3831,27 +5527,45 @@ export function renderApp(): string { await startTriggeredRun(); }); + receiptFormatSelect?.addEventListener('change', () => { + selectedReceiptFormat = receiptFormatSelect.value; + rerenderSelectedReceiptJson(); + }); + async function startTriggeredRun() { if (busy) return; try { setBusy(true, 'create'); + const runId = crypto.randomUUID(); currentRun = null; selectedReceiptRecord = null; + selectedReceiptView = 'record'; stageDisplayTimes = {}; - runIdLabel.textContent = 'pending'; - traceIdLabel.textContent = 'pending'; + resetPanelScroll(answerEl); + resetPanelScroll(proposalEl); + resetPanelScroll(timelineEl); + setRunId(runId); + traceIdLabel.textContent = traceIdFromRunId(runId); updateTraceHeaderCopy(); - startedLabel.textContent = nowTime(0); - receivedLabel.textContent = nowTime(0); + startedLabel.textContent = formatHeaderDate(); + receivedLabel.textContent = formatHeaderDate(); clearReceiptInspector(); - const runPromise = post('/api/runs', { + renderBootProgress(0); + const run = await post('/api/runs', { + run_id: runId, prompt: promptInput.value, }); - for (let index = 0; index < bootStages.length; index += 1) { - renderBootProgress(index); - await sleep(index === bootStages.length - 1 ? 1200 : 1700); + applyRunHeader(run); + renderBootProgress(0, run); + await sleep(1700); + for (let index = 1; index < bootStages.length; index += 1) { + if (bootStages[index]?.key === 'halt') { + render(run); + return; + } + renderBootProgress(index, run); + await sleep(1700); } - const run = await runPromise; render(run); } catch (error) { setStatus('Workflow error', 'error', 'The request failed before the trace could complete.', currentStep); @@ -3863,22 +5577,20 @@ export function renderApp(): string { resetButton.addEventListener('click', () => { if (busy) return; - currentRun = null; - selectedReceiptRecord = null; - stageDisplayTimes = {}; - runIdLabel.textContent = 'pending'; - traceIdLabel.textContent = 'pending'; - updateTraceHeaderCopy(); - startedLabel.textContent = 'waiting'; - receivedLabel.textContent = 'waiting'; - proposalEl.innerHTML = '

Run the trigger to see the agent\\'s proposal, exact payload, diff, risk, and approval controls.

'; - answerEl.innerHTML = '

No active run.

'; - timelineEl.innerHTML = '

Signed records will appear here as the workflow runs.

'; - clearReceiptInspector(); - setStatus('Ready for incoming alert', 'pending', 'Run the prior trigger to start autonomous triage before the human review gate.', 'trigger'); - updateControls(); + setHeaderMenuOpen(false); + setRunModeMenuOpen(false); + startTriggeredRun(); }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + setHeaderMenuOpen(false); + setRunModeMenuOpen(false); + } + }); + + window.addEventListener('resize', syncRailConnectors); + updateControls(); updateTraceHeaderCopy(); if (!autoStarted) { diff --git a/packages/integration/examples/cloudflare-agents/approval-trace/test/approval-trace.worker.test.ts b/packages/integration/examples/cloudflare-agents/approval-trace/test/approval-trace.worker.test.ts index eda8d8dc..6ede6eba 100644 --- a/packages/integration/examples/cloudflare-agents/approval-trace/test/approval-trace.worker.test.ts +++ b/packages/integration/examples/cloudflare-agents/approval-trace/test/approval-trace.worker.test.ts @@ -21,6 +21,7 @@ type WorkflowStatus = | 'pending_approval' | 'approved' | 'rejected' + | 'changes_requested' | 'executing' | 'succeeded' | 'failed' @@ -43,9 +44,9 @@ interface TraceResponse { context_id: string trace_packet: { answer: { - decision: 'approved' | 'rejected' | null + decision: 'approved' | 'rejected' | 'changes_requested' | null executed: boolean - outcome: 'not_run' | 'success' | 'error' | 'pending' + outcome: 'not_run' | 'revision_requested' | 'success' | 'error' | 'pending' changed: string[] diagnostic: string | null } @@ -165,6 +166,12 @@ async function rejectRun(runId: string): Promise { }) } +async function requestChanges(runId: string): Promise { + return postJson(`/api/runs/${runId}/request-changes`, { + feedback: 'The reviewer requested a smaller repository file update.', + }) +} + async function getAgentRun(runId: string): Promise { const stub = testEnv.ApprovalTraceAgent.get(testEnv.ApprovalTraceAgent.idFromName(runId)) return runInDurableObject(stub, (instance) => (instance as unknown as RunReader).getRun(runId)) @@ -337,6 +344,70 @@ describe('Cloudflare approval trace Worker', () => { expect(await getTargetRows(runId, 'server/middleware/rate_limit.ts')).toEqual([]) }) + it('signs requested changes, revises, and waits for second approval', async () => { + const runId = 'changes-requested-local-e2e' + await createRun(runId) + const trace = await requestChanges(runId) + const records = byLabel(trace) + const proposal = records.get('proposal')! + const feedback = records.get('change_request')! + const revision = records.get('revision')! + + expect(trace.status).toBe('pending_approval') + expect(labels(trace)).toEqual(['trigger', 'triage', 'proposal', 'change_request', 'revision']) + await expectSignedTrace(trace) + expect(sorted(feedback.record.informed_by)).toEqual([proposal.record_hash]) + expect(sorted(revision.record.informed_by)).toEqual( + [proposal.record_hash, feedback.record_hash].sort(), + ) + expect(trace.records.some((record) => record.label === 'rejection')).toBe(false) + expect(trace.records.some((record) => record.signer === 'action_mcp')).toBe(false) + expect(feedback.body).toMatchObject({ + kind: 'human_review_feedback', + decision: 'changes_requested', + next_step: 'agent_revision', + }) + expect(revision.body).toMatchObject({ + kind: 'agent_revised_proposal', + revision_number: 2, + feedback_record_hash: feedback.record_hash, + }) + expect(trace.trace_packet.answer).toMatchObject({ + decision: null, + executed: false, + outcome: 'pending', + changed: [], + }) + expect(await getTargetRows(runId, 'server/middleware/rate_limit.ts')).toEqual([]) + + const approved = await approveRun(runId) + const approvedRecords = byLabel(approved) + const approval = approvedRecords.get('approval')! + expect(approved.status).toBe('succeeded') + expect(labels(approved)).toEqual([ + 'trigger', + 'triage', + 'proposal', + 'change_request', + 'revision', + 'approval', + 'preview', + 'execution', + 'outcome', + 'handoff', + ]) + expect(sorted(approval.record.informed_by)).toEqual([revision.record_hash]) + expect(await getTargetRows(runId, 'server/middleware/rate_limit.ts')).toEqual([ + expect.objectContaining({ + file: 'server/middleware/rate_limit.ts', + rate_limit: expect.objectContaining({ + max: 60, + scope: '/v1/report', + }), + }), + ]) + }) + it('records a diagnostic outcome when the approved action fails', async () => { const runId = 'error-local-e2e' await createRun(runId) diff --git a/packages/integration/examples/cloudflare-agents/approval-trace/test/browser/approval-trace.ui.spec.ts b/packages/integration/examples/cloudflare-agents/approval-trace/test/browser/approval-trace.ui.spec.ts index 96fcc8c7..f498e5a0 100644 --- a/packages/integration/examples/cloudflare-agents/approval-trace/test/browser/approval-trace.ui.spec.ts +++ b/packages/integration/examples/cloudflare-agents/approval-trace/test/browser/approval-trace.ui.spec.ts @@ -20,45 +20,1487 @@ async function createProposal(page: Page, path = '/'): Promise { await page.goto(path) await expect(page).toHaveTitle('Cloudflare Agent Trace') await expect(page.getByTestId('approval-trace-app')).toBeVisible() + await expect(page.locator('#coloLabel')).toHaveText(/^[A-Z0-9-]{2,12}$/) + await expect(page.locator('#runIdLabel')).not.toHaveText('pending') + await expect(page.locator('#runIdLabel')).toHaveText(/^run_[A-Z0-9]+/) + await expect(page.locator('#runIdLabel')).toHaveAttribute('data-run-id', /.+/) await expect(page.locator('#answer')).toContainText('Trigger received') await expect(page.locator('#statusTitle')).toHaveText('Halted for human review', { timeout: 15_000, }) await expect(page.locator('#answer')).toContainText('Context gathered') - await expect(page.locator('#answer')).toContainText('Policy and intent analysis') + await expect(page.locator('#answer')).toContainText('Policy & intent analysis') await expect(page.locator('#answer')).toContainText('Proposed action generated') await expect(page.locator('#answer')).toContainText('Human review halted') + await expect(page.locator('#proposal')).toContainText('Proposed action') + await expect(page.locator('#proposal')).toContainText('Diff (unified)') + await expect(page.locator('#receipts pre')).toContainText('"trace_id"') + await expect(page.locator('#receiptSummary')).toContainText('Total records') await expect(page.getByRole('button', { name: 'Approve and resume' })).toBeEnabled() await expect(page.getByRole('button', { name: 'Reject' })).toBeEnabled() await expect(page.getByRole('button', { name: 'Request changes' })).toBeEnabled() - await expect(page.locator('#timeline .event')).toHaveCount(3) + await expect(page.locator('#timeline .event')).toHaveCount(4) + const timelineColumns = await page.evaluate(`Array.from(document.querySelector('#timeline .record-timeline')?.firstElementChild?.children ?? []) + .slice(0, 2) + .map((child) => String(child.className))`) + expect(timelineColumns[0]).toContain('event-marker') + expect(timelineColumns[1]).toContain('event-time') + await expect(page.locator('#timeline .event .event-cue')).toHaveCount(4) + await expect(page.locator('#timeline .event .event-cue svg')).toHaveCount(4) + await expect(page.locator('#timeline .event-future .event-cue')).toHaveCount(0) + await expect(page.locator('#timeline .event.current.selected')).toContainText('human.review.halted') + await expect(page.locator('#timeline .event.current.selected')).toHaveAttribute('aria-current', 'step') + await expect + .poll(async () => page.locator('#timeline .event-future .event-marker').allTextContents()) + .toEqual(['4', '5']) await expect(page.locator('#answer')).toContainText('Human review halted') await expect(page.locator('#answer')).toContainText('Execution is stopped') + await expectProposalProgressDot(page, 'pending') + await expectPendingSignerHashReadable(page) + await expectReferenceSignerIconTreatment(page) + await expectReferenceLeftProgressTypography(page) + await expectWorkflowStepTitlesBold(page) + await expectReviewPillMatchesRailBadge(page) } -async function openTimelineRecord(page: Page, label: string): Promise { +async function openTimelineRecord(page: Page, label: string, receiptLabel = label): Promise { await page.locator('#timeline .event').filter({ hasText: label }).click() await expect(page.locator('#timeline .event.selected')).toContainText(label) - await expect(page.locator('#receipts pre')).toContainText(`"label": "${label}"`) + await expect(page.locator('#receipts pre')).toContainText(`"label": "${receiptLabel}"`) await expect(page.locator('#receipts pre')).toContainText('"record_hash": "sha256:') } +async function expectSelectedAndCurrentRowsLookDistinct(page: Page): Promise { + const rowStyles = await page.evaluate<{ + current: { background: string; boxShadow: string } | null + selected: { background: string; boxShadow: string } | null + }>(`(() => { + const selected = document.querySelector('#timeline .event.selected') + const current = document.querySelector('#timeline .current') + const styleFor = (element) => { + if (!element) return null + const style = getComputedStyle(element) + return { + background: style.backgroundColor, + boxShadow: style.boxShadow, + } + } + return { + current: styleFor(current), + selected: styleFor(selected), + } + })()`) + expect(rowStyles.selected?.background).toBe('rgb(238, 246, 255)') + expect(rowStyles.selected?.boxShadow).toContain('rgb(9, 105, 218)') + expect(rowStyles.current?.background).toBe('rgb(255, 247, 236)') + expect(rowStyles.current?.boxShadow).toContain('rgb(245, 158, 11)') +} + +async function expectSelectedCurrentRowCombinesStates(page: Page): Promise { + const combined = await page.evaluate<{ + ariaCurrent: string | null + background: string + boxShadow: string + markerBackground: string + selectedCount: number + text: string + }>(`(() => { + const row = document.querySelector('#timeline .event.current.selected') + if (!row) throw new Error('missing combined current selected row') + const rowStyle = getComputedStyle(row) + const marker = row.querySelector('.event-marker') + const markerStyle = marker ? getComputedStyle(marker) : null + return { + ariaCurrent: row.getAttribute('aria-current'), + background: rowStyle.backgroundColor, + boxShadow: rowStyle.boxShadow, + markerBackground: markerStyle?.backgroundColor ?? '', + selectedCount: document.querySelectorAll('#timeline .event.selected').length, + text: row.textContent?.replace(/\\s+/g, ' ').trim() ?? '', + } + })()`) + expect(combined.ariaCurrent).toBe('step') + expect(combined.background).toBe('rgb(238, 246, 255)') + expect(combined.boxShadow).toContain('rgb(9, 105, 218)') + expect(combined.markerBackground).toBe('rgb(243, 128, 32)') + expect(combined.selectedCount).toBe(1) + expect(combined.text).toContain('human.review.halted') +} + async function expectCopies(button: Locator): Promise { await button.click() await expect(button).toHaveAttribute('data-copy-state', 'copied') } +async function expectReferenceLeftProgressTypography(page: Page): Promise { + const progress = await page.evaluate<{ + detailWeights: number[] + receivedText: string + rowWeights: Array<{ text: string; weight: number }> + sectionLabel: { + fontSize: number + fontWeight: number + text: string + textTransform: string + } | null + times: string[] + }>(`(() => { + const sectionLabel = document.querySelector('#answer > .section-label') + const sectionStyle = sectionLabel ? getComputedStyle(sectionLabel) : null + return { + detailWeights: Array.from(document.querySelectorAll('.trigger-card .detail-row strong')) + .map((element) => Number.parseFloat(getComputedStyle(element).fontWeight)), + receivedText: document.querySelector('#receivedLabel')?.textContent?.trim() ?? '', + rowWeights: Array.from(document.querySelectorAll('#answer .progress-item strong')) + .map((element) => ({ text: element.textContent?.trim() ?? '', weight: Number.parseFloat(getComputedStyle(element).fontWeight) })), + sectionLabel: sectionLabel && sectionStyle ? { + fontSize: Number.parseFloat(sectionStyle.fontSize), + fontWeight: Number.parseFloat(sectionStyle.fontWeight), + text: sectionLabel.textContent?.trim() ?? '', + textTransform: sectionStyle.textTransform, + } : null, + times: Array.from(document.querySelectorAll('#answer .progress-time')) + .map((element) => element.textContent?.trim() ?? ''), + } + })()`) + expect(progress.sectionLabel?.text).toBe('Agent progress') + expect(progress.sectionLabel?.textTransform).toBe('none') + expect(progress.sectionLabel?.fontSize).toBe(14) + expect(progress.sectionLabel?.fontWeight).toBe(700) + expect(progress.receivedText).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4} \d{2}:\d{2}:\d{2} UTC$/) + expect(progress.detailWeights.every((weight) => weight <= 400)).toBe(true) + for (const row of progress.rowWeights) { + if (row.text === 'Human review halted') { + expect(row.weight).toBe(700) + } else { + expect(row.weight).toBe(400) + } + } + for (const time of progress.times.filter((value) => value !== '-')) { + expect(time).not.toContain('UTC') + expect(time).toMatch(/^\d{2}:\d{2}:\d{2}$/) + } +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + const overflow = await page.evaluate<{ + bodyWidth: number + documentWidth: number + nodes: Array<{ className: string; tagName: string; text: string }> + viewportWidth: number + }>(`(() => { + const nodes = Array.from(document.querySelectorAll('*')) + .map((element) => { + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return null + if (rect.left >= -1 && rect.right <= window.innerWidth + 1) return null + return { + className: String(element.className), + tagName: element.tagName, + text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '', + } + }) + .filter(Boolean) + return { + bodyWidth: document.body.scrollWidth, + documentWidth: document.documentElement.scrollWidth, + nodes, + viewportWidth: window.innerWidth, + } + })()`) + expect(overflow.bodyWidth).toBeLessThanOrEqual(overflow.viewportWidth) + expect(overflow.documentWidth).toBeLessThanOrEqual(overflow.viewportWidth) + expect(overflow.nodes).toEqual([]) +} + +async function expectWorkflowOverviewVisible(page: Page): Promise { + await expect + .poll(async () => page.evaluate(`Math.round(document.querySelector('.hero')?.getBoundingClientRect().top ?? -999)`)) + .toBeGreaterThanOrEqual(0) + const overview = await page.evaluate<{ + firstRailStepTop: number + headerBottom: number + headerTop: number + railTop: number + scrollY: number + }>(`(() => { + const header = document.querySelector('.hero')?.getBoundingClientRect() + const rail = document.querySelector('.workflow-rail')?.getBoundingClientRect() + const firstStep = document.querySelector('.rail-stepper .step')?.getBoundingClientRect() + return { + firstRailStepTop: Math.round(firstStep?.top ?? -999), + headerBottom: Math.round(header?.bottom ?? -999), + headerTop: Math.round(header?.top ?? -999), + railTop: Math.round(rail?.top ?? -999), + scrollY: Math.round(window.scrollY), + } + })()`) + expect(overview.scrollY).toBe(0) + expect(overview.headerTop).toBeGreaterThanOrEqual(0) + expect(overview.headerBottom).toBeLessThanOrEqual(64) + expect(overview.railTop).toBeGreaterThanOrEqual(68) + expect(overview.firstRailStepTop).toBeGreaterThanOrEqual(72) +} + +async function expectReferenceHeaderLogoGeometry(page: Page): Promise { + const logo = await page.evaluate<{ + fills: string[] + h1X: number + markHeight: number + markWidth: number + markX: number + markY: number + pathCount: number + svgHeight: number + svgWidth: number + svgX: number + viewBox: string | null + }>(`(() => { + const svg = document.querySelector('.cloud-mark') + const paths = Array.from(svg?.querySelectorAll('path') ?? []) + const h1 = document.querySelector('.hero h1')?.getBoundingClientRect() + const svgRect = svg?.getBoundingClientRect() + const bbox = svg?.getBBox() + const matrix = svg?.getScreenCTM() + function transform(x, y) { + return { + x: matrix.a * x + matrix.c * y + matrix.e, + y: matrix.b * x + matrix.d * y + matrix.f, + } + } + let markRect = { x: 0, y: 0, width: 0, height: 0 } + if (bbox && matrix) { + const points = [ + transform(bbox.x, bbox.y), + transform(bbox.x + bbox.width, bbox.y), + transform(bbox.x, bbox.y + bbox.height), + transform(bbox.x + bbox.width, bbox.y + bbox.height), + ] + const xs = points.map((point) => point.x) + const ys = points.map((point) => point.y) + markRect = { + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + } + } + return { + fills: paths.map((path) => path.getAttribute('fill') ?? ''), + h1X: Math.round(h1?.x ?? 0), + markHeight: Math.round(markRect.height), + markWidth: Math.round(markRect.width), + markX: Math.round(markRect.x), + markY: Math.round(markRect.y), + pathCount: paths.length, + svgHeight: Math.round(svgRect?.height ?? 0), + svgWidth: Math.round(svgRect?.width ?? 0), + svgX: Math.round(svgRect?.x ?? 0), + viewBox: svg?.getAttribute('viewBox') ?? null, + } + })()`) + expect(logo.svgX).toBe(29) + expect(logo.svgWidth).toBe(54) + expect(logo.svgHeight).toBe(32) + expect(logo.viewBox).toBe('0 0 209.51 94.74') + expect(logo.pathCount).toBe(2) + expect(logo.fills).toEqual(['#f4801f', '#f9ab41']) + expect(logo.markX).toBeGreaterThanOrEqual(29) + expect(logo.markX).toBeLessThanOrEqual(31) + expect(logo.markY).toBeGreaterThanOrEqual(19) + expect(logo.markY).toBeLessThanOrEqual(22) + expect(logo.markWidth).toBeGreaterThanOrEqual(52) + expect(logo.markWidth).toBeLessThanOrEqual(54) + expect(logo.markHeight).toBeGreaterThanOrEqual(23) + expect(logo.markHeight).toBeLessThanOrEqual(25) + expect(logo.h1X).toBeGreaterThanOrEqual(98) + expect(logo.h1X).toBeLessThanOrEqual(101) +} + +async function expectHeaderMenuAboveContent(page: Page): Promise { + const menuHit = await page.evaluate(`(() => { + const menu = document.querySelector('#headerActions') + if (!menu || menu.hidden) return false + const rect = menu.getBoundingClientRect() + const x = Math.floor(rect.left + rect.width / 2) + const y = Math.floor(rect.top + Math.min(rect.height - 2, 18)) + return Boolean(document.elementFromPoint(x, y)?.closest('#headerActions')) + })()`) + expect(menuHit).toBe(true) +} + +async function expectRunModeMenuAboveContent(page: Page): Promise { + const menuHit = await page.evaluate(`(() => { + const menu = document.querySelector('#runModeActions') + if (!menu || menu.hidden) return false + const rect = menu.getBoundingClientRect() + const x = Math.floor(rect.left + rect.width / 2) + const y = Math.floor(rect.top + Math.min(rect.height - 2, 18)) + return Boolean(document.elementFromPoint(x, y)?.closest('#runModeActions')) + })()`) + expect(menuHit).toBe(true) +} + +async function expectRunModeMenuLabelsContained(page: Page): Promise { + const menuGeometry = await page.evaluate<{ + buttonOverflow: boolean[] + menuWidth: number + textInsideMenu: boolean + }>(`(() => { + const menu = document.querySelector('#runModeActions') + if (!menu || menu.hidden) return { buttonOverflow: [], menuWidth: 0, textInsideMenu: false } + const menuRect = menu.getBoundingClientRect() + const buttons = Array.from(menu.querySelectorAll('button')) + return { + buttonOverflow: buttons.map((button) => button.scrollWidth > button.clientWidth + 1), + menuWidth: Math.round(menuRect.width), + textInsideMenu: buttons.every((button) => { + const rect = button.getBoundingClientRect() + return rect.left >= menuRect.left + 4 && rect.right <= menuRect.right - 4 + }), + } + })()`) + expect(menuGeometry.menuWidth).toBeGreaterThanOrEqual(224) + expect(menuGeometry.buttonOverflow).toEqual([false, false]) + expect(menuGeometry.textInsideMenu).toBe(true) +} + +async function expectProgressPanelAtTop(page: Page): Promise { + await expect + .poll(async () => + page.locator('#answer').evaluate((answer) => { + const panel = answer.closest('.panel') + return panel ? Math.round(panel.scrollTop) : -1 + }), + ) + .toBe(0) +} + +async function expectReviewResultVisibleInProgressPanel(page: Page): Promise { + await expect + .poll(async () => + page.locator('#answer').evaluate((answer) => { + const panel = answer.closest('.panel') + const result = answer.querySelector('.review-result') + const header = panel?.querySelector('h2') + const panelRect = panel?.getBoundingClientRect() + const resultRect = result?.getBoundingClientRect() + const headerRect = header?.getBoundingClientRect() + if (!panel || !panelRect || !resultRect || !headerRect) { + return { headerVisible: false, resultVisible: false, scrollTop: -1 } + } + return { + headerVisible: headerRect.top >= panelRect.top - 1 && headerRect.bottom <= panelRect.bottom, + resultVisible: resultRect.top >= headerRect.bottom + 4 && resultRect.bottom <= panelRect.bottom - 4, + scrollTop: Math.round(panel.scrollTop), + } + }), + ) + .toEqual({ + headerVisible: true, + resultVisible: true, + scrollTop: expect.any(Number), + }) + + const scrollTop = await page.locator('#answer').evaluate((answer) => { + const panel = answer.closest('.panel') + return panel ? Math.round(panel.scrollTop) : 0 + }) + expect(scrollTop).toBeGreaterThan(0) +} + +async function expectActionButtonsUseReferenceLayout(page: Page): Promise { + const buttonGeometry = await page.evaluate< + Array<{ + buttonDisplay: string + buttonInsideActions: boolean + captionFontSize: number + captionMuted: boolean + contentCenterDeltaX: number + contentDisplay: string + contentInsideButton: boolean + contentJustifyItems: string + copyCenterDeltaX: number + copyDisplay: string + headingCenterDeltaX: number + headingDisplay: string + headingInsideButton: boolean + iconInsideButton: boolean + iconLabelGap: number + iconLabelYDelta: number + id: string + justify: string + labelFontSize: number + labelFits: boolean + labelWeight: number + noLabelIconCollision: boolean + smallCenterDeltaX: number + smallFits: boolean + textAlign: string + }> + >(`Array.from(document.querySelectorAll('.actions > button')).map((button) => { + const actionsRect = document.querySelector('.actions')?.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const content = button.querySelector('.button-content')?.getBoundingClientRect() + const icon = button.querySelector('.button-icon')?.getBoundingClientRect() + const copy = button.querySelector('.action-copy')?.getBoundingClientRect() + const heading = button.querySelector('.action-heading')?.getBoundingClientRect() + const label = button.querySelector('.button-label')?.getBoundingClientRect() + const small = button.querySelector('.action-copy small')?.getBoundingClientRect() + const labelElement = button.querySelector('.button-label') + const smallElement = button.querySelector('.action-copy small') + const buttonStyle = getComputedStyle(button) + const contentElement = button.querySelector('.button-content') + const copyElement = button.querySelector('.action-copy') + const headingElement = button.querySelector('.action-heading') + const labelStyle = labelElement ? getComputedStyle(labelElement) : null + const smallStyle = smallElement ? getComputedStyle(smallElement) : null + return { + buttonDisplay: buttonStyle.display, + buttonInsideActions: actionsRect + ? buttonRect.left >= actionsRect.left - 0.5 && buttonRect.right <= actionsRect.right + 0.5 + : false, + captionFontSize: smallStyle ? Number.parseFloat(smallStyle.fontSize) : 0, + captionMuted: button.id === 'approve' || (smallStyle ? smallStyle.color === 'rgb(71, 85, 105)' : false), + contentCenterDeltaX: content + ? Math.abs((content.left + content.width / 2) - (buttonRect.left + buttonRect.width / 2)) + : 999, + contentDisplay: contentElement ? getComputedStyle(contentElement).display : '', + contentJustifyItems: contentElement ? getComputedStyle(contentElement).justifyItems : '', + contentInsideButton: content + ? content.left >= buttonRect.left && content.right <= buttonRect.right && content.top >= buttonRect.top && content.bottom <= buttonRect.bottom + : false, + copyCenterDeltaX: copy + ? Math.abs((copy.left + copy.width / 2) - (buttonRect.left + buttonRect.width / 2)) + : 999, + copyDisplay: copyElement ? getComputedStyle(copyElement).display : '', + headingCenterDeltaX: heading + ? Math.abs((heading.left + heading.width / 2) - (buttonRect.left + buttonRect.width / 2)) + : 999, + headingDisplay: headingElement ? getComputedStyle(headingElement).display : '', + headingInsideButton: heading + ? heading.left >= buttonRect.left && heading.right <= buttonRect.right && heading.top >= buttonRect.top && heading.bottom <= buttonRect.bottom + : false, + iconInsideButton: icon + ? icon.left >= buttonRect.left && icon.right <= buttonRect.right && icon.top >= buttonRect.top && icon.bottom <= buttonRect.bottom + : false, + iconLabelGap: icon && label ? label.left - icon.right : 0, + iconLabelYDelta: icon && label + ? Math.abs((icon.top + icon.height / 2) - (label.top + label.height / 2)) + : 999, + id: button.id, + justify: buttonStyle.justifyContent, + labelFontSize: labelStyle ? Number.parseFloat(labelStyle.fontSize) : 0, + labelFits: label ? label.left >= buttonRect.left && label.right <= buttonRect.right : false, + labelWeight: labelStyle ? Number.parseFloat(labelStyle.fontWeight) : 0, + noLabelIconCollision: icon && label ? icon.right + 2 <= label.left : false, + smallCenterDeltaX: small + ? Math.abs((small.left + small.width / 2) - (buttonRect.left + buttonRect.width / 2)) + : 999, + smallFits: small ? small.left >= buttonRect.left && small.right <= buttonRect.right : false, + textAlign: copy ? getComputedStyle(button.querySelector('.action-copy')).textAlign : '', + } + })`) + for (const geometry of buttonGeometry) { + expect(geometry.buttonDisplay).toBe('flex') + expect(geometry.buttonInsideActions).toBe(true) + expect(geometry.justify).toBe('center') + expect(geometry.contentDisplay).toBe('grid') + expect(geometry.contentJustifyItems).toBe('center') + expect(geometry.copyDisplay).toBe('grid') + expect(geometry.headingDisplay).toBe('flex') + expect(geometry.textAlign).toBe('center') + expect(geometry.contentInsideButton).toBe(true) + expect(geometry.contentCenterDeltaX).toBeLessThanOrEqual(1) + expect(geometry.copyCenterDeltaX, geometry.id).toBeLessThanOrEqual(1) + expect(geometry.headingCenterDeltaX, geometry.id).toBeLessThanOrEqual(1) + expect(geometry.smallCenterDeltaX, geometry.id).toBeLessThanOrEqual(1) + expect(geometry.headingInsideButton).toBe(true) + expect(geometry.iconLabelGap).toBeGreaterThanOrEqual(6) + expect(geometry.iconLabelGap).toBeLessThanOrEqual(10) + expect(geometry.iconInsideButton).toBe(true) + expect(geometry.iconLabelYDelta).toBeLessThanOrEqual(1.5) + expect(geometry.labelFontSize).toBeGreaterThanOrEqual(13) + expect(geometry.labelWeight).toBeGreaterThanOrEqual(800) + expect(geometry.captionFontSize).toBe(9) + expect(geometry.captionMuted).toBe(true) + expect(geometry.labelFits).toBe(true) + expect(geometry.noLabelIconCollision).toBe(true) + expect(geometry.smallFits).toBe(true) + } + const actionRow = await page.evaluate<{ + gaps: number[] + viewportWidth: number + widths: number[] + }>(`(() => { + const buttons = Array.from(document.querySelectorAll('.actions > button')).map((button) => button.getBoundingClientRect()) + return { + gaps: buttons.length === 3 + ? [ + Math.round(buttons[1].left - buttons[0].right), + Math.round(buttons[2].left - buttons[1].right), + ] + : [], + viewportWidth: window.innerWidth, + widths: buttons.map((button) => Math.round(button.width)), + } + })()`) + if (actionRow.viewportWidth >= 1451) { + expect(actionRow.widths).toEqual([190, 168, 166]) + expect(actionRow.gaps).toEqual([22, 28]) + } +} + +async function expectDiffLineGutter(page: Page): Promise { + const gutter = await page.evaluate<{ + allRowsNumbered: boolean + firstLine: string + gutterWidth: number + lineCount: number + numberCount: number + textAfterGutter: boolean + }>(`(() => { + const code = document.querySelector('.diff-code')?.getBoundingClientRect() + const rows = Array.from(document.querySelectorAll('.diff-line')) + const numbers = Array.from(document.querySelectorAll('.diff-line-no')) + const firstNumber = numbers[0]?.getBoundingClientRect() + const firstText = document.querySelector('.diff-line-text')?.getBoundingClientRect() + return { + allRowsNumbered: rows.every((row, index) => row.querySelector('.diff-line-no')?.textContent === String(index + 1)), + firstLine: numbers[0]?.textContent ?? '', + gutterWidth: firstNumber ? Math.round(firstNumber.width) : 0, + lineCount: rows.length, + numberCount: numbers.length, + textAfterGutter: Boolean(code && firstNumber && firstText && firstNumber.left >= code.left && firstText.left > firstNumber.right), + } + })()`) + expect(gutter.lineCount).toBeGreaterThan(1) + expect(gutter.numberCount).toBe(gutter.lineCount) + expect(gutter.firstLine).toBe('1') + expect(gutter.allRowsNumbered).toBe(true) + expect(gutter.gutterWidth).toBeGreaterThanOrEqual(20) + expect(gutter.gutterWidth).toBeLessThanOrEqual(24) + expect(gutter.textAfterGutter).toBe(true) +} + +async function expectDiffRowsFillReferenceFrame(page: Page): Promise { + const rhythm = await page.evaluate<{ + bottomGap: number + lineHeight: number + topGap: number + }>(`(() => { + const code = document.querySelector('.diff-code')?.getBoundingClientRect() + const rows = Array.from(document.querySelectorAll('.diff-line')) + const first = rows[0]?.getBoundingClientRect() + const last = rows[rows.length - 1]?.getBoundingClientRect() + const style = document.querySelector('.diff-code') + ? getComputedStyle(document.querySelector('.diff-code')) + : null + if (!code || !first || !last || !style) return { bottomGap: 999, lineHeight: 0, topGap: 999 } + return { + bottomGap: Math.round((code.bottom - last.bottom) * 100) / 100, + lineHeight: Number.parseFloat(style.lineHeight), + topGap: Math.round((first.top - code.top) * 100) / 100, + } + })()`) + expect(rhythm.lineHeight).toBeGreaterThanOrEqual(14) + expect(rhythm.lineHeight).toBeLessThanOrEqual(14.4) + expect(rhythm.topGap).toBeGreaterThanOrEqual(7) + expect(rhythm.topGap).toBeLessThanOrEqual(11) + expect(rhythm.bottomGap).toBeGreaterThanOrEqual(7) + expect(rhythm.bottomGap).toBeLessThanOrEqual(11) +} + +async function expectDiffCopyControlUsesReferencePlacement(page: Page): Promise { + const geometry = await page.evaluate<{ + afterWrapGap: number + buttonHeight: number + buttonWidth: number + centerXDelta: number + centerYDelta: number + hitTargetWorks: boolean + iconHeight: number + iconWidth: number + toolsHeight: number + }>(`(() => { + const tools = document.querySelector('.diff-tools')?.getBoundingClientRect() + const wrap = document.querySelector('#diffWrapToggle')?.getBoundingClientRect() + const button = document.querySelector('#copyDiff')?.getBoundingClientRect() + const icon = document.querySelector('#copyDiff svg')?.getBoundingClientRect() + if (!tools || !wrap || !button || !icon) { + return { + afterWrapGap: 999, + buttonHeight: 0, + buttonWidth: 0, + centerXDelta: 999, + centerYDelta: 999, + hitTargetWorks: false, + iconHeight: 0, + iconWidth: 0, + toolsHeight: 0, + } + } + const x = Math.floor(button.left + button.width / 2) + const y = Math.floor(button.top + button.height / 2) + return { + afterWrapGap: Math.round((button.left - wrap.right) * 100) / 100, + buttonHeight: Math.round(button.height), + buttonWidth: Math.round(button.width), + centerXDelta: Math.abs((icon.left + icon.width / 2) - (button.left + button.width / 2)), + centerYDelta: Math.abs((icon.top + icon.height / 2) - (button.top + button.height / 2)), + hitTargetWorks: Boolean(document.elementFromPoint(x, y)?.closest('#copyDiff')), + iconHeight: Math.round(icon.height), + iconWidth: Math.round(icon.width), + toolsHeight: Math.round(tools.height), + } + })()`) + expect(geometry.afterWrapGap).toBeGreaterThanOrEqual(7) + expect(geometry.afterWrapGap).toBeLessThanOrEqual(9) + expect(geometry.buttonHeight).toBe(24) + expect(geometry.buttonWidth).toBe(24) + expect(geometry.iconHeight).toBe(14) + expect(geometry.iconWidth).toBe(14) + expect(geometry.centerXDelta).toBeLessThanOrEqual(1) + expect(geometry.centerYDelta).toBeLessThanOrEqual(1) + expect(geometry.toolsHeight).toBeGreaterThanOrEqual(24) + expect(geometry.hitTargetWorks).toBe(true) +} + +async function expectReferenceProposalPanelChrome(page: Page): Promise { + const chrome = await page.evaluate<{ + actionPill: { fontSize: number; height: number } + diffLabel: { fontSize: number; text: string; textTransform: string } + targetCode: { fontSize: number; height: number } + }>(`(() => { + const measure = (selector) => { + const element = document.querySelector(selector) + const rect = element?.getBoundingClientRect() + const style = element ? getComputedStyle(element) : null + return rect && style + ? { + fontSize: Number.parseFloat(style.fontSize), + height: Math.round(rect.height * 100) / 100, + text: element.textContent?.trim() ?? '', + textTransform: style.textTransform, + } + : { fontSize: 0, height: 999, text: '', textTransform: '' } + } + return { + actionPill: measure('#proposal .metric .pill'), + diffLabel: measure('#proposal .diff-head .label'), + targetCode: measure('#proposal .metric .meta-code'), + } + })()`) + expect(chrome.actionPill.fontSize).toBe(12) + expect(chrome.actionPill.height).toBeLessThanOrEqual(24) + expect(chrome.targetCode.fontSize).toBe(12) + expect(chrome.targetCode.height).toBeLessThanOrEqual(24) + expect(chrome.diffLabel.text).toBe('Diff (unified)') + expect(chrome.diffLabel.textTransform).toBe('none') + expect(chrome.diffLabel.fontSize).toBe(13) +} + +async function expectReferenceDesktopPrimaryCaption(page: Page): Promise { + const captionGeometry = await page.evaluate<{ + fits: boolean + fontSize: number + height: number + lineHeight: number + whiteSpace: string + }>(`(() => { + const button = document.querySelector('#approve') + const caption = button?.querySelector('.action-copy small') + if (!button || !caption) return { fits: false, fontSize: 0, height: 999, lineHeight: 0, whiteSpace: '' } + const buttonRect = button.getBoundingClientRect() + const captionRect = caption.getBoundingClientRect() + const style = getComputedStyle(caption) + return { + fits: captionRect.left >= buttonRect.left && captionRect.right <= buttonRect.right, + fontSize: Number.parseFloat(style.fontSize), + height: captionRect.height, + lineHeight: Number.parseFloat(style.lineHeight), + whiteSpace: style.whiteSpace, + } + })()`) + expect(captionGeometry.fontSize).toBe(9) + expect(captionGeometry.whiteSpace).toBe('nowrap') + expect(captionGeometry.fits).toBe(true) + expect(captionGeometry.height).toBeLessThanOrEqual(captionGeometry.lineHeight + 1) +} + +async function expectReferenceDesktopRiskTextFits(page: Page): Promise { + const riskGeometry = await page.evaluate<{ + gap: number + detailsInside: boolean + visualGap: number + textOverflow: string + }>(`(() => { + const bar = document.querySelector('.risk-bar') + const value = bar?.querySelector('.value') + const details = bar?.querySelector('.risk-details-toggle') + const style = value ? getComputedStyle(value) : null + const barRect = bar?.getBoundingClientRect() + const valueRect = value?.getBoundingClientRect() + const detailsRect = details?.getBoundingClientRect() + return { + gap: bar ? Number.parseFloat(getComputedStyle(bar).columnGap) : 0, + detailsInside: Boolean(barRect && detailsRect && detailsRect.right <= barRect.right + 1), + visualGap: valueRect && detailsRect ? Math.round(detailsRect.left - valueRect.right) : -999, + textOverflow: style?.textOverflow ?? '', + } + })()`) + expect(riskGeometry.gap).toBeLessThanOrEqual(7) + expect(riskGeometry.textOverflow).toBe('ellipsis') + expect(riskGeometry.visualGap).toBeGreaterThanOrEqual(4) + expect(riskGeometry.detailsInside).toBe(true) +} + +async function expectReferenceVerificationRows(page: Page): Promise { + const rows = await page.evaluate< + Array<{ + borderWidth: string + detailFontSize: number + iconHeight: number + minHeight: number + paddingLeft: number + paddingRight: number + radius: string + rowGap: number + strongFontSize: number + strongWeight: number + }> + >(`Array.from(document.querySelectorAll('#verification .verify-row')).map((row) => { + const style = getComputedStyle(row) + const icon = row.querySelector('.verify-icon')?.getBoundingClientRect() + const strong = row.querySelector('strong') + const detail = row.querySelector('.empty') + const strongStyle = strong ? getComputedStyle(strong) : null + const detailStyle = detail ? getComputedStyle(detail) : null + return { + borderWidth: style.borderTopWidth, + detailFontSize: detailStyle ? Number.parseFloat(detailStyle.fontSize) : 0, + iconHeight: icon ? Math.round(icon.height) : 0, + minHeight: Math.round(row.getBoundingClientRect().height), + paddingLeft: Number.parseFloat(style.paddingLeft), + paddingRight: Number.parseFloat(style.paddingRight), + radius: style.borderTopLeftRadius, + rowGap: Number.parseFloat(style.columnGap), + strongFontSize: strongStyle ? Number.parseFloat(strongStyle.fontSize) : 0, + strongWeight: strongStyle ? Number.parseFloat(strongStyle.fontWeight) : 0, + } + })`) + expect(rows).toHaveLength(3) + for (const row of rows) { + expect(row.borderWidth).toBe('0px') + expect(row.radius).toBe('0px') + expect(row.paddingLeft).toBe(0) + expect(row.paddingRight).toBe(0) + expect(row.detailFontSize).toBe(11) + expect(row.minHeight).toBeGreaterThanOrEqual(42) + expect(row.iconHeight).toBe(28) + expect(row.rowGap).toBe(8) + expect(row.strongFontSize).toBe(12) + expect(row.strongWeight).toBe(700) + } +} + +async function expectVerificationResultRhythm(page: Page): Promise { + const result = await page.evaluate<{ + background: string + borderLeftWidth: string + borderTopWidth: string + rowGap: number + rows: Array<{ + detailTop: number + detailWidth: number + dotTop: number + height: number + labelBottom: number + text: string + verticalGap: number + }> + }>(`(() => { + const result = document.querySelector('#verificationResult') + const resultStyle = result ? getComputedStyle(result) : null + return { + background: resultStyle?.backgroundColor ?? '', + borderLeftWidth: resultStyle?.borderLeftWidth ?? '', + borderTopWidth: resultStyle?.borderTopWidth ?? '', + rowGap: resultStyle ? Number.parseFloat(resultStyle.rowGap) : 0, + rows: Array.from(document.querySelectorAll('#verificationResult .verification-step')).map((row) => { + const rect = row.getBoundingClientRect() + const dot = row.querySelector('.verification-dot')?.getBoundingClientRect() + const copy = row.querySelector(':scope > div') + const label = copy?.querySelector('strong')?.getBoundingClientRect() + const detail = copy?.querySelector('span')?.getBoundingClientRect() + return { + detailTop: Math.round((detail?.top ?? 0) * 100) / 100, + detailWidth: Math.round((detail?.width ?? 0) * 100) / 100, + dotTop: Math.round((dot?.top ?? 0) * 100) / 100, + height: Math.round(rect.height * 100) / 100, + labelBottom: Math.round((label?.bottom ?? 0) * 100) / 100, + text: row.textContent?.trim().replace(/\\s+/g, ' ') ?? '', + verticalGap: label && detail ? Math.round((detail.top - label.bottom) * 100) / 100 : -999, + } + }), + } + })()`) + expect(result.background).toBe('rgba(0, 0, 0, 0)') + expect(result.borderTopWidth).toBe('1px') + expect(result.borderLeftWidth).toBe('0px') + expect(result.rowGap).toBe(8) + expect(result.rows).toHaveLength(3) + for (const row of result.rows) { + expect(row.height).toBeGreaterThanOrEqual(31) + expect(row.verticalGap).toBeGreaterThanOrEqual(2) + expect(row.detailWidth).toBeGreaterThan(200) + expect(row.dotTop).toBeLessThan(row.detailTop) + } +} + +async function expectPendingSignerHashReadable(page: Page): Promise { + const signature = await page.evaluate<{ + hashText: string + hashWidth: number + slotText: string + slotWidth: number + visibleHashFits: boolean + }>(`(() => { + const firstRow = document.querySelector('.signer-row') + const slot = firstRow?.querySelector('.signature-slot') + const hash = firstRow?.querySelector('.signature-slot .hash') + const slotRect = slot?.getBoundingClientRect() + const hashRect = hash?.getBoundingClientRect() + return { + hashText: hash?.textContent?.trim() ?? '', + hashWidth: Math.round((hashRect?.width ?? 0) * 100) / 100, + slotText: slot?.textContent?.trim() ?? '', + slotWidth: Math.round((slotRect?.width ?? 0) * 100) / 100, + visibleHashFits: hash ? hash.scrollWidth <= hash.clientWidth + 1 : false, + } + })()`) + expect(signature.hashText).toMatch(/^[a-f0-9]{6}\.\.\.[a-f0-9]{4}$/) + expect(signature.slotText).toMatch(/^Latest:/) + expect(signature.slotWidth).toBeGreaterThanOrEqual(72) + expect(signature.hashWidth).toBeGreaterThanOrEqual(44) + expect(signature.visibleHashFits).toBe(true) +} + +async function expectProposalProgressDot(page: Page, expected: 'pending' | 'complete'): Promise { + const proposalDot = await page.evaluate<{ + afterContent: string + background: string + text: string + }>(`(() => { + const row = Array.from(document.querySelectorAll('#answer .progress-item')) + .find((item) => item.textContent?.includes('Proposed action generated')) + const dot = row?.querySelector('.dot') + const style = dot ? getComputedStyle(dot) : null + const afterStyle = dot ? getComputedStyle(dot, '::after') : null + return { + afterContent: afterStyle?.content ?? '', + background: style?.backgroundColor ?? '', + text: row?.textContent?.replace(/\\s+/g, ' ').trim() ?? '', + } + })()`) + expect(proposalDot.text).toContain('Proposed action generated') + if (expected === 'pending') { + expect(proposalDot.background).toBe('rgb(9, 105, 218)') + expect(proposalDot.afterContent).toBe('none') + } else { + expect(proposalDot.background).toBe('rgb(7, 136, 97)') + expect(proposalDot.afterContent).not.toBe('none') + } +} + +async function expectReferenceSignerIconTreatment(page: Page): Promise { + const icons = await page.evaluate< + Array<{ + background: string + color: string + iconClass: string + iconHeight: number + iconWidth: number + svgHeight: number + svgWidth: number + }> + >(`Array.from(document.querySelectorAll('.signer-icon')).map((icon) => { + const svg = icon.querySelector('svg') + const iconRect = icon.getBoundingClientRect() + const svgRect = svg?.getBoundingClientRect() + const style = getComputedStyle(icon) + return { + background: style.backgroundColor, + color: style.color, + iconClass: String(icon.className), + iconHeight: Math.round(iconRect.height), + iconWidth: Math.round(iconRect.width), + svgHeight: Math.round(svgRect?.height ?? 0), + svgWidth: Math.round(svgRect?.width ?? 0), + } + })`) + expect(icons).toHaveLength(3) + const [agent, human, mcp] = icons + for (const icon of icons) { + expect(icon.background).toBe('rgba(0, 0, 0, 0)') + expect(icon.iconWidth).toBe(22) + expect(icon.iconHeight).toBe(22) + expect(icon.svgWidth).toBe(20) + expect(icon.svgHeight).toBe(20) + } + expect(agent.iconClass).toContain('agent') + expect(agent.color).toBe('rgb(9, 105, 218)') + expect(human.iconClass).toContain('human') + expect(human.color).toBe('rgb(199, 106, 0)') + expect(mcp.iconClass).toContain('mcp') + expect(mcp.color).toBe('rgb(7, 136, 97)') +} + +async function expectReferenceReceiptJsonSyntax(page: Page): Promise { + const syntax = await page.evaluate<{ + borderWidth: string + gutterWidth: number + keyColor: string + keyCount: number + lineCount: number + maxHeight: number + numberColor: string + stringColor: string + stringCount: number + tenthLineNumber: string + }>(`(() => { + const pre = document.querySelector('#receipts pre') + const preStyle = pre ? getComputedStyle(pre) : null + const numbers = Array.from(document.querySelectorAll('#receipts .json-line-number')) + const firstNumber = numbers[0]?.getBoundingClientRect() + const key = document.querySelector('#receipts .json-token.key') + const string = document.querySelector('#receipts .json-token.string') + const number = document.querySelector('#receipts .json-token.number') + return { + borderWidth: preStyle?.borderTopWidth ?? '', + gutterWidth: firstNumber ? Math.round(firstNumber.width) : 0, + keyColor: key ? getComputedStyle(key).color : '', + keyCount: document.querySelectorAll('#receipts .json-token.key').length, + lineCount: document.querySelectorAll('#receipts .json-line').length, + maxHeight: preStyle ? Number.parseFloat(preStyle.maxHeight) : 0, + numberColor: number ? getComputedStyle(number).color : '', + stringColor: string ? getComputedStyle(string).color : '', + stringCount: document.querySelectorAll('#receipts .json-token.string').length, + tenthLineNumber: numbers[9]?.textContent ?? '', + } + })()`) + expect(syntax.borderWidth).toBe('0px') + expect(syntax.gutterWidth).toBeGreaterThanOrEqual(34) + expect(syntax.keyCount).toBeGreaterThan(4) + expect(syntax.lineCount).toBeGreaterThan(1) + expect(syntax.maxHeight).toBe(188) + expect(syntax.stringCount).toBeGreaterThan(4) + expect(syntax.tenthLineNumber).toBe('10') + expect(syntax.keyColor).toBe('rgb(195, 58, 101)') + expect(syntax.stringColor).toBe('rgb(40, 79, 147)') + expect(syntax.numberColor).toBe('rgb(29, 118, 101)') +} + +async function expectReceiptPanelFitsReferenceViewport(page: Page): Promise { + const receiptFit = await page.evaluate<{ + pageHeight: number + preHeight: number + receiptBottom: number + receiptHeight: number + shellHeight: number + viewportHeight: number + }>(`(() => { + const receipt = document.querySelector('.receipt-panel')?.getBoundingClientRect() + const shell = document.querySelector('.receipt-shell')?.getBoundingClientRect() + const pre = document.querySelector('#receipts pre')?.getBoundingClientRect() + return { + pageHeight: Math.round(document.body.scrollHeight), + preHeight: Math.round(pre?.height ?? 0), + receiptBottom: Math.round(receipt?.bottom ?? 0), + receiptHeight: Math.round(receipt?.height ?? 0), + shellHeight: Math.round(shell?.height ?? 0), + viewportHeight: window.innerHeight, + } + })()`) + expect(receiptFit.viewportHeight).toBe(1024) + expect(receiptFit.pageHeight).toBeLessThanOrEqual(receiptFit.viewportHeight + 1) + expect(receiptFit.receiptBottom).toBeLessThanOrEqual(receiptFit.viewportHeight + 1) + expect(receiptFit.receiptHeight).toBeGreaterThanOrEqual(246) + expect(receiptFit.receiptHeight).toBeLessThanOrEqual(250) + expect(receiptFit.shellHeight).toBeLessThanOrEqual(210) + expect(receiptFit.preHeight).toBeLessThanOrEqual(188) +} + +async function expectReferenceReceiptToolbarRhythm(page: Page): Promise { + const toolbar = await page.evaluate<{ + copyX: number + downloadWidth: number + downloadX: number + formatFontSize: number + formatWidth: number + formatX: number + labelFontSize: number + labelX: number + titleWidth: number + titleX: number + }>(`(() => { + const title = document.querySelector('.receipt-toolbar h2') + const label = document.querySelector('.receipt-controls .label') + const format = document.querySelector('#receiptFormat') + const copy = document.querySelector('#copyReceipt') + const download = document.querySelector('#downloadReceipt') + const titleRect = title?.getBoundingClientRect() + const labelRect = label?.getBoundingClientRect() + const formatRect = format?.getBoundingClientRect() + const copyRect = copy?.getBoundingClientRect() + const downloadRect = download?.getBoundingClientRect() + return { + copyX: Math.round(copyRect?.x ?? 0), + downloadWidth: Math.round(downloadRect?.width ?? 0), + downloadX: Math.round(downloadRect?.x ?? 0), + formatFontSize: Number.parseFloat(format ? getComputedStyle(format).fontSize : '0'), + formatWidth: Math.round(formatRect?.width ?? 0), + formatX: Math.round(formatRect?.x ?? 0), + labelFontSize: Number.parseFloat(label ? getComputedStyle(label).fontSize : '0'), + labelX: Math.round(labelRect?.x ?? 0), + titleWidth: Math.round(titleRect?.width ?? 0), + titleX: Math.round(titleRect?.x ?? 0), + } + })()`) + expect(toolbar.titleX).toBeGreaterThanOrEqual(24) + expect(toolbar.titleX).toBeLessThanOrEqual(26) + expect(toolbar.titleWidth).toBe(123) + expect(toolbar.labelX).toBeGreaterThanOrEqual(155) + expect(toolbar.labelX).toBeLessThanOrEqual(157) + expect(toolbar.labelFontSize).toBe(11) + expect(toolbar.formatX).toBeGreaterThanOrEqual(203) + expect(toolbar.formatX).toBeLessThanOrEqual(205) + expect(toolbar.formatWidth).toBe(121) + expect(toolbar.formatFontSize).toBe(12) + expect(toolbar.copyX).toBeGreaterThanOrEqual(332) + expect(toolbar.copyX).toBeLessThanOrEqual(334) + expect(toolbar.downloadX).toBeGreaterThanOrEqual(368) + expect(toolbar.downloadX).toBeLessThanOrEqual(370) + expect(toolbar.downloadWidth).toBe(128) +} + +async function expectReferenceDesktopCenterStack(page: Page): Promise { + const stackGeometry = await page.evaluate<{ + actionBottomGap: number + actionsY: number + diffCodeHeight: number + panelBottom: number + riskBarHeight: number + }>(`(() => { + const panel = document.querySelector('#proposal')?.closest('.panel')?.getBoundingClientRect() + const diffCode = document.querySelector('.diff-code')?.getBoundingClientRect() + const riskBar = document.querySelector('.risk-bar')?.getBoundingClientRect() + const actions = document.querySelector('.actions')?.getBoundingClientRect() + if (!panel || !diffCode || !riskBar || !actions) { + return { actionBottomGap: 999, actionsY: 0, diffCodeHeight: 0, panelBottom: 0, riskBarHeight: 0 } + } + return { + actionBottomGap: Math.round(panel.bottom - actions.bottom), + actionsY: Math.round(actions.y), + diffCodeHeight: Math.round(diffCode.height), + panelBottom: Math.round(panel.bottom), + riskBarHeight: Math.round(riskBar.height), + } + })()`) + expect(stackGeometry.diffCodeHeight).toBeGreaterThanOrEqual(313) + expect(stackGeometry.diffCodeHeight).toBeLessThanOrEqual(316) + expect(stackGeometry.actionBottomGap).toBeGreaterThanOrEqual(12) + expect(stackGeometry.actionBottomGap).toBeLessThanOrEqual(18) + expect(stackGeometry.actionsY).toBeGreaterThanOrEqual(690) + expect(stackGeometry.actionsY).toBeLessThanOrEqual(698) + expect(stackGeometry.riskBarHeight).toBeGreaterThanOrEqual(38) +} + +async function expectWorkflowStepCopyHugsContent(page: Page): Promise { + const stepGeometry = await page.evaluate< + Array<{ copyWidth: number; rowWidth: number; step: string | null }> + >(`Array.from(document.querySelectorAll('.step')).map((step) => { + const row = step.getBoundingClientRect() + const copy = step.querySelector('.step-copy')?.getBoundingClientRect() + return { + copyWidth: copy ? copy.width : row.width, + rowWidth: row.width, + step: step.getAttribute('data-step'), + } + })`) + for (const geometry of stepGeometry) { + expect(geometry.copyWidth).toBeLessThanOrEqual(geometry.rowWidth - 40) + } +} + +async function expectWorkflowStepTitlesBold(page: Page): Promise { + const weights = await page.evaluate>( + `Array.from(document.querySelectorAll('.step')).map((step) => { + const title = step.querySelector('.step-copy strong [data-step-title]') ?? step.querySelector('.step-copy strong') + return { + step: step.getAttribute('data-step'), + weight: title ? Number.parseFloat(getComputedStyle(title).fontWeight) : 0, + } + })`, + ) + expect(weights).toHaveLength(5) + for (const item of weights) { + expect(item.weight).toBeGreaterThanOrEqual(800) + } +} + +async function expectReviewPillMatchesRailBadge(page: Page): Promise { + const colors = await page.evaluate<{ + badge: { background: string; border: string; color: string; text: string } | null + pill: { background: string; border: string; color: string; text: string } | null + }>(`(() => { + const read = (element) => { + if (!element) return null + const style = getComputedStyle(element) + return { + background: style.backgroundColor, + border: style.borderTopColor, + color: style.color, + text: element.textContent?.trim() ?? '', + } + } + return { + badge: read(document.querySelector('[data-step-badge="halt"]')), + pill: read(document.querySelector('#reviewStatePill')), + } + })()`) + expect(colors.pill).not.toBeNull() + expect(colors.badge).not.toBeNull() + expect(colors.pill?.background).toBe(colors.badge?.background) + expect(colors.pill?.border).toBe(colors.badge?.border) + expect(colors.pill?.color).toBe(colors.badge?.color) +} + +async function expectReferenceDesktopRailGeometry(page: Page): Promise { + const railGeometry = await page.evaluate<{ + badge: { + color: string + fontSize: number + fontWeight: number + height: number + width: number + } | null + haltMarker: { + afterRight: number + afterWidth: number + backgroundImage: string + beforeLeft: number + beforeWidth: number + barHeight: number + borderColor: string + } | null + connectors: Array<{ + backgroundColor: string + backgroundImage: string + height: number + width: number + step: string | null + }> + steps: Array<{ + indexX: number | null + rectH: number + rectW: number + rectX: number + step: string | null + }> + }>(`(() => { + const badge = document.querySelector('[data-step-badge="halt"]') + const badgeRect = badge?.getBoundingClientRect() + const badgeStyle = badge ? getComputedStyle(badge) : null + const marker = document.querySelector('[data-step="halt"] .step-index') + const markerStyle = marker ? getComputedStyle(marker) : null + const markerBefore = marker ? getComputedStyle(marker, '::before') : null + const markerAfter = marker ? getComputedStyle(marker, '::after') : null + return { + badge: badge && badgeRect && badgeStyle ? { + color: badgeStyle.color, + fontSize: Number.parseFloat(badgeStyle.fontSize), + fontWeight: Number.parseFloat(badgeStyle.fontWeight), + height: Math.round(badgeRect.height), + width: Math.round(badgeRect.width), + } : null, + haltMarker: markerStyle && markerBefore && markerAfter ? { + afterRight: Number.parseFloat(markerAfter.right), + afterWidth: Number.parseFloat(markerAfter.width), + backgroundImage: markerStyle.backgroundImage, + beforeLeft: Number.parseFloat(markerBefore.left), + beforeWidth: Number.parseFloat(markerBefore.width), + barHeight: Number.parseFloat(markerBefore.height), + borderColor: markerStyle.borderColor, + } : null, + connectors: Array.from(document.querySelectorAll('.step:not(:last-child)')).map((step) => { + const after = getComputedStyle(step, '::after') + return { + backgroundColor: after.backgroundColor, + backgroundImage: after.backgroundImage, + height: Number.parseFloat(after.height), + width: Number.parseFloat(after.width), + step: step.getAttribute('data-step'), + } + }), + steps: Array.from(document.querySelectorAll('.step')).map((step) => { + const rect = step.getBoundingClientRect() + const index = step.querySelector('.step-index')?.getBoundingClientRect() + return { + indexX: index ? Math.round(index.x) : null, + rectH: Math.round(rect.height), + rectW: Math.round(rect.width), + rectX: Math.round(rect.x), + step: step.getAttribute('data-step'), + } + }), + } + })()`) + const byStep = Object.fromEntries( + railGeometry.steps.map((geometry) => [geometry.step, geometry]), + ) + expect(byStep.trigger.indexX).toBeGreaterThanOrEqual(51) + expect(byStep.trigger.indexX).toBeLessThanOrEqual(53) + expect(byStep.autonomous.indexX).toBeGreaterThanOrEqual(339) + expect(byStep.autonomous.indexX).toBeLessThanOrEqual(341) + expect(byStep.halt.rectX).toBeGreaterThanOrEqual(598) + expect(byStep.halt.rectX).toBeLessThanOrEqual(600) + expect(byStep.halt.rectW).toBeGreaterThanOrEqual(330) + expect(byStep.halt.rectW).toBeLessThanOrEqual(332) + expect(byStep.halt.rectH).toBe(58) + expect(byStep.resume.indexX).toBeGreaterThanOrEqual(985) + expect(byStep.resume.indexX).toBeLessThanOrEqual(987) + expect(byStep.audit.indexX).toBeGreaterThanOrEqual(1326) + expect(byStep.audit.indexX).toBeLessThanOrEqual(1328) + expect(railGeometry.badge?.fontSize).toBe(10) + expect(railGeometry.badge?.fontWeight).toBeGreaterThanOrEqual(800) + expect(railGeometry.badge?.height).toBeLessThanOrEqual(18) + expect(railGeometry.badge?.width).toBeLessThanOrEqual(112) + expect(railGeometry.badge?.color).toBe('rgb(164, 73, 0)') + expect(railGeometry.haltMarker?.backgroundImage).toContain('linear-gradient') + expect(railGeometry.haltMarker?.borderColor).toBe('rgb(245, 158, 11)') + expect(railGeometry.haltMarker?.beforeLeft).toBe(13) + expect(railGeometry.haltMarker?.afterRight).toBe(12) + expect(railGeometry.haltMarker?.beforeWidth).toBe(3) + expect(railGeometry.haltMarker?.afterWidth).toBe(3) + expect(railGeometry.haltMarker?.barHeight).toBe(14) + const connectors = Object.fromEntries( + railGeometry.connectors.map((connector) => [connector.step, connector]), + ) + expect(connectors.trigger.backgroundColor).toBe('rgb(7, 136, 97)') + expect(connectors.autonomous.backgroundColor).toBe('rgb(7, 136, 97)') + expect(connectors.halt.backgroundImage).toContain('repeating-linear-gradient') + expect(connectors.resume.backgroundImage).toContain('repeating-linear-gradient') + expect(connectors.halt.height).toBe(2) + expect(connectors.resume.height).toBe(2) + expect(connectors.halt.width).toBeGreaterThanOrEqual(50) + expect(connectors.halt.width).toBeLessThanOrEqual(70) +} + +async function expectConstrainedDesktopRailGeometry(page: Page): Promise { + const railGeometry = await page.evaluate<{ + badge: { + fitsInHalted: boolean + height: number + text: string + whiteSpace: string + width: number + } | null + meta: { + height: number + width: number + } | null + }>(`(() => { + const badge = document.querySelector('[data-step-badge="halt"]') + const halted = document.querySelector('[data-step="halt"]') + const meta = document.querySelector('[data-step="halt"] .step-meta-line') + const badgeRect = badge?.getBoundingClientRect() + const haltedRect = halted?.getBoundingClientRect() + const metaRect = meta?.getBoundingClientRect() + return { + badge: badge && badgeRect ? { + fitsInHalted: haltedRect ? badgeRect.left >= haltedRect.left && badgeRect.right <= haltedRect.right : false, + height: Math.round(badgeRect.height), + text: badge.textContent?.trim() ?? '', + whiteSpace: getComputedStyle(badge).whiteSpace, + width: Math.round(badgeRect.width), + } : null, + meta: meta && metaRect ? { + height: Math.round(metaRect.height), + width: Math.round(metaRect.width), + } : null, + } + })()`) + expect(railGeometry.badge?.text).toBe('Awaiting review') + expect(railGeometry.badge?.whiteSpace).toBe('nowrap') + expect(railGeometry.badge?.fitsInHalted).toBe(true) + expect(railGeometry.badge?.height).toBeLessThanOrEqual(18) + expect(railGeometry.badge?.width).toBeGreaterThanOrEqual(86) + expect(railGeometry.meta?.height).toBeLessThanOrEqual(18) +} + +async function expectTraceRowsReadable(page: Page): Promise { + const rowOpacity = await page.evaluate>( + `Array.from(document.querySelectorAll('.progress-item, #timeline .event, #timeline .event-future')).map((row) => ({ + opacity: Number(getComputedStyle(row).opacity), + selector: row.className, + }))`, + ) + for (const row of rowOpacity) { + expect(row.opacity).toBeGreaterThanOrEqual(0.98) + } +} + +async function expectTraceIntegrityProofStatusFits(page: Page): Promise { + const proofStatus = await page.evaluate<{ + fontSize: number + externalIconHeight: number + iconVisible: boolean + linkFontSize: number + linkVisible: boolean + textFits: boolean + valueClientWidth: number + valueScrollWidth: number + }>(`(() => { + const row = document.querySelector('.integrity-row.proof-row') + const value = row?.querySelector('.value') + const icon = row?.querySelector('.integrity-proof-dot') + const text = row?.querySelector('.proof-status-text') + const link = row?.querySelector('a') + const externalIcon = link?.querySelector('svg') + const valueStyle = value ? getComputedStyle(value) : null + const linkStyle = link ? getComputedStyle(link) : null + return { + fontSize: valueStyle ? Number.parseFloat(valueStyle.fontSize) : 0, + externalIconHeight: externalIcon ? Math.round(externalIcon.getBoundingClientRect().height) : 0, + iconVisible: !!icon && icon.getBoundingClientRect().width === 12, + linkFontSize: linkStyle ? Number.parseFloat(linkStyle.fontSize) : 0, + linkVisible: !!link && link.getBoundingClientRect().width > 0, + textFits: text ? text.scrollWidth <= text.clientWidth + 1 : false, + valueClientWidth: text ? text.clientWidth : 0, + valueScrollWidth: text ? text.scrollWidth : 999, + } + })()`) + expect(proofStatus.fontSize).toBe(12) + expect(proofStatus.externalIconHeight).toBe(12) + expect(proofStatus.iconVisible).toBe(true) + expect(proofStatus.linkFontSize).toBe(12) + expect(proofStatus.linkVisible).toBe(true) + expect(proofStatus.textFits).toBe(true) + expect(proofStatus.valueClientWidth).toBeGreaterThanOrEqual(proofStatus.valueScrollWidth - 1) +} + +async function expectReferenceTimelineSpacing(page: Page): Promise { + const timeline = await page.evaluate<{ + rows: Array<{ + copyLeft: number + cueLeft: number | null + hashLeft: number + hashTextAlign: string + isRecordRow: boolean + label: string + markerLeft: number + markerRailGap: number + markerToSignerGap: number | null + markerToTimeGap: number + rowClass: string + signerClass: string + signerWidth: number + signerBackground: string + timeLeft: number + timestampToCopyOffset: number + }> + viewportWidth: number + }>(`(() => ({ + viewportWidth: window.innerWidth, + rows: Array.from(document.querySelectorAll('#timeline .event, #timeline .event-future')).map((row) => { + const rowRect = row.getBoundingClientRect() + const marker = row.querySelector('.event-marker')?.getBoundingClientRect() + const time = row.querySelector('.event-time')?.getBoundingClientRect() + const copy = row.querySelector('.event-copy')?.getBoundingClientRect() + const hashElement = row.querySelector('.event-hash') + const hash = hashElement?.getBoundingClientRect() + const cue = row.querySelector('.event-cue')?.getBoundingClientRect() + const signer = row.querySelector('.event-signer-icon') + const signerRect = signer?.getBoundingClientRect() + return { + copyLeft: copy ? Math.round(copy.left) : 0, + cueLeft: cue ? Math.round(cue.left) : null, + hashLeft: hash ? Math.round(hash.left) : 0, + hashTextAlign: hashElement ? getComputedStyle(hashElement).textAlign : '', + isRecordRow: row.classList.contains('event'), + label: row.querySelector('strong')?.textContent?.trim() ?? '', + markerLeft: marker ? Math.round(marker.left) : 0, + markerRailGap: marker ? Math.round(marker.left - rowRect.left - 3) : 0, + markerToSignerGap: marker && signerRect ? Math.round(signerRect.left - marker.right) : null, + markerToTimeGap: marker && time ? Math.round(time.left - marker.right) : 0, + rowClass: row.className, + signerBackground: signer ? getComputedStyle(signer).backgroundColor : '', + signerClass: signer?.className ?? '', + signerWidth: signerRect ? Math.round(signerRect.width) : 0, + timeLeft: time ? Math.round(time.left) : 0, + timestampToCopyOffset: time && copy ? Math.round(copy.left - time.left) : 0, + } + }), + }))()`) + const minTimestampOffset = timeline.viewportWidth >= 1450 ? 102 : 88 + const maxTimestampOffset = timeline.viewportWidth >= 1450 ? 110 : 98 + const minMarkerToSignerGap = timeline.viewportWidth >= 1450 ? 112 : 98 + for (const row of timeline.rows) { + expect(row.markerLeft).toBeLessThan(row.timeLeft) + expect(row.timeLeft).toBeLessThan(row.copyLeft) + expect(row.copyLeft).toBeLessThan(row.hashLeft) + if (row.cueLeft !== null) expect(row.hashLeft).toBeLessThan(row.cueLeft) + expect(row.markerRailGap).toBeGreaterThanOrEqual(5) + expect(row.markerToTimeGap).toBeGreaterThanOrEqual(9) + expect(row.markerToTimeGap).toBeLessThanOrEqual(12) + expect(row.timestampToCopyOffset).toBeGreaterThanOrEqual(minTimestampOffset) + expect(row.timestampToCopyOffset).toBeLessThanOrEqual(maxTimestampOffset) + if (row.rowClass.includes('event-future')) expect(row.hashTextAlign).toBe('left') + if (row.isRecordRow) { + expect(row.signerWidth).toBe(18) + expect(row.signerClass).toMatch(/event-signer-icon (agent|human|mcp)/) + expect(row.signerBackground).not.toBe('rgba(0, 0, 0, 0)') + expect(row.markerToSignerGap).toBeGreaterThanOrEqual(minMarkerToSignerGap) + } + } +} + test.describe('Cloudflare approval trace browser UI', () => { test('clicks through approved execution and opens the signed receipt', async ({ page }) => { await expectCleanConsole(page, async () => { + await page.setViewportSize({ width: 1536, height: 1024 }) await page.context().grantPermissions(['clipboard-write']) await createProposal(page) + await expectNoHorizontalOverflow(page) + await expectReferenceHeaderLogoGeometry(page) + await expectActionButtonsUseReferenceLayout(page) + await expectReferenceProposalPanelChrome(page) + await expectReferenceDesktopPrimaryCaption(page) + await expectReferenceDesktopCenterStack(page) + await expect(page.locator('.risk-bar .value')).toHaveText( + 'Introduces rate limiting which may impact client traffic if misconfigured.', + ) + await expectReferenceDesktopRiskTextFits(page) + await expect( + page.locator('.risk-bar').evaluate((element) => + element.ownerDocument.defaultView?.getComputedStyle(element).backgroundColor ?? '', + ), + ).resolves.toBe('rgb(255, 255, 255)') + await expectWorkflowStepCopyHugsContent(page) + await expectReferenceDesktopRailGeometry(page) + await expectTraceRowsReadable(page) + await expectTraceIntegrityProofStatusFits(page) + await expectReferenceTimelineSpacing(page) + await expectSelectedCurrentRowCombinesStates(page) + await expectReceiptPanelFitsReferenceViewport(page) + await expectReferenceReceiptToolbarRhythm(page) const visibleTimes = await page.locator('#answer .progress-time').allTextContents() const populatedTimes = visibleTimes.filter((time) => time !== '-') expect(new Set(populatedTimes).size).toBeGreaterThan(3) - const runId = await page.locator('#runIdLabel').textContent() + const runId = await page.locator('#runIdLabel').getAttribute('data-run-id') expect(runId).toBeTruthy() const pendingRun = await page.evaluate(async (id) => { const response = await fetch('/api/runs/' + id) @@ -72,24 +1514,108 @@ test.describe('Cloudflare approval trace browser UI', () => { await page.locator('#riskDetailsToggle').click() await expect(page.locator('#riskDetails')).toBeVisible() await expect(page.locator('#riskDetails')).toContainText('Human review gate') + await expect(page.locator('.diff-code')).toContainText('const config = getConfig();') + await expect(page.locator('.diff-code')).toContainText('next();') + await expect(page.locator('.diff-code')).not.toContainText('logRequest') + await expectDiffLineGutter(page) + await expectDiffRowsFillReferenceFrame(page) + await expectDiffCopyControlUsesReferencePlacement(page) + await expectCopies(page.getByRole('button', { name: 'Copy diff' })) - await page.getByRole('button', { name: 'Record details' }).click() + await page.locator('#diffWrapToggle').click() + await expect(page.locator('#diffWrapToggle')).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('.diff-code')).toHaveClass(/wrap/) + const threeLineDiffCount = await page.locator('.diff-line').count() + await page.locator('#diffContext').selectOption('all') + await expect.poll(async () => page.locator('.diff-line').count()).toBeGreaterThan(threeLineDiffCount) + const allLineDiffCount = await page.locator('.diff-line').count() + await expect(page.locator('.diff-line').last().locator('.diff-line-text')).not.toHaveText('') + await expect(page.locator('.diff-code')).toContainText('reportAudit') + await page.locator('#diffContext').selectOption('6') + await expect(page.locator('.diff')).toHaveAttribute('data-context-lines', '6') + await expect(page.locator('.diff-code')).toContainText('logRequest') + await expect(page.locator('.diff-code')).not.toContainText('reportAudit') + await expect.poll(async () => page.locator('.diff-line').count()).toBeLessThan(allLineDiffCount) + await expectDiffLineGutter(page) + + await page.locator('#headerMenu').click() + await expect(page.locator('#headerActions')).toBeVisible() + await expect(page.locator('[data-header-action="copy-link"]')).toBeEnabled() + await expect(page.locator('[data-header-action="open-json"]')).toBeEnabled() + await expect(page.locator('[data-header-action="reset"]')).toBeEnabled() + await expectHeaderMenuAboveContent(page) + await page.keyboard.press('Escape') + await expect(page.locator('#headerActions')).toBeHidden() + + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-haspopup', 'menu') + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-expanded', 'false') + await expect(page.getByRole('button', { name: 'Live run' })).toBeVisible() + await expect(page.locator('#runModeMenu .menu-chevron')).toBeVisible() + await page.getByRole('button', { name: 'Live run' }).click() + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-expanded', 'true') + await expect(page.locator('#runModeActions')).toBeVisible() + await expect(page.locator('[data-run-mode-action="live"]')).toHaveAttribute('aria-checked', 'true') + await expect( + page.locator('[data-run-mode-action="live"]').evaluate((element) => + element.ownerDocument.defaultView?.getComputedStyle(element, '::before').content ?? '', + ), + ).resolves.toBe('"✓"') + await expect(page.locator('[data-run-mode-action="toggle-follow"]')).toHaveAttribute('aria-checked', 'true') + await expect(page.locator('#runModeActions [data-run-mode-action="open-json"]')).toHaveCount(0) + await expect(page.locator('#runModeActions [data-run-mode-action="reset"]')).toHaveCount(0) + await expectRunModeMenuAboveContent(page) + await expectRunModeMenuLabelsContained(page) + await page.locator('[data-run-mode-action="toggle-follow"]').click() + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-expanded', 'false') + await page.locator('#runModeMenu').click() + await expect(page.locator('[data-run-mode-action="toggle-follow"]')).toHaveAttribute('aria-checked', 'false') + await page.locator('[data-run-mode-action="toggle-follow"]').click() + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-expanded', 'false') + await page.locator('#runModeMenu').click() + await expect(page.locator('#runModeActions')).toBeVisible() + await expect(page.locator('[data-run-mode-action="toggle-follow"]')).toHaveAttribute('aria-checked', 'true') + await page.locator('[data-run-mode-action="live"]').click() + await expect(page.locator('#runModeMenu')).toHaveAttribute('aria-expanded', 'false') + await page.locator('#runModeMenu').click() + await expect(page.locator('#runModeActions')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('#runModeActions')).toBeHidden() + + await page.locator('#timeline .event.current').click() + await expectSelectedCurrentRowCombinesStates(page) + + await page.locator('#timeline .event[data-label="proposal"]').click() + await expect(page.locator('#timeline .event.selected')).toContainText('proposal.generated') + await expect(page.locator('#timeline .event.current')).toContainText('human.review.halted') + await expectSelectedAndCurrentRowsLookDistinct(page) + + await page.getByRole('tab', { name: 'Record details' }).click() await expect(page.locator('#receiptSummary')).toContainText('Record hash') await expect(page.locator('#receiptSummary')).toContainText('Timestamp') - await page.getByRole('button', { name: 'Summary' }).click() + await page.getByRole('tab', { name: 'Summary' }).click() + const prettyReceiptLines = await page.locator('#receipts .json-line').count() + expect(prettyReceiptLines).toBeGreaterThan(1) + await expectReferenceReceiptJsonSyntax(page) + await page.locator('#receiptFormat').selectOption('compact') + await expect.poll(async () => page.locator('#receipts .json-line').count()).toBe(1) + await page.locator('#receiptFormat').selectOption('pretty') + await expect.poll(async () => page.locator('#receipts .json-line').count()).toBeGreaterThan(1) + await expectReferenceReceiptJsonSyntax(page) await expectCopies(page.getByRole('button', { name: 'Copy trace ID' })) - await expectCopies(page.getByRole('button', { name: 'Copy Agent signature' })) + await expectCopies(page.getByRole('button', { name: 'Copy Agent latest record' })) await expectCopies(page.locator('.trace-integrity').getByRole('button', { name: 'Copy Merkle root' })) await expectCopies(page.getByRole('button', { name: 'Copy receipt' })) await expect(page.locator('#verification').getByRole('link', { name: 'View proof' })).toHaveAttribute( 'href', /log\.atrib\.dev|\/api\/runs\//, ) + await expectReferenceVerificationRows(page) await page.locator('#verification').getByRole('button', { name: 'Verify' }).click() await expect(page.locator('#verificationResult')).toContainText('Record hash matches') await expect(page.locator('#verificationResult')).toContainText('Signature valid') await expect(page.locator('#verificationResult')).toContainText('Receipt verified') + await expectVerificationResultRhythm(page) await expect(page.locator('#verification').getByRole('button', { name: 'Verified' })).toBeVisible() const pendingDownload = page.waitForEvent('download') await page.getByRole('button', { name: 'Download receipt' }).click() @@ -98,8 +1624,12 @@ test.describe('Cloudflare approval trace browser UI', () => { await page.getByRole('button', { name: 'Approve and resume' }).click() await expect(page.locator('#statusTitle')).toHaveText('Trace complete', { timeout: 30_000 }) + await expectReviewResultVisibleInProgressPanel(page) + await expectProposalProgressDot(page, 'complete') + await expectNoHorizontalOverflow(page) await expect(page.locator('[data-step="halt"]')).toContainText('Approved') await expect(page.locator('[data-step="halt"]')).not.toContainText('Awaiting review') + await expectReviewPillMatchesRailBadge(page) await expect(page.locator('#answer')).toContainText('Agent resumed through MCP') await expect(page.locator('#answer')).toContainText('Audit ready') await expect(page.locator('#answer')).toContainText('repo_files.server/middleware/rate_limit.ts') @@ -110,8 +1640,8 @@ test.describe('Cloudflare approval trace browser UI', () => { await openTimelineRecord(page, 'execution') await expect(page.locator('#receipts pre')).toContainText('"signer": "action_mcp"') await expect(page.locator('#receipts pre')).toContainText('"tool_name": "write_file"') - await expect(page.locator('#receipts pre')).toContainText('"proof": null') - await expectCopies(page.getByRole('button', { name: 'Copy Action MCP signature' })) + await expect(page.locator('#receipts pre')).toContainText('"proof":') + await expectCopies(page.getByRole('button', { name: 'Copy Action MCP latest record' })) await expect(page.locator('#verification').getByRole('link', { name: 'View proof' })).toHaveAttribute( 'href', /log\.atrib\.dev|\/api\/runs\//, @@ -122,15 +1652,89 @@ test.describe('Cloudflare approval trace browser UI', () => { }) }) + test('keeps the desktop trace layout contained through the live stages', async ({ page }) => { + await expectCleanConsole(page, async () => { + await page.setViewportSize({ width: 1280, height: 720 }) + await page.goto('/') + await expect(page).toHaveTitle('Cloudflare Agent Trace') + await expect(page.getByTestId('approval-trace-app')).toBeVisible() + await expectNoHorizontalOverflow(page) + + await expect(page.locator('#statusTitle')).toHaveText('Halted for human review', { + timeout: 15_000, + }) + await expect(page.locator('[data-step="halt"]')).toContainText('Awaiting review') + await expectNoHorizontalOverflow(page) + await expectWorkflowOverviewVisible(page) + await expectActionButtonsUseReferenceLayout(page) + await expectDiffLineGutter(page) + await expectDiffRowsFillReferenceFrame(page) + await expectWorkflowStepCopyHugsContent(page) + await expectConstrainedDesktopRailGeometry(page) + await expectTraceRowsReadable(page) + await expectTraceIntegrityProofStatusFits(page) + await expectReferenceTimelineSpacing(page) + + const firstRunId = await page.locator('#runIdLabel').textContent() + await page.locator('#headerMenu').click() + await page.locator('[data-header-action="reset"]').click() + await expect(page.locator('#runIdLabel')).not.toHaveText('pending') + await expect(page.locator('#runIdLabel')).toHaveText(/^run_[A-Z0-9]+/) + await expect(page.locator('#runIdLabel')).toHaveAttribute('data-run-id', /.+/) + await expect.poll(async () => page.locator('#runIdLabel').textContent()).not.toBe(firstRunId) + await expect(page.locator('#answer')).toContainText('Trigger received') + await expect(page.locator('#statusTitle')).toHaveText('Halted for human review', { + timeout: 15_000, + }) + await expectProgressPanelAtTop(page) + await expectNoHorizontalOverflow(page) + await expectReferenceHeaderLogoGeometry(page) + await expectWorkflowOverviewVisible(page) + + const signerSpacing = await page.evaluate(`Array.from(document.querySelectorAll('.signer-row')) + .map((row) => { + const cells = Array.from(row.children).map((child) => child.getBoundingClientRect()) + const nameCell = cells[1] + const detailCell = cells[2] + return Boolean(nameCell && detailCell && ( + nameCell.right < detailCell.left || detailCell.top > nameCell.top + 10 + )) + })`) + expect(signerSpacing).toEqual([true, true, true]) + + await page.getByRole('button', { name: 'Approve and resume' }).click() + await expect(page.locator('#statusTitle')).toHaveText('Trace complete', { timeout: 30_000 }) + await expectReviewResultVisibleInProgressPanel(page) + await expect(page.locator('[data-step="halt"]')).toContainText('Approved') + await expectReviewPillMatchesRailBadge(page) + await expectNoHorizontalOverflow(page) + }) + }) + test('clicks through rejection and shows no action MCP record', async ({ page }) => { await expectCleanConsole(page, async () => { await createProposal(page) await page.getByRole('button', { name: 'Reject' }).click() await expect(page.locator('#statusTitle')).toHaveText('Rejected') + await expectReviewResultVisibleInProgressPanel(page) + await expect(page.locator('#reviewStatePill')).toHaveText('REJECTED') + await expect(page.locator('[data-step="halt"]')).toContainText('Rejected') + await expectReviewPillMatchesRailBadge(page) + await expect(page.locator('[data-step="resume"]')).not.toHaveClass(/done/) + await expect(page.locator('[data-step="audit"]')).not.toHaveClass(/done/) await expect(page.locator('#answer')).toContainText('not run') + await expect(page.locator('#answer')).toContainText('MCP execution skipped') + await expect(page.locator('#answer')).toContainText('Decision audit ready') + await expect(page.locator('#answer')).not.toContainText('Audit ready') await expect(page.locator('#timeline .event')).toHaveCount(4) + await expect(page.locator('#timeline')).toContainText('mcp.execution.skipped') + await expect(page.locator('#timeline')).toContainText('decision.audit.ready') + await expect(page.locator('.signer-row').filter({ hasText: 'Action MCP' })).toContainText('Skipped') await expect(page.locator('#timeline')).not.toContainText('action_mcp') + await expect(page.locator('[data-step="resume"]')).toContainText('MCP execution skipped') + await expect(page.locator('[data-step="audit"]')).toContainText('Decision audit ready') + await expect(page.locator('#receipts pre')).toContainText('"current_step": 3') await openTimelineRecord(page, 'rejection') await expect(page.locator('#receipts pre')).toContainText('"signer": "human"') @@ -138,6 +1742,59 @@ test.describe('Cloudflare approval trace browser UI', () => { }) }) + test('requests changes, shows a revised proposal, and approves it', async ({ page }) => { + await expectCleanConsole(page, async () => { + await createProposal(page) + await expect(page.locator('#reviewFeedbackDrawer')).toBeHidden() + await page.getByRole('button', { name: 'Request changes' }).click() + await expect(page.locator('#reviewFeedback')).toBeVisible() + await page.locator('#reviewFeedback').fill( + 'Keep the limiter scoped to /v1/report, lower the cap, and show me a revised proposal before any MCP write.', + ) + await expect(page.locator('#requestChanges')).toContainText('Sign feedback') + await page.locator('#requestChanges').click() + + await expect(page.locator('#statusTitle')).toHaveText('Revised proposal ready for review') + await expect(page.locator('#reviewStatePill')).toHaveText('PAUSED') + await expect(page.locator('[data-step="halt"]')).toContainText('Revised proposal halted') + await expect(page.locator('[data-step="halt"]')).toContainText('Awaiting review') + await expectReviewPillMatchesRailBadge(page) + await expect(page.locator('[data-step="resume"]')).not.toHaveClass(/done/) + await expect(page.locator('[data-step="audit"]')).not.toHaveClass(/done/) + await expect(page.locator('#answer')).toContainText('Human review feedback sent') + await expect(page.locator('#answer')).toContainText('Revised proposal generated') + await expect(page.locator('#answer')).toContainText('Revised proposal halted') + await expect(page.locator('#answer')).toContainText('review revised proposal') + await expect(page.locator('#timeline .event')).toHaveCount(6) + await expect(page.locator('#timeline')).toContainText('human.change_request.signed') + await expect(page.locator('#timeline')).toContainText('proposal.revised') + await expect(page.locator('#timeline')).toContainText('human.review.halted') + await expect(page.locator('#timeline')).toContainText('Awaiting decision on revised proposal') + await expect(page.locator('#timeline')).not.toContainText('human.rejection.signed') + await expect(page.locator('#timeline')).not.toContainText('action_mcp') + await expect(page.locator('.signer-row').filter({ hasText: 'Action MCP' })).toContainText('Blocked') + await expect(page.locator('[data-step="resume"]')).toContainText('MCP execution resumed') + await expect(page.locator('[data-step="audit"]')).toContainText('Audit ready') + + await openTimelineRecord(page, 'change_request') + await expect(page.locator('#receipts pre')).toContainText('"kind": "human_review_feedback"') + await expect(page.locator('#receipts pre')).toContainText('"decision": "changes_requested"') + await expect(page.locator('#receipts pre')).toContainText('"next_step": "agent_revision"') + + await openTimelineRecord(page, 'proposal.revised', 'revision') + await expect(page.locator('#receipts pre')).toContainText('"kind": "agent_revised_proposal"') + await expect(page.locator('#receipts pre')).toContainText('"reviewer_feedback"') + + await page.getByRole('button', { name: 'Approve and resume' }).click() + await expect(page.locator('#statusTitle')).toHaveText('Trace complete') + await expect(page.locator('#answer')).toContainText('Agent resumed through MCP') + await expect(page.locator('#answer')).toContainText('Audit ready') + await expect(page.locator('#timeline')).toContainText('mcp.execution.resumed') + await expect(page.locator('#timeline')).toContainText('audit.ready') + await expect(page.locator('.signer-row').filter({ hasText: 'Action MCP' })).toContainText('Signed') + }) + }) + test('clicks through diagnostic error and opens the outcome receipt', async ({ page }) => { await expectCleanConsole(page, async () => { await createProposal(page, '/?simulate_error=1')