@frontera-sdk/cli 1.52.39 → 1.52.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import type { toTrace } from '../local/local-traces'
|
|
2
|
+
import { studioLink, type RuntimeLink } from './mcp-runtime'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Traces for the coding agent, read from the runtime's local trace store
|
|
6
|
+
* (the Langfuse-compatible API Studio's trace views use) and cut down to
|
|
7
|
+
* what an agent reasons about: the span tree, tool calls, tokens, errors.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type LocalTrace = ReturnType<typeof toTrace>
|
|
11
|
+
type Observation = LocalTrace['observations'][number]
|
|
12
|
+
|
|
13
|
+
export interface TraceSummary {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
at: string
|
|
17
|
+
conversationId: string | null
|
|
18
|
+
level: string
|
|
19
|
+
seconds: number
|
|
20
|
+
tokens: number
|
|
21
|
+
link: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TraceNode {
|
|
25
|
+
name: string
|
|
26
|
+
type: string
|
|
27
|
+
ms: number | null
|
|
28
|
+
model?: string
|
|
29
|
+
tokens?: { input: number; output: number; total: number }
|
|
30
|
+
error?: string
|
|
31
|
+
input?: string
|
|
32
|
+
output?: string
|
|
33
|
+
/** A \`reply.blocked\` span: which check fired and its outcome; the trace's \`replyChecks\` has the findings and both drafts. */
|
|
34
|
+
replyCheck?: string
|
|
35
|
+
children?: TraceNode[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CLIP = 1_500
|
|
39
|
+
|
|
40
|
+
function clip(value: unknown): string | undefined {
|
|
41
|
+
if (value === null || value === undefined) return undefined
|
|
42
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
|
43
|
+
return text.length > CLIP ? `${text.slice(0, CLIP)}… (${text.length - CLIP} more characters)` : text
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Studio's page for one trace. */
|
|
47
|
+
export function traceLink(link: RuntimeLink, id: string): string {
|
|
48
|
+
return studioLink(link, `/traces?trace=${encodeURIComponent(id)}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function tracesCall<T>(link: RuntimeLink, path: string): Promise<T | null> {
|
|
52
|
+
const res = await fetch(new URL(path, link.tracesUrl), { signal: AbortSignal.timeout(15_000) })
|
|
53
|
+
if (res.status === 404) return null
|
|
54
|
+
if (!res.ok) throw new Error(`local traces answered ${res.status}`)
|
|
55
|
+
return (await res.json()) as T
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tokensOf(trace: LocalTrace): number {
|
|
59
|
+
return trace.observations.reduce((n, o) => n + (o.usage?.total ?? 0), 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function summarize(link: RuntimeLink, trace: LocalTrace): TraceSummary {
|
|
63
|
+
return {
|
|
64
|
+
id: trace.id,
|
|
65
|
+
name: trace.name,
|
|
66
|
+
at: trace.timestamp,
|
|
67
|
+
conversationId: trace.sessionId,
|
|
68
|
+
level: trace.level,
|
|
69
|
+
seconds: Math.round(trace.latency * 100) / 100,
|
|
70
|
+
tokens: tokensOf(trace),
|
|
71
|
+
link: traceLink(link, trace.id),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Recent traces, newest first, narrowed in this process: the store filters only by time, metadata and level. */
|
|
76
|
+
export async function listTraces(link: RuntimeLink, opts: { limit: number; conversationId?: string; errorsOnly?: boolean; name?: string; since?: string }): Promise<TraceSummary[]> {
|
|
77
|
+
const filter = opts.since ? [{ type: 'datetime', column: 'timestamp', operator: '>=', value: opts.since }] : []
|
|
78
|
+
const page = await tracesCall<{ data: LocalTrace[] }>(link, `/api/public/traces?limit=100&filter=${encodeURIComponent(JSON.stringify(filter))}`)
|
|
79
|
+
const traces = (page?.data ?? []).filter(
|
|
80
|
+
(t) =>
|
|
81
|
+
(!opts.conversationId || t.sessionId === opts.conversationId) &&
|
|
82
|
+
(!opts.errorsOnly || t.level === 'ERROR') &&
|
|
83
|
+
(!opts.name || t.name.toLowerCase().includes(opts.name.toLowerCase())),
|
|
84
|
+
)
|
|
85
|
+
return traces.slice(0, opts.limit).map((t) => summarize(link, t))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getTrace(link: RuntimeLink, id: string): Promise<LocalTrace | null> {
|
|
89
|
+
return tracesCall<LocalTrace>(link, `/api/public/traces/${encodeURIComponent(id)}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function node(o: Observation, children: Map<string, Observation[]>, detail: boolean): TraceNode {
|
|
93
|
+
const kids = (children.get(o.id) ?? []).map((c) => node(c, children, detail))
|
|
94
|
+
return {
|
|
95
|
+
name: o.name,
|
|
96
|
+
type: o.type.toLowerCase(),
|
|
97
|
+
ms: o.latency === null ? null : Math.round(o.latency * 1000),
|
|
98
|
+
...(o.model ? { model: o.model } : {}),
|
|
99
|
+
...(o.usage ? { tokens: { input: o.usage.input, output: o.usage.output, total: o.usage.total } } : {}),
|
|
100
|
+
...(o.level === 'ERROR' ? { error: o.statusMessage ?? 'failed' } : {}),
|
|
101
|
+
...(detail ? { input: clip(o.input), output: clip(o.output) } : {}),
|
|
102
|
+
...(isReplyBlocked(o.name) ? { replyCheck: `${String(o.metadata?.guardrail ?? o.metadata?.check ?? 'a check')} fired, outcome ${String(o.metadata?.outcome ?? 'unknown')}: replyChecks has what it found and both drafts` } : {}),
|
|
103
|
+
...(kids.length ? { children: kids } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A trace as the agent reads it: the tree, the tool calls in order, totals and every error. */
|
|
108
|
+
export function readTrace(link: RuntimeLink, trace: LocalTrace, opts: { detail: boolean }) {
|
|
109
|
+
const ids = new Set(trace.observations.map((o) => o.id))
|
|
110
|
+
const children = new Map<string, Observation[]>()
|
|
111
|
+
const roots: Observation[] = []
|
|
112
|
+
for (const o of trace.observations) {
|
|
113
|
+
if (o.parentObservationId && ids.has(o.parentObservationId)) {
|
|
114
|
+
const list = children.get(o.parentObservationId) ?? []
|
|
115
|
+
list.push(o)
|
|
116
|
+
children.set(o.parentObservationId, list)
|
|
117
|
+
} else roots.push(o)
|
|
118
|
+
}
|
|
119
|
+
const tools = trace.observations
|
|
120
|
+
.filter((o) => o.type === 'TOOL' || /^(ai\.toolCall|tool[:.\s])/i.test(o.name))
|
|
121
|
+
.map((o) => ({ name: o.name.replace(/^(ai\.toolCall|tool)[:.\s]*/i, '') || o.name, ms: o.latency === null ? null : Math.round(o.latency * 1000), ok: o.level !== 'ERROR', input: clip(o.input), output: clip(o.output) }))
|
|
122
|
+
const errors = trace.observations.filter((o) => o.level === 'ERROR').map((o) => ({ span: o.name, message: o.statusMessage ?? 'failed' }))
|
|
123
|
+
const procedures = proceduresOf(trace.observations)
|
|
124
|
+
const replyChecks = replyChecksOfTrace(trace.observations)
|
|
125
|
+
const toolsOffered = toolsOfferedOf(trace.observations)
|
|
126
|
+
const dataScopes = dataScopesOf(trace.observations)
|
|
127
|
+
const version = agentVersionOf(trace)
|
|
128
|
+
return {
|
|
129
|
+
...summarize(link, trace),
|
|
130
|
+
...version,
|
|
131
|
+
...(procedures ? { procedures } : {}),
|
|
132
|
+
// The tools each model call was offered (`pi.turn <n>`): what the Agent could have called, beside what it did.
|
|
133
|
+
...(toolsOffered.length ? { toolsOffered } : {}),
|
|
134
|
+
// Each data scope the turn's Blueprint reads were narrowed by: the type, property, variable and whether it had a value.
|
|
135
|
+
...(dataScopes.length ? { dataScopes } : {}),
|
|
136
|
+
// Each time the reply check changed a reply: the check, what it found, what the person got, both drafts.
|
|
137
|
+
...(replyChecks.length ? { replyChecks } : {}),
|
|
138
|
+
totals: {
|
|
139
|
+
spans: trace.observations.length,
|
|
140
|
+
generations: trace.observations.filter((o) => o.type === 'GENERATION').length,
|
|
141
|
+
toolCalls: tools.length,
|
|
142
|
+
tokens: tokensOf(trace),
|
|
143
|
+
cost: trace.totalCost || undefined,
|
|
144
|
+
},
|
|
145
|
+
input: clip(trace.input),
|
|
146
|
+
output: clip(trace.output),
|
|
147
|
+
errors,
|
|
148
|
+
toolCalls: tools,
|
|
149
|
+
tree: roots.map((r) => node(r, children, opts.detail)),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The tool set each model call of the turn was offered, from the generations
|
|
155
|
+
* named `pi.turn <n>` (`metadata.toolsOffered`, `toolCount`). A call offered
|
|
156
|
+
* the same set as the one before it is folded into it (`turns`). A compact
|
|
157
|
+
* run lists `activate_capability` for what it has not loaded yet.
|
|
158
|
+
*/
|
|
159
|
+
export function toolsOfferedOf(observations: Observation[]): Array<{ turns: string[]; count: number; tools: string[]; note?: string }> {
|
|
160
|
+
const out: Array<{ turns: string[]; count: number; tools: string[]; note?: string }> = []
|
|
161
|
+
const ordered = [...observations].filter((o) => /^pi\.turn\b/.test(o.name)).sort((a, b) => Date.parse(String(a.startTime ?? '')) - Date.parse(String(b.startTime ?? '')))
|
|
162
|
+
for (const o of ordered) {
|
|
163
|
+
const offered = o.metadata?.toolsOffered
|
|
164
|
+
if (!Array.isArray(offered)) continue
|
|
165
|
+
const tools = offered.filter((t): t is string => typeof t === 'string')
|
|
166
|
+
const count = typeof o.metadata?.toolCount === 'number' ? o.metadata.toolCount : tools.length
|
|
167
|
+
const last = out[out.length - 1]
|
|
168
|
+
if (last && last.tools.join('\n') === tools.join('\n')) {
|
|
169
|
+
last.turns.push(o.name)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
out.push({ turns: [o.name], count, tools, ...(tools.includes('activate_capability') ? { note: 'activate_capability stands for capabilities not loaded yet: the Agent loads one by calling it, and the next call is offered its tools' } : {}) })
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The data scopes a turn's Blueprint reads ran under (`blueprint.data-scope` spans, `metadata.scopes`). */
|
|
178
|
+
export function dataScopesOf(observations: Observation[]): Array<Record<string, unknown>> {
|
|
179
|
+
return observations
|
|
180
|
+
.filter((o) => o.name === 'blueprint.data-scope')
|
|
181
|
+
.flatMap((o) => (Array.isArray(o.metadata?.scopes) ? (o.metadata.scopes as unknown[]) : []))
|
|
182
|
+
.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Which version of the Agent ran: the draft (and whose draft) when the trace says so, as a Function's `ctx.agent()` on the local runtime does. */
|
|
186
|
+
function agentVersionOf(trace: LocalTrace): { agentVersion?: string; draftOf?: string } {
|
|
187
|
+
const meta = (trace as { metadata?: Record<string, unknown> }).metadata ?? {}
|
|
188
|
+
const version = typeof meta.agentVersion === 'string' ? meta.agentVersion : undefined
|
|
189
|
+
const draftOf = typeof meta.draftOf === 'string' ? meta.draftOf : undefined
|
|
190
|
+
return { ...(version ? { agentVersion: version } : {}), ...(draftOf ? { draftOf } : {}) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A conversation's traces, newest first, whole: what a turn's trace is chosen from. */
|
|
194
|
+
export async function conversationTraces(link: RuntimeLink, conversationId: string, since?: string): Promise<LocalTrace[]> {
|
|
195
|
+
const filter = since ? [{ type: 'datetime', column: 'timestamp', operator: '>=', value: since }] : []
|
|
196
|
+
const page = await tracesCall<{ data: LocalTrace[] }>(link, `/api/public/traces?limit=100&filter=${encodeURIComponent(JSON.stringify(filter))}`)
|
|
197
|
+
return (page?.data ?? []).filter((t) => t.sessionId === conversationId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The trace's root span (no parent at all) has ended: every span under it ended before it, so the trace is whole. */
|
|
201
|
+
export function traceComplete(trace: LocalTrace): boolean {
|
|
202
|
+
return trace.observations.some((o) => !o.parentObservationId && o.endTime !== null)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hasUsage = (trace: LocalTrace) => trace.observations.some((o) => o.type === 'GENERATION' && o.usage !== null)
|
|
206
|
+
const hasGeneration = (trace: LocalTrace) => trace.observations.some((o) => o.type === 'GENERATION')
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The trace of ONE turn: among the conversation's traces that were not there
|
|
210
|
+
* before the turn was sent, the earliest that called a model (a title or a
|
|
211
|
+
* summary the runtime writes afterwards is a trace of its own, and later).
|
|
212
|
+
*/
|
|
213
|
+
export function pickTurnTrace(traces: LocalTrace[], before: ReadonlySet<string>): LocalTrace | null {
|
|
214
|
+
const fresh = traces.filter((t) => !before.has(t.id)).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
|
|
215
|
+
return fresh.find(hasGeneration) ?? fresh[0] ?? null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface TurnTrace {
|
|
219
|
+
summary: TraceSummary
|
|
220
|
+
/** The models the turn's generations ran on, as the provider named them. */
|
|
221
|
+
models: string[]
|
|
222
|
+
/** Not every span had arrived, or no model call reported usage: `tokens` is a floor, not the total. */
|
|
223
|
+
tokensPartial: boolean
|
|
224
|
+
trace: LocalTrace
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The trace a turn produced. Spans are exported in batches, so a trace
|
|
229
|
+
* shows up in pieces a few seconds after the reply: wait until its root span
|
|
230
|
+
* has ended (the whole turn is in) for at most `waitMs`, then sum what is
|
|
231
|
+
* there and say whether that is all of it.
|
|
232
|
+
*/
|
|
233
|
+
export async function awaitTurnTrace(
|
|
234
|
+
link: RuntimeLink,
|
|
235
|
+
conversationId: string,
|
|
236
|
+
opts: { before: ReadonlySet<string>; since: string; waitMs?: number; pollMs?: number },
|
|
237
|
+
): Promise<TurnTrace | null> {
|
|
238
|
+
const deadline = Date.now() + (opts.waitMs ?? 20_000)
|
|
239
|
+
let last: LocalTrace | null = null
|
|
240
|
+
for (;;) {
|
|
241
|
+
const traces = await conversationTraces(link, conversationId, opts.since).catch(() => [] as LocalTrace[])
|
|
242
|
+
last = pickTurnTrace(traces, opts.before) ?? last
|
|
243
|
+
// Children end before their root, so a trace whose root has ended is whole.
|
|
244
|
+
if ((last && traceComplete(last)) || Date.now() > deadline) break
|
|
245
|
+
await new Promise((r) => setTimeout(r, opts.pollMs ?? 750))
|
|
246
|
+
}
|
|
247
|
+
if (!last) return null
|
|
248
|
+
const models = [...new Set(last.observations.filter((o) => o.type === 'GENERATION' && o.model).map((o) => o.model!))]
|
|
249
|
+
return { summary: summarize(link, last), models, tokensPartial: !traceComplete(last) || !hasUsage(last), trace: last }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const PROCEDURE_REASONS: Record<string, string> = {
|
|
253
|
+
start: "it is the conversation's start procedure",
|
|
254
|
+
keyword: 'a message contained one of its keywords',
|
|
255
|
+
active: 'it was already active from an earlier turn',
|
|
256
|
+
none: 'no procedure applies to this turn',
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface ProcedureTurn {
|
|
260
|
+
id: string | null
|
|
261
|
+
name: string | null
|
|
262
|
+
reason: string
|
|
263
|
+
why: string
|
|
264
|
+
keywords?: unknown
|
|
265
|
+
alsoTriggered?: unknown
|
|
266
|
+
tools?: unknown
|
|
267
|
+
handover?: unknown
|
|
268
|
+
handsOver?: unknown
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Which procedure each model turn ran under and why (`procedure.active`
|
|
273
|
+
* spans, one per pi turn of an Agent with procedures), and the procedures
|
|
274
|
+
* the model loaded through `skill` (`procedure.loaded`). Null when the trace
|
|
275
|
+
* has neither: an Agent without procedures, or a runtime that does not
|
|
276
|
+
* record them.
|
|
277
|
+
*/
|
|
278
|
+
export function proceduresOf(observations: Array<{ name: string; startTime?: string | null; metadata?: Record<string, unknown> | null }>): { active: ProcedureTurn | null; turns?: ProcedureTurn[]; loaded?: Array<Record<string, unknown>> } | null {
|
|
279
|
+
const ordered = [...observations].sort((a, b) => Date.parse(a.startTime ?? '') - Date.parse(b.startTime ?? ''))
|
|
280
|
+
const turns = ordered
|
|
281
|
+
.filter((o) => o.name === 'procedure.active')
|
|
282
|
+
.map((o): ProcedureTurn => {
|
|
283
|
+
const m = o.metadata ?? {}
|
|
284
|
+
const reason = typeof m.reason === 'string' ? m.reason : 'unknown'
|
|
285
|
+
const has = (v: unknown) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)
|
|
286
|
+
return {
|
|
287
|
+
id: typeof m.procedureId === 'string' ? m.procedureId : null,
|
|
288
|
+
name: typeof m.procedureName === 'string' ? m.procedureName : null,
|
|
289
|
+
reason,
|
|
290
|
+
why: PROCEDURE_REASONS[reason] ?? 'the runtime gave no reason it knows',
|
|
291
|
+
...(has(m.keywords) ? { keywords: m.keywords } : {}),
|
|
292
|
+
...(has(m.alsoTriggered) ? { alsoTriggered: m.alsoTriggered } : {}),
|
|
293
|
+
...(has(m.tools) ? { tools: m.tools } : {}),
|
|
294
|
+
...(has(m.handover) ? { handover: m.handover } : {}),
|
|
295
|
+
...(has(m.procedureHandsOver) ? { handsOver: m.procedureHandsOver } : {}),
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
const loaded = ordered.filter((o) => o.name === 'procedure.loaded').map((o) => ({ ...(o.metadata ?? {}) }))
|
|
299
|
+
if (!turns.length && !loaded.length) return null
|
|
300
|
+
const differ = new Set(turns.map((t) => `${t.id}:${t.reason}`)).size > 1
|
|
301
|
+
return { active: turns.at(-1) ?? null, ...(differ ? { turns } : {}), ...(loaded.length ? { loaded } : {}) }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ------------------------------------------------------------------ *
|
|
305
|
+
* The reply check
|
|
306
|
+
* ------------------------------------------------------------------ */
|
|
307
|
+
|
|
308
|
+
/** What a claim kind means: the sentence the reply was read as saying. */
|
|
309
|
+
const CLAIM_KINDS: Record<string, string> = {
|
|
310
|
+
action: 'that an action was, is being or will be carried out',
|
|
311
|
+
escalation: 'that the request was passed on to someone, or that a person or team will take it up',
|
|
312
|
+
channel: 'a way to reach someone (a contact, a page, a desk or a sign-in route; the finding names the phrases) that nothing the Agent was given mentions',
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** What the person read instead of the first draft, by the check's outcome. */
|
|
316
|
+
const OUTCOMES: Record<string, { got: string; fixedReply: boolean }> = {
|
|
317
|
+
regenerated: { got: 'the second draft: the Agent rewrote the reply after the correction, and the rewrite passed', fixedReply: false },
|
|
318
|
+
template: { got: "the fixed safe reply (\"Sorry, I can't answer that right now…\" or its translation): the rewrite failed the check too, so neither draft was sent", fixedReply: true },
|
|
319
|
+
deferred: { got: 'the reply the Agent wrote after the tools it called beside this text had run (checked again then); this text was held back', fixedReply: false },
|
|
320
|
+
'sends-dropped': { got: 'neither draft: the rewrite failed too, so its messages were dropped, and the tools called beside them still ran', fixedReply: false },
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const DRAFT_CLIP = 800
|
|
324
|
+
|
|
325
|
+
function clipDraft(value: unknown): string | undefined {
|
|
326
|
+
if (typeof value !== 'string' || !value) return undefined
|
|
327
|
+
return value.length > DRAFT_CLIP ? `${value.slice(0, DRAFT_CLIP)}… (${value.length - DRAFT_CLIP} more characters)` : value
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const RESERVED = new Set(['guardrail', 'check', 'outcome', 'harness', 'correction', 'firstDraft', 'secondDraft', 'drafts', 'draft', 'secondGuardrail', 'secondDetail', 'secondCheck', 'reason'])
|
|
331
|
+
|
|
332
|
+
/** What one check found, in words, from its name and findings. */
|
|
333
|
+
function foundBy(check: string, findings: Record<string, unknown>, reason: unknown): string {
|
|
334
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : [])
|
|
335
|
+
if (check === 'claims') {
|
|
336
|
+
const kinds = list(findings.kinds)
|
|
337
|
+
const read = kinds.map((k) => `${k}: it says ${CLAIM_KINDS[k] ?? 'something the check does not name'}`)
|
|
338
|
+
return `The reply claims something no tool did in this conversation${read.length ? ` (${read.join('; ')})` : ''}.`
|
|
339
|
+
}
|
|
340
|
+
// The figures check is gone; older sessions' traces still carry it.
|
|
341
|
+
if (check === 'figures') return `The reply states ${list(findings.figures).join(', ') || 'figures'}, which nothing the Agent was given (instructions, messages, tool results, its own call arguments) contains.`
|
|
342
|
+
if (check === 'output') return `The reply uses "${String(findings.term ?? '?')}", a term this Agent must not say.`
|
|
343
|
+
if (check === 'length') return `A message the Agent was sending is ${String(findings.characters ?? '?')} characters, over the Agent's limit.`
|
|
344
|
+
if (check === 'empty') return typeof findings.reason === 'string' ? findings.reason : 'The model wrote no reply text.'
|
|
345
|
+
return typeof reason === 'string' && reason ? reason : `The ${check} check failed.`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface ReplyCheck {
|
|
349
|
+
/** The check that failed: claims, output (a blocked term), length, empty; figures in older traces. */
|
|
350
|
+
check: string
|
|
351
|
+
found: string
|
|
352
|
+
findings: Record<string, unknown>
|
|
353
|
+
outcome: string
|
|
354
|
+
/** What the person read, in words. */
|
|
355
|
+
personGot: string
|
|
356
|
+
/** The person read the fixed safe reply, not anything the Agent wrote. */
|
|
357
|
+
replacedWithFixedReply: boolean
|
|
358
|
+
correction?: string
|
|
359
|
+
firstDraft?: string
|
|
360
|
+
secondDraft?: string
|
|
361
|
+
/** The rewrite failed a check too: which, and what it found. */
|
|
362
|
+
secondCheck?: { check: string; found: string; findings: Record<string, unknown> }
|
|
363
|
+
at?: string
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A `reply.blocked` record — the session event's payload or the span's
|
|
368
|
+
* metadata, which carry the same fields — read defensively: the check, what
|
|
369
|
+
* it found, what the person got instead, and both drafts, clipped.
|
|
370
|
+
*/
|
|
371
|
+
export function replyCheckOf(raw: Record<string, unknown> | null | undefined, at?: string | null): ReplyCheck {
|
|
372
|
+
const r = raw ?? {}
|
|
373
|
+
const check = String(r.guardrail ?? r.check ?? 'unknown')
|
|
374
|
+
const findings: Record<string, unknown> = {}
|
|
375
|
+
for (const [k, v] of Object.entries(r)) if (!RESERVED.has(k)) findings[k] = v
|
|
376
|
+
// `empty` keeps its reason as its finding.
|
|
377
|
+
if (check === 'empty' && typeof r.reason === 'string') findings.reason = r.reason
|
|
378
|
+
const outcome = String(r.outcome ?? 'unknown')
|
|
379
|
+
const known = OUTCOMES[outcome]
|
|
380
|
+
const drafts = Array.isArray(r.drafts) ? r.drafts : []
|
|
381
|
+
const firstDraft = clipDraft(r.firstDraft ?? r.draft ?? drafts[0])
|
|
382
|
+
const secondDraft = clipDraft(r.secondDraft ?? drafts[1])
|
|
383
|
+
const second = (r.secondCheck && typeof r.secondCheck === 'object' ? r.secondCheck : null) as Record<string, unknown> | null
|
|
384
|
+
const secondName = r.secondGuardrail ?? second?.guardrail ?? second?.check
|
|
385
|
+
const secondFindings = ((r.secondDetail ?? second?.findings ?? second?.detail) as Record<string, unknown> | undefined) ?? {}
|
|
386
|
+
return {
|
|
387
|
+
check,
|
|
388
|
+
found: foundBy(check, findings, r.reason),
|
|
389
|
+
findings,
|
|
390
|
+
outcome,
|
|
391
|
+
personGot: known?.got ?? `the runtime recorded the outcome "${outcome}"`,
|
|
392
|
+
replacedWithFixedReply: known?.fixedReply ?? false,
|
|
393
|
+
...(typeof r.correction === 'string' ? { correction: r.correction } : {}),
|
|
394
|
+
...(firstDraft ? { firstDraft } : {}),
|
|
395
|
+
...(secondDraft ? { secondDraft } : {}),
|
|
396
|
+
...(secondName ? { secondCheck: { check: String(secondName), found: foundBy(String(secondName), secondFindings, undefined), findings: secondFindings } } : {}),
|
|
397
|
+
...(at ? { at } : {}),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const isReplyBlocked = (name: string) => /reply[._ -]?blocked/i.test(name)
|
|
402
|
+
|
|
403
|
+
/** The reply checks a trace recorded (its `reply.blocked` spans), in order. */
|
|
404
|
+
export function replyChecksOfTrace(observations: Array<{ name: string; startTime?: string | null; metadata?: Record<string, unknown> | null; statusMessage?: string | null }>): ReplyCheck[] {
|
|
405
|
+
return [...observations]
|
|
406
|
+
.filter((o) => isReplyBlocked(o.name))
|
|
407
|
+
.sort((a, b) => Date.parse(a.startTime ?? '') - Date.parse(b.startTime ?? ''))
|
|
408
|
+
.map((o) => replyCheckOf({ ...(o.statusMessage ? { reason: o.statusMessage } : {}), ...(o.metadata ?? {}) }, o.startTime))
|
|
409
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { RENDER_EVIDENCE_PATH, RENDER_THUMBNAIL_PATH } from '../render-evidence'
|
|
5
|
+
import type { A11yResult } from './a11y-scan'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a page showed and said once it settled: the one shape both browsers
|
|
9
|
+
* answer with, Forge's Browser (the person watches it there) and the
|
|
10
|
+
* headless fallback. `app_render` turns it into render evidence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ObserveRequest {
|
|
14
|
+
url: string
|
|
15
|
+
width?: number
|
|
16
|
+
height?: number
|
|
17
|
+
settleMs?: number
|
|
18
|
+
waitForText?: string
|
|
19
|
+
fullPage?: boolean
|
|
20
|
+
/** axe-core's script, to scan the page once it settles; no scan without it. */
|
|
21
|
+
a11yScript?: string | null
|
|
22
|
+
/** The expression that runs the scan, after the script (`AXE_RUN`). */
|
|
23
|
+
a11yRun?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PageObservation {
|
|
27
|
+
/** The accessibility scan, when one ran. */
|
|
28
|
+
a11y?: A11yResult | null
|
|
29
|
+
url: string
|
|
30
|
+
title: string
|
|
31
|
+
/** The page's visible text, clipped. */
|
|
32
|
+
text: string
|
|
33
|
+
console: Array<{ level: 'error' | 'warning'; text: string }>
|
|
34
|
+
failedRequests: Array<{ url: string; method: string; reason: string }>
|
|
35
|
+
requests: Array<{ url: string; method: string; status: number; type?: string }>
|
|
36
|
+
navigationError: string | null
|
|
37
|
+
/** PNG, base64. */
|
|
38
|
+
screenshot: string
|
|
39
|
+
where: 'forge' | 'headless'
|
|
40
|
+
/** Forge's tab, so the person can find the page the agent rendered. */
|
|
41
|
+
tabId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The Blueprint reads and governed Action requests the page made, from its requests. */
|
|
45
|
+
export function platformCalls(requests: PageObservation['requests']) {
|
|
46
|
+
const path = (url: string) => {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(url).pathname
|
|
49
|
+
} catch {
|
|
50
|
+
return url
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const blueprint = requests
|
|
54
|
+
.filter((r) => /\/v1\/blueprint\//.test(path(r.url)) && !/\/governed-actions\//.test(path(r.url)))
|
|
55
|
+
.map((r) => ({ method: r.method, path: path(r.url), status: r.status }))
|
|
56
|
+
const actions = requests
|
|
57
|
+
.filter((r) => r.method === 'POST' && /\/v1\/blueprint\/governed-actions\/actions\/[^/]+\/requests$/.test(path(r.url)))
|
|
58
|
+
.map((r) => ({ action: decodeURIComponent(path(r.url).split('/').at(-2) ?? ''), status: r.status }))
|
|
59
|
+
const functions = requests
|
|
60
|
+
.filter((r) => /\/v1\/(functions|automations)\/[^/]+\/(invoke|run)/.test(path(r.url)))
|
|
61
|
+
.map((r) => ({ method: r.method, path: path(r.url), status: r.status }))
|
|
62
|
+
return { blueprint, actions, functions }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RenderRecord {
|
|
66
|
+
renderedAt: string
|
|
67
|
+
renderedAtMs: number
|
|
68
|
+
path: string
|
|
69
|
+
title: string
|
|
70
|
+
consoleErrors: number
|
|
71
|
+
failedRequests: number
|
|
72
|
+
/** Where it rendered and against what: the evidence a reviewer weighs. */
|
|
73
|
+
source: 'frontera-mcp'
|
|
74
|
+
browser: 'forge' | 'headless'
|
|
75
|
+
runtime: { kind: 'local'; url: string; version: string | null } | { kind: 'url'; url: string }
|
|
76
|
+
/**
|
|
77
|
+
* What was rendered, for a deploy to check it is evidence of this App: its
|
|
78
|
+
* dev server, its own preview (`/apps/<slug>`), or an address that is
|
|
79
|
+
* neither (`elsewhere`), which a deploy does not attach.
|
|
80
|
+
*/
|
|
81
|
+
target?: RenderTarget
|
|
82
|
+
blueprintCalls: number
|
|
83
|
+
actionRequests: number
|
|
84
|
+
/** The basic accessibility scan, when one ran: what a deployment's Accessibility check reads. */
|
|
85
|
+
a11y?: A11yResult
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RenderTarget {
|
|
89
|
+
of: 'dev-server' | 'preview' | 'elsewhere'
|
|
90
|
+
/** The address opened, without any session token. */
|
|
91
|
+
url: string
|
|
92
|
+
/** The App's slug it was rendered for. */
|
|
93
|
+
app: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether an address `app_render` was given is this App's: under its running
|
|
98
|
+
* dev server, or one of its preview links (`/apps/<slug>`, any deployment).
|
|
99
|
+
*/
|
|
100
|
+
export function renderTargetOf(url: string, app: string, devServerUrl: string | null): RenderTarget {
|
|
101
|
+
let at: URL
|
|
102
|
+
try {
|
|
103
|
+
at = new URL(url)
|
|
104
|
+
} catch {
|
|
105
|
+
return { of: 'elsewhere', url, app }
|
|
106
|
+
}
|
|
107
|
+
if (devServerUrl) {
|
|
108
|
+
try {
|
|
109
|
+
if (new URL(devServerUrl).origin === at.origin) return { of: 'dev-server', url, app }
|
|
110
|
+
} catch {
|
|
111
|
+
// Not a URL: no dev server to compare with.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const segments = at.pathname.split('/').filter(Boolean)
|
|
115
|
+
const i = segments.indexOf('apps')
|
|
116
|
+
if (i >= 0 && decodeURIComponent(segments[i + 1] ?? '') === app) return { of: 'preview', url, app }
|
|
117
|
+
return { of: 'elsewhere', url, app }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, '-')
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Write what `frontera app deploy` reads (`.frontera/last-render.json` and
|
|
124
|
+
* the screenshot beside it, in the App's folder) and keep a copy in the
|
|
125
|
+
* project's evidence folder, per session, for the person and a change
|
|
126
|
+
* request. Returns the evidence paths relative to the solution.
|
|
127
|
+
*/
|
|
128
|
+
export function writeRenderEvidence(args: {
|
|
129
|
+
appRoot: string
|
|
130
|
+
solutionRoot: string
|
|
131
|
+
session: string | null
|
|
132
|
+
slug: string
|
|
133
|
+
path: string
|
|
134
|
+
observation: PageObservation
|
|
135
|
+
runtime: RenderRecord['runtime']
|
|
136
|
+
target?: RenderTarget
|
|
137
|
+
now?: Date
|
|
138
|
+
}): { record: RenderRecord; screenshot: string; evidence: string } {
|
|
139
|
+
const now = args.now ?? new Date()
|
|
140
|
+
const calls = platformCalls(args.observation.requests)
|
|
141
|
+
const record: RenderRecord = {
|
|
142
|
+
renderedAt: now.toISOString(),
|
|
143
|
+
renderedAtMs: now.getTime(),
|
|
144
|
+
path: args.path,
|
|
145
|
+
title: args.observation.title,
|
|
146
|
+
consoleErrors: args.observation.console.filter((c) => c.level === 'error').length,
|
|
147
|
+
failedRequests: args.observation.failedRequests.length,
|
|
148
|
+
source: 'frontera-mcp',
|
|
149
|
+
browser: args.observation.where,
|
|
150
|
+
runtime: args.runtime,
|
|
151
|
+
...(args.target ? { target: args.target } : {}),
|
|
152
|
+
blueprintCalls: calls.blueprint.length,
|
|
153
|
+
actionRequests: calls.actions.length,
|
|
154
|
+
...(args.observation.a11y ? { a11y: args.observation.a11y } : {}),
|
|
155
|
+
}
|
|
156
|
+
const png = Buffer.from(args.observation.screenshot, 'base64')
|
|
157
|
+
const evidenceFile = join(args.appRoot, RENDER_EVIDENCE_PATH)
|
|
158
|
+
mkdirSync(dirname(evidenceFile), { recursive: true })
|
|
159
|
+
writeFileSync(evidenceFile, `${JSON.stringify(record, null, 2)}\n`)
|
|
160
|
+
writeFileSync(join(args.appRoot, RENDER_THUMBNAIL_PATH), png)
|
|
161
|
+
|
|
162
|
+
const folder = evidenceDir(args.solutionRoot, args.session)
|
|
163
|
+
mkdirSync(folder, { recursive: true })
|
|
164
|
+
const base = `${stamp(now)}-${args.slug.replace(/[^a-z0-9-]+/gi, '-')}-render`
|
|
165
|
+
writeFileSync(join(folder, `${base}.png`), png)
|
|
166
|
+
writeFileSync(join(folder, `${base}.json`), `${JSON.stringify({ ...record, url: args.observation.url, console: args.observation.console, failed: args.observation.failedRequests, calls }, null, 2)}\n`)
|
|
167
|
+
return { record, screenshot: relative(args.solutionRoot, join(folder, `${base}.png`)), evidence: relative(args.solutionRoot, join(folder, `${base}.json`)) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `.frontera/evidence/<session>/`: screenshots and render results, gitignored with the rest of `.frontera`'s local state. */
|
|
171
|
+
export function evidenceDir(solutionRoot: string, session: string | null): string {
|
|
172
|
+
ignoreEvidence(solutionRoot)
|
|
173
|
+
return join(solutionRoot, '.frontera', 'evidence', (session ?? 'cli').replace(/[^a-z0-9-]+/gi, '-'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Screenshots can show real records: they never reach git. */
|
|
177
|
+
function ignoreEvidence(root: string): void {
|
|
178
|
+
const ignore = join(root, '.frontera', '.gitignore')
|
|
179
|
+
mkdirSync(dirname(ignore), { recursive: true })
|
|
180
|
+
const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
|
|
181
|
+
if (text.split(/\r?\n/).some((l) => l.trim() === 'evidence/' || l.trim() === 'evidence')) return
|
|
182
|
+
if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}evidence/\n`)
|
|
183
|
+
else writeFileSync(ignore, 'local/\nevidence/\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Save a screenshot into the session's evidence folder; returns its path from the solution root. */
|
|
187
|
+
export function saveScreenshot(solutionRoot: string, session: string | null, png: string, label: string, now = new Date()): string {
|
|
188
|
+
const folder = evidenceDir(solutionRoot, session)
|
|
189
|
+
mkdirSync(folder, { recursive: true })
|
|
190
|
+
const file = join(folder, `${stamp(now)}-${label.replace(/[^a-z0-9-]+/gi, '-').slice(0, 40) || 'page'}.png`)
|
|
191
|
+
writeFileSync(file, Buffer.from(png, 'base64'))
|
|
192
|
+
return relative(solutionRoot, file)
|
|
193
|
+
}
|