@openwop/openwop-conformance 1.47.0 → 1.51.0
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/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/coverage.md +2 -1
- package/package.json +1 -1
- package/schemas/README.md +3 -0
- package/schemas/capabilities.schema.json +34 -0
- package/schemas/orchestrator-decision.schema.json +5 -0
- package/schemas/self-hosted-runner-dispatch-frame.schema.json +79 -0
- package/schemas/self-hosted-runner-registration.schema.json +53 -0
- package/schemas/self-hosted-runner-result-frame.schema.json +38 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +22 -2
- package/src/lib/workflow-chain-expansion.ts +287 -2
- package/src/scenarios/dispatch-per-item-input.test.ts +198 -0
- package/src/scenarios/self-hosted-runner.test.ts +232 -0
- package/src/scenarios/workflow-chain-deferred-parameters.test.ts +298 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +49 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +24 -10
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +59 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/self-hosted-runner-dispatch-frame.schema.json",
|
|
4
|
+
"title": "SelfHostedRunnerDispatchFrame",
|
|
5
|
+
"description": "RFC 0122 §Registration + channel. A single model/tool DISPATCH STEP the host routes to a registered runner over the runner↔host channel (`self-hosted-runner.md`). The host is the sole orchestration/persistence/replay authority; this frame carries ONE step's inputs, never a whole run. `inputs` is opaque model/tool input (messages, tool arguments) and MUST NOT contain runner-credential material — the runner holds its own credential locally (SECURITY invariant `runner-credential-non-transit`). `seq` is the per-runner monotonic DISPATCH cursor, a DISTINCT sequence from the run event-log `sequence` (they MUST NOT be conflated); a reconnecting runner resumes via `Last-Event-ID` at this cursor and the host drops any redelivered `{runId, stepId}` whose result is already persisted (at-most-once).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["runId", "stepId", "seq", "kind", "inputs"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"runId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "The backing OpenWOP run this dispatch belongs to. Host-owned run identity."
|
|
14
|
+
},
|
|
15
|
+
"stepId": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The dispatch step within the run. The `{runId, stepId}` pair is the at-most-once idempotency key: a runner MUST return the result under the same pair, and the host MUST drop (not re-dispatch) a redelivered `{runId, stepId}` whose result is already persisted."
|
|
19
|
+
},
|
|
20
|
+
"seq": {
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"minimum": 0,
|
|
23
|
+
"description": "The per-runner monotonic dispatch cursor for the channel. DISTINCT from the run event-log `sequence` (MUST NOT be conflated — conflating them breaks resume dedup). A reconnecting runner resumes via the SSE `Last-Event-ID` at this cursor so a dispatch is never redelivered past it (`stream-modes.md §Resumption`)."
|
|
24
|
+
},
|
|
25
|
+
"kind": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["model", "tool"],
|
|
28
|
+
"description": "Whether this step dispatches a model call (`model`) or a tool call (`tool`). A host MAY advertise only a subset via `selfHostedRunner.dispatchKinds` and route only those kinds to runners."
|
|
29
|
+
},
|
|
30
|
+
"provider": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"minLength": 1,
|
|
33
|
+
"description": "REQUIRED when `kind == 'model'`. The model provider id the runner dispatches to under its locally-held credential (e.g. a subscription CLI login, a private endpoint). Never a host-held credential."
|
|
34
|
+
},
|
|
35
|
+
"model": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1,
|
|
38
|
+
"description": "REQUIRED when `kind == 'model'`. The model id to run."
|
|
39
|
+
},
|
|
40
|
+
"tool": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1,
|
|
43
|
+
"description": "REQUIRED when `kind == 'tool'`. The tool the runner executes locally."
|
|
44
|
+
},
|
|
45
|
+
"inputs": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"description": "Opaque model/tool inputs for this step (messages, tool arguments). MUST NOT contain runner-credential material (`runner-credential-non-transit`). The host sends inputs and receives outputs; the credential stays on the runner."
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"allOf": [
|
|
51
|
+
{
|
|
52
|
+
"if": { "properties": { "kind": { "const": "model" } } },
|
|
53
|
+
"then": { "required": ["provider", "model"] }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"if": { "properties": { "kind": { "const": "tool" } } },
|
|
57
|
+
"then": { "required": ["tool"] }
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"examples": [
|
|
61
|
+
{
|
|
62
|
+
"runId": "run_x",
|
|
63
|
+
"stepId": "step_3",
|
|
64
|
+
"seq": 12,
|
|
65
|
+
"kind": "model",
|
|
66
|
+
"provider": "anthropic",
|
|
67
|
+
"model": "claude-opus-4-8",
|
|
68
|
+
"inputs": { "messages": [{ "role": "user", "content": "summarize the attached notes" }] }
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"runId": "run_y",
|
|
72
|
+
"stepId": "step_1",
|
|
73
|
+
"seq": 0,
|
|
74
|
+
"kind": "tool",
|
|
75
|
+
"tool": "local.shell.readFile",
|
|
76
|
+
"inputs": { "path": "/home/user/notes.txt" }
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/self-hosted-runner-registration.schema.json",
|
|
4
|
+
"title": "SelfHostedRunnerRegistration",
|
|
5
|
+
"description": "RFC 0122 §Registration + channel. The per-subject runtime record the host keeps for a registered runner after it dials in and authenticates with a host-minted, subject-scoped runner bearer (NEVER a provider credential). This record is per-subject runtime state and MUST NOT appear on `/.well-known/openwop` (only the `selfHostedRunner` capability block does). Work is addressed by `runnerId` (direct) or by capability-match; a capability match MUST filter on `subject` FIRST, then capability, so a subject's step is never routed to another subject's runner (subject-first match, SR-1-adjacent — a normative MUST in `self-hosted-runner.md` §Behavior verified by the gated conformance scenario).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["runnerId", "subject", "capabilities"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"runnerId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "The host-assigned identifier for this runner connection. Used for direct dispatch addressing."
|
|
14
|
+
},
|
|
15
|
+
"subject": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The owning principal — the stable RFC 0048 subject the host stamps on the backing run's `run.metadata`, NOT a fresh id. A runner is bound to exactly one owning subject; capability-match and fork-reroute MUST key on this subject FIRST."
|
|
19
|
+
},
|
|
20
|
+
"capabilities": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": false,
|
|
23
|
+
"description": "What this runner can serve. Empty arrays / omitted keys mean the runner serves none of that kind; capability-match considers only runners whose declared capability covers the dispatch step.",
|
|
24
|
+
"properties": {
|
|
25
|
+
"providers": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": { "type": "string", "minLength": 1 },
|
|
28
|
+
"uniqueItems": true,
|
|
29
|
+
"description": "Model provider ids the runner can dispatch to under its locally-held credentials."
|
|
30
|
+
},
|
|
31
|
+
"models": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": { "type": "string", "minLength": 1 },
|
|
34
|
+
"uniqueItems": true,
|
|
35
|
+
"description": "Specific model ids the runner can run (a refinement of `providers`)."
|
|
36
|
+
},
|
|
37
|
+
"tools": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": { "type": "string", "minLength": 1 },
|
|
40
|
+
"uniqueItems": true,
|
|
41
|
+
"description": "Tool ids the runner can execute locally."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"examples": [
|
|
47
|
+
{
|
|
48
|
+
"runnerId": "runner_laptop_01",
|
|
49
|
+
"subject": "user_42",
|
|
50
|
+
"capabilities": { "providers": ["anthropic"], "models": ["claude-opus-4-8"] }
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/self-hosted-runner-result-frame.schema.json",
|
|
4
|
+
"title": "SelfHostedRunnerResultFrame",
|
|
5
|
+
"description": "RFC 0122 §Registration + channel. The runner's answer to one `SelfHostedRunnerDispatchFrame`, POSTed back to the host over ordinary HTTP. The `{runId, stepId}` pair MUST equal the dispatch it answers (the at-most-once idempotency key); `seq` echoes the dispatch cursor. `output` is the opaque model/tool output the host persists as a normal step record — replay reads from persistence and NEVER re-dispatches. `output` MUST NOT contain runner-credential material (`runner-credential-non-transit`), and the host MUST treat it as UNTRUSTED transport: content re-entering an agent loop is fenced `<UNTRUSTED>` (`runner-output-untrusted-transport`). A provider-side error is carried inside `output` (it is still a model/tool output); host-side liveness failure is signalled out-of-band as the retriable `runner_unavailable` error, NOT as a result frame.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["runId", "stepId", "seq", "output"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"runId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "The run this result answers. MUST equal the dispatch frame's `runId`."
|
|
14
|
+
},
|
|
15
|
+
"stepId": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The dispatch step this result answers. MUST equal the dispatch frame's `stepId`. The host drops the frame if a result for this `{runId, stepId}` is already persisted (at-most-once)."
|
|
19
|
+
},
|
|
20
|
+
"seq": {
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"minimum": 0,
|
|
23
|
+
"description": "Echoes the answered dispatch frame's per-runner cursor `seq`."
|
|
24
|
+
},
|
|
25
|
+
"output": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"description": "Opaque model/tool output for the step, persisted by the host as a normal step record. MUST NOT contain runner-credential material; the host fences it `<UNTRUSTED>` before it re-enters an agent loop (`runner-output-untrusted-transport`). A provider/tool error response is carried here (still an output)."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"examples": [
|
|
31
|
+
{
|
|
32
|
+
"runId": "run_x",
|
|
33
|
+
"stepId": "step_3",
|
|
34
|
+
"seq": 12,
|
|
35
|
+
"output": { "role": "assistant", "content": "Here is a summary of the notes: ..." }
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
},
|
|
89
89
|
"parameters": {
|
|
90
90
|
"type": "object",
|
|
91
|
-
"description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`.",
|
|
91
|
+
"description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`. RECOGNIZED EXTENSION KEY (RFC 0124 / WCP4): a property MAY carry `x-openwop-sensitive: true` to declare the parameter secret-class. A host that recognizes it MUST NOT expansion-time-freeze that parameter (plaintext secret-at-rest leak, SR-1) and MUST NOT materialize it as a plaintext `source:\"variable\"` binding (the value would land in the run-scoped bag / `RunSnapshot.variables` — the same SR-1 leak one layer down). Instead, per the 2026-07-04 §Security amendment: it is deferrable ONLY in a prompt-body position, where it MUST be materialized as a `source:\"secret\"` `PromptVariable` (BYOK-resolved via `capabilities.secrets`, redacted to `[REDACTED:<secretId>]` in `prompt.composed`, never bagged); in ANY other position (whole-value `node.inputs`, embedded non-prompt `config`, or a host lacking deferred / `secrets` support) it MUST fail closed with `sensitive_param_not_deferrable` (422). Per-run supply is a `credentialRef` secret reference, never plaintext. See `workflow-chain-packs.md` §\"Deferred-parameter expansion\" / RFC 0124 §Security.",
|
|
92
92
|
"additionalProperties": true,
|
|
93
93
|
"$comment": "Open by design — this field IS a JSON Schema document, so it must accept any of the 30+ JSON Schema 2020-12 keywords (`type`, `properties`, `required`, `oneOf`, `allOf`, etc.). Strict closure would require importing the JSON Schema meta-schema."
|
|
94
94
|
},
|
|
@@ -197,12 +197,32 @@
|
|
|
197
197
|
"description": "Target node id (must reference a node in `nodes[]`). MAY use `nodeId.inputPort` syntax."
|
|
198
198
|
},
|
|
199
199
|
"condition": {
|
|
200
|
+
"$ref": "#/$defs/EdgeCondition",
|
|
201
|
+
"description": "Optional edge condition — the SAME shape as a top-level workflow edge's condition (workflow-definition.schema.json §EdgeCondition). When present, the edge contributes to the target only if the condition holds against the source node's output, letting a chain express content routing (router/switch/conditional branches). Safety-fix (RFC 0013 amendment 2026-07-03): the field was previously typed `string`, contradicting this description; no chain used the string form (hosts dropped the field at expansion), so correcting it to the object shape breaks no conformant behavior."
|
|
202
|
+
},
|
|
203
|
+
"triggerRule": {
|
|
200
204
|
"type": "string",
|
|
201
|
-
"
|
|
205
|
+
"enum": ["all_success", "any_success", "all_complete", "none_failed", "any_failed"],
|
|
206
|
+
"default": "all_success",
|
|
207
|
+
"description": "Optional fan-in / error-routing rule — the SAME shape and enum as a top-level workflow edge's `triggerRule` (workflow-definition.schema.json §WorkflowEdge). Governs how the target node fires given its incoming edges: `all_success` (default — every incoming edge's source succeeded), `any_success`, `all_complete` (fire when all sources have finished regardless of success — best-effort completion), `none_failed`, `any_failed`. Additive (RFC 0125): omitting it is identical to `all_success`, the implicit prior behavior. Mirrors the RFC 0013 2026-07-03 `condition` amendment's WorkflowEdge→FragmentEdge move. Expansion MUST carry this value onto the resulting `WorkflowEdge` so the scheduler honors it (see `workflow-chain-packs.md` §\"Expansion semantics\")."
|
|
202
208
|
}
|
|
203
209
|
},
|
|
204
210
|
"additionalProperties": false
|
|
205
211
|
},
|
|
212
|
+
"EdgeCondition": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"description": "Edge condition — identical to workflow-definition.schema.json §EdgeCondition (RFC 0013 §edges: 'same shape as a top-level workflow definition'). Inlined here so the manifest schema is self-contained for pack-loader validators.",
|
|
215
|
+
"properties": {
|
|
216
|
+
"type": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"enum": ["expression", "equals", "notEquals", "contains", "regex"]
|
|
219
|
+
},
|
|
220
|
+
"left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved')." },
|
|
221
|
+
"right": { "description": "Right operand value (any JSON value)." },
|
|
222
|
+
"expression": { "type": "string", "description": "Used when type='expression'." }
|
|
223
|
+
},
|
|
224
|
+
"additionalProperties": false
|
|
225
|
+
},
|
|
206
226
|
"Signing": {
|
|
207
227
|
"type": "object",
|
|
208
228
|
"description": "Optional signing metadata. Reuses node-packs.md §signing unchanged.",
|
|
@@ -54,10 +54,25 @@ export interface FragmentNode {
|
|
|
54
54
|
inputs?: Record<string, unknown>;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A fan-in / error-routing rule mirrored from `WorkflowEdge.triggerRule`
|
|
58
|
+
* (workflow-definition.schema.json). RFC 0125. */
|
|
59
|
+
export type TriggerRule =
|
|
60
|
+
| 'all_success'
|
|
61
|
+
| 'any_success'
|
|
62
|
+
| 'all_complete'
|
|
63
|
+
| 'none_failed'
|
|
64
|
+
| 'any_failed';
|
|
65
|
+
|
|
57
66
|
export interface FragmentEdge {
|
|
58
67
|
from: string;
|
|
59
68
|
to: string;
|
|
60
|
-
condition
|
|
69
|
+
/** Edge condition — an `EdgeCondition` object (RFC 0013 amendment #818).
|
|
70
|
+
* Carried through expansion opaquely; typed `unknown` since the lib does
|
|
71
|
+
* not evaluate it. */
|
|
72
|
+
condition?: unknown;
|
|
73
|
+
/** Fan-in / error-routing rule (RFC 0125). Carried through expansion onto
|
|
74
|
+
* the resulting WorkflowEdge so the scheduler honors it. */
|
|
75
|
+
triggerRule?: TriggerRule;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** Per-expansion context the caller supplies. */
|
|
@@ -90,7 +105,7 @@ export interface ExpandedFragment {
|
|
|
90
105
|
inputs?: Record<string, unknown>;
|
|
91
106
|
capabilities?: ReadonlyArray<string>;
|
|
92
107
|
}>;
|
|
93
|
-
edges: ReadonlyArray<{ from: string; to: string; condition?:
|
|
108
|
+
edges: ReadonlyArray<{ from: string; to: string; condition?: unknown; triggerRule?: TriggerRule }>;
|
|
94
109
|
/** Map of original-fragment-id → rewritten-id, so the caller can
|
|
95
110
|
* wire the parent workflow's adjacent edges into the expansion. */
|
|
96
111
|
idMap: ReadonlyMap<string, string>;
|
|
@@ -110,6 +125,12 @@ export class ChainUnresolvableTypeIdError extends Error {
|
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
const PARAM_PATTERN = /\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
128
|
+
/** A value that is EXACTLY a single `{{params.<name>}}` token (whole-value),
|
|
129
|
+
* distinct from a token embedded in a larger string. Whole-value tokens are
|
|
130
|
+
* the only non-prompt position deferrable to a variable-sourced PortValue
|
|
131
|
+
* (WCP2 raw-typed rule); an embedded non-prompt token has no runtime `{{}}`
|
|
132
|
+
* construct and MUST resolve at expansion time. */
|
|
133
|
+
const WHOLE_VALUE_PATTERN = /^\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}$/;
|
|
113
134
|
|
|
114
135
|
/** Recursive literal substitution of `{{params.<name>}}` placeholders in
|
|
115
136
|
* any string field. Non-string values pass through unchanged; nested
|
|
@@ -206,8 +227,272 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
|
|
|
206
227
|
to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
|
|
207
228
|
};
|
|
208
229
|
if (e.condition !== undefined) out.condition = e.condition;
|
|
230
|
+
// RFC 0125: carry the fan-in/error-routing rule onto the expanded
|
|
231
|
+
// WorkflowEdge so the scheduler honors it (mirrors the `condition`
|
|
232
|
+
// pass-through; without this the field is silently dropped at expansion).
|
|
233
|
+
if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
|
|
209
234
|
return out;
|
|
210
235
|
});
|
|
211
236
|
|
|
212
237
|
return { nodes: expandedNodes, edges: expandedEdges, idMap };
|
|
213
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// RFC 0124 (WCP4) — Portable per-run parameter deferral.
|
|
242
|
+
//
|
|
243
|
+
// The DEFERRED expansion mode: instead of freezing `{{params.*}}` values into
|
|
244
|
+
// persisted `config`/`inputs` at drop time (the RFC 0013 default), the host
|
|
245
|
+
// materializes the chain's `parameters` into top-level workflow `variables[]`
|
|
246
|
+
// (author value → `defaultValue`) and rewrites each token into an already-spec'd
|
|
247
|
+
// RUNTIME binding — so the persisted fragment carries ZERO `{{params.*}}` tokens
|
|
248
|
+
// yet every parameter stays overridable per run via `configurable`. This is the
|
|
249
|
+
// spec-authoritative reference for `spec/v1/workflow-chain-packs.md`
|
|
250
|
+
// §"Deferred-parameter expansion (RFC 0124)".
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/** The parameter JSON Schema fragment (`chain.parameters`), narrowed to the
|
|
254
|
+
* fields deferred expansion reads: each property's `type`, `description`, and
|
|
255
|
+
* the RFC 0124 `x-openwop-sensitive` extension key. */
|
|
256
|
+
export interface ParameterSchema {
|
|
257
|
+
properties?: Record<
|
|
258
|
+
string,
|
|
259
|
+
{ type?: string; description?: string; 'x-openwop-sensitive'?: boolean }
|
|
260
|
+
>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Host capability context deferred expansion needs (RFC 0124 §Capability
|
|
264
|
+
* gating). The prompt-bearing rewrite path requires `prompts.variableSources`
|
|
265
|
+
* to include `variable`; a `source:"secret"` sensitive lift requires
|
|
266
|
+
* `capabilities.secrets.supported`. */
|
|
267
|
+
export interface DeferredHostContext {
|
|
268
|
+
/** `capabilities.prompts.variableSources` includes `"variable"`. */
|
|
269
|
+
promptVariableSource: boolean;
|
|
270
|
+
/** `capabilities.secrets.supported`. */
|
|
271
|
+
secretsSupported: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface DeferredExpansionContext {
|
|
275
|
+
expansionId: string;
|
|
276
|
+
/** Author-supplied parameter values (already validated) → `defaultValue` seeds. */
|
|
277
|
+
params: Record<string, unknown>;
|
|
278
|
+
/** The chain's `parameters` JSON Schema (type + `x-openwop-sensitive` per property). */
|
|
279
|
+
parameterSchema: ParameterSchema;
|
|
280
|
+
isTypeIdResolvable: (typeId: string) => boolean;
|
|
281
|
+
host: DeferredHostContext;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A materialized top-level `WorkflowVariable` (subset — the fields deferred
|
|
285
|
+
* expansion sets). A NON-sensitive parameter materializes here with its author
|
|
286
|
+
* value as `defaultValue`. A sensitive parameter does NOT (its value never
|
|
287
|
+
* lands in the run-scoped bag); it is bound as a `source:"secret"` prompt
|
|
288
|
+
* variable instead. */
|
|
289
|
+
export interface MaterializedVariable {
|
|
290
|
+
name: string;
|
|
291
|
+
type: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
defaultValue?: unknown;
|
|
294
|
+
sensitive?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A rewritten prompt-template variable slot (RFC 0027). `source:"variable"`
|
|
298
|
+
* resolves from the run bag; `source:"secret"` resolves a BYOK secret at
|
|
299
|
+
* compose time, redacted in `prompt.composed` (RFC 0124 §Security). */
|
|
300
|
+
export interface PromptVariableBinding {
|
|
301
|
+
name: string;
|
|
302
|
+
source: 'variable' | 'secret';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface DeferredExpandedFragment {
|
|
306
|
+
nodes: ExpandedFragment['nodes'];
|
|
307
|
+
edges: ExpandedFragment['edges'];
|
|
308
|
+
idMap: ReadonlyMap<string, string>;
|
|
309
|
+
/** Materialized top-level `variables[]` (non-sensitive params only). */
|
|
310
|
+
variables: ReadonlyArray<MaterializedVariable>;
|
|
311
|
+
/** Prompt-site variable bindings introduced by the rewrite. */
|
|
312
|
+
promptVariables: ReadonlyArray<PromptVariableBinding>;
|
|
313
|
+
/** Auto-generated `configurableSchema` mapping the BARE param name (the
|
|
314
|
+
* normative override key) to its type, so a per-run `configurable` keyed on
|
|
315
|
+
* the bare name resolves (R6 cross-host key stability). */
|
|
316
|
+
configurableSchema: { properties: Record<string, { type: string }> };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Thrown when a `x-openwop-sensitive` parameter cannot be securely deferred:
|
|
320
|
+
* it resolves to a non-prompt position (whole-value `node.inputs`, embedded
|
|
321
|
+
* non-prompt `config`), or the host lacks `secrets`/deferred support. Wire
|
|
322
|
+
* code `sensitive_param_not_deferrable` (HTTP 422) per
|
|
323
|
+
* `workflow-chain-packs.md` §"Error codes" (RFC 0124 §Security). */
|
|
324
|
+
export class SensitiveParamNotDeferrableError extends Error {
|
|
325
|
+
readonly code = 'sensitive_param_not_deferrable';
|
|
326
|
+
readonly httpStatus = 422;
|
|
327
|
+
constructor(readonly param: string, readonly reason: string) {
|
|
328
|
+
super(`sensitive_param_not_deferrable: '${param}' — ${reason}`);
|
|
329
|
+
this.name = 'SensitiveParamNotDeferrableError';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The prompt-bearing `config` fields a token in which is a "prompt position"
|
|
334
|
+
* (lifted to a PromptTemplate `{{varName}}` slot). Everything else in `config`
|
|
335
|
+
* is a non-prompt position. */
|
|
336
|
+
const PROMPT_CONFIG_FIELDS: ReadonlySet<string> = new Set(['systemPrompt', 'userPrompt']);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Deferred-mode expansion (RFC 0124). Rewrites every `{{params.<name>}}` token
|
|
340
|
+
* into a spec'd runtime binding and materializes non-sensitive parameters into
|
|
341
|
+
* top-level `variables[]`, leaving ZERO `{{params.*}}` tokens in the persisted
|
|
342
|
+
* fragment. Sensitive parameters (`x-openwop-sensitive`) are handled per
|
|
343
|
+
* §Security: prompt-body → `source:"secret"`; anywhere else → fail closed.
|
|
344
|
+
*
|
|
345
|
+
* @throws SensitiveParamNotDeferrableError when a sensitive parameter is in a
|
|
346
|
+
* non-prompt position, or the host lacks `secrets` support.
|
|
347
|
+
* @throws ChainUnresolvableTypeIdError when any node typeId fails resolution.
|
|
348
|
+
*/
|
|
349
|
+
export function expandChainDeferred(
|
|
350
|
+
chain: WorkflowChain,
|
|
351
|
+
ctx: DeferredExpansionContext,
|
|
352
|
+
): DeferredExpandedFragment {
|
|
353
|
+
for (const node of chain.dag.nodes) {
|
|
354
|
+
if (!ctx.isTypeIdResolvable(node.typeId)) {
|
|
355
|
+
throw new ChainUnresolvableTypeIdError(node.typeId, chain.chainId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const props = ctx.parameterSchema.properties ?? {};
|
|
360
|
+
const isSensitive = (name: string): boolean => props[name]?.['x-openwop-sensitive'] === true;
|
|
361
|
+
const typeOf = (name: string): string => props[name]?.type ?? 'string';
|
|
362
|
+
|
|
363
|
+
const prefix = computePrefix(chain.chainId, ctx.expansionId);
|
|
364
|
+
const fragmentNodeIds = new Set(chain.dag.nodes.map((n) => n.id));
|
|
365
|
+
const idMap = new Map<string, string>();
|
|
366
|
+
for (const id of fragmentNodeIds) idMap.set(id, `${prefix}${id}`);
|
|
367
|
+
|
|
368
|
+
const usedParams = new Set<string>();
|
|
369
|
+
const promptVariables = new Map<string, PromptVariableBinding>();
|
|
370
|
+
|
|
371
|
+
/** Rewrite a prompt-position string: each embedded `{{params.x}}` → a
|
|
372
|
+
* PromptTemplate `{{x}}` slot. A sensitive param binds `source:"secret"`
|
|
373
|
+
* (requires host secrets support), else `source:"variable"`. If the host
|
|
374
|
+
* does not advertise the `variable` prompt source at all, the deferred
|
|
375
|
+
* prompt path is unavailable — the caller falls back to expansion-time
|
|
376
|
+
* substitution (G5); we surface that by returning `null`. */
|
|
377
|
+
function rewritePrompt(text: string): string | null {
|
|
378
|
+
if (!ctx.host.promptVariableSource) return null; // G5 fallback → expansion-time
|
|
379
|
+
return text.replace(PARAM_PATTERN, (_m, name: string) => {
|
|
380
|
+
usedParams.add(name);
|
|
381
|
+
if (isSensitive(name)) {
|
|
382
|
+
if (!ctx.host.secretsSupported) {
|
|
383
|
+
throw new SensitiveParamNotDeferrableError(name, 'host lacks capabilities.secrets support');
|
|
384
|
+
}
|
|
385
|
+
promptVariables.set(name, { name, source: 'secret' });
|
|
386
|
+
} else {
|
|
387
|
+
promptVariables.set(name, { name, source: 'variable' });
|
|
388
|
+
}
|
|
389
|
+
return `{{${name}}}`;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Rewrite a config object: prompt fields → PromptTemplate slots; a token in
|
|
394
|
+
* any NON-prompt config field is embedded-non-prompt → sensitive fails
|
|
395
|
+
* closed, non-sensitive resolves at expansion time (author-trusted). */
|
|
396
|
+
function rewriteConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
397
|
+
const out: Record<string, unknown> = {};
|
|
398
|
+
for (const [k, v] of Object.entries(config)) {
|
|
399
|
+
if (typeof v === 'string' && PROMPT_CONFIG_FIELDS.has(k) && PARAM_PATTERN.test(v)) {
|
|
400
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
401
|
+
const rewritten = rewritePrompt(v);
|
|
402
|
+
out[k] = rewritten === null ? substitute(v, ctx.params) : rewritten;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
406
|
+
if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
|
|
407
|
+
// embedded non-prompt token
|
|
408
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
409
|
+
let m: RegExpExecArray | null;
|
|
410
|
+
while ((m = PARAM_PATTERN.exec(v)) !== null) {
|
|
411
|
+
if (isSensitive(m[1])) {
|
|
412
|
+
throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt config field '${k}'`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
out[k] = substitute(v, ctx.params); // author-trusted expansion-time resolution
|
|
416
|
+
} else {
|
|
417
|
+
out[k] = substitute(v, ctx.params);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Rewrite an inputs object: a WHOLE-VALUE `{{params.x}}` → a variable-sourced
|
|
424
|
+
* PortValue (WCP2 raw-typed); a sensitive whole-value fails closed (no
|
|
425
|
+
* plaintext-bag path for a secret). Embedded input tokens follow the same
|
|
426
|
+
* non-prompt rule as config. */
|
|
427
|
+
function rewriteInputs(inputs: Record<string, unknown>): Record<string, unknown> {
|
|
428
|
+
const out: Record<string, unknown> = {};
|
|
429
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
430
|
+
const whole = typeof v === 'string' ? WHOLE_VALUE_PATTERN.exec(v) : null;
|
|
431
|
+
if (whole) {
|
|
432
|
+
const name = whole[1];
|
|
433
|
+
if (isSensitive(name)) {
|
|
434
|
+
throw new SensitiveParamNotDeferrableError(name, `whole-value node input '${k}'`);
|
|
435
|
+
}
|
|
436
|
+
usedParams.add(name);
|
|
437
|
+
out[k] = { source: 'variable', variable: name }; // variable-sourced PortValue
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
441
|
+
if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
|
|
442
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
443
|
+
let m: RegExpExecArray | null;
|
|
444
|
+
while ((m = PARAM_PATTERN.exec(v)) !== null) {
|
|
445
|
+
if (isSensitive(m[1])) {
|
|
446
|
+
throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt input '${k}'`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
out[k] = substitute(v, ctx.params);
|
|
450
|
+
} else {
|
|
451
|
+
out[k] = substitute(v, ctx.params);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const expandedNodes = chain.dag.nodes.map((n) => {
|
|
458
|
+
const out: ExpandedFragment['nodes'][number] = { id: `${prefix}${n.id}`, typeId: n.typeId };
|
|
459
|
+
if (n.name !== undefined) out.name = n.name;
|
|
460
|
+
if (n.position !== undefined) out.position = n.position;
|
|
461
|
+
if (n.config !== undefined) out.config = rewriteConfig(n.config);
|
|
462
|
+
if (n.inputs !== undefined) out.inputs = rewriteInputs(n.inputs);
|
|
463
|
+
if (chain.capabilities && chain.capabilities.length > 0) out.capabilities = [...chain.capabilities];
|
|
464
|
+
return out;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const expandedEdges = (chain.dag.edges ?? []).map((e) => {
|
|
468
|
+
const out: ExpandedFragment['edges'][number] = {
|
|
469
|
+
from: rewriteEdgeRef(e.from, fragmentNodeIds, prefix),
|
|
470
|
+
to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
|
|
471
|
+
};
|
|
472
|
+
if (e.condition !== undefined) out.condition = e.condition;
|
|
473
|
+
if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
|
|
474
|
+
return out;
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Materialize NON-sensitive used params into top-level variables[]; build the
|
|
478
|
+
// bare-param → type configurableSchema for the override key.
|
|
479
|
+
const variables: MaterializedVariable[] = [];
|
|
480
|
+
const configurableSchema: { properties: Record<string, { type: string }> } = { properties: {} };
|
|
481
|
+
for (const name of usedParams) {
|
|
482
|
+
configurableSchema.properties[name] = { type: typeOf(name) };
|
|
483
|
+
if (isSensitive(name)) continue; // sensitive value never lands in the bag
|
|
484
|
+
const v: MaterializedVariable = { name, type: typeOf(name) };
|
|
485
|
+
if (props[name]?.description !== undefined) v.description = props[name]!.description;
|
|
486
|
+
if (name in ctx.params) v.defaultValue = ctx.params[name];
|
|
487
|
+
variables.push(v);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
nodes: expandedNodes,
|
|
492
|
+
edges: expandedEdges,
|
|
493
|
+
idMap,
|
|
494
|
+
variables,
|
|
495
|
+
promptVariables: [...promptVariables.values()],
|
|
496
|
+
configurableSchema,
|
|
497
|
+
};
|
|
498
|
+
}
|