@nanobpm/nano-workforce 0.108.0 → 0.110.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 +14 -0
- package/app/agentSkill.ts +54 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +357 -0
- package/app/deliveryGraph.ts +463 -0
- package/app/resolveApiBase.test.ts +43 -0
- package/app/resolveApiBase.ts +28 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +221 -0
- package/openapi.yaml +298 -0
- package/operations/getAgentInstructions.ts +2 -16
- package/operations/getAgentSkill.test.ts +72 -0
- package/operations/getAgentSkill.ts +36 -0
- package/package.json +1 -1
- package/pages/home.page.json +0 -14
- package/pages/overview.page.json +14 -0
- package/skills/README.md +50 -0
- package/skills/nano-workforce/SKILL.md +126 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// nano-workforce — the pure, side-effect-free SEMANTIC validator for an agent-authored delivery
|
|
2
|
+
// graph (ADR 0005, slice S0). The `DeliveryGraph` SHAPE is validated at the edge by the openapi
|
|
3
|
+
// schema (`openapi.yaml` → generated `DeliveryGraph` contract); this module validates the semantics
|
|
4
|
+
// the JSON Schema CANNOT express and that a compiler/runner must be able to trust before it does
|
|
5
|
+
// anything:
|
|
6
|
+
//
|
|
7
|
+
// • unknown `kind` — a node whose `kind` is not in the CLOSED allowlist (the trust boundary,
|
|
8
|
+
// Decision 1/2). Defensive because the body arrives untyped from a request.
|
|
9
|
+
// • duplicate node id — two nodes sharing an id, which would make every edge to it ambiguous.
|
|
10
|
+
// • dangling edge — an edge endpoint (`from`/`to`) that names no node in the graph.
|
|
11
|
+
// • bad `from` reference — a qualified `<nodeId>.<fact>` whose fact is not declared in that node's
|
|
12
|
+
// typed `emits[]` (Decision 3/4 — binds are validated, not stringly).
|
|
13
|
+
// • cycle — the edge set must be a DAG (discovered-fact dependencies flow forward only).
|
|
14
|
+
//
|
|
15
|
+
// It is modelled on the epic-set validator `validateEpicSet` (app/plan.ts): a PURE in-memory walk
|
|
16
|
+
// that runs BEFORE any side effect. Unlike `validateEpicSet` (which throws at the first offending
|
|
17
|
+
// edge), this COLLECTS every error and returns them, so a co-designing agent gets ONE actionable,
|
|
18
|
+
// path-qualified list per compile attempt (the S1 compiler surfaces them as `{ ok:false, errors }`).
|
|
19
|
+
// Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
|
|
20
|
+
// caller can point the author straight at the offending input.
|
|
21
|
+
|
|
22
|
+
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
23
|
+
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
24
|
+
* single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
|
|
25
|
+
export const DELIVERY_NODE_KINDS = ["agent", "wait", "human", "connector"] as const;
|
|
26
|
+
|
|
27
|
+
/** A node's `kind`, narrowed to the closed allowlist. */
|
|
28
|
+
export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
|
|
29
|
+
|
|
30
|
+
/** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
|
|
31
|
+
* enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
|
|
32
|
+
* untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
|
|
33
|
+
* delegate), since later compilation/execution steps rely on this allowlist. */
|
|
34
|
+
export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url"] as const;
|
|
35
|
+
|
|
36
|
+
/** An emitted fact's declared `type`, narrowed to the closed allowlist. */
|
|
37
|
+
export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
|
|
38
|
+
|
|
39
|
+
/** A machine-readable classification of a semantic failure, so a caller can branch on the error
|
|
40
|
+
* class (unknown-kind / dangling / cycle / bad-`from`) without string-matching the message. */
|
|
41
|
+
export type DeliveryGraphErrorCode =
|
|
42
|
+
| "empty-graph"
|
|
43
|
+
| "missing-id"
|
|
44
|
+
| "invalid-id"
|
|
45
|
+
| "duplicate-id"
|
|
46
|
+
| "unknown-kind"
|
|
47
|
+
| "missing-config"
|
|
48
|
+
| "missing-required-field"
|
|
49
|
+
| "duplicate-fact"
|
|
50
|
+
| "invalid-fact-name"
|
|
51
|
+
| "invalid-fact-type"
|
|
52
|
+
| "invalid-edges"
|
|
53
|
+
| "dangling-edge"
|
|
54
|
+
| "bad-from"
|
|
55
|
+
| "self-edge"
|
|
56
|
+
| "cycle";
|
|
57
|
+
|
|
58
|
+
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
59
|
+
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
60
|
+
* and `code` is the stable error class. Shaped so the S1 compiler can forward it verbatim as one of
|
|
61
|
+
* its `{ ok:false, errors:[{ path, message }] }` entries. */
|
|
62
|
+
export interface DeliveryGraphError {
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
readonly code: DeliveryGraphErrorCode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when `kind` is a member of the closed allowlist. */
|
|
74
|
+
function isDeliveryNodeKind(kind: unknown): kind is DeliveryNodeKind {
|
|
75
|
+
if (typeof kind !== "string") return false;
|
|
76
|
+
for (const k of DELIVERY_NODE_KINDS) if (k === kind) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when `type` is a member of the closed emitted-fact type allowlist. */
|
|
81
|
+
function isDeliveryFactType(type: unknown): type is DeliveryFactType {
|
|
82
|
+
if (typeof type !== "string") return false;
|
|
83
|
+
for (const t of DELIVERY_FACT_TYPES) if (t === type) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A fact `name` must be a bare identifier (no dots) — mirrors openapi's `DeliveryFact.name`
|
|
88
|
+
* `^[A-Za-z_][A-Za-z0-9_]*$`. `resolveFrom` RELIES on fact names being dot-free (a node id MAY
|
|
89
|
+
* contain dots) to disambiguate a qualified edge `from`, so the semantic validator re-enforces the
|
|
90
|
+
* pattern INDEPENDENTLY of the OpenAPI shape gate: if that gate is bypassed (a direct delegate call,
|
|
91
|
+
* a test, a future internal use), a dotted fact name could otherwise make `<nodeId>.<fact>` resolution
|
|
92
|
+
* ambiguous and quietly build the wrong DAG — undermining the trust boundary this validator exists to
|
|
93
|
+
* hold. */
|
|
94
|
+
const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
95
|
+
const FACT_NAME_MAX_LENGTH = 128;
|
|
96
|
+
|
|
97
|
+
/** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
|
|
98
|
+
* within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
|
|
99
|
+
* compile/render steps trust these ids: an id with whitespace, a leading digit, or an over-long value
|
|
100
|
+
* could otherwise pass semantic validation (a bypassed shape gate — a direct delegate call, a test)
|
|
101
|
+
* and then break id-based compilation/rendering downstream. Unlike a fact name, an id MAY contain
|
|
102
|
+
* dots/hyphens — `resolveFrom` splits a qualified `from` on the LAST dot, so a dotted id stays
|
|
103
|
+
* resolvable while dot-free fact names keep `<nodeId>.<fact>` unambiguous. */
|
|
104
|
+
const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
105
|
+
const NODE_ID_MAX_LENGTH = 128;
|
|
106
|
+
|
|
107
|
+
/** The per-kind config key a node of the given kind must carry (`agent` → `agent`, etc.). */
|
|
108
|
+
const CONFIG_KEY: Record<DeliveryNodeKind, string> = {
|
|
109
|
+
agent: "agent",
|
|
110
|
+
wait: "wait",
|
|
111
|
+
human: "human",
|
|
112
|
+
connector: "connector",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The REQUIRED non-empty-string fields inside each kind's per-kind config object, mirroring the
|
|
116
|
+
* `required` lists in openapi (`DeliveryNodeAgent.agent.jobType`, the `ReadinessProbe.kind`/`target`
|
|
117
|
+
* a `wait` reuses, `DeliveryNodeConnector.connector.target`). Re-enforced here INDEPENDENTLY of the
|
|
118
|
+
* OpenAPI shape gate so that, when that gate is bypassed (a direct delegate call, a test, a future
|
|
119
|
+
* internal use), a config object present-but-missing its required fields (e.g. `{ kind:"agent",
|
|
120
|
+
* agent:{} }`) is rejected with an actionable error rather than passing semantic validation and
|
|
121
|
+
* crashing a downstream compiler/runner that assumes those fields exist. `human` has no required
|
|
122
|
+
* config field (its config is optional). Kept as the single source of truth so this list and openapi
|
|
123
|
+
* agree. NOTE: field PRESENCE + non-emptiness is enforced here, not the `ReadinessProbe.kind` enum —
|
|
124
|
+
* that enum evolves per slice (S2 adds `pr`), so enumerating it here would drift; the enum stays
|
|
125
|
+
* owned by the shape gate / `app/readiness.ts`. */
|
|
126
|
+
const REQUIRED_CONFIG_FIELDS: Record<DeliveryNodeKind, readonly string[]> = {
|
|
127
|
+
agent: ["jobType"],
|
|
128
|
+
wait: ["kind", "target"],
|
|
129
|
+
human: [],
|
|
130
|
+
connector: ["target"],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Resolve an edge `from` endpoint against the known node set. A node id MAY itself contain dots (the
|
|
134
|
+
* openapi id pattern allows them) while a fact name (an identifier) cannot, so resolution is
|
|
135
|
+
* disambiguated by the node set rather than by naive splitting: (1) if the WHOLE string is a node id
|
|
136
|
+
* it is a bare completion-fact reference (`nodeId`, no fact); (2) else split at the LAST dot and, if
|
|
137
|
+
* the prefix is a node id, it is a qualified `<nodeId>.<fact>` reference; (3) else it is dangling —
|
|
138
|
+
* return the whole string as the (unresolvable) node id so the caller reports it against `from`.
|
|
139
|
+
* When BOTH interpretations resolve — the whole string is a node id AND its last-dot prefix is a
|
|
140
|
+
* node that emits the suffix as a fact — the reference is genuinely ambiguous; surface it via
|
|
141
|
+
* `ambiguousWith` so the caller rejects it (`bad-from`) rather than silently choosing the whole-node
|
|
142
|
+
* reading and producing an unintended DAG. */
|
|
143
|
+
function resolveFrom(
|
|
144
|
+
from: string,
|
|
145
|
+
nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
146
|
+
): { nodeId: string; fact?: string; ambiguousWith?: { nodeId: string; fact: string } } {
|
|
147
|
+
const dot = from.lastIndexOf(".");
|
|
148
|
+
const split =
|
|
149
|
+
dot > 0 && dot < from.length - 1 ? { prefix: from.slice(0, dot), suffix: from.slice(dot + 1) } : undefined;
|
|
150
|
+
if (nodeFacts.has(from)) {
|
|
151
|
+
if (split !== undefined && nodeFacts.get(split.prefix)?.has(split.suffix)) {
|
|
152
|
+
return { nodeId: from, ambiguousWith: { nodeId: split.prefix, fact: split.suffix } };
|
|
153
|
+
}
|
|
154
|
+
return { nodeId: from };
|
|
155
|
+
}
|
|
156
|
+
if (split !== undefined && nodeFacts.has(split.prefix)) return { nodeId: split.prefix, fact: split.suffix };
|
|
157
|
+
return { nodeId: from };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pure, side-effect-free SEMANTIC validation of a delivery graph (ADR 0005 slice S0). Accepts the
|
|
162
|
+
* graph as `unknown` because it arrives from an untyped request body — every field is read
|
|
163
|
+
* defensively, so a malformed input maps to a clean {@link DeliveryGraphError} (never an uncaught
|
|
164
|
+
* TypeError). Returns every error found (empty array ⇒ the graph is semantically valid), each
|
|
165
|
+
* path-qualified — one entry per offending node/edge/fact, except cycle detection, which reports at
|
|
166
|
+
* most ONE cycle per call to keep the output actionable (fix it and re-validate to surface the next).
|
|
167
|
+
* Run this BEFORE any compile/deploy so a cycle, dangling edge, unknown kind, or unresolvable fact
|
|
168
|
+
* reference is rejected with nothing started.
|
|
169
|
+
*/
|
|
170
|
+
export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
171
|
+
const errors: DeliveryGraphError[] = [];
|
|
172
|
+
|
|
173
|
+
if (!isRecord(graph) || !Array.isArray(graph.nodes)) {
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
path: "nodes",
|
|
177
|
+
message: "delivery graph must be an object with a `nodes` array",
|
|
178
|
+
code: "empty-graph",
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
const nodes = graph.nodes;
|
|
183
|
+
if (nodes.length === 0) {
|
|
184
|
+
errors.push({
|
|
185
|
+
path: "nodes",
|
|
186
|
+
message: "delivery graph is empty — declare at least one node",
|
|
187
|
+
code: "empty-graph",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Pass 1: node ids + kinds + per-kind config + declared facts. Build the id → declared-facts map
|
|
192
|
+
// used to resolve edge `from` references in pass 2.
|
|
193
|
+
const nodeFacts = new Map<string, Set<string>>();
|
|
194
|
+
nodes.forEach((rawNode, i) => {
|
|
195
|
+
const path = `nodes[${i}]`;
|
|
196
|
+
if (!isRecord(rawNode)) {
|
|
197
|
+
errors.push({ path, message: "each node must be an object", code: "missing-config" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const id = rawNode.id;
|
|
201
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
202
|
+
errors.push({ path: `${path}.id`, message: "node is missing a string `id`", code: "missing-id" });
|
|
203
|
+
} else {
|
|
204
|
+
if (id.length > NODE_ID_MAX_LENGTH || !NODE_ID_PATTERN.test(id)) {
|
|
205
|
+
// Mirror openapi's `DeliveryNodeCommon.id` pattern/length so an invalid id can't slip past a
|
|
206
|
+
// bypassed shape gate and break id-based compilation/rendering in a later slice.
|
|
207
|
+
errors.push({
|
|
208
|
+
path: `${path}.id`,
|
|
209
|
+
message:
|
|
210
|
+
`node id "${id}" must be a bare identifier (\`^[A-Za-z_][A-Za-z0-9_.-]*$\`, ` +
|
|
211
|
+
`\u2264 ${NODE_ID_MAX_LENGTH} chars) so downstream id-based compilation stays safe`,
|
|
212
|
+
code: "invalid-id",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (nodeFacts.has(id)) {
|
|
216
|
+
errors.push({
|
|
217
|
+
path: `${path}.id`,
|
|
218
|
+
message: `duplicate node id "${id}" — every node id must be unique in the graph`,
|
|
219
|
+
code: "duplicate-id",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const kind = rawNode.kind;
|
|
225
|
+
if (!isDeliveryNodeKind(kind)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
path: `${path}.kind`,
|
|
228
|
+
message:
|
|
229
|
+
`unknown node kind ${JSON.stringify(kind)} — must be one of ` +
|
|
230
|
+
`${DELIVERY_NODE_KINDS.join(", ")} (the closed vocabulary is the trust boundary)`,
|
|
231
|
+
code: "unknown-kind",
|
|
232
|
+
});
|
|
233
|
+
} else if (kind !== "human") {
|
|
234
|
+
// Every kind but `human` REQUIRES its per-kind config object.
|
|
235
|
+
const configKey = CONFIG_KEY[kind];
|
|
236
|
+
const config = rawNode[configKey];
|
|
237
|
+
if (!isRecord(config)) {
|
|
238
|
+
errors.push({
|
|
239
|
+
path: `${path}.${configKey}`,
|
|
240
|
+
message: `${kind} node is missing its required \`${configKey}\` config`,
|
|
241
|
+
code: "missing-config",
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
// The config object is present — re-enforce the fields openapi marks REQUIRED (a bypassed
|
|
245
|
+
// shape gate could otherwise let `{ kind:"agent", agent:{} }` through and crash a downstream
|
|
246
|
+
// compiler/runner that trusts those fields exist).
|
|
247
|
+
for (const field of REQUIRED_CONFIG_FIELDS[kind]) {
|
|
248
|
+
const value = config[field];
|
|
249
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
250
|
+
errors.push({
|
|
251
|
+
path: `${path}.${configKey}.${field}`,
|
|
252
|
+
message: `${kind} node's \`${configKey}.${field}\` is required and must be a non-empty string`,
|
|
253
|
+
code: "missing-required-field",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
|
|
259
|
+
// `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
|
|
260
|
+
// when PRESENT it must be a plain object so later slices can safely read `human.formKey` /
|
|
261
|
+
// `human.prompt` — a string/array/null `human` would crash them downstream.
|
|
262
|
+
errors.push({
|
|
263
|
+
path: `${path}.human`,
|
|
264
|
+
message: "`human` config, when present, must be an object",
|
|
265
|
+
code: "missing-config",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
|
|
270
|
+
// under the id even when other fields are invalid, so downstream edge resolution is best-effort.
|
|
271
|
+
const facts = new Set<string>();
|
|
272
|
+
if (rawNode.emits !== undefined) {
|
|
273
|
+
if (!Array.isArray(rawNode.emits)) {
|
|
274
|
+
errors.push({
|
|
275
|
+
path: `${path}.emits`,
|
|
276
|
+
message: "`emits` must be an array of typed fact declarations",
|
|
277
|
+
code: "missing-config",
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
rawNode.emits.forEach((rawFact, j) => {
|
|
281
|
+
if (!isRecord(rawFact) || typeof rawFact.name !== "string" || rawFact.name.length === 0) {
|
|
282
|
+
errors.push({
|
|
283
|
+
path: `${path}.emits[${j}].name`,
|
|
284
|
+
message: "each emitted fact needs a non-empty string `name`",
|
|
285
|
+
code: "missing-config",
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (facts.has(rawFact.name)) {
|
|
290
|
+
errors.push({
|
|
291
|
+
path: `${path}.emits[${j}].name`,
|
|
292
|
+
message: `duplicate emitted fact "${rawFact.name}" on node "${String(id)}"`,
|
|
293
|
+
code: "duplicate-fact",
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (rawFact.name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(rawFact.name)) {
|
|
298
|
+
// A fact name must be a dot-free identifier within openapi's 128-char cap (openapi's
|
|
299
|
+
// `DeliveryFact.name` `pattern` + `maxLength`) so a qualified edge `from`
|
|
300
|
+
// "<nodeId>.<fact>" resolves unambiguously and a later step trusting the cap can't be
|
|
301
|
+
// overrun — enforced here too, in case the OpenAPI shape gate is bypassed.
|
|
302
|
+
errors.push({
|
|
303
|
+
path: `${path}.emits[${j}].name`,
|
|
304
|
+
message:
|
|
305
|
+
`emitted fact name "${rawFact.name}" must be a bare identifier ` +
|
|
306
|
+
"(`^[A-Za-z_][A-Za-z0-9_]*$`, no dots) of " +
|
|
307
|
+
`\u2264 ${FACT_NAME_MAX_LENGTH} chars so qualified edge \`from\` references stay unambiguous`,
|
|
308
|
+
code: "invalid-fact-name",
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!isDeliveryFactType(rawFact.type)) {
|
|
313
|
+
// emits are TYPED (Decision 3/4). An invalid/missing `type` must be rejected even when the
|
|
314
|
+
// OpenAPI shape validator is bypassed, or a later step reading the type allowlist breaks.
|
|
315
|
+
errors.push({
|
|
316
|
+
path: `${path}.emits[${j}].type`,
|
|
317
|
+
message:
|
|
318
|
+
`emitted fact "${rawFact.name}" has an invalid \`type\` — must be one of ` +
|
|
319
|
+
`${DELIVERY_FACT_TYPES.join(", ")}`,
|
|
320
|
+
code: "invalid-fact-type",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
facts.add(rawFact.name);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (typeof id === "string" && id.length > 0 && !nodeFacts.has(id)) {
|
|
328
|
+
nodeFacts.set(id, facts);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Pass 2: edges. Resolve each endpoint against the node set and each qualified `from` against the
|
|
333
|
+
// upstream node's declared facts, and build the adjacency for the cycle check.
|
|
334
|
+
const edges: readonly unknown[] = Array.isArray(graph.edges) ? graph.edges : [];
|
|
335
|
+
if (graph.edges !== undefined && !Array.isArray(graph.edges)) {
|
|
336
|
+
// A non-array `edges` must not be silently treated as "no edges" — that would let a malformed
|
|
337
|
+
// body pass semantic validation when the OpenAPI shape validator is bypassed. This is a
|
|
338
|
+
// shape/type error (not an endpoint-resolution failure), so it carries `invalid-edges` — callers
|
|
339
|
+
// branching on error codes must distinguish "edges isn't a list" from a genuine dangling endpoint.
|
|
340
|
+
errors.push({
|
|
341
|
+
path: "edges",
|
|
342
|
+
message: "`edges`, when present, must be an array of `{ from, to }` dependency edges",
|
|
343
|
+
code: "invalid-edges",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
|
|
347
|
+
const adjacency = new Map<string, Set<string>>();
|
|
348
|
+
edges.forEach((rawEdge, i) => {
|
|
349
|
+
const path = `edges[${i}]`;
|
|
350
|
+
// A non-object entry or a missing/empty `from`/`to` is an edge *shape* error, not an
|
|
351
|
+
// endpoint-resolution failure — so it carries `invalid-edges` (like the non-array `edges` case
|
|
352
|
+
// above), reserving `dangling-edge` for a well-formed endpoint that names no node/fact.
|
|
353
|
+
if (!isRecord(rawEdge)) {
|
|
354
|
+
errors.push({ path, message: "each edge must be an object with `from` and `to`", code: "invalid-edges" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const from = rawEdge.from;
|
|
358
|
+
const to = rawEdge.to;
|
|
359
|
+
if (typeof from !== "string" || from.length === 0) {
|
|
360
|
+
errors.push({ path: `${path}.from`, message: "edge is missing a string `from`", code: "invalid-edges" });
|
|
361
|
+
}
|
|
362
|
+
if (typeof to !== "string" || to.length === 0) {
|
|
363
|
+
errors.push({ path: `${path}.to`, message: "edge is missing a string `to`", code: "invalid-edges" });
|
|
364
|
+
}
|
|
365
|
+
if (typeof from !== "string" || typeof to !== "string" || from.length === 0 || to.length === 0) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!nodeFacts.has(to)) {
|
|
370
|
+
errors.push({
|
|
371
|
+
path: `${path}.to`,
|
|
372
|
+
message: `edge \`to\` "${to}" names no node in the graph`,
|
|
373
|
+
code: "dangling-edge",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const { nodeId, fact, ambiguousWith } = resolveFrom(from, nodeFacts);
|
|
378
|
+
if (ambiguousWith !== undefined) {
|
|
379
|
+
errors.push({
|
|
380
|
+
path: `${path}.from`,
|
|
381
|
+
message:
|
|
382
|
+
`edge \`from\` "${from}" is ambiguous — it names both node "${from}" (a completion ` +
|
|
383
|
+
`dependency) and fact "${ambiguousWith.fact}" of node "${ambiguousWith.nodeId}"; rename ` +
|
|
384
|
+
"a node id or choose a different fact to disambiguate",
|
|
385
|
+
code: "bad-from",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const upstreamFacts = nodeFacts.get(nodeId);
|
|
389
|
+
if (upstreamFacts === undefined) {
|
|
390
|
+
errors.push({
|
|
391
|
+
path: `${path}.from`,
|
|
392
|
+
message: `edge \`from\` "${from}" names no node in the graph`,
|
|
393
|
+
code: "dangling-edge",
|
|
394
|
+
});
|
|
395
|
+
} else if (fact !== undefined && !upstreamFacts.has(fact)) {
|
|
396
|
+
errors.push({
|
|
397
|
+
path: `${path}.from`,
|
|
398
|
+
message:
|
|
399
|
+
`edge \`from\` "${from}" references fact "${fact}" that node "${nodeId}" does not ` +
|
|
400
|
+
"declare in its `emits[]`",
|
|
401
|
+
code: "bad-from",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (nodeId === to) {
|
|
406
|
+
errors.push({
|
|
407
|
+
path,
|
|
408
|
+
message: `node "${to}" cannot depend on itself`,
|
|
409
|
+
code: "self-edge",
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Only wire the cycle graph for edges whose endpoints both resolve — a dangling edge is already
|
|
415
|
+
// reported and must not crash the walk.
|
|
416
|
+
if (nodeFacts.has(to) && upstreamFacts !== undefined) {
|
|
417
|
+
const ups = adjacency.get(to) ?? new Set<string>();
|
|
418
|
+
ups.add(nodeId);
|
|
419
|
+
adjacency.set(to, ups);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
collectCycle(adjacency, errors);
|
|
424
|
+
return errors;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Depth-first cycle detection over the consumer(`to`)→producer(`from`) graph. Pushes ONE
|
|
428
|
+
* {@link DeliveryGraphError} naming the offending cycle (the "reject at the offending edge"
|
|
429
|
+
* guarantee) — a pure in-memory walk, no I/O. Reports at most one cycle so the message stays
|
|
430
|
+
* actionable; the author fixes it and re-validates to surface any next one. */
|
|
431
|
+
function collectCycle(adjacency: Map<string, Set<string>>, errors: DeliveryGraphError[]): void {
|
|
432
|
+
const VISITING = 1;
|
|
433
|
+
const DONE = 2;
|
|
434
|
+
const state = new Map<string, number>();
|
|
435
|
+
let reported = false;
|
|
436
|
+
const visit = (node: string, stack: string[]): void => {
|
|
437
|
+
if (reported) return;
|
|
438
|
+
state.set(node, VISITING);
|
|
439
|
+
stack.push(node);
|
|
440
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
441
|
+
if (reported) break;
|
|
442
|
+
const s = state.get(next);
|
|
443
|
+
if (s === VISITING) {
|
|
444
|
+
const cycleStart = stack.indexOf(next);
|
|
445
|
+
const cycle = [...stack.slice(cycleStart), next];
|
|
446
|
+
errors.push({
|
|
447
|
+
path: "edges",
|
|
448
|
+
message: `dependency cycle detected: ${cycle.join(" → ")} — the graph must be a DAG`,
|
|
449
|
+
code: "cycle",
|
|
450
|
+
});
|
|
451
|
+
reported = true;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (s !== DONE) visit(next, stack);
|
|
455
|
+
}
|
|
456
|
+
stack.pop();
|
|
457
|
+
state.set(node, DONE);
|
|
458
|
+
};
|
|
459
|
+
for (const node of adjacency.keys()) {
|
|
460
|
+
if (reported) break;
|
|
461
|
+
if (state.get(node) !== DONE) visit(node, []);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Tests for app/resolveApiBase.ts — the single canonical control-API base reconstruction shared by
|
|
2
|
+
// getAgentInstructions and getAgentSkill. Covers proxy-header handling, scheme restriction,
|
|
3
|
+
// host-absent fallback, and mount-suffix stripping for both mount depths.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { resolveApiBase } from "./resolveApiBase.ts";
|
|
7
|
+
|
|
8
|
+
function req(headers: Record<string, string>, path: string) {
|
|
9
|
+
return { path, headers: new Headers(headers) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("strips the single-segment mount suffix to recover the base", () => {
|
|
13
|
+
assertEquals(resolveApiBase(req({ host: "wf.example.com" }, "/app/api/agent"), "agent"), "http://wf.example.com/app/api");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("strips the nested mount suffix to recover the base", () => {
|
|
17
|
+
assertEquals(
|
|
18
|
+
resolveApiBase(req({ host: "wf.example.com" }, "/app/api/agent/skill"), "agent/skill"),
|
|
19
|
+
"http://wf.example.com/app/api",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("honours x-forwarded-proto and x-forwarded-host", () => {
|
|
24
|
+
const r = req({ host: "internal", "x-forwarded-host": "wf.example.com", "x-forwarded-proto": "https" }, "/app/api/agent");
|
|
25
|
+
assertEquals(resolveApiBase(r, "agent"), "https://wf.example.com/app/api");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("restricts x-forwarded-proto to http/https", () => {
|
|
29
|
+
const r = req({ host: "wf.example.com", "x-forwarded-proto": "javascript" }, "/app/api/agent/skill");
|
|
30
|
+
assertEquals(resolveApiBase(r, "agent/skill"), "http://wf.example.com/app/api");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("falls back to a localhost default when the Host header is absent", () => {
|
|
34
|
+
assertEquals(resolveApiBase(req({}, "/app/api/agent"), "agent"), "http://localhost:3000/app/api");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("tolerates a leading slash on the mount suffix", () => {
|
|
38
|
+
assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent"), "/agent"), "http://h/app/api");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("strips multiple trailing slashes after the mount suffix", () => {
|
|
42
|
+
assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
|
|
43
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Canonical reconstruction of the app control-API base a caller reached us on (e.g.
|
|
2
|
+
// "https://host/app/api"), so an operation can rewrite its embedded examples to THIS instance and
|
|
3
|
+
// keep them copy-pasteable. One implementation shared by every /app/api operation that keys output
|
|
4
|
+
// to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over
|
|
5
|
+
// duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork.
|
|
6
|
+
//
|
|
7
|
+
// Honour reverse-proxy forwarding headers; fall back to a localhost default when the Host header is
|
|
8
|
+
// absent (e.g. a raw unit-test request).
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recover the control-API base from a request, stripping the operation's own mount suffix.
|
|
12
|
+
*
|
|
13
|
+
* @param req the request (path + headers)
|
|
14
|
+
* @param mountSuffix the operation's path suffix to strip to recover the base, e.g. "agent" or
|
|
15
|
+
* "agent/skill" (with or without a leading slash). The base defaults to
|
|
16
|
+
* "/app/api" when the path is nothing but the suffix.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string {
|
|
19
|
+
const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
|
|
20
|
+
// x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
|
|
21
|
+
const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
|
|
22
|
+
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
|
|
23
|
+
// The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
|
|
24
|
+
const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
25
|
+
const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
|
|
26
|
+
const basePath = req.path.replace(stripRe, "") || "/app/api";
|
|
27
|
+
return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
|
|
28
|
+
}
|