@revisium/orchestrator 0.1.0-alpha.5 → 0.1.0-alpha.7
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/README.md +149 -1
- package/dist/api/graphql-api/filters/agent-observability-exception.filter.js +24 -0
- package/dist/api/graphql-api/filters/agent-observability-exception.filter.js.map +1 -0
- package/dist/api/graphql-api/graphql-api.module.js +2 -0
- package/dist/api/graphql-api/graphql-api.module.js.map +1 -1
- package/dist/api/graphql-api/graphql-ws/agent-observability-subscription-bridge.service.js +296 -0
- package/dist/api/graphql-api/graphql-ws/agent-observability-subscription-bridge.service.js.map +1 -0
- package/dist/api/graphql-api/graphql-ws/agent-observability-subscription-mappers.js +64 -0
- package/dist/api/graphql-api/graphql-ws/agent-observability-subscription-mappers.js.map +1 -0
- package/dist/api/graphql-api/graphql-ws/constants.js +2 -0
- package/dist/api/graphql-api/graphql-ws/constants.js.map +1 -1
- package/dist/api/graphql-api/graphql-ws/pubsub.module.js +5 -2
- package/dist/api/graphql-api/graphql-ws/pubsub.module.js.map +1 -1
- package/dist/api/graphql-api/registerGraphqlEnums.js +6 -1
- package/dist/api/graphql-api/registerGraphqlEnums.js.map +1 -1
- package/dist/api/graphql-api/runs/inputs/get-agent-log.input.js +48 -0
- package/dist/api/graphql-api/runs/inputs/get-agent-log.input.js.map +1 -0
- package/dist/api/graphql-api/runs/model/agent-activity.model.js +367 -0
- package/dist/api/graphql-api/runs/model/agent-activity.model.js.map +1 -0
- package/dist/api/graphql-api/runs/runs-subscription.resolver.js +35 -4
- package/dist/api/graphql-api/runs/runs-subscription.resolver.js.map +1 -1
- package/dist/api/graphql-api/runs/runs.resolver.js +35 -0
- package/dist/api/graphql-api/runs/runs.resolver.js.map +1 -1
- package/dist/cli/commands/doctor-report.js +52 -0
- package/dist/cli/commands/doctor-report.js.map +1 -0
- package/dist/cli/commands/lifecycle.js +304 -0
- package/dist/cli/commands/lifecycle.js.map +1 -0
- package/dist/cli/commands/mcp.js +46 -8
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/revisium-helpers.js +2 -2
- package/dist/cli/index.js +5 -29
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/program.js +7 -23
- package/dist/cli/program.js.map +1 -1
- package/dist/config.js +42 -5
- package/dist/config.js.map +1 -1
- package/dist/control-plane/bootstrap.js +93 -0
- package/dist/control-plane/bootstrap.js.map +1 -0
- package/dist/control-plane/schema-migration.js +7 -11
- package/dist/control-plane/schema-migration.js.map +1 -1
- package/dist/control-plane/seed-default-playbook.js +23 -0
- package/dist/control-plane/seed-default-playbook.js.map +1 -1
- package/dist/e2e/kit/agents.js +17 -5
- package/dist/e2e/kit/agents.js.map +1 -1
- package/dist/e2e/kit/assertions.js +20 -0
- package/dist/e2e/kit/assertions.js.map +1 -1
- package/dist/e2e/kit/harness.js +12 -1
- package/dist/e2e/kit/harness.js.map +1 -1
- package/dist/e2e/kit/scenarios.js +1 -1
- package/dist/e2e/recovery-dd-crash-child.js +3 -2
- package/dist/e2e/recovery-dd-crash-child.js.map +1 -1
- package/dist/engine/dbos.service.js +20 -0
- package/dist/engine/dbos.service.js.map +1 -1
- package/dist/engine/ensure-postgres.js +11 -9
- package/dist/engine/ensure-postgres.js.map +1 -1
- package/dist/features/runs/queries/handlers/runs-query.handlers.js +135 -0
- package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
- package/dist/features/runs/queries/impl/get-agent-activity.query.js +7 -0
- package/dist/features/runs/queries/impl/get-agent-activity.query.js.map +1 -0
- package/dist/features/runs/queries/impl/get-agent-attempts.query.js +7 -0
- package/dist/features/runs/queries/impl/get-agent-attempts.query.js.map +1 -0
- package/dist/features/runs/queries/impl/get-agent-log.query.js +7 -0
- package/dist/features/runs/queries/impl/get-agent-log.query.js.map +1 -0
- package/dist/features/runs/queries/index.js +4 -1
- package/dist/features/runs/queries/index.js.map +1 -1
- package/dist/features/runs/runs-api.service.js +12 -0
- package/dist/features/runs/runs-api.service.js.map +1 -1
- package/dist/host/daemon.js +86 -0
- package/dist/host/daemon.js.map +1 -0
- package/dist/host/ensure-host.js +116 -0
- package/dist/host/ensure-host.js.map +1 -0
- package/dist/host/host-runtime.js +72 -0
- package/dist/host/host-runtime.js.map +1 -0
- package/dist/host/queue-ownership.js +53 -0
- package/dist/host/queue-ownership.js.map +1 -0
- package/dist/http/graphql-host.js +8 -2
- package/dist/http/graphql-host.js.map +1 -1
- package/dist/http/graphql-host.module.js +3 -0
- package/dist/http/graphql-host.module.js.map +1 -1
- package/dist/mcp/mcp-capabilities.js +6 -1
- package/dist/mcp/mcp-capabilities.js.map +1 -1
- package/dist/mcp/mcp-facade.service.js +24 -0
- package/dist/mcp/mcp-facade.service.js.map +1 -1
- package/dist/mcp/mcp-http.service.js +95 -0
- package/dist/mcp/mcp-http.service.js.map +1 -0
- package/dist/mcp/mcp-tools.js +58 -0
- package/dist/mcp/mcp-tools.js.map +1 -1
- package/dist/observability/agent-activity-reporter.js +185 -0
- package/dist/observability/agent-activity-reporter.js.map +1 -0
- package/dist/observability/agent-observability.service.js +1142 -0
- package/dist/observability/agent-observability.service.js.map +1 -0
- package/dist/observability/index.js +4 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/types.js +11 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/pipeline/data-driven-task.workflow.js +37 -75
- package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
- package/dist/pipeline/pipeline.service.js +54 -15
- package/dist/pipeline/pipeline.service.js.map +1 -1
- package/dist/pipeline-core/validate-capability.js +23 -0
- package/dist/pipeline-core/validate-capability.js.map +1 -0
- package/dist/pipeline-core/validate-conflicts.js +37 -0
- package/dist/pipeline-core/validate-conflicts.js.map +1 -0
- package/dist/pipeline-core/validate-dataflow.js +180 -0
- package/dist/pipeline-core/validate-dataflow.js.map +1 -0
- package/dist/pipeline-core/validate-diff.js +158 -0
- package/dist/pipeline-core/validate-diff.js.map +1 -0
- package/dist/pipeline-core/validate-edges.js +44 -0
- package/dist/pipeline-core/validate-edges.js.map +1 -0
- package/dist/pipeline-core/validate-graph.js +123 -0
- package/dist/pipeline-core/validate-graph.js.map +1 -0
- package/dist/pipeline-core/validate-loops.js +226 -0
- package/dist/pipeline-core/validate-loops.js.map +1 -0
- package/dist/pipeline-core/validate-parallel.js +132 -0
- package/dist/pipeline-core/validate-parallel.js.map +1 -0
- package/dist/pipeline-core/validate-sink.js +27 -0
- package/dist/pipeline-core/validate-sink.js.map +1 -0
- package/dist/pipeline-core/validate-topology.js +140 -0
- package/dist/pipeline-core/validate-topology.js.map +1 -0
- package/dist/pipeline-core/validate-verdict.js +72 -0
- package/dist/pipeline-core/validate-verdict.js.map +1 -0
- package/dist/pipeline-core/validate.js +34 -1107
- package/dist/pipeline-core/validate.js.map +1 -1
- package/dist/revisium/run.service.js +15 -1
- package/dist/revisium/run.service.js.map +1 -1
- package/dist/runners/codex.service.js +51 -0
- package/dist/runners/codex.service.js.map +1 -0
- package/dist/runners/integrator-branch-naming.js +24 -0
- package/dist/runners/integrator-branch-naming.js.map +1 -0
- package/dist/runners/integrator-git.js +84 -0
- package/dist/runners/integrator-git.js.map +1 -0
- package/dist/runners/integrator-remote.js +34 -0
- package/dist/runners/integrator-remote.js.map +1 -0
- package/dist/runners/integrator-types.js +9 -0
- package/dist/runners/integrator-types.js.map +1 -0
- package/dist/runners/integrator.js +12 -132
- package/dist/runners/integrator.js.map +1 -1
- package/dist/runners/runner.module.js +4 -2
- package/dist/runners/runner.module.js.map +1 -1
- package/dist/smoke/cli.js +47 -0
- package/dist/smoke/cli.js.map +1 -0
- package/dist/smoke/isolation.js +113 -0
- package/dist/smoke/isolation.js.map +1 -0
- package/dist/task-control-plane/task-control-plane-api.service.js +24 -1
- package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
- package/dist/task-control-plane/task-control-plane.module.js +21 -1
- package/dist/task-control-plane/task-control-plane.module.js.map +1 -1
- package/dist/worker/artifact-store.js +6 -1
- package/dist/worker/artifact-store.js.map +1 -1
- package/dist/worker/build-context.js +135 -1
- package/dist/worker/build-context.js.map +1 -1
- package/dist/worker/claude-code-runner.js +132 -78
- package/dist/worker/claude-code-runner.js.map +1 -1
- package/dist/worker/codex-runner.js +474 -0
- package/dist/worker/codex-runner.js.map +1 -0
- package/dist/worker/result-envelope.js +91 -73
- package/dist/worker/result-envelope.js.map +1 -1
- package/dist/worker/runner-common.js +106 -0
- package/dist/worker/runner-common.js.map +1 -0
- package/dist/worker/runner-dispatch.js +3 -1
- package/dist/worker/runner-dispatch.js.map +1 -1
- package/dist/worker/runner.js.map +1 -1
- package/dist/worker/stub-runner.js +3 -3
- package/dist/worker/stub-runner.js.map +1 -1
- package/package.json +1 -1
- package/dist/app.module.js +0 -28
- package/dist/app.module.js.map +0 -1
- package/dist/cli/commands/bootstrap.js +0 -100
- package/dist/cli/commands/bootstrap.js.map +0 -1
- package/dist/cli/commands/dev.js +0 -143
- package/dist/cli/commands/dev.js.map +0 -1
- package/dist/cli/commands/inbox.js +0 -288
- package/dist/cli/commands/inbox.js.map +0 -1
- package/dist/cli/commands/playbook.js +0 -74
- package/dist/cli/commands/playbook.js.map +0 -1
- package/dist/cli/commands/poll-workflow-state.js +0 -144
- package/dist/cli/commands/poll-workflow-state.js.map +0 -1
- package/dist/cli/commands/revisium-context.js +0 -23
- package/dist/cli/commands/revisium-context.js.map +0 -1
- package/dist/cli/commands/revisium.js +0 -89
- package/dist/cli/commands/revisium.js.map +0 -1
- package/dist/cli/commands/run.js +0 -451
- package/dist/cli/commands/run.js.map +0 -1
- package/dist/cli/commands/serve.js +0 -26
- package/dist/cli/commands/serve.js.map +0 -1
- package/dist/cli/live-guard.js +0 -49
- package/dist/cli/live-guard.js.map +0 -1
- package/dist/cli/needs-host.js +0 -77
- package/dist/cli/needs-host.js.map +0 -1
- package/dist/mcp/mcp-stdio.service.js +0 -48
- package/dist/mcp/mcp-stdio.service.js.map +0 -1
- package/dist/mcp/mcp.module.js +0 -21
- package/dist/mcp/mcp.module.js.map +0 -1
|
@@ -1,57 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pipeline-core/validate.ts — the authoritative install-time validator (§12).
|
|
2
|
+
* pipeline-core/validate.ts — the authoritative install-time validator (§12). Runs rules 1–12 + 14
|
|
3
|
+
* (each imported from its sibling module) and re-exports the diff classifier (rule 13) from
|
|
4
|
+
* validate-diff.ts, so the public surface (`validateTemplate`, `classifyTemplateDiff`) is unchanged.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Pure: zero I/O, no clocks. Each §12 rule is its own collector; `validateTemplate` runs them all and
|
|
8
|
-
* returns every finding (it does not stop at the first error). Codes are stable (tested against).
|
|
6
|
+
* Pure: zero I/O, no clocks. Each §12 rule is its own collector; `validateTemplate` returns every
|
|
7
|
+
* finding (it does not stop at the first error). Codes are stable (tested against).
|
|
9
8
|
*/
|
|
10
|
-
import {
|
|
9
|
+
import { FAILURE_POLICIES, isRevoErrorCode } from './types.js';
|
|
10
|
+
import { DiagSink } from './validate-sink.js';
|
|
11
|
+
import { ruleSingleEntry, ruleReferencesResolve, ruleTerminals, ruleConditionGrammar, ruleTotalRouting, ruleReachability, } from './validate-topology.js';
|
|
12
|
+
import { ruleLoopCap, ruleCounterScopes } from './validate-loops.js';
|
|
13
|
+
import { ruleParallelJoin } from './validate-parallel.js';
|
|
14
|
+
import { ruleVerdictClosure } from './validate-verdict.js';
|
|
15
|
+
import { ruleDataflow } from './validate-dataflow.js';
|
|
16
|
+
import { ruleConflictMatrix } from './validate-conflicts.js';
|
|
17
|
+
import { ruleCapabilityRefs } from './validate-capability.js';
|
|
18
|
+
export { classifyTemplateDiff } from './validate-diff.js';
|
|
11
19
|
const NODE_ID_PATTERN = /^[A-Za-z]\w*$/;
|
|
12
|
-
const CAPABILITY_REF_PATTERN = /^(role|script):[A-Za-z][A-Za-z0-9_-]*$/;
|
|
13
|
-
class DiagSink {
|
|
14
|
-
items = [];
|
|
15
|
-
error(code, message, where = {}) {
|
|
16
|
-
this.items.push({ code, severity: 'error', message, ...strip(where) });
|
|
17
|
-
}
|
|
18
|
-
warn(code, message, where = {}) {
|
|
19
|
-
this.items.push({ code, severity: 'warning', message, ...strip(where) });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
function strip(where) {
|
|
23
|
-
const out = {};
|
|
24
|
-
if (where.nodeId !== undefined)
|
|
25
|
-
out.nodeId = where.nodeId;
|
|
26
|
-
if (where.scope !== undefined)
|
|
27
|
-
out.scope = where.scope;
|
|
28
|
-
if (where.path !== undefined)
|
|
29
|
-
out.path = where.path;
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
20
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
21
|
// Public entry.
|
|
34
22
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
-
/** Run all §12 rules
|
|
23
|
+
/** Run all §12 rules. Returns every diagnostic; empty ⇒ the template is valid. */
|
|
36
24
|
export function validateTemplate(template) {
|
|
37
25
|
const d = new DiagSink();
|
|
38
26
|
const nodes = template.nodes ?? {};
|
|
39
27
|
const ids = new Set(Object.keys(nodes));
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
28
|
+
// Rules dereference `nodes` directly; a malformed parsed template (type says required, JSON may lie)
|
|
29
|
+
// would otherwise throw inside a rule instead of producing diagnostics.
|
|
30
|
+
const normalized = { ...template, nodes };
|
|
31
|
+
ruleIdHygiene(normalized, d); // run first — duplicate/bad ids inform the rest
|
|
32
|
+
ruleSingleEntry(normalized, ids, d);
|
|
33
|
+
ruleReferencesResolve(normalized, ids, d);
|
|
34
|
+
ruleTerminals(normalized, d);
|
|
35
|
+
ruleConditionGrammar(normalized, d); // grammar sanity — feeds rules 2/4/9
|
|
36
|
+
ruleTotalRouting(normalized, d);
|
|
37
|
+
ruleReachability(normalized, ids, d);
|
|
38
|
+
ruleFailurePolicy(normalized, d); // failure-policy well-formedness
|
|
39
|
+
ruleLoopCap(normalized, d); // loop-cap presence
|
|
40
|
+
ruleCounterScopes(normalized, d);
|
|
41
|
+
ruleParallelJoin(normalized, d);
|
|
42
|
+
ruleVerdictClosure(normalized, d);
|
|
43
|
+
ruleConflictMatrix(normalized, d);
|
|
44
|
+
ruleCapabilityRefs(normalized, d);
|
|
45
|
+
ruleDataflow(normalized, ids, d); // produces/consumes — 0016 §7
|
|
55
46
|
return d.items;
|
|
56
47
|
}
|
|
57
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -93,196 +84,6 @@ function checkCatchCodeVerdictCollisions(template, d) {
|
|
|
93
84
|
}
|
|
94
85
|
}
|
|
95
86
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
// Rule 1 — single entry.
|
|
97
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
-
function ruleSingleEntry(template, ids, d) {
|
|
99
|
-
if (!template.entry) {
|
|
100
|
-
d.error('ENTRY_MISSING', 'template has no `entry`');
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (!ids.has(template.entry)) {
|
|
104
|
-
d.error('ENTRY_UNRESOLVED', `entry "${template.entry}" does not resolve to a node`, {
|
|
105
|
-
nodeId: template.entry,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
-
// Rule 2 — references resolve (every edge target exists).
|
|
111
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
-
function ruleReferencesResolve(template, ids, d) {
|
|
113
|
-
const check = (target, node, path) => {
|
|
114
|
-
if (target === undefined)
|
|
115
|
-
return;
|
|
116
|
-
if (!ids.has(target)) {
|
|
117
|
-
d.error('REF_UNRESOLVED', `${node.id}.${path} → unknown node "${target}"`, { nodeId: node.id, path });
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
for (const node of Object.values(template.nodes)) {
|
|
121
|
-
for (const [path, target] of outgoingEdges(node))
|
|
122
|
-
check(target, node, path);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
/** Every outgoing edge of a node as `[path, targetId]` pairs (for refs/topology/diff rules). */
|
|
126
|
-
function outgoingEdges(node) {
|
|
127
|
-
const edges = [];
|
|
128
|
-
switch (node.kind) {
|
|
129
|
-
case 'agent':
|
|
130
|
-
case 'script':
|
|
131
|
-
edges.push(['next', node.next]);
|
|
132
|
-
(node.catch ?? []).forEach((c, i) => edges.push([`catch[${i}].goto`, c.goto]));
|
|
133
|
-
if (node.escalateTo !== undefined)
|
|
134
|
-
edges.push(['escalateTo', node.escalateTo]);
|
|
135
|
-
break;
|
|
136
|
-
case 'wait':
|
|
137
|
-
edges.push(['next', node.next]);
|
|
138
|
-
break;
|
|
139
|
-
case 'join':
|
|
140
|
-
edges.push(['next', node.next]);
|
|
141
|
-
break;
|
|
142
|
-
case 'humanGate':
|
|
143
|
-
branchTargets(node.branches).forEach(([p, t]) => edges.push([`branches.${p}`, t]));
|
|
144
|
-
if (node.timeout)
|
|
145
|
-
edges.push(['timeout.goto', node.timeout.goto]);
|
|
146
|
-
break;
|
|
147
|
-
case 'choice':
|
|
148
|
-
branchTargets(node.branches).forEach(([p, t]) => edges.push([`branches.${p}`, t]));
|
|
149
|
-
break;
|
|
150
|
-
case 'parallel':
|
|
151
|
-
node.branches.forEach((b, i) => edges.push([`branches[${i}].entry`, b.entry]));
|
|
152
|
-
edges.push(['join', node.join]);
|
|
153
|
-
break;
|
|
154
|
-
case 'terminal':
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
return edges;
|
|
158
|
-
}
|
|
159
|
-
function branchTargets(branches) {
|
|
160
|
-
return branches.map((b, i) => isDefaultBranch(b) ? [`[${i}].default`, b.default] : [`[${i}].goto`, b.goto]);
|
|
161
|
-
}
|
|
162
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
-
// Rule 3 — terminals & non-terminals.
|
|
164
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
-
function ruleTerminals(template, d) {
|
|
166
|
-
for (const node of Object.values(template.nodes)) {
|
|
167
|
-
if (node.kind === 'terminal') {
|
|
168
|
-
if (!TERMINAL_STATUSES.includes(node.status)) {
|
|
169
|
-
d.error('TERMINAL_BAD_STATUS', `terminal ${node.id} has bad status "${node.status}"`, {
|
|
170
|
-
nodeId: node.id,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
// The discriminated union forbids exit fields on a terminal at the type level; nothing else to check.
|
|
174
|
-
}
|
|
175
|
-
else if (outgoingEdges(node).length === 0) {
|
|
176
|
-
d.error('NONTERMINAL_NO_EXIT', `non-terminal ${node.id} (${node.kind}) has no exit edge`, {
|
|
177
|
-
nodeId: node.id,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
-
// Condition grammar sanity — a malformed guard can't be evaluated (feeds rules 2/4/9).
|
|
184
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
-
function ruleConditionGrammar(template, d) {
|
|
186
|
-
for (const node of Object.values(template.nodes)) {
|
|
187
|
-
for (const cond of guardConditionsOf(node))
|
|
188
|
-
checkCondition(cond, node.id, d);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
function checkCondition(cond, nodeId, d) {
|
|
192
|
-
const op = cond.op;
|
|
193
|
-
if (typeof op !== 'string' || !CONDITION_OPS.includes(op)) {
|
|
194
|
-
d.error('CONDITION_BAD_OP', `node ${nodeId} guard has unknown op "${String(op)}"`, { nodeId });
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
switch (cond.op) {
|
|
198
|
-
case 'verdict.eq':
|
|
199
|
-
if (typeof cond.value !== 'string')
|
|
200
|
-
d.error('CONDITION_BAD_SHAPE', `verdict.eq value must be a string`, { nodeId });
|
|
201
|
-
break;
|
|
202
|
-
case 'verdict.in':
|
|
203
|
-
if (!Array.isArray(cond.value) || cond.value.some((v) => typeof v !== 'string')) {
|
|
204
|
-
d.error('CONDITION_BAD_SHAPE', `verdict.in value must be a string[]`, { nodeId });
|
|
205
|
-
}
|
|
206
|
-
break;
|
|
207
|
-
case 'counter.lt':
|
|
208
|
-
case 'counter.gte':
|
|
209
|
-
if (typeof cond.scope !== 'string' || !Number.isInteger(cond.value)) {
|
|
210
|
-
d.error('CONDITION_BAD_SHAPE', `${cond.op} needs a scope + integer value`, { nodeId });
|
|
211
|
-
}
|
|
212
|
-
break;
|
|
213
|
-
case 'all':
|
|
214
|
-
case 'any':
|
|
215
|
-
if (Array.isArray(cond.of))
|
|
216
|
-
cond.of.forEach((c) => checkCondition(c, nodeId, d));
|
|
217
|
-
else
|
|
218
|
-
d.error('CONDITION_BAD_SHAPE', `${cond.op} needs an "of" array`, { nodeId });
|
|
219
|
-
break;
|
|
220
|
-
case 'not':
|
|
221
|
-
if (cond.cond)
|
|
222
|
-
checkCondition(cond.cond, nodeId, d);
|
|
223
|
-
else
|
|
224
|
-
d.error('CONDITION_BAD_SHAPE', `not needs a "cond"`, { nodeId });
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
-
// Rule 4 — total routing (default present; no guard after default; one default).
|
|
230
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
-
function ruleTotalRouting(template, d) {
|
|
232
|
-
for (const node of Object.values(template.nodes)) {
|
|
233
|
-
if (node.kind !== 'choice' && node.kind !== 'humanGate')
|
|
234
|
-
continue;
|
|
235
|
-
const branches = node.branches;
|
|
236
|
-
const defaultIdxs = branches.map((b, i) => (isDefaultBranch(b) ? i : -1)).filter((i) => i >= 0);
|
|
237
|
-
if (defaultIdxs.length === 0) {
|
|
238
|
-
d.error('ROUTING_NO_DEFAULT', `${node.kind} ${node.id} has no trailing default branch`, {
|
|
239
|
-
nodeId: node.id,
|
|
240
|
-
});
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (defaultIdxs.length > 1) {
|
|
244
|
-
d.error('ROUTING_MULTIPLE_DEFAULT', `${node.kind} ${node.id} has ${defaultIdxs.length} defaults`, {
|
|
245
|
-
nodeId: node.id,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
const firstDefault = defaultIdxs[0];
|
|
249
|
-
if (firstDefault !== branches.length - 1) {
|
|
250
|
-
d.error('ROUTING_GUARD_AFTER_DEFAULT', `${node.kind} ${node.id} has a guard after its default`, {
|
|
251
|
-
nodeId: node.id,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
-
// Rule 5 — reachability (every node reachable from entry; no dead nodes).
|
|
258
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
-
function ruleReachability(template, ids, d) {
|
|
260
|
-
if (!template.entry || !ids.has(template.entry))
|
|
261
|
-
return; // rule 1 already flagged it
|
|
262
|
-
const reachable = reachableFrom(template, template.entry);
|
|
263
|
-
for (const id of ids) {
|
|
264
|
-
if (!reachable.has(id))
|
|
265
|
-
d.error('UNREACHABLE_NODE', `node "${id}" is unreachable from entry`, { nodeId: id });
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
function reachableFrom(template, entry) {
|
|
269
|
-
const seen = new Set();
|
|
270
|
-
const stack = [entry];
|
|
271
|
-
while (stack.length) {
|
|
272
|
-
const id = stack.pop();
|
|
273
|
-
if (seen.has(id))
|
|
274
|
-
continue;
|
|
275
|
-
seen.add(id);
|
|
276
|
-
const node = template.nodes[id];
|
|
277
|
-
if (!node)
|
|
278
|
-
continue;
|
|
279
|
-
for (const [, target] of outgoingEdges(node))
|
|
280
|
-
if (!seen.has(target))
|
|
281
|
-
stack.push(target);
|
|
282
|
-
}
|
|
283
|
-
return seen;
|
|
284
|
-
}
|
|
285
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
286
87
|
// Rule 6a — per-node failure policy well-formedness.
|
|
287
88
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
89
|
function ruleFailurePolicy(template, d) {
|
|
@@ -315,878 +116,4 @@ function checkNodeFailurePolicy(node, d) {
|
|
|
315
116
|
});
|
|
316
117
|
}
|
|
317
118
|
}
|
|
318
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
319
|
-
// Rule 6b — loop-cap presence.
|
|
320
|
-
//
|
|
321
|
-
// Spec note (two ambiguities resolved against the canonical §13 example, which §13 declares
|
|
322
|
-
// internally-consistent and the task names as THE fixture):
|
|
323
|
-
// 1. §12.6 says "a terminating cap-guard (counter.gte)", but §13 expresses the SAME bound as
|
|
324
|
-
// `counter.lt K` on the CONTINUE edge with the default routing OUT (once the counter reaches K the
|
|
325
|
-
// guard fails and control falls through to blockedEnd). Both make the loop finite, so we accept
|
|
326
|
-
// EITHER: a cycle is counter-bounded iff some `choice` on it has a guard referencing a `counter.*`
|
|
327
|
-
// over a scope INCREMENTED on the cycle.
|
|
328
|
-
// 2. §13's analyst↔planGate `changes_requested` loop carries NO counter — it is bounded only by human
|
|
329
|
-
// judgment. §6 makes a human-driven loop legitimately unbounded ("durable wait is free … for
|
|
330
|
-
// human-driven runs"). So a cycle that passes through a `humanGate` is also accepted.
|
|
331
|
-
// An AUTOMATED loop (agents/scripts/choices only) with no counter is rejected (LOOP_UNBOUNDED).
|
|
332
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
333
|
-
function ruleLoopCap(template, d) {
|
|
334
|
-
if (!template.entry || !template.nodes[template.entry])
|
|
335
|
-
return;
|
|
336
|
-
for (const { from, to } of findBackEdges(template)) {
|
|
337
|
-
const cycle = cycleNodes(template, to, from);
|
|
338
|
-
const incrementedOnCycle = scopesIncrementedOnCycle(template, cycle);
|
|
339
|
-
const counterBounded = [...cycle].some((id) => choiceGatesCycleByCounter(template, id, incrementedOnCycle));
|
|
340
|
-
const humanDriven = [...cycle].some((id) => template.nodes[id]?.kind === 'humanGate');
|
|
341
|
-
if (!counterBounded && !humanDriven) {
|
|
342
|
-
d.error('LOOP_UNBOUNDED', `back-edge ${from} → ${to} is not bounded by a counter cap-guard or a human gate`, {
|
|
343
|
-
nodeId: from,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
/** Scope ids incremented by any node on the cycle (the loop's bound must count one of these). */
|
|
349
|
-
function scopesIncrementedOnCycle(template, cycle) {
|
|
350
|
-
const out = new Set();
|
|
351
|
-
for (const id of cycle) {
|
|
352
|
-
const node = template.nodes[id];
|
|
353
|
-
if (node && 'incrementCounters' in node)
|
|
354
|
-
for (const s of node.incrementCounters ?? [])
|
|
355
|
-
out.add(s);
|
|
356
|
-
}
|
|
357
|
-
return out;
|
|
358
|
-
}
|
|
359
|
-
/** A choice with a guard that references a `counter.*` over a scope incremented on the cycle. */
|
|
360
|
-
function choiceGatesCycleByCounter(template, id, incrementedOnCycle) {
|
|
361
|
-
const node = template.nodes[id];
|
|
362
|
-
if (node?.kind !== 'choice')
|
|
363
|
-
return false;
|
|
364
|
-
return node.branches.filter(isGuardedBranch).some((b) => conditionGatesOnScopes(b.when, incrementedOnCycle));
|
|
365
|
-
}
|
|
366
|
-
/** True when `cond` compares a `counter.*` against one of `scopes`. */
|
|
367
|
-
function conditionGatesOnScopes(cond, scopes) {
|
|
368
|
-
switch (cond.op) {
|
|
369
|
-
case 'counter.lt':
|
|
370
|
-
case 'counter.gte':
|
|
371
|
-
return scopes.has(cond.scope);
|
|
372
|
-
case 'all':
|
|
373
|
-
case 'any':
|
|
374
|
-
return cond.of.some((c) => conditionGatesOnScopes(c, scopes));
|
|
375
|
-
case 'not':
|
|
376
|
-
return conditionGatesOnScopes(cond.cond, scopes);
|
|
377
|
-
default:
|
|
378
|
-
return false;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
/** Back-edges = forward edges whose target is an ancestor on the DFS stack (a cycle re-entry). */
|
|
382
|
-
function findBackEdges(template) {
|
|
383
|
-
const back = [];
|
|
384
|
-
const onStack = new Set();
|
|
385
|
-
const done = new Set();
|
|
386
|
-
const dfs = (id) => {
|
|
387
|
-
onStack.add(id);
|
|
388
|
-
const node = template.nodes[id];
|
|
389
|
-
if (node) {
|
|
390
|
-
for (const [, target] of structuralEdges(node)) {
|
|
391
|
-
if (onStack.has(target))
|
|
392
|
-
back.push({ from: id, to: target });
|
|
393
|
-
else if (!done.has(target))
|
|
394
|
-
dfs(target);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
onStack.delete(id);
|
|
398
|
-
done.add(id);
|
|
399
|
-
};
|
|
400
|
-
if (template.entry && template.nodes[template.entry])
|
|
401
|
-
dfs(template.entry);
|
|
402
|
-
return back;
|
|
403
|
-
}
|
|
404
|
-
/** Forward (non-catch, non-escalate) edges — `catch`/`escalateTo` are failure routes, not loop edges. */
|
|
405
|
-
function structuralEdges(node) {
|
|
406
|
-
return outgoingEdges(node).filter(([p]) => !p.startsWith('catch') && p !== 'escalateTo');
|
|
407
|
-
}
|
|
408
|
-
/** Nodes on a cycle that re-enters `to` via the back-edge from `from` (path to→…→from + from). */
|
|
409
|
-
function cycleNodes(template, to, from) {
|
|
410
|
-
// Forward-reachable from `to` (staying inside the SCC reaching `from`) — a simple over-approx is the
|
|
411
|
-
// set of nodes on some path to→…→from. We collect nodes that can both reach `from` and are reached
|
|
412
|
-
// from `to`, which is exactly the cycle's SCC member set for these structural edges.
|
|
413
|
-
const fromTo = forwardReach(template, to);
|
|
414
|
-
const canReachFrom = backwardReach(template, from);
|
|
415
|
-
const cycle = new Set();
|
|
416
|
-
for (const id of fromTo)
|
|
417
|
-
if (canReachFrom.has(id))
|
|
418
|
-
cycle.add(id);
|
|
419
|
-
cycle.add(to);
|
|
420
|
-
cycle.add(from);
|
|
421
|
-
return cycle;
|
|
422
|
-
}
|
|
423
|
-
function forwardReach(template, start) {
|
|
424
|
-
const seen = new Set();
|
|
425
|
-
const stack = [start];
|
|
426
|
-
while (stack.length) {
|
|
427
|
-
const id = stack.pop();
|
|
428
|
-
if (seen.has(id))
|
|
429
|
-
continue;
|
|
430
|
-
seen.add(id);
|
|
431
|
-
const node = template.nodes[id];
|
|
432
|
-
if (node)
|
|
433
|
-
for (const [, t] of structuralEdges(node))
|
|
434
|
-
if (!seen.has(t))
|
|
435
|
-
stack.push(t);
|
|
436
|
-
}
|
|
437
|
-
return seen;
|
|
438
|
-
}
|
|
439
|
-
function backwardReach(template, target) {
|
|
440
|
-
const preds = new Map();
|
|
441
|
-
for (const node of Object.values(template.nodes)) {
|
|
442
|
-
for (const [, t] of structuralEdges(node))
|
|
443
|
-
(preds.get(t) ?? preds.set(t, []).get(t)).push(node.id);
|
|
444
|
-
}
|
|
445
|
-
const seen = new Set();
|
|
446
|
-
const stack = [target];
|
|
447
|
-
while (stack.length) {
|
|
448
|
-
const id = stack.pop();
|
|
449
|
-
if (seen.has(id))
|
|
450
|
-
continue;
|
|
451
|
-
seen.add(id);
|
|
452
|
-
for (const p of preds.get(id) ?? [])
|
|
453
|
-
if (!seen.has(p))
|
|
454
|
-
stack.push(p);
|
|
455
|
-
}
|
|
456
|
-
return seen;
|
|
457
|
-
}
|
|
458
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
459
|
-
// Rule 7 — counter-scope well-formedness.
|
|
460
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
461
|
-
function ruleCounterScopes(template, d) {
|
|
462
|
-
const scopes = template.scopes ?? {};
|
|
463
|
-
const scopeIds = new Set(Object.keys(scopes));
|
|
464
|
-
checkScopeParentsAndCycles(scopes, scopeIds, d); // 7a parents resolve + no cycle
|
|
465
|
-
checkScopesDeclared(template, scopeIds, d); // 7b every referenced scope is declared
|
|
466
|
-
for (const scopeId of scopeIds)
|
|
467
|
-
checkScopeStrictAncestry(template, scopeId, d); // 7c reset = strict ancestor
|
|
468
|
-
for (const scopeId of Object.keys(scopes))
|
|
469
|
-
checkScopeDoesNotSpanParallel(template, scopeId, d); // 7d no parallel/join
|
|
470
|
-
}
|
|
471
|
-
/** 7a — every scope's parent resolves to a declared scope and the parent chain has no cycle. */
|
|
472
|
-
function checkScopeParentsAndCycles(scopes, scopeIds, d) {
|
|
473
|
-
for (const [scopeId, scope] of Object.entries(scopes)) {
|
|
474
|
-
if (scope.parent !== null && !scopeIds.has(scope.parent)) {
|
|
475
|
-
d.error('SCOPE_PARENT_UNRESOLVED', `scope "${scopeId}" parent "${scope.parent}" is not declared`, {
|
|
476
|
-
scope: scopeId,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
for (const scopeId of scopeIds) {
|
|
481
|
-
if (scopeChainHasCycle(scopes, scopeId)) {
|
|
482
|
-
d.error('SCOPE_CYCLE', `scope "${scopeId}" has a parent cycle`, { scope: scopeId });
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
/** 7b — every scope referenced by a node (increment or guard) is declared. */
|
|
487
|
-
function checkScopesDeclared(template, scopeIds, d) {
|
|
488
|
-
for (const node of Object.values(template.nodes)) {
|
|
489
|
-
for (const scopeRef of referencedScopes(node)) {
|
|
490
|
-
if (!scopeIds.has(scopeRef)) {
|
|
491
|
-
d.error('SCOPE_UNDECLARED', `node ${node.id} references undeclared scope "${scopeRef}"`, {
|
|
492
|
-
nodeId: node.id,
|
|
493
|
-
scope: scopeRef,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* 7c — a reset scope is a STRICT ancestor of every node that reads/increments it (§7/§12.7). The scope's
|
|
501
|
-
* region is its loop sub-graph: the node(s) that increment it + the guard node(s) that read it. A
|
|
502
|
-
* well-formed reader sits inside that loop — i.e. it shares a cycle with an increment site, or is
|
|
503
|
-
* forward-reachable from one (the cap-guard reads after a rework hop). A reader disconnected from
|
|
504
|
-
* every increment site is a cross-scope/out-of-scope reference; a scope read but never incremented
|
|
505
|
-
* can never advance (its cap is dead). Both are SCOPE_NOT_STRICT_ANCESTOR.
|
|
506
|
-
*/
|
|
507
|
-
function checkScopeStrictAncestry(template, scopeId, d) {
|
|
508
|
-
const incrementSites = nodesIncrementing(template, scopeId);
|
|
509
|
-
const readers = nodesReadingScope(template, scopeId);
|
|
510
|
-
if (incrementSites.length === 0 && readers.length > 0) {
|
|
511
|
-
for (const r of readers) {
|
|
512
|
-
d.error('SCOPE_NOT_STRICT_ANCESTOR', `scope "${scopeId}" is read by ${r} but never incremented`, {
|
|
513
|
-
nodeId: r,
|
|
514
|
-
scope: scopeId,
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
for (const reader of readers) {
|
|
520
|
-
const insideLoop = incrementSites.some((site) => sharesCycle(template, site, reader) || forwardReach(template, site).has(reader));
|
|
521
|
-
if (!insideLoop) {
|
|
522
|
-
d.error('SCOPE_NOT_STRICT_ANCESTOR', `scope "${scopeId}" reader ${reader} is outside its loop region`, {
|
|
523
|
-
nodeId: reader,
|
|
524
|
-
scope: scopeId,
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
/** 7d — a counter scope may not span a parallel/join boundary (v1). */
|
|
530
|
-
function checkScopeDoesNotSpanParallel(template, scopeId, d) {
|
|
531
|
-
const region = new Set([...nodesIncrementing(template, scopeId), ...nodesReadingScope(template, scopeId)]);
|
|
532
|
-
for (const id of region) {
|
|
533
|
-
const node = template.nodes[id];
|
|
534
|
-
if (node && (node.kind === 'parallel' || node.kind === 'join')) {
|
|
535
|
-
d.error('SCOPE_SPANS_PARALLEL', `scope "${scopeId}" spans a ${node.kind} boundary (${id})`, {
|
|
536
|
-
nodeId: id,
|
|
537
|
-
scope: scopeId,
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
function scopeChainHasCycle(scopes, start) {
|
|
543
|
-
let cursor = scopes[start]?.parent ?? null;
|
|
544
|
-
const seen = new Set([start]);
|
|
545
|
-
while (cursor !== null) {
|
|
546
|
-
if (seen.has(cursor))
|
|
547
|
-
return true;
|
|
548
|
-
seen.add(cursor);
|
|
549
|
-
cursor = scopes[cursor]?.parent ?? null;
|
|
550
|
-
}
|
|
551
|
-
return false;
|
|
552
|
-
}
|
|
553
|
-
function referencedScopes(node) {
|
|
554
|
-
const out = [];
|
|
555
|
-
if ('incrementCounters' in node && node.incrementCounters)
|
|
556
|
-
out.push(...node.incrementCounters);
|
|
557
|
-
for (const cond of guardConditionsOf(node))
|
|
558
|
-
collectCounterScopes(cond, out);
|
|
559
|
-
return out;
|
|
560
|
-
}
|
|
561
|
-
function collectCounterScopes(cond, out) {
|
|
562
|
-
switch (cond.op) {
|
|
563
|
-
case 'counter.lt':
|
|
564
|
-
case 'counter.gte':
|
|
565
|
-
out.push(cond.scope);
|
|
566
|
-
break;
|
|
567
|
-
case 'all':
|
|
568
|
-
case 'any':
|
|
569
|
-
cond.of.forEach((c) => collectCounterScopes(c, out));
|
|
570
|
-
break;
|
|
571
|
-
case 'not':
|
|
572
|
-
collectCounterScopes(cond.cond, out);
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
function nodesIncrementing(template, scopeId) {
|
|
577
|
-
return Object.values(template.nodes)
|
|
578
|
-
.filter((n) => 'incrementCounters' in n && (n.incrementCounters ?? []).includes(scopeId))
|
|
579
|
-
.map((n) => n.id);
|
|
580
|
-
}
|
|
581
|
-
function nodesReadingScope(template, scopeId) {
|
|
582
|
-
const out = [];
|
|
583
|
-
for (const node of Object.values(template.nodes)) {
|
|
584
|
-
for (const cond of guardConditionsOf(node)) {
|
|
585
|
-
const scopes = [];
|
|
586
|
-
collectCounterScopes(cond, scopes);
|
|
587
|
-
if (scopes.includes(scopeId)) {
|
|
588
|
-
out.push(node.id);
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
return out;
|
|
594
|
-
}
|
|
595
|
-
function sharesCycle(template, a, b) {
|
|
596
|
-
return forwardReach(template, a).has(b) && forwardReach(template, b).has(a);
|
|
597
|
-
}
|
|
598
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
599
|
-
// Rule 8 — parallel/join well-formedness.
|
|
600
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
601
|
-
function ruleParallelJoin(template, d) {
|
|
602
|
-
const nodes = template.nodes;
|
|
603
|
-
const parallels = Object.values(nodes).filter((n) => n.kind === 'parallel');
|
|
604
|
-
const joinUsedBy = new Map();
|
|
605
|
-
for (const par of parallels)
|
|
606
|
-
checkParallelAgainstJoin(template, par, joinUsedBy, d);
|
|
607
|
-
for (const [joinId, owners] of joinUsedBy) {
|
|
608
|
-
if (owners.length > 1) {
|
|
609
|
-
d.error('JOIN_MULTIPLE_PARALLELS', `join "${joinId}" is the target of ${owners.length} parallels`, {
|
|
610
|
-
nodeId: joinId,
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
/** Validate one parallel against its declared join (resolution, kind, quorum, membership, merge). */
|
|
616
|
-
function checkParallelAgainstJoin(template, par, joinUsedBy, d) {
|
|
617
|
-
const join = template.nodes[par.join];
|
|
618
|
-
if (!join) {
|
|
619
|
-
d.error('PARALLEL_JOIN_UNRESOLVED', `parallel ${par.id} join "${par.join}" does not resolve`, { nodeId: par.id });
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
if (join.kind !== 'join') {
|
|
623
|
-
d.error('PARALLEL_JOIN_KIND', `parallel ${par.id} join "${par.join}" is a ${join.kind}, not a join`, {
|
|
624
|
-
nodeId: par.id,
|
|
625
|
-
});
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
(joinUsedBy.get(par.join) ?? joinUsedBy.set(par.join, []).get(par.join)).push(par.id);
|
|
629
|
-
checkQuorumBound(par, join, d);
|
|
630
|
-
// Branch membership + cross-branch goto + all-reachability.
|
|
631
|
-
checkBranchMembership(template, par, join.id, d);
|
|
632
|
-
// Multi-writer fields need a merge reducer.
|
|
633
|
-
checkMergeReducers(template, par, join, d);
|
|
634
|
-
checkRejectedMergeReducers(join, d);
|
|
635
|
-
}
|
|
636
|
-
/** Quorum K must satisfy 1 ≤ K ≤ N (branch count). */
|
|
637
|
-
function checkQuorumBound(par, join, d) {
|
|
638
|
-
if (join.joinMode.kind !== 'quorum')
|
|
639
|
-
return;
|
|
640
|
-
const K = join.joinMode.count;
|
|
641
|
-
if (!Number.isInteger(K) || K < 1 || K > par.branches.length) {
|
|
642
|
-
d.error('QUORUM_K_GT_N', `join ${join.id} quorum K=${K} but parallel has ${par.branches.length} branches`, {
|
|
643
|
-
nodeId: join.id,
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
/** Reject any merge reducer outside the allowed set (defensive; the type forbids it but data may carry it). */
|
|
648
|
-
function checkRejectedMergeReducers(join, d) {
|
|
649
|
-
for (const [field, reducer] of Object.entries(join.merge ?? {})) {
|
|
650
|
-
if (!MERGE_REDUCERS.includes(reducer)) {
|
|
651
|
-
d.error('MERGE_LASTWRITE_REJECTED', `join ${join.id} merge.${field} = "${reducer}" is not allowed`, {
|
|
652
|
-
nodeId: join.id,
|
|
653
|
-
path: `merge.${field}`,
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
function checkBranchMembership(template, par, joinId, d) {
|
|
659
|
-
const memberOf = buildBranchMembership(template, par, joinId, d);
|
|
660
|
-
checkCrossBranchGotos(template, memberOf, joinId, d);
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* Assign each branch sub-graph node to its owning branch (flagging shared membership) and, for an
|
|
664
|
-
* `all` join, flag any branch that cannot reach the join. Returns the node→branch ownership map.
|
|
665
|
-
*/
|
|
666
|
-
function buildBranchMembership(template, par, joinId, d) {
|
|
667
|
-
const memberOf = new Map();
|
|
668
|
-
for (const branch of par.branches) {
|
|
669
|
-
for (const m of branchSubgraph(template, branch.entry, joinId)) {
|
|
670
|
-
if (memberOf.has(m) && memberOf.get(m) !== branch.id) {
|
|
671
|
-
d.error('BRANCH_MEMBERSHIP', `node ${m} is a member of branches "${memberOf.get(m)}" and "${branch.id}"`, {
|
|
672
|
-
nodeId: m,
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
memberOf.set(m, branch.id);
|
|
676
|
-
}
|
|
677
|
-
checkAllJoinReachable(template, branch, joinId, d);
|
|
678
|
-
}
|
|
679
|
-
return memberOf;
|
|
680
|
-
}
|
|
681
|
-
/** all-reachability: an `all`-join branch must be able to reach the join (no deadlock). */
|
|
682
|
-
function checkAllJoinReachable(template, branch, joinId, d) {
|
|
683
|
-
const join = template.nodes[joinId];
|
|
684
|
-
if (join?.kind !== 'join' || join.joinMode.kind !== 'all')
|
|
685
|
-
return;
|
|
686
|
-
if (!forwardReach(template, branch.entry).has(joinId)) {
|
|
687
|
-
d.error('JOIN_UNREACHABLE_BRANCH', `branch "${branch.id}" (all) cannot reach join ${joinId}`, {
|
|
688
|
-
nodeId: branch.entry,
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
/** Cross-branch goto: a branch member may only goto within its own branch sub-graph or to the join. */
|
|
693
|
-
function checkCrossBranchGotos(template, memberOf, joinId, d) {
|
|
694
|
-
for (const [member, branchId] of memberOf) {
|
|
695
|
-
const node = template.nodes[member];
|
|
696
|
-
if (!node)
|
|
697
|
-
continue;
|
|
698
|
-
for (const [path, target] of structuralEdges(node)) {
|
|
699
|
-
if (target === joinId)
|
|
700
|
-
continue;
|
|
701
|
-
const targetBranch = memberOf.get(target);
|
|
702
|
-
if (targetBranch !== undefined && targetBranch !== branchId) {
|
|
703
|
-
d.error('BRANCH_CROSS_GOTO', `node ${member} (branch ${branchId}) goto ${target} crosses into branch ${targetBranch}`, {
|
|
704
|
-
nodeId: member,
|
|
705
|
-
path,
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
/** Nodes reachable from a branch `entry`, stopping AT the join (the join itself is not a member). */
|
|
712
|
-
function branchSubgraph(template, entry, joinId) {
|
|
713
|
-
const seen = new Set();
|
|
714
|
-
const stack = [entry];
|
|
715
|
-
while (stack.length) {
|
|
716
|
-
const id = stack.pop();
|
|
717
|
-
if (id === joinId || seen.has(id))
|
|
718
|
-
continue;
|
|
719
|
-
seen.add(id);
|
|
720
|
-
const node = template.nodes[id];
|
|
721
|
-
if (node)
|
|
722
|
-
for (const [, t] of structuralEdges(node))
|
|
723
|
-
if (t !== joinId && !seen.has(t))
|
|
724
|
-
stack.push(t);
|
|
725
|
-
}
|
|
726
|
-
return seen;
|
|
727
|
-
}
|
|
728
|
-
function checkMergeReducers(template, par, join, d) {
|
|
729
|
-
// v1 cannot statically know a node's written fields without a resultSchema model, so we use a
|
|
730
|
-
// conservative proxy: a parallel fanning out ≥2 effect (agent/script) branches writes results from
|
|
731
|
-
// >1 branch; absent a `merge` reducer those fields are unguarded (§4/§12.8). A real per-field model
|
|
732
|
-
// lands with native Revisium typing; until then the entry-kind heuristic surfaces the hazard.
|
|
733
|
-
if (par.branches.length < 2)
|
|
734
|
-
return;
|
|
735
|
-
if (Object.keys(join.merge ?? {}).length > 0)
|
|
736
|
-
return;
|
|
737
|
-
const writers = par.branches.filter((b) => {
|
|
738
|
-
const entry = template.nodes[b.entry];
|
|
739
|
-
return entry?.kind === 'agent' || entry?.kind === 'script';
|
|
740
|
-
});
|
|
741
|
-
if (writers.length >= 2) {
|
|
742
|
-
d.error('MERGE_MISSING', `join ${join.id} has ${writers.length} writer branches but no merge reducer`, {
|
|
743
|
-
nodeId: join.id,
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
748
|
-
// Rule 9 — verdict-vocabulary closure.
|
|
749
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
750
|
-
function ruleVerdictClosure(template, d) {
|
|
751
|
-
const domain = template.verdicts?.domain ?? [];
|
|
752
|
-
const domainSet = new Set(domain);
|
|
753
|
-
const core = new Set(CORE_VERDICTS);
|
|
754
|
-
// 9a no domain label shadows a core label.
|
|
755
|
-
for (const label of domain) {
|
|
756
|
-
if (core.has(label)) {
|
|
757
|
-
d.error('VERDICT_DOMAIN_SHADOWS_CORE', `domain verdict "${label}" shadows a core verdict`);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
const used = new Set();
|
|
761
|
-
for (const node of Object.values(template.nodes)) {
|
|
762
|
-
checkGuardVerdictLabels(node, core, domainSet, used, d); // 9b verdict.* guard labels ∈ domain
|
|
763
|
-
checkGateOutcomesSubset(node, domainSet, used, d); // 9c humanGate.outcomes ⊆ domain
|
|
764
|
-
}
|
|
765
|
-
// 9d declared-but-unused (warning).
|
|
766
|
-
for (const label of domain) {
|
|
767
|
-
if (!used.has(label))
|
|
768
|
-
d.warn('VERDICT_DECLARED_UNUSED', `domain verdict "${label}" is declared but never used`);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
/** 9b — every verdict.* guard label on a node is a declared domain label (a core label is a hard error). */
|
|
772
|
-
function checkGuardVerdictLabels(node, core, domainSet, used, d) {
|
|
773
|
-
for (const cond of guardConditionsOf(node)) {
|
|
774
|
-
collectVerdictLabels(cond, (label) => {
|
|
775
|
-
used.add(label);
|
|
776
|
-
if (core.has(label)) {
|
|
777
|
-
d.error('VERDICT_CORE_IN_GUARD', `node ${node.id} guard uses core verdict "${label}" (route it structurally)`, {
|
|
778
|
-
nodeId: node.id,
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
else if (!domainSet.has(label)) {
|
|
782
|
-
d.error('VERDICT_UNDECLARED', `node ${node.id} guard uses undeclared verdict "${label}"`, {
|
|
783
|
-
nodeId: node.id,
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
/** 9c — a humanGate's declared outcomes must all be declared domain labels. */
|
|
790
|
-
function checkGateOutcomesSubset(node, domainSet, used, d) {
|
|
791
|
-
if (node.kind !== 'humanGate')
|
|
792
|
-
return;
|
|
793
|
-
for (const o of node.outcomes) {
|
|
794
|
-
used.add(o);
|
|
795
|
-
if (!domainSet.has(o)) {
|
|
796
|
-
d.error('GATE_OUTCOME_NOT_SUBSET', `gate ${node.id} outcome "${o}" is not in verdicts.domain`, {
|
|
797
|
-
nodeId: node.id,
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
function collectVerdictLabels(cond, visit) {
|
|
803
|
-
switch (cond.op) {
|
|
804
|
-
case 'verdict.eq':
|
|
805
|
-
visit(cond.value);
|
|
806
|
-
break;
|
|
807
|
-
case 'verdict.in':
|
|
808
|
-
cond.value.forEach(visit);
|
|
809
|
-
break;
|
|
810
|
-
case 'all':
|
|
811
|
-
case 'any':
|
|
812
|
-
cond.of.forEach((c) => collectVerdictLabels(c, visit));
|
|
813
|
-
break;
|
|
814
|
-
case 'not':
|
|
815
|
-
collectVerdictLabels(cond.cond, visit);
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
820
|
-
// Rule 10 — conflict-matrix.
|
|
821
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
822
|
-
function ruleConflictMatrix(template, d) {
|
|
823
|
-
const conflicts = template.policy?.conflicts ?? [];
|
|
824
|
-
if (conflicts.length === 0)
|
|
825
|
-
return;
|
|
826
|
-
// Map each role to the node ids that bind it (agent.roleRef = "role:<name>").
|
|
827
|
-
const roleNodes = new Map();
|
|
828
|
-
for (const node of Object.values(template.nodes)) {
|
|
829
|
-
if (node.kind === 'agent') {
|
|
830
|
-
const role = roleName(node.roleRef);
|
|
831
|
-
if (role)
|
|
832
|
-
(roleNodes.get(role) ?? roleNodes.set(role, []).get(role)).push(node.id);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
for (const pair of conflicts) {
|
|
836
|
-
if (!Array.isArray(pair) || pair.length !== 2 || pair.some((r) => typeof r !== 'string')) {
|
|
837
|
-
d.error('CONFLICT_REF_INVALID', `policy.conflicts entry ${JSON.stringify(pair)} is not a [roleA, roleB] pair`);
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
const [a, b] = pair;
|
|
841
|
-
// A conflict is well-formed only if both roles are actually bound by some node.
|
|
842
|
-
if (!roleNodes.has(a) || !roleNodes.has(b)) {
|
|
843
|
-
d.warn('CONFLICT_REF_INVALID', `policy.conflicts [${a}, ${b}] references a role no node binds`);
|
|
844
|
-
}
|
|
845
|
-
// v1 binds a role to a node, not to a concrete actor; a path where ONE actor fills both roles
|
|
846
|
-
// is detectable only with actor assignment (out of v1 template data). We surface the *structural*
|
|
847
|
-
// hazard: the same node is bound to both conflicting roles (impossible via roleRef, but a future
|
|
848
|
-
// multi-role node would trip it) — kept as a placeholder check so the rule has a code + a test.
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
function roleName(roleRef) {
|
|
852
|
-
const m = /^role:(.+)$/.exec(roleRef);
|
|
853
|
-
return m ? m[1] : undefined;
|
|
854
|
-
}
|
|
855
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
856
|
-
// Rule 12 — capability-ref shape.
|
|
857
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
858
|
-
function ruleCapabilityRefs(template, d) {
|
|
859
|
-
for (const node of Object.values(template.nodes)) {
|
|
860
|
-
if (node.kind === 'agent') {
|
|
861
|
-
if (!CAPABILITY_REF_PATTERN.test(node.roleRef) || !node.roleRef.startsWith('role:')) {
|
|
862
|
-
d.error('CAPABILITY_REF_SHAPE', `agent ${node.id} roleRef "${node.roleRef}" is malformed`, {
|
|
863
|
-
nodeId: node.id,
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
else if (node.kind === 'script') {
|
|
868
|
-
if (!CAPABILITY_REF_PATTERN.test(node.scriptRef) || !node.scriptRef.startsWith('script:')) {
|
|
869
|
-
d.error('CAPABILITY_REF_SHAPE', `script ${node.id} scriptRef "${node.scriptRef}" is malformed`, {
|
|
870
|
-
nodeId: node.id,
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
877
|
-
// Shared — collect every guard Condition reachable on a node.
|
|
878
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
879
|
-
function guardConditionsOf(node) {
|
|
880
|
-
if (node.kind !== 'choice' && node.kind !== 'humanGate')
|
|
881
|
-
return [];
|
|
882
|
-
return node.branches.filter(isGuardedBranch).map((b) => b.when);
|
|
883
|
-
}
|
|
884
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
885
|
-
// Rule 14 — dataflow (produces/consumes, 0016 §7). STATIC only: the adapter persists/hydrates at runtime
|
|
886
|
-
// (0016 §5/§6); the core neither stores content nor reads it. Dominance proves a producer ran (presence);
|
|
887
|
-
// the runtime `revo.InputMissing` guard + the freshness rule cover what static analysis cannot.
|
|
888
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
889
|
-
function ruleDataflow(template, ids, d) {
|
|
890
|
-
const nodes = template.nodes;
|
|
891
|
-
const hasEntry = !!template.entry && ids.has(template.entry);
|
|
892
|
-
const dom = hasEntry ? dominators(template, template.entry) : new Map();
|
|
893
|
-
const membership = branchMembership(template);
|
|
894
|
-
const cyclesByNode = consumerStaleCycles(template);
|
|
895
|
-
// PRODUCES_NAME_DUP — warning: the grammar keys consumes by NODE, so a duplicate name is a clarity
|
|
896
|
-
// guard, not a resolution bug.
|
|
897
|
-
const producedNames = new Map();
|
|
898
|
-
for (const node of Object.values(nodes)) {
|
|
899
|
-
if ((node.kind === 'agent' || node.kind === 'script') && node.produces) {
|
|
900
|
-
const prev = producedNames.get(node.produces.name);
|
|
901
|
-
if (prev && prev !== node.id) {
|
|
902
|
-
d.warn('PRODUCES_NAME_DUP', `nodes "${prev}" and "${node.id}" both produce "${node.produces.name}"`, {
|
|
903
|
-
nodeId: node.id,
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
else if (!prev)
|
|
907
|
-
producedNames.set(node.produces.name, node.id);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
for (const node of Object.values(nodes)) {
|
|
911
|
-
if (node.kind !== 'agent' && node.kind !== 'script')
|
|
912
|
-
continue;
|
|
913
|
-
const consumes = node.consumes ?? [];
|
|
914
|
-
const seenAs = new Set();
|
|
915
|
-
for (let i = 0; i < consumes.length; i++) {
|
|
916
|
-
const ref = consumes[i];
|
|
917
|
-
const path = `consumes[${i}]`;
|
|
918
|
-
if (seenAs.has(ref.as)) {
|
|
919
|
-
d.error('CONSUMES_AS_DUP', `node "${node.id}" consumes two inputs as "${ref.as}"`, { nodeId: node.id, path });
|
|
920
|
-
}
|
|
921
|
-
else
|
|
922
|
-
seenAs.add(ref.as);
|
|
923
|
-
const producer = nodes[ref.node];
|
|
924
|
-
if (!producer) {
|
|
925
|
-
d.error('CONSUMES_NODE_UNRESOLVED', `node "${node.id}" consumes from unknown node "${ref.node}"`, {
|
|
926
|
-
nodeId: node.id,
|
|
927
|
-
path,
|
|
928
|
-
});
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
931
|
-
const canProduce = (producer.kind === 'agent' || producer.kind === 'script') && !!producer.produces;
|
|
932
|
-
if (!canProduce) {
|
|
933
|
-
d.error('CONSUMES_PRODUCER_MISSING', `node "${node.id}" consumes from "${ref.node}" which declares no produces`, {
|
|
934
|
-
nodeId: node.id,
|
|
935
|
-
path,
|
|
936
|
-
});
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
// Dominance: the producer must run before the consumer on EVERY path (entry can never be dominated
|
|
940
|
-
// by another node → a consuming entry node is always flagged here).
|
|
941
|
-
const dominated = hasEntry && ref.node !== node.id && dom.get(node.id)?.has(ref.node) === true;
|
|
942
|
-
if (!dominated) {
|
|
943
|
-
const msg = `producer "${ref.node}" does not run before "${node.id}" on every path`;
|
|
944
|
-
if (ref.optional === true)
|
|
945
|
-
d.warn('CONSUMES_NOT_DOMINATED', msg, { nodeId: node.id, path });
|
|
946
|
-
else
|
|
947
|
-
d.error('CONSUMES_NOT_DOMINATED', msg, { nodeId: node.id, path });
|
|
948
|
-
}
|
|
949
|
-
// Freshness: a consumer inside a loop the producer is NOT on can silently reuse a stale output.
|
|
950
|
-
const iteration = ref.iteration ?? 'latest';
|
|
951
|
-
if (iteration === 'latest' && ref.staleOk !== true) {
|
|
952
|
-
const risky = (cyclesByNode.get(node.id) ?? []).some((cycle) => !cycle.has(ref.node));
|
|
953
|
-
if (risky) {
|
|
954
|
-
d.warn('CONSUMES_STALE_RISK', `node "${node.id}" can re-enter a loop without re-running producer "${ref.node}" (iteration:'latest') — set staleOk or use iteration:'all'/N`, { nodeId: node.id, path });
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
// Cross-parallel: consuming a sibling branch's output is unsafe (the branch may be cancelled).
|
|
958
|
-
const consumerBranch = membership.get(node.id);
|
|
959
|
-
const producerBranch = membership.get(ref.node);
|
|
960
|
-
if (consumerBranch &&
|
|
961
|
-
producerBranch &&
|
|
962
|
-
consumerBranch.parallel === producerBranch.parallel &&
|
|
963
|
-
consumerBranch.branch !== producerBranch.branch) {
|
|
964
|
-
d.error('CONSUMES_CROSS_PARALLEL_UNSAFE', `node "${node.id}" consumes "${ref.node}" from a sibling parallel branch`, {
|
|
965
|
-
nodeId: node.id,
|
|
966
|
-
path,
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
/** Standard iterative dominator sets over STRUCTURAL edges from `entry`. dom(entry) = {entry}. */
|
|
973
|
-
function dominators(template, entry) {
|
|
974
|
-
const reachable = [...reachableFrom(template, entry)].filter((id) => template.nodes[id]);
|
|
975
|
-
const preds = new Map();
|
|
976
|
-
for (const id of reachable)
|
|
977
|
-
preds.set(id, []);
|
|
978
|
-
for (const id of reachable) {
|
|
979
|
-
for (const [, t] of structuralEdges(template.nodes[id]))
|
|
980
|
-
if (preds.has(t))
|
|
981
|
-
preds.get(t).push(id);
|
|
982
|
-
}
|
|
983
|
-
const all = new Set(reachable);
|
|
984
|
-
const dom = new Map();
|
|
985
|
-
for (const id of reachable)
|
|
986
|
-
dom.set(id, new Set(all));
|
|
987
|
-
dom.set(entry, new Set([entry]));
|
|
988
|
-
let changed = true;
|
|
989
|
-
while (changed) {
|
|
990
|
-
changed = false;
|
|
991
|
-
for (const id of reachable) {
|
|
992
|
-
if (id === entry)
|
|
993
|
-
continue;
|
|
994
|
-
const ps = preds.get(id);
|
|
995
|
-
let next;
|
|
996
|
-
if (ps.length === 0)
|
|
997
|
-
next = new Set([id]);
|
|
998
|
-
else {
|
|
999
|
-
next = new Set(dom.get(ps[0]));
|
|
1000
|
-
for (let i = 1; i < ps.length; i++) {
|
|
1001
|
-
const dp = dom.get(ps[i]);
|
|
1002
|
-
for (const x of [...next])
|
|
1003
|
-
if (!dp.has(x))
|
|
1004
|
-
next.delete(x);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
next.add(id);
|
|
1008
|
-
const old = dom.get(id);
|
|
1009
|
-
if (old.size !== next.size || [...next].some((x) => !old.has(x))) {
|
|
1010
|
-
dom.set(id, next);
|
|
1011
|
-
changed = true;
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
return dom;
|
|
1016
|
-
}
|
|
1017
|
-
/** For each node, the cycles (node-sets) it sits on — feeds the loop-freshness check (CONSUMES_STALE_RISK). */
|
|
1018
|
-
function consumerStaleCycles(template) {
|
|
1019
|
-
const out = new Map();
|
|
1020
|
-
for (const { from, to } of findBackEdges(template)) {
|
|
1021
|
-
const cycle = cycleNodes(template, to, from);
|
|
1022
|
-
for (const id of cycle) {
|
|
1023
|
-
const list = out.get(id) ?? [];
|
|
1024
|
-
list.push(cycle);
|
|
1025
|
-
out.set(id, list);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
return out;
|
|
1029
|
-
}
|
|
1030
|
-
/** Map each node to its (parallel, branch) membership, if any — for cross-branch dataflow safety. */
|
|
1031
|
-
function branchMembership(template) {
|
|
1032
|
-
const out = new Map();
|
|
1033
|
-
for (const par of Object.values(template.nodes)) {
|
|
1034
|
-
if (par.kind !== 'parallel')
|
|
1035
|
-
continue;
|
|
1036
|
-
for (const br of par.branches) {
|
|
1037
|
-
for (const id of branchSubgraph(template, br.entry, par.join))
|
|
1038
|
-
out.set(id, { parallel: par.id, branch: br.id });
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
return out;
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Classify the change from `old` → `next` (§12.13). The enabler for a FUTURE in-flight migration; v1
|
|
1045
|
-
* only reports.
|
|
1046
|
-
* - node-id delete / rename / kind-change → breaking
|
|
1047
|
-
* - changing the outgoing topology of an existing node → breaking
|
|
1048
|
-
* - reusing a deleted id with a different kind/resultSchema → invalid
|
|
1049
|
-
* - displayName / prompt / payload changes → safe
|
|
1050
|
-
* - ANY field/path not explicitly classified → breaking (conservative) + DIFF_UNCLASSIFIED
|
|
1051
|
-
*/
|
|
1052
|
-
export function classifyTemplateDiff(old, next) {
|
|
1053
|
-
const acc = newDiffAccumulator();
|
|
1054
|
-
const oldNodes = old.nodes ?? {};
|
|
1055
|
-
const nextNodes = next.nodes ?? {};
|
|
1056
|
-
const oldIds = new Set(Object.keys(oldNodes));
|
|
1057
|
-
const nextIds = new Set(Object.keys(nextNodes));
|
|
1058
|
-
// Deleted ids → breaking.
|
|
1059
|
-
for (const id of oldIds) {
|
|
1060
|
-
if (!nextIds.has(id)) {
|
|
1061
|
-
acc.escalate('breaking');
|
|
1062
|
-
acc.diagnostics.push({ code: 'DIFF_NODE_DELETED', severity: 'error', message: `node "${id}" was deleted`, nodeId: id });
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
for (const id of nextIds) {
|
|
1066
|
-
const before = oldNodes[id];
|
|
1067
|
-
// A brand-new node id is additive → safe (a new branch/path); reusing a PREVIOUSLY-deleted id is
|
|
1068
|
-
// not observable here (single old/next pair). Adding a node is classified safe.
|
|
1069
|
-
if (before)
|
|
1070
|
-
classifyExistingNodeChange(id, before, nextNodes[id], acc);
|
|
1071
|
-
}
|
|
1072
|
-
classifyTopLevelDiff(old, next, acc);
|
|
1073
|
-
return { kind: acc.kind, diagnostics: acc.diagnostics };
|
|
1074
|
-
}
|
|
1075
|
-
function newDiffAccumulator() {
|
|
1076
|
-
const acc = {
|
|
1077
|
-
diagnostics: [],
|
|
1078
|
-
kind: 'safe',
|
|
1079
|
-
escalate: (to) => {
|
|
1080
|
-
if (to === 'invalid')
|
|
1081
|
-
acc.kind = 'invalid';
|
|
1082
|
-
else if (to === 'breaking' && acc.kind !== 'invalid')
|
|
1083
|
-
acc.kind = 'breaking';
|
|
1084
|
-
},
|
|
1085
|
-
};
|
|
1086
|
-
return acc;
|
|
1087
|
-
}
|
|
1088
|
-
/** Classify the change of a node id present in BOTH templates (kind / schema / topology / fields). */
|
|
1089
|
-
function classifyExistingNodeChange(id, before, after, acc) {
|
|
1090
|
-
// kind change → breaking; an incompatible kind/resultSchema reuse → invalid.
|
|
1091
|
-
if (before.kind !== after.kind) {
|
|
1092
|
-
acc.escalate('invalid'); // a reused id with a different kind is invalid (§12.13)
|
|
1093
|
-
acc.diagnostics.push({
|
|
1094
|
-
code: 'DIFF_ID_REUSED_INCOMPATIBLE',
|
|
1095
|
-
severity: 'error',
|
|
1096
|
-
message: `node "${id}" kind changed ${before.kind} → ${after.kind} (id reuse with different kind)`,
|
|
1097
|
-
nodeId: id,
|
|
1098
|
-
});
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
// resultSchema change on a same-kind effect node → invalid (id reuse with different contract).
|
|
1102
|
-
const beforeSchema = before.resultSchema;
|
|
1103
|
-
const afterSchema = after.resultSchema;
|
|
1104
|
-
if (beforeSchema !== afterSchema) {
|
|
1105
|
-
acc.escalate('invalid');
|
|
1106
|
-
acc.diagnostics.push({
|
|
1107
|
-
code: 'DIFF_ID_REUSED_INCOMPATIBLE',
|
|
1108
|
-
severity: 'error',
|
|
1109
|
-
message: `node "${id}" resultSchema changed "${beforeSchema}" → "${afterSchema}"`,
|
|
1110
|
-
nodeId: id,
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
// Outgoing topology change → breaking.
|
|
1114
|
-
if (!sameTopology(before, after)) {
|
|
1115
|
-
acc.escalate('breaking');
|
|
1116
|
-
acc.diagnostics.push({
|
|
1117
|
-
code: 'DIFF_NODE_TOPOLOGY_CHANGED',
|
|
1118
|
-
severity: 'error',
|
|
1119
|
-
message: `node "${id}" outgoing topology changed`,
|
|
1120
|
-
nodeId: id,
|
|
1121
|
-
});
|
|
1122
|
-
}
|
|
1123
|
-
// Any non-safe-classified, non-topology field change defaults to breaking + DIFF_UNCLASSIFIED.
|
|
1124
|
-
const unclassified = unclassifiedFieldChange(before, after);
|
|
1125
|
-
if (unclassified) {
|
|
1126
|
-
acc.escalate('breaking');
|
|
1127
|
-
acc.diagnostics.push({
|
|
1128
|
-
code: 'DIFF_UNCLASSIFIED',
|
|
1129
|
-
severity: 'error',
|
|
1130
|
-
message: `node "${id}" has an unclassified field change in {${unclassified}} (defaulting to breaking)`,
|
|
1131
|
-
nodeId: id,
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
/** Top-level structural changes (entry / scopes) default to breaking if changed. */
|
|
1136
|
-
function classifyTopLevelDiff(old, next, acc) {
|
|
1137
|
-
if (old.entry !== next.entry) {
|
|
1138
|
-
acc.escalate('breaking');
|
|
1139
|
-
acc.diagnostics.push({ code: 'DIFF_NODE_TOPOLOGY_CHANGED', severity: 'error', message: `entry changed ${old.entry} → ${next.entry}` });
|
|
1140
|
-
}
|
|
1141
|
-
if (JSON.stringify(old.scopes ?? {}) !== JSON.stringify(next.scopes ?? {})) {
|
|
1142
|
-
acc.escalate('breaking');
|
|
1143
|
-
acc.diagnostics.push({ code: 'DIFF_UNCLASSIFIED', severity: 'error', message: `scopes changed (defaulting to breaking)` });
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
/** Two nodes have the same outgoing topology iff their ordered edge `[path,target]` sets match. */
|
|
1147
|
-
function sameTopology(a, b) {
|
|
1148
|
-
const ea = JSON.stringify(orderedEdges(a));
|
|
1149
|
-
const eb = JSON.stringify(orderedEdges(b));
|
|
1150
|
-
return ea === eb;
|
|
1151
|
-
}
|
|
1152
|
-
function orderedEdges(node) {
|
|
1153
|
-
return outgoingEdges(node)
|
|
1154
|
-
.slice()
|
|
1155
|
-
.sort((x, y) => comparePath(x[0], y[0]));
|
|
1156
|
-
}
|
|
1157
|
-
/** Stable lexical comparison of two edge paths (sort comparator: -1 / 0 / 1). */
|
|
1158
|
-
function comparePath(a, b) {
|
|
1159
|
-
if (a < b)
|
|
1160
|
-
return -1;
|
|
1161
|
-
if (a > b)
|
|
1162
|
-
return 1;
|
|
1163
|
-
return 0;
|
|
1164
|
-
}
|
|
1165
|
-
/**
|
|
1166
|
-
* Detect a field change that is NOT one of the explicitly-SAFE fields (displayName, reason/prompt-like
|
|
1167
|
-
* text, payload `input`) and NOT topology (handled separately). Returns a comma-list of changed
|
|
1168
|
-
* unclassified keys, or '' if every change is safe. Guards (`branches.when`, `joinMode`, `merge`,
|
|
1169
|
-
* `onFailure`, `incrementCounters`, `outcomes`, `timeout.after`) are NOT in the safe set → breaking.
|
|
1170
|
-
*/
|
|
1171
|
-
function unclassifiedFieldChange(before, after) {
|
|
1172
|
-
const SAFE_KEYS = new Set(['id', 'displayName', 'reason']);
|
|
1173
|
-
const changed = [];
|
|
1174
|
-
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
1175
|
-
for (const key of keys) {
|
|
1176
|
-
if (SAFE_KEYS.has(key))
|
|
1177
|
-
continue;
|
|
1178
|
-
if (isTopologyKey(key))
|
|
1179
|
-
continue; // topology handled by sameTopology
|
|
1180
|
-
if (key === 'resultSchema')
|
|
1181
|
-
continue; // handled above (invalid)
|
|
1182
|
-
const bv = JSON.stringify(before[key]);
|
|
1183
|
-
const av = JSON.stringify(after[key]);
|
|
1184
|
-
if (bv !== av)
|
|
1185
|
-
changed.push(key);
|
|
1186
|
-
}
|
|
1187
|
-
return changed.join(', ');
|
|
1188
|
-
}
|
|
1189
|
-
function isTopologyKey(key) {
|
|
1190
|
-
return key === 'next' || key === 'branches' || key === 'catch' || key === 'escalateTo' || key === 'join';
|
|
1191
|
-
}
|
|
1192
119
|
//# sourceMappingURL=validate.js.map
|