@ikon85/agent-workflow-kit 0.44.2 → 0.45.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/.agents/skills/audit-skills/SKILL.md +7 -4
- package/.agents/skills/code-review/SKILL.md +7 -4
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
- package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
- package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
- package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
- package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
- package/.agents/skills/research/SKILL.md +7 -4
- package/.agents/skills/to-issues/SKILL.md +25 -4
- package/.claude/skills/audit-skills/SKILL.md +7 -4
- package/.claude/skills/code-review/SKILL.md +7 -4
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
- package/.claude/skills/codex-build/SKILL.md +13 -0
- package/.claude/skills/codex-review/SKILL.md +13 -0
- package/.claude/skills/grill-me-codex/SKILL.md +14 -0
- package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
- package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
- package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
- package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
- package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
- package/.claude/skills/research/SKILL.md +7 -4
- package/.claude/skills/skill-manifest.json +34 -23
- package/.claude/skills/to-issues/SKILL.md +25 -4
- package/README.md +64 -0
- package/agent-workflow-kit.package.json +149 -45
- package/package.json +1 -1
- package/scripts/doctrine-migration/index.mjs +296 -0
- package/scripts/kit-release.mjs +41 -9
- package/src/cli.mjs +515 -79
- package/src/commands/routing-status.mjs +288 -0
- package/src/lib/bundle.mjs +158 -2
- package/src/lib/dispatchJournal.mjs +300 -0
- package/src/lib/dispatchPlan.mjs +286 -0
- package/src/lib/dispatchReceipt.mjs +226 -89
- package/src/lib/frontendWorkloads.mjs +35 -33
- package/src/lib/routeDispatcher.mjs +367 -70
- package/src/lib/routingAccessGraph.mjs +265 -20
- package/src/lib/routingAccessGraphStore.mjs +300 -0
- package/src/lib/routingAdapters/claude.mjs +104 -4
- package/src/lib/routingAdapters/codex.mjs +132 -7
- package/src/lib/routingAdapters/hostBridge.mjs +291 -0
- package/src/lib/routingCatalog.mjs +201 -24
- package/src/lib/routingDispatchLease.mjs +253 -0
- package/src/lib/routingEvidenceCache.mjs +78 -0
- package/src/lib/routingIntent.mjs +176 -10
- package/src/lib/routingIntentClassifier.mjs +137 -0
- package/src/lib/routingInventory/snapshots/claude.json +49 -0
- package/src/lib/routingInventory/snapshots/codex.json +109 -0
- package/src/lib/routingInventory.mjs +182 -0
- package/src/lib/routingPolicy.mjs +193 -6
- package/src/lib/routingProfile.mjs +1251 -123
- package/src/lib/routingProfilePolicy.mjs +156 -0
- package/src/lib/routingProfileStorage.mjs +299 -0
- package/src/lib/routingResolver.mjs +369 -86
- package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
- package/src/lib/routingSources/benchlm.mjs +5 -0
- package/src/lib/routingSources/codeArena.mjs +13 -3
- package/src/lib/routingSources/deepswe.mjs +19 -5
- package/src/lib/routingSources/openhands.mjs +17 -4
- package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
- package/src/lib/safeText.mjs +26 -0
- package/src/lib/updateCandidate.mjs +36 -3
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dispatch journal — the durable, owner-only record of what a dispatch was
|
|
3
|
+
* about to do and what became of it.
|
|
4
|
+
*
|
|
5
|
+
* A receipt proves what ran; the journal is what makes a *crash* provable. The
|
|
6
|
+
* `prepared` entry is written before the spawn and its terminal entry after, so
|
|
7
|
+
* a process that dies in between leaves an entry that is neither dispatched nor
|
|
8
|
+
* blocked. That entry is indeterminate: the agent may or may not have been
|
|
9
|
+
* started, and nothing on disk can tell the difference.
|
|
10
|
+
*
|
|
11
|
+
* How an indeterminate entry resolves is a property of the surface, not a
|
|
12
|
+
* preference:
|
|
13
|
+
*
|
|
14
|
+
* - **Claude** has a caller-assignable spawn id (`claude --session-id <uuid>`),
|
|
15
|
+
* assigned before the spawn and refused loudly on reuse. A retry against that
|
|
16
|
+
* id therefore cannot start a second agent. The id is scoped per
|
|
17
|
+
* `(cwd, session-id)` — the same uuid re-run from another working directory
|
|
18
|
+
* was accepted — so the journal keys on the pair and a recovery is valid only
|
|
19
|
+
* from the directory that prepared it.
|
|
20
|
+
* - **Codex** exposes no caller-assignable thread id at all (`resume` only
|
|
21
|
+
* targets an already-recorded session), so an indeterminate Codex entry keeps
|
|
22
|
+
* blocking pending reconciliation.
|
|
23
|
+
*
|
|
24
|
+
* The file is `0600`, append-only under an exclusive lock, and carries a closed,
|
|
25
|
+
* redacted field set: no task text, no prompt, no adapter diagnostic. A line
|
|
26
|
+
* truncated by a crash is quarantined rather than fatal — the readable entries
|
|
27
|
+
* survive, the damage is counted, and an execution whose state cannot be proven
|
|
28
|
+
* clear blocks until a prune reconciles it.
|
|
29
|
+
*/
|
|
30
|
+
import { appendFile, chmod, mkdir, readFile, rmdir } from 'node:fs/promises';
|
|
31
|
+
import { dirname } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { writeAtomic } from './atomicWrite.mjs';
|
|
34
|
+
|
|
35
|
+
export const DISPATCH_JOURNAL_VERSION = 1;
|
|
36
|
+
/** User-local dispatch evidence: owner-only, like every other routing document. */
|
|
37
|
+
export const DISPATCH_JOURNAL_MODE = 0o600;
|
|
38
|
+
export const DEFAULT_JOURNAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
export const JOURNAL_PHASES = Object.freeze([
|
|
41
|
+
'prepared', 'dispatched', 'handed-off', 'blocked',
|
|
42
|
+
]);
|
|
43
|
+
export const TERMINAL_JOURNAL_PHASES = Object.freeze(['dispatched', 'handed-off', 'blocked']);
|
|
44
|
+
export const DISPATCH_RECOVERY_STATES = Object.freeze([
|
|
45
|
+
'clear', 'settled', 'retry-idempotent', 'blocked-pending-reconciliation',
|
|
46
|
+
]);
|
|
47
|
+
/** The surfaces that can pre-assign a spawn id, and so may retry a crash. */
|
|
48
|
+
export const IDEMPOTENT_DISPATCH_SURFACES = Object.freeze(['claude']);
|
|
49
|
+
|
|
50
|
+
const ENTRY_FIELDS = [
|
|
51
|
+
'schemaVersion', 'phase', 'executionId', 'surfaceId', 'transportId', 'cwd',
|
|
52
|
+
'idempotencyKey', 'taskId', 'reason', 'authorizationId', 'recordedAt',
|
|
53
|
+
];
|
|
54
|
+
const RECEIPT_PHASE = Object.freeze({
|
|
55
|
+
'routed-dispatch': 'dispatched',
|
|
56
|
+
'inherited-dispatch': 'dispatched',
|
|
57
|
+
handoff: 'handed-off',
|
|
58
|
+
blocked: 'blocked',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const MAX_FIELD_LENGTH = 512;
|
|
62
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/;
|
|
63
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
64
|
+
const LOCK_POLL_MS = 10;
|
|
65
|
+
|
|
66
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
67
|
+
|
|
68
|
+
/** Every recorded value is short, printable and free of consumer task data. */
|
|
69
|
+
function redacted(value, field, optional = false) {
|
|
70
|
+
if (value == null && optional) return null;
|
|
71
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
72
|
+
throw new TypeError(`dispatch journal entry ${field} must be a non-empty string`);
|
|
73
|
+
}
|
|
74
|
+
if (value.length > MAX_FIELD_LENGTH) {
|
|
75
|
+
throw new TypeError(`dispatch journal entry ${field} exceeds the redacted length limit`);
|
|
76
|
+
}
|
|
77
|
+
if (CONTROL_CHARACTERS.test(value)) {
|
|
78
|
+
throw new TypeError(`dispatch journal entry ${field} must carry no control characters`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertPhaseFields(entry) {
|
|
84
|
+
if (!JOURNAL_PHASES.includes(entry.phase)) {
|
|
85
|
+
throw new TypeError(
|
|
86
|
+
`dispatch journal entry phase must be one of: ${JOURNAL_PHASES.join(', ')}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (entry.taskId != null && entry.phase !== 'dispatched') {
|
|
90
|
+
throw new TypeError('dispatch journal entry taskId is recorded on a dispatched entry only');
|
|
91
|
+
}
|
|
92
|
+
if (entry.reason != null && !TERMINAL_JOURNAL_PHASES.includes(entry.phase)) {
|
|
93
|
+
throw new TypeError('dispatch journal entry reason is recorded on a terminal entry only');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateDispatchJournalEntry(entry) {
|
|
98
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
99
|
+
throw new TypeError('dispatch journal entry must be an object');
|
|
100
|
+
}
|
|
101
|
+
for (const key of Object.keys(entry)) {
|
|
102
|
+
if (!ENTRY_FIELDS.includes(key)) {
|
|
103
|
+
throw new TypeError(`dispatch journal entry must carry no consumer task data: ${key}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (entry.schemaVersion != null && entry.schemaVersion !== DISPATCH_JOURNAL_VERSION) {
|
|
107
|
+
throw new TypeError(`dispatch journal entry schemaVersion must be ${DISPATCH_JOURNAL_VERSION}`);
|
|
108
|
+
}
|
|
109
|
+
assertPhaseFields(entry);
|
|
110
|
+
const recordedAt = redacted(entry.recordedAt, 'recordedAt');
|
|
111
|
+
if (!Number.isFinite(Date.parse(recordedAt))) {
|
|
112
|
+
throw new TypeError('dispatch journal entry recordedAt must be an ISO timestamp');
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
schemaVersion: DISPATCH_JOURNAL_VERSION,
|
|
116
|
+
phase: entry.phase,
|
|
117
|
+
executionId: redacted(entry.executionId, 'executionId'),
|
|
118
|
+
surfaceId: redacted(entry.surfaceId, 'surfaceId'),
|
|
119
|
+
transportId: redacted(entry.transportId, 'transportId', true),
|
|
120
|
+
cwd: redacted(entry.cwd, 'cwd'),
|
|
121
|
+
idempotencyKey: redacted(entry.idempotencyKey, 'idempotencyKey', true),
|
|
122
|
+
taskId: redacted(entry.taskId, 'taskId', true),
|
|
123
|
+
reason: redacted(entry.reason, 'reason', true),
|
|
124
|
+
authorizationId: redacted(entry.authorizationId, 'authorizationId', true),
|
|
125
|
+
recordedAt,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The pre-assigned spawn id a retry may reuse, or `null` when the surface has
|
|
131
|
+
* none. The key is the `(cwd, session-id)` pair because that is the scope the
|
|
132
|
+
* host actually enforces.
|
|
133
|
+
*/
|
|
134
|
+
export function dispatchIdempotencyKey({ surfaceId, cwd, sessionId = null } = {}) {
|
|
135
|
+
redacted(cwd, 'cwd');
|
|
136
|
+
if (!IDEMPOTENT_DISPATCH_SURFACES.includes(surfaceId) || sessionId == null) return null;
|
|
137
|
+
return `${surfaceId}:${cwd}::${redacted(sessionId, 'idempotencyKey')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function withJournalLock(file, lockTimeoutMs, run) {
|
|
141
|
+
const lock = `${file}.lock`;
|
|
142
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
143
|
+
for (;;) {
|
|
144
|
+
try {
|
|
145
|
+
await mkdir(lock, { recursive: false });
|
|
146
|
+
break;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error.code !== 'EEXIST') throw error;
|
|
149
|
+
if (Date.now() >= deadline) throw new Error(`dispatch journal is locked: ${lock}`);
|
|
150
|
+
await sleep(LOCK_POLL_MS);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return await run();
|
|
155
|
+
} finally {
|
|
156
|
+
await rmdir(lock).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Append one entry. The lock serializes writers; `0600` is enforced every time. */
|
|
161
|
+
export async function appendDispatchJournalEntry(file, entry, options = {}) {
|
|
162
|
+
const validated = validateDispatchJournalEntry(entry);
|
|
163
|
+
const { lockTimeoutMs = LOCK_TIMEOUT_MS } = options;
|
|
164
|
+
await mkdir(dirname(file), { recursive: true });
|
|
165
|
+
return withJournalLock(file, lockTimeoutMs, async () => {
|
|
166
|
+
await appendFile(file, `${JSON.stringify(validated)}\n`, {
|
|
167
|
+
encoding: 'utf8', mode: DISPATCH_JOURNAL_MODE,
|
|
168
|
+
});
|
|
169
|
+
await chmod(file, DISPATCH_JOURNAL_MODE);
|
|
170
|
+
return validated;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read the journal. A line a crash truncated is counted as damage — by position
|
|
176
|
+
* and size, never by content — and every readable entry survives it.
|
|
177
|
+
*/
|
|
178
|
+
export async function readDispatchJournal(file) {
|
|
179
|
+
let raw;
|
|
180
|
+
try {
|
|
181
|
+
raw = await readFile(file, 'utf8');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error.code !== 'ENOENT') throw error;
|
|
184
|
+
return Object.freeze({ entries: Object.freeze([]), damaged: Object.freeze([]) });
|
|
185
|
+
}
|
|
186
|
+
const entries = [];
|
|
187
|
+
const damaged = [];
|
|
188
|
+
raw.split('\n').forEach((line, index) => {
|
|
189
|
+
if (line.trim() === '') return;
|
|
190
|
+
try {
|
|
191
|
+
entries.push(validateDispatchJournalEntry(JSON.parse(line)));
|
|
192
|
+
} catch {
|
|
193
|
+
damaged.push(Object.freeze({ line: index + 1, bytes: line.length }));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return Object.freeze({ entries: Object.freeze(entries), damaged: Object.freeze(damaged) });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function outcome(state, reason, entry) {
|
|
200
|
+
if (!DISPATCH_RECOVERY_STATES.includes(state)) {
|
|
201
|
+
throw new TypeError(`unknown dispatch recovery state: ${state}`);
|
|
202
|
+
}
|
|
203
|
+
return Object.freeze({ state, reason, entry });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What an indeterminate `prepared` entry permits, decided by surface. */
|
|
207
|
+
function indeterminate(entry, surfaceId, idempotencyKey) {
|
|
208
|
+
if (!IDEMPOTENT_DISPATCH_SURFACES.includes(surfaceId)) {
|
|
209
|
+
return outcome(
|
|
210
|
+
'blocked-pending-reconciliation', `no-idempotency-key-on-surface:${surfaceId}`, entry,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (idempotencyKey == null || entry.idempotencyKey == null) {
|
|
214
|
+
return outcome('blocked-pending-reconciliation', 'no-idempotency-key-recorded', entry);
|
|
215
|
+
}
|
|
216
|
+
if (entry.idempotencyKey !== idempotencyKey) {
|
|
217
|
+
return outcome('blocked-pending-reconciliation', 'idempotency-key-scope-mismatch', entry);
|
|
218
|
+
}
|
|
219
|
+
return outcome('retry-idempotent', 'idempotency-key-reserved', entry);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Decide what one execution may still do. A settled execution never dispatches
|
|
224
|
+
* again, an indeterminate one retries only against a pre-assigned key, and
|
|
225
|
+
* unidentifiable damage cannot prove anything clear.
|
|
226
|
+
*/
|
|
227
|
+
export function planDispatchRecovery({
|
|
228
|
+
entries = [], damaged = [], executionId, surfaceId, idempotencyKey = null,
|
|
229
|
+
} = {}) {
|
|
230
|
+
redacted(executionId, 'executionId');
|
|
231
|
+
redacted(surfaceId, 'surfaceId');
|
|
232
|
+
const mine = entries.filter((entry) => entry.executionId === executionId);
|
|
233
|
+
const last = mine[mine.length - 1] ?? null;
|
|
234
|
+
if (last && TERMINAL_JOURNAL_PHASES.includes(last.phase)) {
|
|
235
|
+
return outcome('settled', `already-recorded:${last.phase}`, last);
|
|
236
|
+
}
|
|
237
|
+
if (last) return indeterminate(last, surfaceId, idempotencyKey);
|
|
238
|
+
if (damaged.length > 0) {
|
|
239
|
+
return outcome('blocked-pending-reconciliation', 'journal-damaged', null);
|
|
240
|
+
}
|
|
241
|
+
return outcome('clear', 'no-indeterminate-entry', null);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function indeterminateExecutions(entries) {
|
|
245
|
+
const last = new Map();
|
|
246
|
+
for (const entry of entries) last.set(entry.executionId, entry);
|
|
247
|
+
return new Set([...last.values()]
|
|
248
|
+
.filter((entry) => entry.phase === 'prepared')
|
|
249
|
+
.map((entry) => entry.executionId));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Retention: aged terminal entries are dropped, an indeterminate execution is
|
|
254
|
+
* kept at any age (dropping it would silently unblock a crash), and damage is
|
|
255
|
+
* only discarded when the caller explicitly reconciles it.
|
|
256
|
+
*/
|
|
257
|
+
export async function pruneDispatchJournal(file, options = {}) {
|
|
258
|
+
const {
|
|
259
|
+
retainMs = DEFAULT_JOURNAL_RETENTION_MS, now = Date.now(),
|
|
260
|
+
dropDamaged = false, lockTimeoutMs = LOCK_TIMEOUT_MS,
|
|
261
|
+
} = options;
|
|
262
|
+
return withJournalLock(file, lockTimeoutMs, async () => {
|
|
263
|
+
const { entries, damaged } = await readDispatchJournal(file);
|
|
264
|
+
if (damaged.length > 0 && !dropDamaged) {
|
|
265
|
+
throw new Error('dispatch journal has damaged entries: reconcile with dropDamaged');
|
|
266
|
+
}
|
|
267
|
+
const pending = indeterminateExecutions(entries);
|
|
268
|
+
const kept = entries.filter((entry) => pending.has(entry.executionId)
|
|
269
|
+
|| Date.parse(entry.recordedAt) > now - retainMs);
|
|
270
|
+
await writeAtomic(
|
|
271
|
+
file,
|
|
272
|
+
kept.map((entry) => `${JSON.stringify(entry)}\n`).join(''),
|
|
273
|
+
DISPATCH_JOURNAL_MODE,
|
|
274
|
+
);
|
|
275
|
+
return Object.freeze({
|
|
276
|
+
kept: kept.length,
|
|
277
|
+
dropped: entries.length - kept.length,
|
|
278
|
+
damagedDropped: damaged.length,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The terminal entry one receipt of any kind leaves behind. */
|
|
284
|
+
export function journalEntryForReceipt(receipt, context = {}) {
|
|
285
|
+
const phase = RECEIPT_PHASE[receipt?.kind];
|
|
286
|
+
if (!phase) throw new TypeError(`unknown dispatch receipt kind: ${receipt?.kind}`);
|
|
287
|
+
const route = receipt.appliedRoute ?? receipt.requestedRoute ?? null;
|
|
288
|
+
return validateDispatchJournalEntry({
|
|
289
|
+
phase,
|
|
290
|
+
executionId: receipt.executionId,
|
|
291
|
+
surfaceId: context.surfaceId ?? route?.surfaceId ?? null,
|
|
292
|
+
transportId: context.transportId ?? route?.transportId ?? null,
|
|
293
|
+
cwd: context.cwd,
|
|
294
|
+
idempotencyKey: context.idempotencyKey ?? null,
|
|
295
|
+
taskId: phase === 'dispatched' ? context.taskId ?? null : null,
|
|
296
|
+
reason: phase === 'dispatched' ? null : receipt.reason,
|
|
297
|
+
authorizationId: receipt.authorizationId ?? null,
|
|
298
|
+
recordedAt: receipt.dispatchedAt,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Dispatch plan and the authorization record that binds it.
|
|
3
|
+
*
|
|
4
|
+
* A delegating workflow presents one table before its first dispatch: each unit
|
|
5
|
+
* of work with its Routing intent, the chosen route, and the reason that route
|
|
6
|
+
* won. It runs the two stages in order — intent resolution first (an explicit
|
|
7
|
+
* intent, otherwise the provider-neutral workflow classifier), then route
|
|
8
|
+
* selection against decisive evidence, otherwise the Standard route for the
|
|
9
|
+
* resolved workload class. A route is never an intent source, so nothing that
|
|
10
|
+
* comes out of stage two ever flows back into stage one.
|
|
11
|
+
*
|
|
12
|
+
* The plan is canonicalized and hashed over every unit, intent, route, reason
|
|
13
|
+
* and the policy, catalog and Access-graph revisions, and that hash is bound to
|
|
14
|
+
* an authorization record naming id, scope, mode, timestamp and actor. Every
|
|
15
|
+
* dispatch references the record and every receipt carries its id, so a wave is
|
|
16
|
+
* authorized once and that authorization cannot silently cover something else:
|
|
17
|
+
* a mismatch blocks pending a newly attributed authorization. Only a recorded
|
|
18
|
+
* mode that explicitly permits bounded dynamic re-resolution continues, and only
|
|
19
|
+
* within the axes it names — a bound may accept a *different route for the same
|
|
20
|
+
* work*, never different work and never a changed authorization basis, which is
|
|
21
|
+
* why `units`, `intent`, `intentSource` and `policy` can never be bounded.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
import { resolveRoute } from './routingResolver.mjs';
|
|
26
|
+
import { resolveRoutingIntent } from './routingIntentClassifier.mjs';
|
|
27
|
+
|
|
28
|
+
export const DISPATCH_PLAN_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export const PLAN_AUTHORIZATION_MODES = Object.freeze(['fixed', 'bounded-re-resolution']);
|
|
31
|
+
|
|
32
|
+
/** What a bounded mode may cover: a re-chosen route and the facts that moved it. */
|
|
33
|
+
export const BOUNDED_RE_RESOLUTION_AXES = Object.freeze([
|
|
34
|
+
'route', 'reason', 'origin', 'state', 'catalog', 'accessGraph',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const PLAN_AUTHORIZATION_MISMATCH = 'dispatch plan authorization does not cover this dispatch';
|
|
38
|
+
export const PLAN_UNIT_UNAUTHORIZED = 'dispatch plan authorization names no such unit';
|
|
39
|
+
export const PLAN_ROUTE_DRIFT = 'dispatch route differs from the authorized dispatch plan';
|
|
40
|
+
|
|
41
|
+
const UNIT_FIELDS = ['unitId', 'intent', 'signals', 'approval'];
|
|
42
|
+
const UNIT_AXES = ['intentSource', 'intent', 'origin', 'state', 'route', 'reason'];
|
|
43
|
+
const REVISION_AXES = ['catalog', 'accessGraph', 'policy'];
|
|
44
|
+
const ROUTE_FIELDS = ['providerId', 'modelId', 'effort', 'surfaceId', 'transportId'];
|
|
45
|
+
const RECORD_FIELDS = ['id', 'scope', 'mode', 'timestamp', 'actor', 'bounds'];
|
|
46
|
+
|
|
47
|
+
function object(value, field) {
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
49
|
+
throw new TypeError(`${field} must be an object`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function string(value, field) {
|
|
55
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
56
|
+
throw new TypeError(`${field} must be a non-empty string`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function closedFields(value, allowed, message) {
|
|
62
|
+
for (const key of Object.keys(value)) {
|
|
63
|
+
if (!allowed.includes(key)) throw new TypeError(`${message}: ${key}`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The dispatchable identity of the selected candidate, or none if it has no path. */
|
|
69
|
+
function plannedRoute(selected) {
|
|
70
|
+
if (!selected || typeof selected.modelId !== 'string' || typeof selected.surfaceId !== 'string') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze(Object.fromEntries(
|
|
74
|
+
ROUTE_FIELDS.map((field) => [field, selected[field] ?? null]),
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function plannedUnit(input, resolverInput) {
|
|
79
|
+
closedFields(object(input, 'dispatch plan unit'), UNIT_FIELDS, 'unknown dispatch plan unit field');
|
|
80
|
+
const resolved = resolveRoutingIntent({
|
|
81
|
+
explicit: input.intent ?? null, signals: input.signals ?? null,
|
|
82
|
+
});
|
|
83
|
+
const decision = resolveRoute({
|
|
84
|
+
...resolverInput,
|
|
85
|
+
intent: resolved.intent,
|
|
86
|
+
...(input.approval == null ? {} : { approval: input.approval }),
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
revisions: decision.revisions,
|
|
90
|
+
unit: Object.freeze({
|
|
91
|
+
unitId: string(input.unitId, 'dispatch plan unitId'),
|
|
92
|
+
intentSource: resolved.source,
|
|
93
|
+
intent: resolved.intent,
|
|
94
|
+
origin: decision.origin,
|
|
95
|
+
state: decision.state,
|
|
96
|
+
route: plannedRoute(decision.selected),
|
|
97
|
+
reason: decision.selected?.reason ?? decision.reason,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One plan decides under one set of revisions; a plan that cannot name them is not bindable. */
|
|
103
|
+
function planRevisions(entries) {
|
|
104
|
+
const [first, ...rest] = entries.map(({ revisions }) => revisions);
|
|
105
|
+
for (const field of REVISION_AXES) {
|
|
106
|
+
if (typeof first[field] !== 'string') {
|
|
107
|
+
throw new TypeError(`dispatch plan requires a ${field} revision`);
|
|
108
|
+
}
|
|
109
|
+
if (rest.some((revisions) => revisions[field] !== first[field])) {
|
|
110
|
+
throw new TypeError(`dispatch plan units disagree on the ${field} revision`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return Object.freeze(Object.fromEntries(REVISION_AXES.map((field) => [field, first[field]])));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildDispatchPlan(input) {
|
|
117
|
+
object(input, 'dispatch plan input');
|
|
118
|
+
if (!Array.isArray(input.units) || input.units.length === 0) {
|
|
119
|
+
throw new TypeError('dispatch plan needs at least one unit');
|
|
120
|
+
}
|
|
121
|
+
const entries = input.units.map((unit) => plannedUnit(unit, input.resolverInput));
|
|
122
|
+
const units = sortedUnits(entries.map(({ unit }) => unit));
|
|
123
|
+
units.forEach((unit, index) => {
|
|
124
|
+
if (index > 0 && units[index - 1].unitId === unit.unitId) {
|
|
125
|
+
throw new TypeError(`duplicate dispatch plan unit: ${unit.unitId}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const document = {
|
|
129
|
+
schemaVersion: DISPATCH_PLAN_VERSION,
|
|
130
|
+
units: Object.freeze(units),
|
|
131
|
+
revisions: planRevisions(entries),
|
|
132
|
+
};
|
|
133
|
+
return Object.freeze({ ...document, planHash: dispatchPlanHash(document) });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sortedUnits = (units) =>
|
|
137
|
+
[...units].sort((left, right) => left.unitId.localeCompare(right.unitId));
|
|
138
|
+
|
|
139
|
+
/** Key order, unit order and absent values never change what a plan is. */
|
|
140
|
+
function canonicalValue(value) {
|
|
141
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
142
|
+
if (value && typeof value === 'object') {
|
|
143
|
+
return Object.fromEntries(Object.keys(value).sort()
|
|
144
|
+
.map((key) => [key, canonicalValue(value[key])]));
|
|
145
|
+
}
|
|
146
|
+
return value === undefined ? null : value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function canonicalizeDispatchPlan(plan) {
|
|
150
|
+
object(plan, 'dispatch plan');
|
|
151
|
+
if (!Array.isArray(plan.units)) throw new TypeError('dispatch plan units must be an array');
|
|
152
|
+
return JSON.stringify(canonicalValue({
|
|
153
|
+
schemaVersion: plan.schemaVersion,
|
|
154
|
+
units: sortedUnits(plan.units),
|
|
155
|
+
revisions: plan.revisions,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function dispatchPlanHash(plan) {
|
|
160
|
+
return `sha256-${createHash('sha256').update(canonicalizeDispatchPlan(plan)).digest('hex')}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateBounds(input, mode) {
|
|
164
|
+
if (mode === 'fixed') {
|
|
165
|
+
if (input != null) throw new TypeError('a fixed plan authorization records no bounds');
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (input == null || !Array.isArray(object(input, 'plan authorization bounds').axes)
|
|
169
|
+
|| input.axes.length === 0) {
|
|
170
|
+
throw new TypeError('bounded re-resolution must name the axes it covers');
|
|
171
|
+
}
|
|
172
|
+
closedFields(input, ['axes'], 'unknown plan authorization bounds field');
|
|
173
|
+
for (const axis of input.axes) {
|
|
174
|
+
if (!BOUNDED_RE_RESOLUTION_AXES.includes(axis)) {
|
|
175
|
+
throw new TypeError(`bounded re-resolution cannot cover: ${axis}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({ axes: Object.freeze([...new Set(input.axes)].sort()) });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function authorizeDispatchPlan(plan, input) {
|
|
182
|
+
const planHash = dispatchPlanHash(plan);
|
|
183
|
+
closedFields(object(input, 'plan authorization'), RECORD_FIELDS, 'unknown plan authorization field');
|
|
184
|
+
if (!PLAN_AUTHORIZATION_MODES.includes(input.mode)) {
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
`plan authorization mode must be one of: ${PLAN_AUTHORIZATION_MODES.join(', ')}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
string(input.timestamp, 'plan authorization timestamp');
|
|
190
|
+
if (!Number.isFinite(Date.parse(input.timestamp))) {
|
|
191
|
+
throw new TypeError('plan authorization timestamp must be an ISO timestamp');
|
|
192
|
+
}
|
|
193
|
+
return Object.freeze({
|
|
194
|
+
schemaVersion: DISPATCH_PLAN_VERSION,
|
|
195
|
+
id: string(input.id, 'plan authorization id'),
|
|
196
|
+
scope: string(input.scope, 'plan authorization scope'),
|
|
197
|
+
mode: input.mode,
|
|
198
|
+
timestamp: input.timestamp,
|
|
199
|
+
actor: string(input.actor, 'plan authorization actor'),
|
|
200
|
+
bounds: validateBounds(input.bounds ?? null, input.mode),
|
|
201
|
+
planHash,
|
|
202
|
+
// The bound contents, so a mismatch can name what actually moved.
|
|
203
|
+
plan: Object.freeze({
|
|
204
|
+
schemaVersion: plan.schemaVersion,
|
|
205
|
+
units: Object.freeze(sortedUnits(plan.units)),
|
|
206
|
+
revisions: plan.revisions,
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sameValue = (left, right) =>
|
|
212
|
+
JSON.stringify(canonicalValue(left)) === JSON.stringify(canonicalValue(right));
|
|
213
|
+
|
|
214
|
+
/** Which axes moved between the authorized plan and the one about to run. */
|
|
215
|
+
function planDrift(authorized, current) {
|
|
216
|
+
const axes = new Set();
|
|
217
|
+
for (const field of REVISION_AXES) {
|
|
218
|
+
if (authorized.revisions?.[field] !== current.revisions?.[field]) axes.add(field);
|
|
219
|
+
}
|
|
220
|
+
const currentUnits = new Map(current.units.map((unit) => [unit.unitId, unit]));
|
|
221
|
+
const authorizedIds = authorized.units.map((unit) => unit.unitId);
|
|
222
|
+
if (authorizedIds.length !== currentUnits.size
|
|
223
|
+
|| authorizedIds.some((unitId) => !currentUnits.has(unitId))) {
|
|
224
|
+
axes.add('units');
|
|
225
|
+
}
|
|
226
|
+
for (const unit of authorized.units) {
|
|
227
|
+
const other = currentUnits.get(unit.unitId);
|
|
228
|
+
if (!other) continue;
|
|
229
|
+
for (const axis of UNIT_AXES) {
|
|
230
|
+
if (!sameValue(unit[axis], other[axis])) axes.add(axis);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return Object.freeze([...axes].sort());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const blockedCheck = (mode, drift, outside) => Object.freeze({
|
|
237
|
+
state: 'blocked', mode, drift, outside, reason: PLAN_AUTHORIZATION_MISMATCH,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export function checkPlanAuthorization(input) {
|
|
241
|
+
object(input, 'plan authorization check');
|
|
242
|
+
const authorization = object(input.authorization, 'plan authorization');
|
|
243
|
+
const plan = object(input.plan, 'dispatch plan');
|
|
244
|
+
// A record whose bound contents no longer hash to the id it carries proves
|
|
245
|
+
// nothing about the plan the user saw, so it authorizes nothing.
|
|
246
|
+
if (dispatchPlanHash(authorization.plan) !== authorization.planHash) {
|
|
247
|
+
return blockedCheck(authorization.mode, Object.freeze(['authorization']), Object.freeze(['authorization']));
|
|
248
|
+
}
|
|
249
|
+
const drift = dispatchPlanHash(plan) === authorization.planHash
|
|
250
|
+
? Object.freeze([])
|
|
251
|
+
: planDrift(authorization.plan, plan);
|
|
252
|
+
if (drift.length === 0) {
|
|
253
|
+
return Object.freeze({
|
|
254
|
+
state: 'authorized', mode: authorization.mode, drift, outside: drift, reason: null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const allowed = authorization.bounds?.axes ?? [];
|
|
258
|
+
const outside = Object.freeze(drift.filter((axis) => !allowed.includes(axis)));
|
|
259
|
+
if (authorization.mode === 'bounded-re-resolution' && outside.length === 0) {
|
|
260
|
+
return Object.freeze({
|
|
261
|
+
state: 'authorized', mode: authorization.mode, drift, outside, reason: null,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return blockedCheck(authorization.mode, drift, outside);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The dispatch-time gate: the authorization must still cover the plan, the plan
|
|
269
|
+
* must carry the unit, and the route about to run must be the one the unit was
|
|
270
|
+
* authorized for. The returned id is what the Dispatch receipt carries, blocked
|
|
271
|
+
* or not, so a refusal stays attributable to the authorization it was checked
|
|
272
|
+
* against.
|
|
273
|
+
*/
|
|
274
|
+
export function authorizeDispatchUnit(input) {
|
|
275
|
+
object(input, 'dispatch authorization input');
|
|
276
|
+
const authorizationId = input.authorization?.id ?? null;
|
|
277
|
+
const blocked = (reason) => Object.freeze({ authorizationId, reason });
|
|
278
|
+
const check = checkPlanAuthorization(input);
|
|
279
|
+
if (check.state !== 'authorized') return blocked(check.reason);
|
|
280
|
+
const unit = input.plan.units.find((entry) => entry.unitId === input.unitId);
|
|
281
|
+
if (!unit) return blocked(PLAN_UNIT_UNAUTHORIZED);
|
|
282
|
+
if (input.route != null && !sameValue(unit.route, plannedRoute(input.route))) {
|
|
283
|
+
return blocked(PLAN_ROUTE_DRIFT);
|
|
284
|
+
}
|
|
285
|
+
return blocked(null);
|
|
286
|
+
}
|