@@ -7,6 +7,8 @@ export * from "./workspace-policy.js"
77export type RuntimeBackendKind = "wordpress-playground" | ( string & { } )
88
99export 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
1113export 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
424464export 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
431473export 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+
12051274export 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 - f 0 - 9 ] { 64 } $ / . test ( value . value )
1500+ }
1501+
12891502function collectForbiddenRuntimeEpisodeTraceFields (
12901503 value : unknown ,
12911504 path : string ,
@@ -1329,11 +1542,17 @@ function stableJson(value: unknown): string {
13291542}
13301543
13311544function 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
13351548function 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
13391558function 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