Skip to content

Commit c57cecf

Browse files
authored
Tighten runtime episode trace envelopes
* Tighten runtime episode trace envelopes * Fix runtime episode JSON schema
1 parent 8692acc commit c57cecf

3 files changed

Lines changed: 305 additions & 11 deletions

File tree

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -305,16 +305,33 @@ scenario, benchmark, task-set, and model-eval belong in consumers that project
305305
the trace into their own schemas.
306306

307307
Episode steps include stable ids and refs for the step, requested action,
308-
runtime execution, optional observation, and collected artifact bundle. Action
309-
and execution refs include SHA-256 digests over the canonical trace v1 JSON
310-
payload so callers can compare command/result records without depending on
308+
runtime execution, optional observation, and collected artifact bundle. Actions
309+
use the generic `wp-codebox/runtime-episode-action/v1` envelope with
310+
`kind: "command"`, a command name, string args, optional `cwd`, optional
311+
`timeoutMs`, and a SHA-256 digest over that replay payload. Observations use
312+
the generic `wp-codebox/runtime-episode-observation/v1` envelope with id, type,
313+
data, timestamp, and digest. Refs carry matching digests so callers can compare
314+
action args, observations, executions, snapshots, and artifacts without parsing
311315
presentation logs.
312316

317+
Replay is bounded to the generic runtime contract. A consumer can replay a step
318+
by creating a compatible backend runtime, applying the same mounts/artifact
319+
inputs, and executing the action envelope in order. WP Codebox intentionally
320+
does not record rewards, scenario ids, benchmark metadata, model-eval fields, or
321+
product-specific success semantics; those belong to the caller's projection of
322+
the trace. Backend behavior still matters: command availability, WordPress
323+
version, installed packages, mounted files, network policy, and secrets policy
324+
must be reproduced by the replay harness.
325+
313326
Runtime snapshots are explicit about their semantics. The current Playground
314327
backend returns `semantics: "metadata-only"`, which records runtime and mount
315328
metadata for audit context but is not a full replayable WordPress state image.
316-
Call `collectArtifacts()` when the caller needs replay inputs, changed files,
317-
patches, logs, observations, and artifact bundle refs.
329+
These snapshots are trace markers, not database or filesystem checkpoints. Call
330+
`collectArtifacts()` when the caller needs replay inputs, changed files, patches,
331+
logs, observations, and artifact bundle refs. Future backends may emit stronger
332+
snapshot semantics such as runtime-state artifacts, but replay consumers should
333+
branch on the snapshot `semantics` field instead of assuming a portable restore
334+
point.
318335

319336
## CLI Commands
320337

packages/runtime-core/src/index.ts

Lines changed: 230 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export * from "./workspace-policy.js"
77
export type RuntimeBackendKind = "wordpress-playground" | (string & {})
88

99
export const RUNTIME_EPISODE_TRACE_SCHEMA = "wp-codebox/runtime-episode-trace/v1" as const
10+
export const RUNTIME_EPISODE_ACTION_SCHEMA = "wp-codebox/runtime-episode-action/v1" as const
11+
export const RUNTIME_EPISODE_OBSERVATION_SCHEMA = "wp-codebox/runtime-episode-observation/v1" as const
1012

1113
export const RUNTIME_EPISODE_TRACE_JSON_SCHEMA = {
1214
$id: RUNTIME_EPISODE_TRACE_SCHEMA,
@@ -21,7 +23,39 @@ export const RUNTIME_EPISODE_TRACE_JSON_SCHEMA = {
2123
reset: { type: "object", required: ["id", "runtime", "observations", "observationRefs"] },
2224
steps: {
2325
type: "array",
24-
items: { type: "object", required: ["id", "index", "action", "actionRef", "execution", "executionRef"] },
26+
items: {
27+
type: "object",
28+
required: ["id", "index", "action", "actionRef", "execution", "executionRef"],
29+
properties: {
30+
action: {
31+
type: "object",
32+
required: ["schema", "id", "kind", "command", "args", "digest"],
33+
properties: {
34+
schema: { const: RUNTIME_EPISODE_ACTION_SCHEMA },
35+
id: { type: "string", minLength: 1 },
36+
kind: { const: "command" },
37+
command: { type: "string", minLength: 1 },
38+
args: { type: "array", items: { type: "string" } },
39+
cwd: { type: "string" },
40+
timeoutMs: { type: "number", minimum: 0 },
41+
digest: {
42+
type: "object",
43+
required: ["algorithm", "value"],
44+
properties: {
45+
algorithm: { const: "sha256" },
46+
value: { type: "string", pattern: "^[a-f0-9]{64}$" },
47+
},
48+
additionalProperties: false,
49+
},
50+
},
51+
additionalProperties: false,
52+
},
53+
observation: {
54+
type: "object",
55+
required: ["schema", "id", "type", "data", "observedAt", "digest"],
56+
},
57+
},
58+
},
2559
},
2660
snapshots: {
2761
type: "array",
@@ -400,8 +434,14 @@ export interface RuntimeEpisodeTraceRef {
400434
path?: string
401435
}
402436

403-
export interface RuntimeEpisodeActionRecord extends ExecutionSpec {
437+
export interface RuntimeEpisodeActionRecord {
438+
schema: typeof RUNTIME_EPISODE_ACTION_SCHEMA
404439
id: string
440+
kind: "command"
441+
command: string
442+
args: string[]
443+
cwd?: string
444+
timeoutMs?: number
405445
digest: RuntimeEpisodeContentDigest
406446
}
407447

@@ -422,10 +462,12 @@ export interface ObservationSpec {
422462
}
423463

424464
export interface ObservationResult {
465+
schema?: typeof RUNTIME_EPISODE_OBSERVATION_SCHEMA
425466
id?: string
426467
type: string
427468
data: unknown
428469
observedAt: string
470+
digest?: RuntimeEpisodeContentDigest
429471
}
430472

431473
export interface LifecycleEvent {
@@ -1202,6 +1244,33 @@ export function runtimeEpisodeDigest(value: unknown): RuntimeEpisodeContentDiges
12021244
}
12031245
}
12041246

1247+
function runtimeEpisodeActionDigestPayload(action: RuntimeEpisodeActionRecord | ExecutionSpec): Record<string, unknown> {
1248+
const payload: Record<string, unknown> = {
1249+
schema: RUNTIME_EPISODE_ACTION_SCHEMA,
1250+
kind: "command",
1251+
command: action.command,
1252+
args: Array.isArray(action.args) ? action.args : [],
1253+
}
1254+
1255+
if (typeof action.cwd === "string") {
1256+
payload.cwd = action.cwd
1257+
}
1258+
if (typeof action.timeoutMs === "number") {
1259+
payload.timeoutMs = action.timeoutMs
1260+
}
1261+
1262+
return payload
1263+
}
1264+
1265+
function runtimeEpisodeObservationDigestPayload(observation: ObservationResult): Record<string, unknown> {
1266+
return {
1267+
schema: RUNTIME_EPISODE_OBSERVATION_SCHEMA,
1268+
type: observation.type,
1269+
data: observation.data,
1270+
observedAt: observation.observedAt,
1271+
}
1272+
}
1273+
12051274
export function validateRuntimeEpisodeTrace(trace: unknown): RuntimeEpisodeTraceValidationResult {
12061275
const issues: RuntimeEpisodeTraceValidationIssue[] = []
12071276
const candidate = trace as Partial<RuntimeEpisodeTrace> | null
@@ -1230,9 +1299,21 @@ export function validateRuntimeEpisodeTrace(trace: unknown): RuntimeEpisodeTrace
12301299
}
12311300
if (!Array.isArray(candidate.reset?.observations)) {
12321301
issues.push({ path: "$.reset.observations", message: "reset observations must be an array" })
1302+
} else {
1303+
candidate.reset.observations.forEach((observation, index) => {
1304+
validateRuntimeEpisodeObservation(observation, `$.reset.observations[${index}]`, issues)
1305+
})
12331306
}
12341307
if (!Array.isArray(candidate.reset?.observationRefs)) {
12351308
issues.push({ path: "$.reset.observationRefs", message: "reset observationRefs must be an array" })
1309+
} else {
1310+
candidate.reset.observationRefs.forEach((ref, index) => {
1311+
validateRuntimeEpisodeTraceRef(ref, `$.reset.observationRefs[${index}]`, "observation", issues)
1312+
const observation = candidate.reset?.observations?.[index]
1313+
if (observation) {
1314+
validateRuntimeEpisodeRefDigest(ref, observation.digest, `$.reset.observationRefs[${index}]`, issues)
1315+
}
1316+
})
12361317
}
12371318
if (!Array.isArray(candidate.steps)) {
12381319
issues.push({ path: "$.steps", message: "steps must be an array" })
@@ -1271,21 +1352,153 @@ function validateRuntimeEpisodeStep(
12711352
}
12721353
if (!nonEmptyString(step.action?.id)) {
12731354
issues.push({ path: `${path}.action.id`, message: "action id is required" })
1355+
} else {
1356+
validateRuntimeEpisodeAction(step.action, `${path}.action`, issues)
12741357
}
12751358
if (!nonEmptyString(step.actionRef?.id)) {
12761359
issues.push({ path: `${path}.actionRef.id`, message: "actionRef id is required" })
1360+
} else {
1361+
validateRuntimeEpisodeTraceRef(step.actionRef, `${path}.actionRef`, "action", issues)
1362+
validateRuntimeEpisodeRefDigest(step.actionRef, step.action?.digest, `${path}.actionRef`, issues)
12771363
}
12781364
if (!nonEmptyString(step.execution?.id)) {
12791365
issues.push({ path: `${path}.execution.id`, message: "execution id is required" })
12801366
}
12811367
if (!nonEmptyString(step.executionRef?.id)) {
12821368
issues.push({ path: `${path}.executionRef.id`, message: "executionRef id is required" })
1369+
} else {
1370+
validateRuntimeEpisodeTraceRef(step.executionRef, `${path}.executionRef`, "execution", issues)
1371+
validateRuntimeEpisodeRefDigest(step.executionRef, step.execution ? runtimeEpisodeDigest(step.execution) : undefined, `${path}.executionRef`, issues)
12831372
}
12841373
if (step.observation && !nonEmptyString(step.observation.id)) {
12851374
issues.push({ path: `${path}.observation.id`, message: "observation id is required" })
1375+
} else if (step.observation) {
1376+
validateRuntimeEpisodeObservation(step.observation, `${path}.observation`, issues)
1377+
}
1378+
if (step.observationRef) {
1379+
validateRuntimeEpisodeTraceRef(step.observationRef, `${path}.observationRef`, "observation", issues)
1380+
if (step.observation) {
1381+
validateRuntimeEpisodeRefDigest(step.observationRef, step.observation.digest, `${path}.observationRef`, issues)
1382+
}
1383+
}
1384+
}
1385+
1386+
function validateRuntimeEpisodeAction(
1387+
action: RuntimeEpisodeActionRecord | unknown,
1388+
path: string,
1389+
issues: RuntimeEpisodeTraceValidationIssue[],
1390+
): void {
1391+
if (!isRecord(action)) {
1392+
issues.push({ path, message: "action must be an object" })
1393+
return
1394+
}
1395+
1396+
if (action.schema !== RUNTIME_EPISODE_ACTION_SCHEMA) {
1397+
issues.push({ path: `${path}.schema`, message: `action schema must be ${RUNTIME_EPISODE_ACTION_SCHEMA}` })
1398+
}
1399+
if (action.kind !== "command") {
1400+
issues.push({ path: `${path}.kind`, message: "action kind must be command" })
1401+
}
1402+
if (!nonEmptyString(action.command)) {
1403+
issues.push({ path: `${path}.command`, message: "action command is required" })
1404+
}
1405+
if (!Array.isArray(action.args) || !action.args.every((arg) => typeof arg === "string")) {
1406+
issues.push({ path: `${path}.args`, message: "action args must be an array of strings" })
1407+
}
1408+
if (action.cwd !== undefined && typeof action.cwd !== "string") {
1409+
issues.push({ path: `${path}.cwd`, message: "action cwd must be a string when present" })
1410+
}
1411+
const timeoutMs = action.timeoutMs
1412+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0)) {
1413+
issues.push({ path: `${path}.timeoutMs`, message: "action timeoutMs must be a non-negative number when present" })
1414+
}
1415+
if (!validDigest(action.digest)) {
1416+
issues.push({ path: `${path}.digest`, message: "action digest must be a sha256 digest" })
1417+
return
1418+
}
1419+
1420+
const expected = runtimeEpisodeDigest(runtimeEpisodeActionDigestPayload(action as unknown as RuntimeEpisodeActionRecord))
1421+
if (action.digest.value !== expected.value) {
1422+
issues.push({ path: `${path}.digest`, message: "action digest must match the canonical replay payload" })
1423+
}
1424+
}
1425+
1426+
function validateRuntimeEpisodeObservation(
1427+
observation: ObservationResult | unknown,
1428+
path: string,
1429+
issues: RuntimeEpisodeTraceValidationIssue[],
1430+
): void {
1431+
if (!isRecord(observation)) {
1432+
issues.push({ path, message: "observation must be an object" })
1433+
return
1434+
}
1435+
1436+
if (observation.schema !== RUNTIME_EPISODE_OBSERVATION_SCHEMA) {
1437+
issues.push({ path: `${path}.schema`, message: `observation schema must be ${RUNTIME_EPISODE_OBSERVATION_SCHEMA}` })
1438+
}
1439+
if (!nonEmptyString(observation.id)) {
1440+
issues.push({ path: `${path}.id`, message: "observation id is required" })
1441+
}
1442+
if (!nonEmptyString(observation.type)) {
1443+
issues.push({ path: `${path}.type`, message: "observation type is required" })
1444+
}
1445+
if (!("data" in observation)) {
1446+
issues.push({ path: `${path}.data`, message: "observation data is required" })
1447+
}
1448+
if (!nonEmptyString(observation.observedAt)) {
1449+
issues.push({ path: `${path}.observedAt`, message: "observation observedAt is required" })
1450+
}
1451+
if (!validDigest(observation.digest)) {
1452+
issues.push({ path: `${path}.digest`, message: "observation digest must be a sha256 digest" })
1453+
return
1454+
}
1455+
1456+
const expected = runtimeEpisodeDigest(runtimeEpisodeObservationDigestPayload(observation as unknown as ObservationResult))
1457+
if (observation.digest.value !== expected.value) {
1458+
issues.push({ path: `${path}.digest`, message: "observation digest must match the canonical observation payload" })
1459+
}
1460+
}
1461+
1462+
function validateRuntimeEpisodeTraceRef(
1463+
ref: RuntimeEpisodeTraceRef | unknown,
1464+
path: string,
1465+
kind: RuntimeEpisodeTraceRef["kind"],
1466+
issues: RuntimeEpisodeTraceValidationIssue[],
1467+
): void {
1468+
if (!isRecord(ref)) {
1469+
issues.push({ path, message: "ref must be an object" })
1470+
return
1471+
}
1472+
1473+
if (ref.kind !== kind) {
1474+
issues.push({ path: `${path}.kind`, message: `ref kind must be ${kind}` })
1475+
}
1476+
if (!nonEmptyString(ref.id)) {
1477+
issues.push({ path: `${path}.id`, message: "ref id is required" })
1478+
}
1479+
if (!validDigest(ref.digest)) {
1480+
issues.push({ path: `${path}.digest`, message: "ref digest must be a sha256 digest" })
12861481
}
12871482
}
12881483

1484+
function validateRuntimeEpisodeRefDigest(
1485+
ref: RuntimeEpisodeTraceRef,
1486+
targetDigest: RuntimeEpisodeContentDigest | undefined,
1487+
path: string,
1488+
issues: RuntimeEpisodeTraceValidationIssue[],
1489+
): void {
1490+
if (!validDigest(ref.digest) || !validDigest(targetDigest)) {
1491+
return
1492+
}
1493+
if (ref.digest.value !== targetDigest.value) {
1494+
issues.push({ path: `${path}.digest`, message: "ref digest must match the referenced envelope digest" })
1495+
}
1496+
}
1497+
1498+
function validDigest(value: unknown): value is RuntimeEpisodeContentDigest {
1499+
return isRecord(value) && value.algorithm === "sha256" && typeof value.value === "string" && /^[a-f0-9]{64}$/.test(value.value)
1500+
}
1501+
12891502
function collectForbiddenRuntimeEpisodeTraceFields(
12901503
value: unknown,
12911504
path: string,
@@ -1329,11 +1542,17 @@ function stableJson(value: unknown): string {
13291542
}
13301543

13311544
function observationRef(observation: ObservationResult, fallbackId: string): RuntimeEpisodeTraceRef {
1332-
return { kind: "observation", id: observation.id || fallbackId, digest: runtimeEpisodeDigest(observation) }
1545+
return { kind: "observation", id: observation.id || fallbackId, digest: observation.digest ?? runtimeEpisodeDigest(runtimeEpisodeObservationDigestPayload(observation)) }
13331546
}
13341547

13351548
function observationWithId(observation: ObservationResult, fallbackId: string): ObservationResult {
1336-
return { ...observation, id: observation.id || fallbackId }
1549+
const enveloped = {
1550+
...observation,
1551+
schema: RUNTIME_EPISODE_OBSERVATION_SCHEMA,
1552+
id: observation.id || fallbackId,
1553+
}
1554+
1555+
return { ...enveloped, digest: runtimeEpisodeDigest(runtimeEpisodeObservationDigestPayload(enveloped)) }
13371556
}
13381557

13391558
function snapshotWithSemantics(snapshot: Snapshot): Snapshot {
@@ -1406,9 +1625,14 @@ class RuntimeEpisodeRunner implements RuntimeEpisode {
14061625
const index = this.steps.length
14071626
const stepId = `${execution.id}:step:${index}`
14081627
const actionRecord = {
1409-
...action,
1628+
schema: RUNTIME_EPISODE_ACTION_SCHEMA,
14101629
id: `${stepId}:action`,
1411-
digest: runtimeEpisodeDigest(action),
1630+
kind: "command" as const,
1631+
command: action.command,
1632+
args: action.args ?? [],
1633+
...(action.cwd ? { cwd: action.cwd } : {}),
1634+
...(action.timeoutMs !== undefined ? { timeoutMs: action.timeoutMs } : {}),
1635+
digest: runtimeEpisodeDigest(runtimeEpisodeActionDigestPayload(action)),
14121636
}
14131637
const stepObservation = observation ? observationWithId(await runtime.observe(observation), `${stepId}:observation`) : undefined
14141638
const result: RuntimeEpisodeStepResult = {

0 commit comments

Comments
 (0)