diff --git a/docs/ROCKETRIDE_EXECUTION_PORT.md b/docs/ROCKETRIDE_EXECUTION_PORT.md new file mode 100644 index 00000000..8fd01f66 --- /dev/null +++ b/docs/ROCKETRIDE_EXECUTION_PORT.md @@ -0,0 +1,53 @@ +# NodeRoom Execution Port + +NodeRoom can accept candidate work from its native executor, a RocketRide +sidecar, or a LangChain sidecar through the versioned +`node.workflow-execution/v1` envelope. The app does not import either framework. + +The executor returns an `AlgorithmPatchBundle` with `commitPolicy=patch_bundle_only_runtime_must_cas`. `inspectRoomWorkflowCandidate()` verifies: + +- request, fixture, trace, input digest, and idempotency-key binding; +- the frozen application commit and runtime provenance; +- canonical candidate SHA-256, bounded size, event order, deadline, counters, and reported runtime health; +- NodeRoom's existing candidate invariants. + +A successful receipt says `candidate_validated`, not committed. Final authority +remains with `RoomTools` lock, CAS, proposal, and review operations. The +inspector accepts no backend mutation port, so a sidecar cannot bypass those +controls. + +## Adapter Shape + +```ts +const executionPort = + createNodeWorkflowSidecarExecutionPort({ + framework: "rocketride", // Use "langchain" for that sidecar. + endpoint: process.env.NODEROOM_WORKFLOW_SIDECAR_URL!, + headers: sidecarToken ? { authorization: `Bearer ${sidecarToken}` } : {}, + }); +const result = await executionPort.execute(request, { signal }); +const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit, + digestCandidate, +}); + +if (!admission.accepted) return admission.receipt; +// Submit admission.candidate to the existing proposal path; do not write directly. +``` + +The endpoint is fixed at port creation, requires HTTPS except on localhost, +inherits the request deadline, and rejects non-JSON or oversized responses. +`createNativeNodeWorkflowExecutionPort()` wraps the current native control +behind the same request/result contract. + +The deterministic study requires no model or cloud credential. A cloud transport +may implement the same port, but must report `location: "cloud"` and is an +operational appendix rather than a replacement for the pinned local benchmark. + +## Verify + +```powershell +npm test -- --run tests/nodeWorkflowExecutionPort.test.ts +``` diff --git a/src/nodeagent/integrations/index.ts b/src/nodeagent/integrations/index.ts index b3954954..e9cb7fe7 100644 --- a/src/nodeagent/integrations/index.ts +++ b/src/nodeagent/integrations/index.ts @@ -1,3 +1,5 @@ export * from "./langchain"; export * from "./langsmith"; export * from "./modelInterop"; +export * from "./workflowExecutionPort"; +export * from "./roomWorkflowCandidate"; diff --git a/src/nodeagent/integrations/roomWorkflowCandidate.ts b/src/nodeagent/integrations/roomWorkflowCandidate.ts new file mode 100644 index 00000000..4016edce --- /dev/null +++ b/src/nodeagent/integrations/roomWorkflowCandidate.ts @@ -0,0 +1,266 @@ +import type { AlgorithmPatchBundle } from "../skills/spreadsheet/algorithmArtifacts"; +import { + resolveSemanticConflictPacket, + type SemanticConflictPacket, + type SemanticResolution, +} from "../skills/spreadsheet/semanticRebase"; +import { + canonicalNodeWorkflowJson, + inspectNodeWorkflowCandidate, + type CandidateAdmission, + type NodeWorkflowRequest, + type NodeWorkflowResult, +} from "./workflowExecutionPort"; + +export interface RoomAlgorithmWorkflowCandidate { + kind: "algorithm_patch_bundle"; + bundle: AlgorithmPatchBundle; +} + +export interface RoomSemanticWorkflowCandidate { + kind: "semantic_conflict_resolution"; + packet: SemanticConflictPacket; + resolution: SemanticResolution; +} + +export type RoomWorkflowCandidate = + RoomAlgorithmWorkflowCandidate | RoomSemanticWorkflowCandidate; + +/** + * Validates an executor-produced candidate only. Applying the returned bundle is + * intentionally a separate RoomTools operation so lock, CAS, and review policy + * cannot be bypassed by a native, RocketRide, or LangChain worker. + */ +export function inspectRoomWorkflowCandidate(args: { + request: NodeWorkflowRequest; + result: NodeWorkflowResult; + expectedAppCommit: string; + expectedRoomId?: string; + digestCandidate: ( + candidate: RoomWorkflowCandidate, + ) => string | Promise; + now?: () => Date; +}): Promise> { + return inspectNodeWorkflowCandidate({ + request: args.request, + result: args.result, + expectedAppCommit: args.expectedAppCommit, + digestCandidate: args.digestCandidate, + now: args.now, + expectedApp: "noderoom", + validateCandidate: (candidate) => { + const issues = validateRoomWorkflowCandidate(candidate); + if ( + args.expectedRoomId && + candidate.kind === "semantic_conflict_resolution" && + candidate.packet.roomId !== args.expectedRoomId + ) { + issues.push( + "NodeRoom semantic candidate crossed the expected room boundary.", + ); + } + return [...new Set(issues)]; + }, + }); +} + +export function validateRoomWorkflowCandidate( + candidate: RoomWorkflowCandidate, +): string[] { + if (candidate?.kind === "semantic_conflict_resolution") { + return validateSemanticConflictCandidate(candidate); + } + if (candidate?.kind !== "algorithm_patch_bundle") { + return ["NodeRoom candidate kind is unsupported."]; + } + const issues: string[] = []; + + const bundle = candidate.bundle; + if (bundle?.schema !== 1) + issues.push("NodeRoom patch bundle schema is invalid."); + if (bundle?.status !== "passed") + issues.push("NodeRoom patch bundle did not pass its runner."); + if (bundle?.commitPolicy !== "patch_bundle_only_runtime_must_cas") { + issues.push( + "NodeRoom patch bundle must preserve runtime-managed CAS authority.", + ); + } + if (!bundle?.proof?.deterministic) + issues.push("NodeRoom patch bundle must be deterministic."); + if (!bounded(bundle?.proof?.runnerVersion, 1, 160)) { + issues.push("NodeRoom patch bundle runner version is invalid."); + } + if ( + !Number.isSafeInteger(bundle?.proof?.testsPassed) || + bundle.proof.testsPassed < 1 + ) { + issues.push( + "NodeRoom patch bundle must include passing deterministic tests.", + ); + } + if (!bounded(bundle?.algorithmId, 1, 160)) + issues.push("NodeRoom algorithm ID is invalid."); + if (!bounded(bundle?.artifactHash, 1, 160)) + issues.push("NodeRoom artifact hash is invalid."); + if ( + !Array.isArray(bundle?.patches) || + bundle.patches.length < 1 || + bundle.patches.length > 128 + ) { + issues.push("NodeRoom patch bundle must contain 1 to 128 patches."); + return issues; + } + + const targets = new Set(); + for (const patch of bundle.patches) { + if (!bounded(patch.elementId, 1, 256)) + issues.push("NodeRoom patch target is invalid."); + if (targets.has(patch.elementId)) + issues.push(`Duplicate NodeRoom patch target: ${patch.elementId}.`); + targets.add(patch.elementId); + if (!Number.isSafeInteger(patch.baseVersion) || patch.baseVersion < 0) { + issues.push( + `NodeRoom patch ${patch.elementId} has an invalid base version.`, + ); + } + if (patch.kind !== "set") + issues.push(`NodeRoom patch ${patch.elementId} has an invalid kind.`); + } + + const writeOps = bundle.writeLockedCellResultsArgs?.ops; + if (!Array.isArray(writeOps) || writeOps.length !== bundle.patches.length) { + issues.push( + "NodeRoom managed-write arguments do not match the patch count.", + ); + } else { + for (const patch of bundle.patches) { + const write = writeOps.find( + (operation) => operation.elementId === patch.elementId, + ); + if ( + !write || + write.baseVersion !== patch.baseVersion || + write.kind !== patch.kind + ) { + issues.push( + `NodeRoom managed-write arguments do not match patch ${patch.elementId}.`, + ); + } else { + const expectedWrite = { + elementId: patch.elementId, + baseVersion: patch.baseVersion, + value: patch.value.value, + status: patch.value.status, + confidence: patch.value.confidence, + normalizedValue: patch.value.normalizedValue, + formula: patch.value.formula, + evidence: patch.value.evidence, + kind: patch.kind, + }; + if ( + canonicalNodeWorkflowJson(write) !== + canonicalNodeWorkflowJson(expectedWrite) + ) { + issues.push( + `NodeRoom managed-write payload does not match patch ${patch.elementId}.`, + ); + } + } + } + } + + const outputRefs = bundle.proof?.outputRefs; + if ( + !Array.isArray(outputRefs) || + outputRefs.length !== bundle.patches.length + ) { + issues.push( + "NodeRoom proof output references do not match the patch count.", + ); + } else { + for (const patch of bundle.patches) { + const output = outputRefs.find( + (reference) => reference.elementId === patch.elementId, + ); + if ( + !output || + output.baseVersion !== patch.baseVersion || + output.expression !== patch.value.formula || + output.normalizedValue !== patch.value.normalizedValue + ) { + issues.push( + `NodeRoom proof output does not match patch ${patch.elementId}.`, + ); + } + } + } + return [...new Set(issues)]; +} + +function validateSemanticConflictCandidate( + candidate: RoomSemanticWorkflowCandidate, +): string[] { + const issues: string[] = []; + const packet = candidate.packet; + if (!bounded(packet?.conflictId, 1, 256)) + issues.push("NodeRoom conflict ID is invalid."); + if (!bounded(packet?.roomId, 1, 256)) + issues.push("NodeRoom conflict room ID is invalid."); + if (!bounded(packet?.artifactId, 1, 256)) + issues.push("NodeRoom conflict artifact ID is invalid."); + if ( + !Array.isArray(packet?.targetRefs) || + packet.targetRefs.length < 1 || + packet.targetRefs.length > 128 + ) { + issues.push("NodeRoom conflict must contain 1 to 128 target references."); + } + if (!Array.isArray(packet?.proposed?.ops) || packet.proposed.ops.length < 1) { + issues.push("NodeRoom conflict must contain proposed operations."); + } + if (packet?.status !== "needs_review" && packet?.status !== "open") { + issues.push( + "NodeRoom conflict candidate must still be open for application review.", + ); + } + + const expected = resolveSemanticConflictPacket(packet); + if ( + canonicalNodeWorkflowJson(candidate.resolution) !== + canonicalNodeWorkflowJson(expected) + ) { + issues.push( + "NodeRoom semantic resolution does not match the application-owned policy resolver.", + ); + } + if ( + candidate.resolution.classification.action === "commit_after_final_cas" && + !candidate.resolution.classification.requiredValidators.includes( + "fresh_final_cas", + ) + ) { + issues.push( + "NodeRoom semantic resolution omitted the final CAS validator.", + ); + } + if ( + candidate.resolution.resolvedOps.some( + (operation) => operation.kind === "create_proposal", + ) + ) { + if (!candidate.resolution.reviewerNote.includes("final CAS")) { + issues.push( + "NodeRoom semantic proposal must preserve final CAS authority.", + ); + } + } + return [...new Set(issues)]; +} + +function bounded(value: unknown, min: number, max: number): value is string { + return ( + typeof value === "string" && + value.trim().length >= min && + value.length <= max + ); +} diff --git a/src/nodeagent/integrations/workflowExecutionPort.ts b/src/nodeagent/integrations/workflowExecutionPort.ts new file mode 100644 index 00000000..39fe9739 --- /dev/null +++ b/src/nodeagent/integrations/workflowExecutionPort.ts @@ -0,0 +1,666 @@ +export const NODE_WORKFLOW_PROTOCOL_VERSION = + "node.workflow-execution/v1" as const; + +export type NodeWorkflowApp = + "nodevideo" | "nodeslide" | "noderoom" | "nodebenchai"; +export type NodeWorkflowFramework = "native" | "rocketride" | "langchain"; + +export interface NodeWorkflowRequest { + schemaVersion: typeof NODE_WORKFLOW_PROTOCOL_VERSION; + app: NodeWorkflowApp; + workflow: string; + fixtureId: string; + traceId: string; + inputDigest: string; + baseVersion?: number; + idempotencyKey: string; + concurrency: number; + deadlineMs: number; + failureSeed?: string; +} + +export interface NodeRunEvent { + sequence: number; + atMs: number; + kind: string; + unitId?: string; + detail?: string; +} + +export interface NodeRunMetrics { + coldStartMs: number; + warmupMs: number; + executionMs: number; + totalMs: number; + retryCount: number; + completedUnits: number; + failedUnits: number; + duplicateUnits: number; + leakedUnits: number; + peakRssBytes?: number; + cpuTimeMs?: number; + runtimeHealthyAfter?: boolean; +} + +export interface RuntimeProvenance { + adapter: string; + adapterVersion: string; + runtime: string; + runtimeVersion: string; + appCommit: string; + deterministic: boolean; + location: "local" | "cloud"; +} + +export interface StructuredRunError { + code: string; + message: string; + retryable: boolean; +} + +export interface NodeWorkflowResult { + schemaVersion: typeof NODE_WORKFLOW_PROTOCOL_VERSION; + runId: string; + traceId: string; + framework: NodeWorkflowFramework; + candidate: TCandidate; + inputDigest: string; + idempotencyKey: string; + outputDigest: string; + events: NodeRunEvent[]; + metrics: NodeRunMetrics; + provenance: RuntimeProvenance; + error?: StructuredRunError; +} + +export interface NodeWorkflowExecutionPort { + readonly framework: NodeWorkflowFramework; + execute( + request: Readonly, + options?: { signal?: AbortSignal }, + ): Promise>; +} + +export interface NodeWorkflowSidecarPortOptions { + framework: Exclude; + endpoint: string; + headers?: Readonly>; + fetch?: typeof fetch; + maxResponseBytes?: number; +} + +export function createNativeNodeWorkflowExecutionPort( + execute: ( + request: Readonly, + options?: { signal?: AbortSignal }, + ) => Promise>, +): NodeWorkflowExecutionPort { + return { + framework: "native", + execute: async (request, options) => { + const issues = validateNodeWorkflowRequest(request); + if (issues.length > 0) + throw new Error(`Invalid workflow request: ${issues.join(" ")}`); + const result = await execute(structuredClone(request), options); + if (result.framework !== "native") { + throw new Error( + "Native workflow result framework does not match the configured port.", + ); + } + return result; + }, + }; +} + +/** + * Calls a configured candidate-only sidecar. The endpoint is fixed when the + * port is created, never derived from a workflow request, and cannot receive an + * application write capability. + */ +export function createNodeWorkflowSidecarExecutionPort( + options: NodeWorkflowSidecarPortOptions, +): NodeWorkflowExecutionPort { + const endpoint = validateSidecarEndpoint(options.endpoint); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (typeof fetchImpl !== "function") + throw new Error("A fetch implementation is required."); + const maxResponseBytes = options.maxResponseBytes ?? 1_250_000; + if (!safeIntegerInRange(maxResponseBytes, 1_024, 10_000_000)) { + throw new Error( + "Sidecar response limit must be between 1024 and 10000000 bytes.", + ); + } + const headers = validateSidecarHeaders(options.headers ?? {}); + + return { + framework: options.framework, + async execute(request, executionOptions) { + const requestIssues = validateNodeWorkflowRequest(request); + if (requestIssues.length > 0) { + throw new Error(`Invalid workflow request: ${requestIssues.join(" ")}`); + } + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort("workflow deadline exceeded"), + request.deadlineMs, + ); + const relayAbort = () => + controller.abort(executionOptions?.signal?.reason); + executionOptions?.signal?.addEventListener("abort", relayAbort, { + once: true, + }); + try { + if (executionOptions?.signal?.aborted) relayAbort(); + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(structuredClone(request)), + redirect: "error", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Workflow sidecar returned HTTP ${response.status}.`); + } + const contentType = + response.headers.get("content-type")?.toLowerCase() ?? ""; + if (!contentType.includes("application/json")) { + throw new Error("Workflow sidecar must return application/json."); + } + const result = JSON.parse( + await readBoundedResponse(response, maxResponseBytes), + ) as unknown; + if ( + !isRecord(result) || + result["schemaVersion"] !== NODE_WORKFLOW_PROTOCOL_VERSION + ) { + throw new Error( + "Workflow sidecar returned an unsupported result schema.", + ); + } + if (result["framework"] !== options.framework) { + throw new Error( + "Workflow sidecar result framework does not match the configured port.", + ); + } + return result as unknown as NodeWorkflowResult; + } finally { + clearTimeout(timeout); + executionOptions?.signal?.removeEventListener("abort", relayAbort); + } + }, + }; +} + +export interface CandidateAdmissionReceipt { + schemaVersion: "node.workflow-candidate-admission/v1"; + runId: string; + traceId: string; + app: NodeWorkflowApp; + framework: NodeWorkflowFramework; + inputDigest: string; + outputDigest: string; + status: "candidate_validated" | "candidate_rejected"; + issues: string[]; + finalWriteAuthority: "application_validation_cas_review"; + verifiedAt: string; +} + +export type CandidateAdmission = + | { + accepted: true; + candidate: TCandidate; + receipt: CandidateAdmissionReceipt; + } + | { accepted: false; receipt: CandidateAdmissionReceipt }; + +export async function inspectNodeWorkflowCandidate(args: { + request: NodeWorkflowRequest; + result: NodeWorkflowResult; + expectedApp: NodeWorkflowApp; + expectedAppCommit: string; + digestCandidate: (candidate: TCandidate) => string | Promise; + validateCandidate: ( + candidate: TCandidate, + ) => readonly string[] | Promise; + requireDeterministic?: boolean; + now?: () => Date; +}): Promise> { + const issues = validateNodeWorkflowEnvelope( + args.request, + args.result, + args.expectedApp, + args.expectedAppCommit, + args.requireDeterministic ?? true, + ); + + try { + const candidateBytes = new TextEncoder().encode( + canonicalNodeWorkflowJson(args.result.candidate), + ); + if (candidateBytes.byteLength > 1_000_000) { + issues.push("Candidate exceeds the 1 MB protocol limit."); + } + } catch (error) { + issues.push(`Candidate is not canonical JSON: ${errorMessage(error)}`); + } + + let computedDigest = ""; + try { + computedDigest = await args.digestCandidate(args.result.candidate); + if (!isSha256Digest(computedDigest)) { + issues.push("Candidate digester did not return a sha256 digest."); + } else if (computedDigest !== args.result.outputDigest) { + issues.push( + "Candidate output digest does not match the execution receipt.", + ); + } + } catch (error) { + issues.push(`Candidate digest failed: ${errorMessage(error)}`); + } + + try { + issues.push(...(await args.validateCandidate(args.result.candidate))); + } catch (error) { + issues.push( + `Application candidate validation failed: ${errorMessage(error)}`, + ); + } + + const uniqueIssues = [...new Set(issues)]; + const receipt: CandidateAdmissionReceipt = { + schemaVersion: "node.workflow-candidate-admission/v1", + runId: cleanText(args.result.runId) || "invalid-run", + traceId: cleanText(args.result.traceId) || "invalid-trace", + app: args.expectedApp, + framework: args.result.framework, + inputDigest: args.result.inputDigest, + outputDigest: computedDigest || args.result.outputDigest, + status: + uniqueIssues.length === 0 ? "candidate_validated" : "candidate_rejected", + issues: uniqueIssues, + finalWriteAuthority: "application_validation_cas_review", + verifiedAt: (args.now ?? (() => new Date()))().toISOString(), + }; + + if (uniqueIssues.length > 0) return { accepted: false, receipt }; + return { + accepted: true, + candidate: deepFreeze(structuredClone(args.result.candidate)), + receipt, + }; +} + +export function validateNodeWorkflowEnvelope( + request: NodeWorkflowRequest, + result: NodeWorkflowResult, + expectedApp: NodeWorkflowApp, + expectedAppCommit: string, + requireDeterministic = true, +): string[] { + const issues = validateNodeWorkflowRequest(request, expectedApp); + if (result.schemaVersion !== NODE_WORKFLOW_PROTOCOL_VERSION) { + issues.push("Unsupported workflow result schema."); + } + if (!boundedText(result.runId, 1, 256)) issues.push("Run ID is invalid."); + if (!boundedText(result.traceId, 1, 256)) + issues.push("Result trace ID is invalid."); + if (!["native", "rocketride", "langchain"].includes(result.framework)) { + issues.push("Framework is invalid."); + } + if (result.inputDigest !== request.inputDigest) { + issues.push("Result is not bound to the request input digest."); + } + if (result.traceId !== request.traceId) { + issues.push("Result is not bound to the request trace ID."); + } + if (result.idempotencyKey !== request.idempotencyKey) { + issues.push("Result is not bound to the request idempotency key."); + } + if (!isSha256Digest(result.outputDigest)) + issues.push("Result output digest is invalid."); + if (result.error) + issues.push( + `Executor returned ${result.error.code}: ${result.error.message}`, + ); + + validateEvents(result.events, issues); + validateMetrics(result.metrics, request.deadlineMs, issues); + validateProvenance( + result.provenance, + expectedAppCommit, + requireDeterministic, + issues, + ); + return issues; +} + +export function validateNodeWorkflowRequest( + request: NodeWorkflowRequest, + expectedApp?: NodeWorkflowApp, +): string[] { + const issues: string[] = []; + if (request.schemaVersion !== NODE_WORKFLOW_PROTOCOL_VERSION) { + issues.push("Unsupported workflow request schema."); + } + if ( + !["nodevideo", "nodeslide", "noderoom", "nodebenchai"].includes(request.app) + ) { + issues.push("Request app is invalid."); + } + if (expectedApp && request.app !== expectedApp) + issues.push(`Request app must be ${expectedApp}.`); + if (!boundedText(request.workflow, 1, 160)) + issues.push("Workflow name is invalid."); + if (!boundedText(request.fixtureId, 1, 256)) + issues.push("Fixture ID is invalid."); + if (!boundedText(request.traceId, 1, 256)) + issues.push("Request trace ID is invalid."); + if (!isSha256Digest(request.inputDigest)) + issues.push("Request input digest is invalid."); + if (!boundedText(request.idempotencyKey, 1, 256)) + issues.push("Idempotency key is invalid."); + if (!safeIntegerInRange(request.concurrency, 1, 256)) + issues.push("Concurrency is invalid."); + if (!safeIntegerInRange(request.deadlineMs, 1, 30 * 60 * 1000)) { + issues.push("Deadline is invalid."); + } + if ( + request.baseVersion !== undefined && + !safeIntegerInRange(request.baseVersion, 0, Number.MAX_SAFE_INTEGER) + ) { + issues.push("Base version is invalid."); + } + if ( + request.failureSeed !== undefined && + !boundedText(request.failureSeed, 1, 256) + ) { + issues.push("Failure seed is invalid."); + } + return issues; +} + +/** Stable JSON shared with the Python sidecar before SHA-256 binding. */ +export function canonicalNodeWorkflowJson(value: unknown): string { + return canonicalize(value, new Set()); +} + +function validateEvents( + events: readonly NodeRunEvent[], + issues: string[], +): void { + if (!Array.isArray(events) || events.length === 0) { + issues.push("Execution events are required."); + return; + } + if (events.length > 10_000) { + issues.push("Execution event count exceeds the protocol limit."); + return; + } + let lastAt = -1; + for (let index = 0; index < events.length; index += 1) { + const event = events[index]; + if (!event || event.sequence !== index + 1) { + issues.push("Execution event sequences must be contiguous."); + break; + } + if (!Number.isFinite(event.atMs) || event.atMs < lastAt || event.atMs < 0) { + issues.push("Execution event clocks must be finite and monotonic."); + break; + } + if (!boundedText(event.kind, 1, 120)) { + issues.push("Execution event kind is invalid."); + break; + } + lastAt = event.atMs; + } +} + +function validateSidecarEndpoint(value: string): string { + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new Error("Workflow sidecar endpoint is not a valid URL."); + } + if (endpoint.username || endpoint.password) { + throw new Error( + "Workflow sidecar credentials must be supplied as headers, not URL userinfo.", + ); + } + const hostname = endpoint.hostname.replace(/^\[|\]$/g, ""); + const local = ["localhost", "127.0.0.1", "::1"].includes(hostname); + if ( + endpoint.protocol !== "https:" && + !(endpoint.protocol === "http:" && local) + ) { + throw new Error("Workflow sidecars require HTTPS except on localhost."); + } + endpoint.hash = ""; + return endpoint.toString(); +} + +function validateSidecarHeaders( + input: Readonly>, +): Record { + const headers: Record = {}; + for (const [rawName, rawValue] of Object.entries(input)) { + const name = rawName.trim().toLowerCase(); + if (!/^[a-z0-9!#$%&'*+.^_\`|~-]+$/.test(name)) { + throw new Error("Workflow sidecar header name is invalid."); + } + if (["host", "content-length", "content-type"].includes(name)) { + throw new Error( + `Workflow sidecar header ${name} is managed by the execution port.`, + ); + } + if (/[\r\n]/.test(rawValue)) + throw new Error("Workflow sidecar header value is invalid."); + headers[name] = rawValue; + } + return headers; +} + +async function readBoundedResponse( + response: Response, + maxBytes: number, +): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error( + "Workflow sidecar response exceeds the configured byte limit.", + ); + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("response limit exceeded"); + throw new Error( + "Workflow sidecar response exceeds the configured byte limit.", + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +function validateMetrics( + metrics: NodeRunMetrics | undefined, + deadlineMs: number, + issues: string[], +): void { + if (!metrics || typeof metrics !== "object") { + issues.push("Execution metrics are required."); + return; + } + const durations = [ + metrics.coldStartMs, + metrics.warmupMs, + metrics.executionMs, + metrics.totalMs, + ]; + if (durations.some((value) => !Number.isFinite(value) || value < 0)) { + issues.push("Execution durations must be finite and non-negative."); + } + if (metrics.totalMs > deadlineMs) + issues.push("Execution exceeded the request deadline."); + const counts = [ + metrics.retryCount, + metrics.completedUnits, + metrics.failedUnits, + metrics.duplicateUnits, + metrics.leakedUnits, + ]; + if ( + counts.some( + (value) => !safeIntegerInRange(value, 0, Number.MAX_SAFE_INTEGER), + ) + ) { + issues.push("Execution counters must be non-negative safe integers."); + } + if (metrics.completedUnits < 1) + issues.push("Execution completed no work units."); + if (metrics.failedUnits > 0) + issues.push("Execution contains failed work units."); + if (metrics.duplicateUnits > 0) + issues.push("Execution contains duplicate work units."); + if (metrics.leakedUnits > 0) + issues.push("Execution contains cross-scope leaked work units."); + for (const value of [metrics.peakRssBytes, metrics.cpuTimeMs]) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + issues.push("Optional resource metrics must be finite and non-negative."); + break; + } + } + if ( + metrics.runtimeHealthyAfter !== undefined && + typeof metrics.runtimeHealthyAfter !== "boolean" + ) { + issues.push("Post-execution runtime health must be boolean when reported."); + } +} + +function validateProvenance( + provenance: RuntimeProvenance | undefined, + expectedAppCommit: string, + requireDeterministic: boolean, + issues: string[], +): void { + if (!provenance || typeof provenance !== "object") { + issues.push("Runtime provenance is required."); + return; + } + for (const [label, value] of [ + ["adapter", provenance.adapter], + ["adapter version", provenance.adapterVersion], + ["runtime", provenance.runtime], + ["runtime version", provenance.runtimeVersion], + ["application commit", provenance.appCommit], + ] as const) { + if (!boundedText(value, 1, 256)) + issues.push(`Runtime ${label} is invalid.`); + } + if (!["local", "cloud"].includes(provenance.location)) { + issues.push("Runtime location is invalid."); + } + if (provenance.appCommit !== expectedAppCommit) { + issues.push( + "Runtime provenance does not match the frozen application commit.", + ); + } + if (requireDeterministic && !provenance.deterministic) { + issues.push("Scored candidate must use deterministic provenance."); + } +} + +function isSha256Digest(value: string): boolean { + return /^sha256:[0-9a-f]{64}$/.test(value); +} + +function canonicalize(value: unknown, ancestors: Set): string { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new Error("Canonical JSON numbers must be finite."); + return JSON.stringify(value); + } + if (typeof value !== "object") + throw new Error("Candidate contains a non-JSON value."); + if (ancestors.has(value)) throw new Error("Candidate contains a cycle."); + ancestors.add(value); + let encoded: string; + if (Array.isArray(value)) { + encoded = `[${value.map((item) => canonicalize(item, ancestors)).join(",")}]`; + } else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("Candidate must contain plain JSON objects."); + } + encoded = `{${Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map( + ([key, item]) => + `${JSON.stringify(key)}:${canonicalize(item, ancestors)}`, + ) + .join(",")}}`; + } + ancestors.delete(value); + return encoded; +} + +function boundedText( + value: unknown, + min: number, + max: number, +): value is string { + return ( + typeof value === "string" && + value.trim().length >= min && + value.length <= max + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) + return value; + for (const item of Object.values(value as Record)) + deepFreeze(item); + return Object.freeze(value) as T; +} + +function safeIntegerInRange(value: number, min: number, max: number): boolean { + return Number.isSafeInteger(value) && value >= min && value <= max; +} + +function cleanText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/fixtures/rocketride-noderoom-conflict-proposal.json b/tests/fixtures/rocketride-noderoom-conflict-proposal.json new file mode 100644 index 00000000..3c6f8154 --- /dev/null +++ b/tests/fixtures/rocketride-noderoom-conflict-proposal.json @@ -0,0 +1,113 @@ +{ + "fixtureId": "noderoom-conflict-proposal-v1", + "app": "noderoom", + "workflow": "compare-reason-swap-conflict", + "appCommit": "ca25e347dc467bc37f06918e1a18656f7336ee28", + "concurrency": 4, + "deadlineMs": 10000, + "delayMs": 20, + "baseVersion": 3, + "units": [ + { "id": "compare-base-current", "scope": "room-1" }, + { "id": "reason-business-impact", "scope": "room-1" }, + { "id": "classify-review-tier", "scope": "room-1" }, + { "id": "build-review-proposal", "scope": "room-1" } + ], + "candidate": { + "kind": "semantic_conflict_resolution", + "packet": { + "conflictId": "conflict-1", + "roomId": "room-1", + "artifactId": "sheet-1", + "artifactKind": "spreadsheet", + "draftId": "draft-1", + "trigger": "draft_conflict", + "conflictKind": "cell_value", + "overlap": "same_element", + "actor": { + "kind": "agent", + "id": "room-agent", + "name": "Room Agent", + "scope": "public" + }, + "targetRefs": [{ "kind": "cell", "ref": "r_rev__variance" }], + "base": { + "values": { "r_rev__variance": "+24.0%" }, + "versions": { "r_rev__variance": 3 } + }, + "current": { + "values": { "r_rev__variance": "+25.0%" }, + "versions": { "r_rev__variance": 4 }, + "changedBy": { + "r_rev__variance": { "kind": "user", "id": "host-1", "name": "Host" } + } + }, + "proposed": { + "values": { "r_rev__variance": "+30.0%" }, + "ops": [ + { + "opId": "op-variance-agent", + "artifactId": "sheet-1", + "elementId": "r_rev__variance", + "kind": "set", + "value": "+30.0%", + "baseVersion": 3 + } + ] + }, + "context": { + "userIntent": "Update the forecast variance.", + "mergeNote": "Host edited the same forecast cell.", + "deterministicConflicts": [ + { + "opId": "op-variance-agent", + "elementId": "r_rev__variance", + "reason": "base_version_changed", + "current": "+25.0%" + } + ], + "openQuestions": [] + }, + "policy": { + "humanWinsByDefault": true, + "formulaOverwriteAllowed": false, + "publicPrivateBoundary": "public_only", + "autoCommitAllowed": false + }, + "businessImpact": "forecast", + "status": "needs_review", + "createdAt": 1 + }, + "resolution": { + "decision": "needs_human_review", + "reason": "business assumption impact requires human approval: forecast", + "resolvedOps": [ + { + "targetRef": "r_rev__variance", + "kind": "create_proposal", + "value": "+30.0%", + "baseVersion": 4, + "comment": "Business-value conflict requires human review before changing the current value.", + "status": "needs_review" + } + ], + "reviewerNote": "CRS compared base/current/proposed state. It did not bypass managed writes; every accepted op still needs final CAS.", + "openQuestions": [], + "confidence": 0.62, + "classification": { + "tier": "human_review_required", + "action": "create_review_proposal", + "canAutoCommit": false, + "reasons": [ + "business assumption impact requires human approval: forecast" + ], + "requiredValidators": [ + "fresh_final_cas", + "policy_check", + "evidence_check", + "review_tier" + ] + } + } + } +} diff --git a/tests/fixtures/rocketride-noderoom-independent-writes.json b/tests/fixtures/rocketride-noderoom-independent-writes.json new file mode 100644 index 00000000..174d1d6c --- /dev/null +++ b/tests/fixtures/rocketride-noderoom-independent-writes.json @@ -0,0 +1,84 @@ +{ + "fixtureId": "noderoom-independent-writes-v1", + "app": "noderoom", + "workflow": "independent-cell-enrichment", + "appCommit": "ca25e347dc467bc37f06918e1a18656f7336ee28", + "concurrency": 4, + "deadlineMs": 10000, + "delayMs": 20, + "units": [ + { "id": "cell-r_rev__q2", "scope": "room-1" }, + { "id": "cell-r_rev__q3", "scope": "room-1" }, + { "id": "cell-r_rev__variance", "scope": "room-1" }, + { "id": "notebook-summary", "scope": "room-1" } + ], + "candidate": { + "kind": "algorithm_patch_bundle", + "bundle": { + "schema": 1, + "algorithmId": "revenue_variance_pct_v1", + "artifactHash": "fnv1a32:7bcf2e10", + "status": "passed", + "commitPolicy": "patch_bundle_only_runtime_must_cas", + "patches": [ + { + "elementId": "r_rev__variance", + "baseVersion": 3, + "kind": "set", + "value": { + "value": "+24.0%", + "status": "complete", + "confidence": 1, + "normalizedValue": 0.24, + "formula": "(q3 - q2) / q2", + "evidence": [] + } + } + ], + "writeLockedCellResultsArgs": { + "reason": "Apply the validated revenue variance candidate.", + "ops": [ + { + "elementId": "r_rev__variance", + "baseVersion": 3, + "value": "+24.0%", + "status": "complete", + "confidence": 1, + "normalizedValue": 0.24, + "formula": "(q3 - q2) / q2", + "evidence": [], + "kind": "set" + } + ] + }, + "proof": { + "runnerVersion": "algorithm-artifact-runner:v1", + "deterministic": true, + "testsPassed": 1, + "inputRefs": [ + { + "id": "q2", + "elementId": "r_rev__q2", + "version": 1, + "valueHash": "fixture:q2" + }, + { + "id": "q3", + "elementId": "r_rev__q3", + "version": 1, + "valueHash": "fixture:q3" + } + ], + "outputRefs": [ + { + "id": "variancePct", + "elementId": "r_rev__variance", + "baseVersion": 3, + "expression": "(q3 - q2) / q2", + "normalizedValue": 0.24 + } + ] + } + } + } +} diff --git a/tests/nodeWorkflowExecutionPort.test.ts b/tests/nodeWorkflowExecutionPort.test.ts new file mode 100644 index 00000000..0cc8bc15 --- /dev/null +++ b/tests/nodeWorkflowExecutionPort.test.ts @@ -0,0 +1,367 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + runAlgorithmArtifact, + type AlgorithmArtifact, + type AlgorithmArtifactResult, +} from "../src/nodeagent/skills/spreadsheet/algorithmArtifacts"; +import { + inspectRoomWorkflowCandidate, + canonicalNodeWorkflowJson, + createNativeNodeWorkflowExecutionPort, + createNodeWorkflowSidecarExecutionPort, + NODE_WORKFLOW_PROTOCOL_VERSION, + type NodeWorkflowRequest, + type NodeWorkflowResult, + type RoomAlgorithmWorkflowCandidate, + type RoomWorkflowCandidate, +} from "../src/nodeagent/integrations"; + +const request: NodeWorkflowRequest = { + schemaVersion: NODE_WORKFLOW_PROTOCOL_VERSION, + app: "noderoom", + workflow: "independent-cell-enrichment", + fixtureId: "noderoom-independent-writes-v1", + traceId: "trace-noderoom-independent-writes-1", + inputDigest: `sha256:${"1".repeat(64)}`, + idempotencyKey: "noderoom-independent-writes-v1:run-1", + concurrency: 4, + deadlineMs: 10_000, +}; + +describe("NodeRoom workflow execution port", () => { + it("admits a deterministic patch candidate without exposing a write path", async () => { + const candidate = frozenStudyCandidate(); + const result = resultFor(candidate); + + const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + digestCandidate: digest, + now: () => new Date("2026-07-15T10:00:00.000Z"), + }); + + expect(admission.accepted).toBe(true); + expect(admission.receipt).toMatchObject({ + status: "candidate_validated", + traceId: request.traceId, + finalWriteAuthority: "application_validation_cas_review", + }); + expect(Object.keys(admission)).not.toContain("commit"); + expect(Object.keys(admission)).not.toContain("roomTools"); + if (!admission.accepted) throw new Error("expected candidate admission"); + expect(Object.isFrozen(admission.candidate)).toBe(true); + if (admission.candidate.kind !== "algorithm_patch_bundle") + throw new Error("fixture kind mismatch"); + expect(Object.isFrozen(admission.candidate.bundle.patches)).toBe(true); + }); + + it("rejects a swapped request digest before a candidate can reach RoomTools", async () => { + const candidate = buildCandidate(); + const result = resultFor(candidate); + result.inputDigest = `sha256:${"2".repeat(64)}`; + + const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + digestCandidate: digest, + }); + + expect(admission.accepted).toBe(false); + expect(admission.receipt.issues).toContain( + "Result is not bound to the request input digest.", + ); + }); + + it("rejects duplicate targets even when an executor reports success", async () => { + const candidate = buildCandidate(); + candidate.bundle.patches.push( + structuredClone(candidate.bundle.patches[0]!), + ); + candidate.bundle.writeLockedCellResultsArgs.ops.push( + structuredClone(candidate.bundle.writeLockedCellResultsArgs.ops[0]!), + ); + candidate.bundle.proof.outputRefs.push( + structuredClone(candidate.bundle.proof.outputRefs[0]!), + ); + const result = resultFor(candidate); + + const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + digestCandidate: digest, + }); + + expect(admission.accepted).toBe(false); + expect(admission.receipt.issues.join("\n")).toContain( + "Duplicate NodeRoom patch target", + ); + }); + + it("rejects replay-key and frozen-commit provenance mismatches", async () => { + const candidate = frozenStudyCandidate(); + const result = resultFor(candidate); + result.idempotencyKey = "different-delivery"; + result.traceId = "different-trace"; + result.provenance.appCommit = "unfrozen-commit"; + + const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + digestCandidate: digest, + }); + + expect(admission.accepted).toBe(false); + expect(admission.receipt.issues).toContain( + "Result is not bound to the request idempotency key.", + ); + expect(admission.receipt.issues).toContain( + "Result is not bound to the request trace ID.", + ); + expect(admission.receipt.issues).toContain( + "Runtime provenance does not match the frozen application commit.", + ); + }); + + it("calls a fixed RocketRide sidecar with only the workflow request", async () => { + const candidate = frozenStudyCandidate(); + const sidecarResult = resultFor(candidate); + sidecarResult.framework = "rocketride"; + let posted: unknown; + const port = createNodeWorkflowSidecarExecutionPort({ + framework: "rocketride", + endpoint: "http://127.0.0.1:5567/v1/candidates", + fetch: (async (_input, init) => { + posted = JSON.parse(String(init?.body)); + return new Response(JSON.stringify(sidecarResult), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch, + }); + + const received = await port.execute(request); + + expect(received.framework).toBe("rocketride"); + expect(posted).toEqual(request); + expect(posted).not.toHaveProperty("roomTools"); + expect(() => + createNodeWorkflowSidecarExecutionPort({ + framework: "langchain", + endpoint: "http://example.com/v1/candidates", + }), + ).toThrow("require HTTPS"); + expect(() => + createNodeWorkflowSidecarExecutionPort({ + framework: "langchain", + endpoint: "http://[::1]:5567/v1/candidates", + }), + ).not.toThrow(); + }); + + it("clones requests before invoking the native control", async () => { + const candidate = frozenStudyCandidate(); + const port = createNativeNodeWorkflowExecutionPort( + async (received) => { + (received as NodeWorkflowRequest).workflow = "mutated-by-control"; + return resultFor(candidate); + }, + ); + + await port.execute(request); + + expect(request.workflow).toBe("independent-cell-enrichment"); + }); + + it("rejects an invalid post-execution runtime health signal", async () => { + const candidate = frozenStudyCandidate(); + const result = resultFor(candidate); + result.metrics.runtimeHealthyAfter = "healthy" as unknown as boolean; + + const admission = await inspectRoomWorkflowCandidate({ + request, + result, + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + digestCandidate: digest, + }); + + expect(admission.accepted).toBe(false); + expect(admission.receipt.issues).toContain( + "Post-execution runtime health must be boolean when reported.", + ); + }); + + it("admits the frozen conflict proposal but rejects a sidecar policy override", async () => { + const conflictRequest: NodeWorkflowRequest = { + ...request, + workflow: "compare-reason-swap-conflict", + fixtureId: "noderoom-conflict-proposal-v1", + traceId: "trace-noderoom-conflict-proposal-1", + baseVersion: 3, + idempotencyKey: "noderoom-conflict-proposal-v1:run-1", + }; + const candidate = frozenConflictCandidate(); + const admission = await inspectRoomWorkflowCandidate({ + request: conflictRequest, + result: resultFor(candidate, conflictRequest), + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + expectedRoomId: "room-1", + digestCandidate: digest, + }); + + expect(admission.accepted).toBe(true); + if (candidate.kind !== "semantic_conflict_resolution") + throw new Error("fixture kind mismatch"); + candidate.resolution.decision = "accept_proposed"; + const tampered = await inspectRoomWorkflowCandidate({ + request: conflictRequest, + result: resultFor(candidate, conflictRequest), + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + expectedRoomId: "room-1", + digestCandidate: digest, + }); + expect(tampered.accepted).toBe(false); + expect(tampered.receipt.issues).toContain( + "NodeRoom semantic resolution does not match the application-owned policy resolver.", + ); + + const crossRoomCandidate = frozenConflictCandidate(); + const crossRoom = await inspectRoomWorkflowCandidate({ + request: conflictRequest, + result: resultFor(crossRoomCandidate, conflictRequest), + expectedAppCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + expectedRoomId: "room-2", + digestCandidate: digest, + }); + expect(crossRoom.accepted).toBe(false); + expect(crossRoom.receipt.issues).toContain( + "NodeRoom semantic candidate crossed the expected room boundary.", + ); + }); +}); + +function frozenStudyCandidate(): RoomWorkflowCandidate { + const fixture = JSON.parse( + readFileSync( + new URL( + "./fixtures/rocketride-noderoom-independent-writes.json", + import.meta.url, + ), + "utf8", + ), + ) as { candidate: RoomWorkflowCandidate }; + return fixture.candidate; +} + +function frozenConflictCandidate(): RoomWorkflowCandidate { + const fixture = JSON.parse( + readFileSync( + new URL( + "./fixtures/rocketride-noderoom-conflict-proposal.json", + import.meta.url, + ), + "utf8", + ), + ) as { candidate: RoomWorkflowCandidate }; + return fixture.candidate; +} + +function buildCandidate(): RoomAlgorithmWorkflowCandidate { + const artifact: AlgorithmArtifact = { + schema: 1, + algorithmId: "revenue_variance_pct_v1", + name: "Revenue variance percent", + kind: "spreadsheet_formula", + language: "formula_dsl", + inputs: [ + { id: "q2", elementId: "r_rev__q2", label: "Q2 revenue" }, + { id: "q3", elementId: "r_rev__q3", label: "Q3 revenue" }, + ], + outputs: [ + { + id: "variancePct", + elementId: "r_rev__variance", + expression: "(q3 - q2) / q2", + format: "percent", + }, + ], + constraints: { + deterministic: true, + noNetwork: true, + noRandom: true, + noDateNow: true, + }, + tests: [ + { + name: "revenue variance", + inputs: { q2: 10_000, q3: 12_400 }, + expected: { variancePct: 0.24 }, + }, + ], + }; + const produced = runAlgorithmArtifact(artifact, { + r_rev__q2: { id: "r_rev__q2", value: "$10,000", version: 1 }, + r_rev__q3: { id: "r_rev__q3", value: "$12,400", version: 1 }, + r_rev__variance: { id: "r_rev__variance", value: "", version: 3 }, + }); + return { kind: "algorithm_patch_bundle", bundle: expectBundle(produced) }; +} + +function resultFor( + candidate: RoomWorkflowCandidate, + sourceRequest: NodeWorkflowRequest = request, +): NodeWorkflowResult { + return { + schemaVersion: NODE_WORKFLOW_PROTOCOL_VERSION, + runId: "noderoom-native-001", + traceId: sourceRequest.traceId, + framework: "native", + candidate, + inputDigest: sourceRequest.inputDigest, + idempotencyKey: sourceRequest.idempotencyKey, + outputDigest: digest(candidate), + events: [ + { sequence: 1, atMs: 0, kind: "run.started" }, + { + sequence: 2, + atMs: 12, + kind: "candidate.produced", + unitId: "r_rev__variance", + }, + ], + metrics: { + coldStartMs: 1, + warmupMs: 0, + executionMs: 11, + totalMs: 12, + retryCount: 0, + completedUnits: 1, + failedUnits: 0, + duplicateUnits: 0, + leakedUnits: 0, + }, + provenance: { + adapter: "noderoom-native", + adapterVersion: "1.0.0", + runtime: "node", + runtimeVersion: process.version, + appCommit: "ca25e347dc467bc37f06918e1a18656f7336ee28", + deterministic: true, + location: "local", + }, + }; +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256").update(canonicalNodeWorkflowJson(value)).digest("hex")}`; +} + +function expectBundle(result: AlgorithmArtifactResult) { + if (!result.ok) throw new Error(result.errors.join("\n")); + return result.bundle; +} diff --git a/tests/nodeagentTraceSpine.test.ts b/tests/nodeagentTraceSpine.test.ts index ef553442..04f32374 100644 --- a/tests/nodeagentTraceSpine.test.ts +++ b/tests/nodeagentTraceSpine.test.ts @@ -7,6 +7,8 @@ import { traceContextPackFromContext, traceExcellenceLevel, summarizeTrace, + NODE_WORKFLOW_PROTOCOL_VERSION, + validateNodeWorkflowRequest, type AgentResult, } from "../src/nodeagent"; @@ -20,6 +22,22 @@ const budget = { }; describe("nodeagent trace spine", () => { + it("requires external workflow candidates to bind to a trace", () => { + const issues = validateNodeWorkflowRequest({ + schemaVersion: NODE_WORKFLOW_PROTOCOL_VERSION, + app: "noderoom", + workflow: "candidate-only-sidecar", + fixtureId: "trace-binding-fixture", + traceId: "", + inputDigest: `sha256:${"1".repeat(64)}`, + idempotencyKey: "trace-binding-fixture:run-1", + concurrency: 1, + deadlineMs: 10_000, + }); + + expect(issues).toContain("Request trace ID is invalid."); + }); + it("turns runtime tool events into redacted workpaper receipts", () => { const contextPack = traceContextPackFromContext( {