@ikon85/agent-workflow-kit 0.44.1 → 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 +71 -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 +521 -80
- package/src/commands/routing-status.mjs +288 -0
- package/src/commands/update.mjs +9 -1
- package/src/consumer-migrations.json +23 -1
- package/src/lib/bundle.mjs +158 -2
- package/src/lib/consumerMigrations.mjs +55 -0
- 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
|
@@ -1,4 +1,165 @@
|
|
|
1
1
|
export const EVIDENCE_CATALOG_VERSION = 1;
|
|
2
|
+
export const UNKNOWN_EFFORT = 'unknown';
|
|
3
|
+
|
|
4
|
+
// Taxonomy — docs/research/agent-task-taxonomy-benchmark-coverage.md §1. The
|
|
5
|
+
// identity stays `workload:domain:axis`, and the frontend vocabulary is a strict
|
|
6
|
+
// subset of it: the generalization is additive.
|
|
7
|
+
const FRONTEND_WORKLOADS = Object.freeze(['frontend-greenfield', 'frontend-repository-repair']);
|
|
8
|
+
|
|
9
|
+
export const EVIDENCE_WORKLOADS = Object.freeze([
|
|
10
|
+
...FRONTEND_WORKLOADS,
|
|
11
|
+
'repository-repair', 'repository-comprehension', 'code-transformation', 'test-authoring',
|
|
12
|
+
'greenfield-application', 'algorithmic-synthesis', 'terminal-operations', 'tool-orchestration',
|
|
13
|
+
'knowledge-deliverable', 'architecture-reasoning', 'long-horizon-autonomy',
|
|
14
|
+
'long-context-operation',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// A domain segment exists only where the owner publishes a separate score for it
|
|
18
|
+
// (§1.3); everything else stays `general`, so no aggregate is laundered.
|
|
19
|
+
const FRONTEND_DOMAINS = Object.freeze([
|
|
20
|
+
'general', 'reference-design', 'marketing', 'analytics',
|
|
21
|
+
'product', 'game', 'simulation', 'editor',
|
|
22
|
+
]);
|
|
23
|
+
const GENERAL_DOMAIN = Object.freeze(['general']);
|
|
24
|
+
const TOOL_ORCHESTRATION_DOMAINS = Object.freeze([
|
|
25
|
+
'general', 'airline', 'retail', 'telecom', 'banking',
|
|
26
|
+
]);
|
|
27
|
+
// Four axes stay frontend-scoped (§1.4); `functional` is the general
|
|
28
|
+
// executable-verifier axis, exactly how `openhands-frontend` already uses it.
|
|
29
|
+
const FRONTEND_SCOPED_AXES = Object.freeze([
|
|
30
|
+
'visual-fidelity', 'visual-preference', 'accessibility', 'responsive',
|
|
31
|
+
]);
|
|
32
|
+
const GENERAL_AXES = Object.freeze([
|
|
33
|
+
'functional', 'rubric-quality', 'answer-accuracy', 'blind-preference',
|
|
34
|
+
'policy-adherence', 'time-horizon', 'context-retention',
|
|
35
|
+
]);
|
|
36
|
+
export const EVIDENCE_AXES = Object.freeze([...GENERAL_AXES, ...FRONTEND_SCOPED_AXES]);
|
|
37
|
+
export const FRONTEND_QUALITY_AXES = Object.freeze(['functional', ...FRONTEND_SCOPED_AXES]);
|
|
38
|
+
|
|
39
|
+
// Owners publish cost per attempted task or per whole run (§6); a per-success
|
|
40
|
+
// figure is Kit-derived and says so. Currency stays its own field, so `USD` plus
|
|
41
|
+
// `attempt` is the researched `usd-per-attempt`.
|
|
42
|
+
export const EVIDENCE_COST_UNITS = Object.freeze(['attempt', 'run', 'success-derived']);
|
|
43
|
+
const DERIVED_COST_UNIT = 'success-derived';
|
|
44
|
+
|
|
45
|
+
function requireWorkload(workload) {
|
|
46
|
+
if (!EVIDENCE_WORKLOADS.includes(workload)) {
|
|
47
|
+
throw new TypeError(`unknown evidence workload: ${workload}`);
|
|
48
|
+
}
|
|
49
|
+
return FRONTEND_WORKLOADS.includes(workload);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function evidenceDomainsFor(workload) {
|
|
53
|
+
if (requireWorkload(workload)) return FRONTEND_DOMAINS;
|
|
54
|
+
return workload === 'tool-orchestration' ? TOOL_ORCHESTRATION_DOMAINS : GENERAL_DOMAIN;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function evidenceAxesFor(workload) {
|
|
58
|
+
return requireWorkload(workload) ? EVIDENCE_AXES : GENERAL_AXES;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function evidenceIdentity({ workload, domain = 'general', axis }) {
|
|
62
|
+
if (!evidenceDomainsFor(workload).includes(domain)) {
|
|
63
|
+
throw new TypeError(`evidence domain ${domain} is not scored separately for ${workload}`);
|
|
64
|
+
}
|
|
65
|
+
if (!evidenceAxesFor(workload).includes(axis)) {
|
|
66
|
+
throw new TypeError(`evidence axis ${axis} is not defined for ${workload}`);
|
|
67
|
+
}
|
|
68
|
+
return `${workload}:${domain}:${axis}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseEvidenceIdentity(identity) {
|
|
72
|
+
string(identity, 'evidence identity');
|
|
73
|
+
const [workload, domain, axis, ...rest] = identity.split(':');
|
|
74
|
+
if (rest.length > 0 || !workload || !domain || !axis) {
|
|
75
|
+
throw new TypeError(`evidence identity must name workload, domain, and axis: ${identity}`);
|
|
76
|
+
}
|
|
77
|
+
evidenceIdentity({ workload, domain, axis });
|
|
78
|
+
return Object.freeze({ workload, domain, axis });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The three-part decisiveness test (§0, §4): a source is decisive only when it
|
|
82
|
+
// measures the exact triple and preserves effort and harness identity, and a
|
|
83
|
+
// false flag names the collapsed dimension rather than hiding it in a boolean.
|
|
84
|
+
// `freshness.maxAgeDays` is a Kit-side policy per source: almost no owner
|
|
85
|
+
// publishes a cadence (§7.8), so the expiry is the Kit's decision, never read
|
|
86
|
+
// from the owner.
|
|
87
|
+
function sourceClaim(sourceId, flags, maxAgeDays, reason) {
|
|
88
|
+
const [measuresTriple, preservesEffort, preservesHarness] = flags;
|
|
89
|
+
const dimensions = ['triple', 'effort', 'harness'];
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
sourceId,
|
|
92
|
+
measuresTriple,
|
|
93
|
+
preservesEffort,
|
|
94
|
+
preservesHarness,
|
|
95
|
+
collapsedDimensions: Object.freeze(dimensions.filter((_, index) => !flags[index])),
|
|
96
|
+
freshness: Object.freeze({ maxAgeDays, basis: 'kit-policy' }),
|
|
97
|
+
reason,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// [sourceId, [measuresTriple, preservesEffort, preservesHarness], maxAgeDays, reason]
|
|
102
|
+
const SOURCE_CLAIM_ROWS = Object.freeze([
|
|
103
|
+
['deepswe', [true, true, true], 30, 'Every row names effort, harness and run config.'],
|
|
104
|
+
['artificial-analysis-coding-agents', [true, false, true], 30,
|
|
105
|
+
'The Coding Agent Index runs each agent on its default reasoning effort settings.'],
|
|
106
|
+
['openhands-evaluation', [true, false, true], 60,
|
|
107
|
+
'The harness is named per row, but reasoning effort is not a reported dimension.'],
|
|
108
|
+
['code-arena-webdev', [true, false, true], 30,
|
|
109
|
+
'Per-domain boards with the harness in the model label, but no reasoning-effort column.'],
|
|
110
|
+
['openhands-frontend', [true, false, true], 60, 'Names its SDK harness, not the effort.'],
|
|
111
|
+
['benchlm', [false, false, false], 7, 'An aggregator: no triple, no effort, no harness.'],
|
|
112
|
+
['vision2web', [true, false, false], 30,
|
|
113
|
+
"The active season's leaderboard is empty, so no row preserves harness or effort."],
|
|
114
|
+
['design2code', [true, false, false], 30, 'A static artifact: no harness, no effort.'],
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
export const EVIDENCE_SOURCE_CLAIMS = Object.freeze(Object.fromEntries(
|
|
118
|
+
SOURCE_CLAIM_ROWS.map((row) => [row[0], sourceClaim(...row)]),
|
|
119
|
+
));
|
|
120
|
+
|
|
121
|
+
export function evidenceSourceClaim(sourceId) {
|
|
122
|
+
const claim = EVIDENCE_SOURCE_CLAIMS[sourceId];
|
|
123
|
+
if (!claim) throw new TypeError(`unknown evidence source: ${sourceId}`);
|
|
124
|
+
return claim;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isDecisiveEvidence(claim) {
|
|
128
|
+
object(claim, 'evidence claim');
|
|
129
|
+
return claim.measuresTriple && claim.preservesEffort && claim.preservesHarness;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function evidenceFreshness({ sourceId, observedAt }) {
|
|
133
|
+
const { freshness } = evidenceSourceClaim(sourceId);
|
|
134
|
+
const observed = Date.parse(timestamp(observedAt, 'observedAt'));
|
|
135
|
+
const window = freshness.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
observedAt,
|
|
138
|
+
expiresAt: new Date(observed + window).toISOString(),
|
|
139
|
+
maxAgeDays: freshness.maxAgeDays,
|
|
140
|
+
basis: freshness.basis,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function assertPublishedEffort({ sourceId, effort }) {
|
|
145
|
+
const { preservesEffort } = evidenceSourceClaim(sourceId);
|
|
146
|
+
string(effort, `${sourceId} effort`);
|
|
147
|
+
if (!preservesEffort && effort !== UNKNOWN_EFFORT) {
|
|
148
|
+
throw new TypeError(`${sourceId} does not preserve reasoning effort: effort must be `
|
|
149
|
+
+ `${UNKNOWN_EFFORT}, found ${effort}`);
|
|
150
|
+
}
|
|
151
|
+
if (preservesEffort && effort === UNKNOWN_EFFORT) {
|
|
152
|
+
throw new TypeError(`${sourceId} preserves reasoning effort: a row must name it`);
|
|
153
|
+
}
|
|
154
|
+
return effort;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function assertCostUnit(unit, field = 'cost.unit') {
|
|
158
|
+
if (!EVIDENCE_COST_UNITS.includes(unit)) {
|
|
159
|
+
throw new TypeError(`${field} must be one of: ${EVIDENCE_COST_UNITS.join(', ')}`);
|
|
160
|
+
}
|
|
161
|
+
return unit;
|
|
162
|
+
}
|
|
2
163
|
|
|
3
164
|
function object(value, field) {
|
|
4
165
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -21,14 +182,13 @@ function number(value, field) {
|
|
|
21
182
|
|
|
22
183
|
function timestamp(value, field) {
|
|
23
184
|
string(value, field);
|
|
24
|
-
|
|
25
|
-
|
|
185
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
186
|
+
throw new TypeError(`${field} must be an ISO timestamp`);
|
|
187
|
+
}
|
|
26
188
|
return value;
|
|
27
189
|
}
|
|
28
190
|
|
|
29
|
-
|
|
30
|
-
return structuredClone(value);
|
|
31
|
-
}
|
|
191
|
+
const clone = (value) => structuredClone(value);
|
|
32
192
|
|
|
33
193
|
function deepFreeze(value) {
|
|
34
194
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
@@ -41,12 +201,40 @@ const identity = ({ providerId, modelId }) => `${providerId}:${modelId}`;
|
|
|
41
201
|
|
|
42
202
|
function validateModel(model, index) {
|
|
43
203
|
object(model, `models[${index}]`);
|
|
204
|
+
const field = `models[${index}]`;
|
|
44
205
|
return {
|
|
45
|
-
providerId: string(model.providerId,
|
|
46
|
-
modelId: string(model.modelId,
|
|
206
|
+
providerId: string(model.providerId, `${field}.providerId`),
|
|
207
|
+
modelId: string(model.modelId, `${field}.modelId`),
|
|
47
208
|
};
|
|
48
209
|
}
|
|
49
210
|
|
|
211
|
+
// A taxonomy identity is checked against the researched vocabulary; a colon-free
|
|
212
|
+
// workload is a legacy aggregate that can never satisfy an evidence selection.
|
|
213
|
+
const isTaxonomyIdentity = (workload) => workload.includes(':');
|
|
214
|
+
|
|
215
|
+
function validateWorkloadIdentity(value, field) {
|
|
216
|
+
const workload = string(value, field);
|
|
217
|
+
if (isTaxonomyIdentity(workload)) parseEvidenceIdentity(workload);
|
|
218
|
+
return workload;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// An observation claiming a taxonomy identity carries the whole taxonomy
|
|
222
|
+
// contract, cost unit included; a legacy aggregate keeps its free-form unit.
|
|
223
|
+
function validateCost(cost, field, taxonomy) {
|
|
224
|
+
if (number(cost.amount, `${field}.amount`) < 0) {
|
|
225
|
+
throw new TypeError(`${field}.amount must be non-negative`);
|
|
226
|
+
}
|
|
227
|
+
string(cost.currency, `${field}.currency`);
|
|
228
|
+
if (!taxonomy) return string(cost.unit, `${field}.unit`);
|
|
229
|
+
assertCostUnit(cost.unit, `${field}.unit`);
|
|
230
|
+
if ((cost.derived === true) !== (cost.unit === DERIVED_COST_UNIT)) {
|
|
231
|
+
throw new TypeError(
|
|
232
|
+
`${field}: a per-completed-task value is Kit-derived and must carry derived: true`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return cost.unit;
|
|
236
|
+
}
|
|
237
|
+
|
|
50
238
|
function validateObservation(entry, index, knownModels) {
|
|
51
239
|
const field = `observations[${index}]`;
|
|
52
240
|
object(entry, field);
|
|
@@ -55,7 +243,7 @@ function validateObservation(entry, index, knownModels) {
|
|
|
55
243
|
providerId: string(entry.providerId, `${field}.providerId`),
|
|
56
244
|
modelId: string(entry.modelId, `${field}.modelId`),
|
|
57
245
|
effort: string(entry.effort, `${field}.effort`),
|
|
58
|
-
workload:
|
|
246
|
+
workload: validateWorkloadIdentity(entry.workload, `${field}.workload`),
|
|
59
247
|
harness: clone(object(entry.harness, `${field}.harness`)),
|
|
60
248
|
score: number(entry.score, `${field}.score`),
|
|
61
249
|
source: clone(object(entry.source, `${field}.source`)),
|
|
@@ -65,12 +253,9 @@ function validateObservation(entry, index, knownModels) {
|
|
|
65
253
|
};
|
|
66
254
|
string(observation.harness.id, `${field}.harness.id`);
|
|
67
255
|
string(observation.harness.version, `${field}.harness.version`);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
string(observation.source.benchmark, `${field}.source.benchmark`);
|
|
72
|
-
string(observation.source.version, `${field}.source.version`);
|
|
73
|
-
string(observation.source.snapshotHash, `${field}.source.snapshotHash`);
|
|
256
|
+
for (const key of ['owner', 'id', 'url', 'benchmark', 'version', 'snapshotHash']) {
|
|
257
|
+
string(observation.source[key], `${field}.source.${key}`);
|
|
258
|
+
}
|
|
74
259
|
string(observation.uncertainty.kind, `${field}.uncertainty.kind`);
|
|
75
260
|
number(observation.uncertainty.value, `${field}.uncertainty.value`);
|
|
76
261
|
const observedAt = timestamp(observation.freshness.observedAt, `${field}.freshness.observedAt`);
|
|
@@ -78,10 +263,7 @@ function validateObservation(entry, index, knownModels) {
|
|
|
78
263
|
if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
|
|
79
264
|
throw new TypeError(`${field}.freshness.expiresAt must follow observedAt`);
|
|
80
265
|
}
|
|
81
|
-
|
|
82
|
-
if (amount < 0) throw new TypeError(`${field}.cost.amount must be non-negative`);
|
|
83
|
-
string(observation.cost.currency, `${field}.cost.currency`);
|
|
84
|
-
string(observation.cost.unit, `${field}.cost.unit`);
|
|
266
|
+
validateCost(observation.cost, `${field}.cost`, isTaxonomyIdentity(observation.workload));
|
|
85
267
|
if (!knownModels.has(identity(observation))) {
|
|
86
268
|
throw new TypeError(`${field} references an unknown model`);
|
|
87
269
|
}
|
|
@@ -114,10 +296,5 @@ export function validateEvidenceCatalog(input) {
|
|
|
114
296
|
}
|
|
115
297
|
observationIds.add(entry.id);
|
|
116
298
|
}
|
|
117
|
-
return deepFreeze({
|
|
118
|
-
schemaVersion: EVIDENCE_CATALOG_VERSION,
|
|
119
|
-
revision,
|
|
120
|
-
models,
|
|
121
|
-
observations,
|
|
122
|
-
});
|
|
299
|
+
return deepFreeze({ schemaVersion: EVIDENCE_CATALOG_VERSION, revision, models, observations });
|
|
123
300
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dispatch lease — the revision-bound window between deciding a route and
|
|
3
|
+
* spawning the agent that runs it.
|
|
4
|
+
*
|
|
5
|
+
* Comparing revisions before and after the handoff is not enough on its own: a
|
|
6
|
+
* comparison leaves a window between the check and the external task creation in
|
|
7
|
+
* which an authorization can be revoked, and the dispatch would still spawn. The
|
|
8
|
+
* lease closes that window from both sides. A dispatch holds it across the whole
|
|
9
|
+
* handoff, bound to the store-backed snapshot token it read; a writer must
|
|
10
|
+
* invalidate the lease or wait for it, and a lease whose revisions no longer
|
|
11
|
+
* match fails the dispatch instead of racing it.
|
|
12
|
+
*
|
|
13
|
+
* Four mechanics carry that:
|
|
14
|
+
*
|
|
15
|
+
* - **Fencing token.** Every acquisition raises a monotone counter per scope. A
|
|
16
|
+
* holder proves itself by its token, so a holder that lost the lease can
|
|
17
|
+
* neither continue nor release its successor's.
|
|
18
|
+
* - **Expiry.** A lease is only live until its deadline. A crashed holder
|
|
19
|
+
* therefore cannot block a scope forever.
|
|
20
|
+
* - **Abandoned-lease recovery.** An expired lease is taken over by the next
|
|
21
|
+
* acquirer or by a waiting writer, and the fence bump makes the abandoned
|
|
22
|
+
* holder's next check fail rather than letting it spawn late.
|
|
23
|
+
* - **Writer fairness.** A writer queues for its turn, and while any writer is
|
|
24
|
+
* queued no new dispatch may acquire the scope. Writers are served in the
|
|
25
|
+
* order they queued, so a stream of dispatches can never starve a revocation.
|
|
26
|
+
*
|
|
27
|
+
* The registry is per-process mutual exclusion. What catches a mutation from
|
|
28
|
+
* *another* process is the revision binding: the lease re-reads the persisted
|
|
29
|
+
* profile generations, inventory, Access graph and catalog and recomputes the
|
|
30
|
+
* Routing policy, then compares. Both halves are needed — a cooperating writer
|
|
31
|
+
* is serialized, an uncoordinated one is detected.
|
|
32
|
+
*/
|
|
33
|
+
import { readAccessGraphDocument } from './routingAccessGraphStore.mjs';
|
|
34
|
+
import { readComposedRoutingProfile } from './routingProfile.mjs';
|
|
35
|
+
import {
|
|
36
|
+
assertRoutingSnapshotMatches,
|
|
37
|
+
assertRoutingStoreSnapshotUnchanged,
|
|
38
|
+
captureRoutingStoreSnapshot,
|
|
39
|
+
} from './routingEvidenceCache.mjs';
|
|
40
|
+
|
|
41
|
+
export const DISPATCH_LEASE_VERSION = 1;
|
|
42
|
+
export const DEFAULT_DISPATCH_LEASE_TTL_MS = 30_000;
|
|
43
|
+
export const DEFAULT_WRITER_TURN_TIMEOUT_MS = 5_000;
|
|
44
|
+
|
|
45
|
+
/** The Route decision axes a lease binds: exactly what the decision named. */
|
|
46
|
+
const DECISION_REVISION_FIELDS = Object.freeze(['catalog', 'accessGraph', 'policy']);
|
|
47
|
+
|
|
48
|
+
const sleepMs = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
49
|
+
|
|
50
|
+
function scopeKey(value, field = 'dispatch lease key') {
|
|
51
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
52
|
+
throw new TypeError(`${field} must be a non-empty string`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One registry owns the leases of one process. `now` and `ttlMs` are injected so
|
|
59
|
+
* expiry, recovery and fairness are decidable without waiting for wall time.
|
|
60
|
+
*/
|
|
61
|
+
export function createDispatchLeaseRegistry({
|
|
62
|
+
now = () => Date.now(),
|
|
63
|
+
ttlMs = DEFAULT_DISPATCH_LEASE_TTL_MS,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const scopes = new Map();
|
|
66
|
+
|
|
67
|
+
const scopeOf = (key) => {
|
|
68
|
+
const name = scopeKey(key);
|
|
69
|
+
if (!scopes.has(name)) {
|
|
70
|
+
scopes.set(name, { fencingToken: 0, lease: null, writers: [], tickets: 0 });
|
|
71
|
+
}
|
|
72
|
+
return scopes.get(name);
|
|
73
|
+
};
|
|
74
|
+
/** A lease is live only while it exists and its deadline has not passed. */
|
|
75
|
+
const live = (scope, at) => (scope.lease && at < scope.lease.expiresAt ? scope.lease : null);
|
|
76
|
+
|
|
77
|
+
function acquire({ key, holder = null, ttlMs: leaseTtlMs = ttlMs } = {}) {
|
|
78
|
+
const scope = scopeOf(key);
|
|
79
|
+
if (scope.writers.length > 0) {
|
|
80
|
+
throw new Error(`dispatch lease is reserved for a writer: ${key}`);
|
|
81
|
+
}
|
|
82
|
+
const at = now();
|
|
83
|
+
if (live(scope, at)) throw new Error(`dispatch lease is held: ${key}`);
|
|
84
|
+
// An expired lease is abandoned: the recovering acquirer fences its holder.
|
|
85
|
+
scope.fencingToken += 1;
|
|
86
|
+
scope.lease = {
|
|
87
|
+
schemaVersion: DISPATCH_LEASE_VERSION,
|
|
88
|
+
key,
|
|
89
|
+
holder,
|
|
90
|
+
fencingToken: scope.fencingToken,
|
|
91
|
+
acquiredAt: at,
|
|
92
|
+
expiresAt: at + leaseTtlMs,
|
|
93
|
+
};
|
|
94
|
+
return Object.freeze({ ...scope.lease });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Still ours, still live — or the dispatch fails with what went wrong. */
|
|
98
|
+
function assertHeld(lease) {
|
|
99
|
+
const current = scopes.get(lease?.key)?.lease ?? null;
|
|
100
|
+
if (!current || current.fencingToken !== lease?.fencingToken) {
|
|
101
|
+
throw new Error(`dispatch lease superseded: ${lease?.key}`);
|
|
102
|
+
}
|
|
103
|
+
if (now() >= current.expiresAt) throw new Error(`dispatch lease expired: ${lease.key}`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Only the current holder releases; a fenced holder cannot free its successor. */
|
|
108
|
+
function release(lease) {
|
|
109
|
+
const scope = scopes.get(lease?.key);
|
|
110
|
+
if (scope?.lease && scope.lease.fencingToken === lease.fencingToken) scope.lease = null;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Queue for a write. From here on, no new dispatch may take this scope. */
|
|
115
|
+
function requestWriterTurn({ key, writerId = null } = {}) {
|
|
116
|
+
const scope = scopeOf(key);
|
|
117
|
+
scope.tickets += 1;
|
|
118
|
+
const ticket = Object.freeze({ key, writerId, sequence: scope.tickets });
|
|
119
|
+
scope.writers.push(ticket);
|
|
120
|
+
return ticket;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Take the turn if it is this writer's and no live lease is in the way. The
|
|
125
|
+
* grant invalidates whatever remains of an abandoned lease and raises the
|
|
126
|
+
* fence, so a stale holder can no longer spawn against the old revisions.
|
|
127
|
+
*/
|
|
128
|
+
function tryBeginWrite(ticket) {
|
|
129
|
+
const scope = scopeOf(ticket?.key);
|
|
130
|
+
const head = scope.writers[0];
|
|
131
|
+
if (!head || head.sequence !== ticket.sequence) {
|
|
132
|
+
return Object.freeze({ granted: false, reason: 'writer-not-next' });
|
|
133
|
+
}
|
|
134
|
+
if (live(scope, now())) {
|
|
135
|
+
return Object.freeze({ granted: false, reason: 'dispatch-lease-held' });
|
|
136
|
+
}
|
|
137
|
+
scope.lease = null;
|
|
138
|
+
scope.fencingToken += 1;
|
|
139
|
+
return Object.freeze({ granted: true, fencingToken: scope.fencingToken });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Leave the queue, whether the turn was taken or given up. */
|
|
143
|
+
function endWrite(ticket) {
|
|
144
|
+
const scope = scopeOf(ticket?.key);
|
|
145
|
+
scope.writers = scope.writers.filter((queued) => queued.sequence !== ticket.sequence);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function inspect(key) {
|
|
150
|
+
const scope = scopeOf(key);
|
|
151
|
+
const current = live(scope, now());
|
|
152
|
+
return Object.freeze({
|
|
153
|
+
fencingToken: scope.fencingToken,
|
|
154
|
+
held: Boolean(current),
|
|
155
|
+
holder: current?.holder ?? null,
|
|
156
|
+
expiresAt: scope.lease?.expiresAt ?? null,
|
|
157
|
+
writers: scope.writers.length,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
acquire, assertHeld, release, requestWriterTurn, tryBeginWrite, endWrite, inspect,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Wait for a queued writer's turn. The wait is bounded by wall time, so a holder
|
|
168
|
+
* whose process died mid-dispatch cannot keep a revocation waiting forever; the
|
|
169
|
+
* timeout names the reason it was still blocked.
|
|
170
|
+
*/
|
|
171
|
+
export async function awaitWriteTurn(registry, ticket, options = {}) {
|
|
172
|
+
const {
|
|
173
|
+
timeoutMs = DEFAULT_WRITER_TURN_TIMEOUT_MS,
|
|
174
|
+
pollMs = 5,
|
|
175
|
+
sleep = sleepMs,
|
|
176
|
+
clock = Date.now,
|
|
177
|
+
} = options;
|
|
178
|
+
const deadline = clock() + timeoutMs;
|
|
179
|
+
for (;;) {
|
|
180
|
+
const attempt = registry.tryBeginWrite(ticket);
|
|
181
|
+
if (attempt.granted) return attempt;
|
|
182
|
+
if (clock() >= deadline) {
|
|
183
|
+
throw new Error(`dispatch lease writer timed out: ${ticket?.key} (${attempt.reason})`);
|
|
184
|
+
}
|
|
185
|
+
await sleep(pollMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The snapshot source over the real user-local stores: the committed profile
|
|
191
|
+
* generations plus the pinned inventory (composed and then derived into a
|
|
192
|
+
* policy), the stored Access graph, and the Evidence catalog its own reader
|
|
193
|
+
* supplies. Every `read` goes back to the bytes — nothing is cached across the
|
|
194
|
+
* handoff, because a cached read cannot see a concurrent writer.
|
|
195
|
+
*/
|
|
196
|
+
export function createPersistedRoutingSnapshot({
|
|
197
|
+
profileRoot, projectRoot, identity, runGit, loadInventory, inventory,
|
|
198
|
+
accessGraphFile, readCatalog, policyOptions,
|
|
199
|
+
} = {}) {
|
|
200
|
+
scopeKey(accessGraphFile, 'persisted routing snapshot accessGraphFile');
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
read: () => captureRoutingStoreSnapshot({
|
|
203
|
+
readProfile: () => readComposedRoutingProfile({
|
|
204
|
+
profileRoot, projectRoot, identity, runGit, loadInventory, inventory,
|
|
205
|
+
}),
|
|
206
|
+
readAccessGraph: () => readAccessGraphDocument(accessGraphFile),
|
|
207
|
+
readCatalog,
|
|
208
|
+
policyOptions,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Open the lease for one dispatch: take the scope first, then read the store and
|
|
215
|
+
* bind the lease to what the Route decision named. A store that already moved
|
|
216
|
+
* fails here, before an adapter prepares anything, and the scope is released
|
|
217
|
+
* again — an unopened lease never leaks.
|
|
218
|
+
*/
|
|
219
|
+
export async function openDispatchLease({
|
|
220
|
+
registry, key, holder = null, ttlMs, snapshot, expected = null,
|
|
221
|
+
}) {
|
|
222
|
+
if (!registry || typeof registry.acquire !== 'function') {
|
|
223
|
+
throw new TypeError('a dispatch lease needs a dispatch lease registry');
|
|
224
|
+
}
|
|
225
|
+
if (!snapshot || typeof snapshot.read !== 'function') {
|
|
226
|
+
throw new TypeError('a dispatch lease needs a store-backed snapshot source');
|
|
227
|
+
}
|
|
228
|
+
const lease = registry.acquire({ key, holder, ttlMs });
|
|
229
|
+
try {
|
|
230
|
+
const token = await snapshot.read();
|
|
231
|
+
if (expected) assertRoutingSnapshotMatches(expected, token, DECISION_REVISION_FIELDS);
|
|
232
|
+
registry.assertHeld(lease);
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
key: lease.key,
|
|
235
|
+
holder: lease.holder,
|
|
236
|
+
fencingToken: lease.fencingToken,
|
|
237
|
+
expiresAt: lease.expiresAt,
|
|
238
|
+
token,
|
|
239
|
+
/** The spawn handoff: re-read, compare, and prove the lease is still ours. */
|
|
240
|
+
revalidate: async () => {
|
|
241
|
+
registry.assertHeld(lease);
|
|
242
|
+
const current = await snapshot.read();
|
|
243
|
+
assertRoutingStoreSnapshotUnchanged(token, current);
|
|
244
|
+
registry.assertHeld(lease);
|
|
245
|
+
return current;
|
|
246
|
+
},
|
|
247
|
+
release: () => registry.release(lease),
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
registry.release(lease);
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { validateEvidenceCatalog } from './routingCatalog.mjs';
|
|
2
2
|
import { validateAccessGraph } from './routingAccessGraph.mjs';
|
|
3
3
|
import { validateRoutingPolicy } from './routingPolicy.mjs';
|
|
4
|
+
import { deriveRoutingPolicy } from './routingProfilePolicy.mjs';
|
|
4
5
|
|
|
5
6
|
export const ROUTING_EVIDENCE_CACHE_VERSION = 1;
|
|
7
|
+
export const ROUTING_STORE_SNAPSHOT_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
/** Every axis a store-backed snapshot compares, each named by what moved. */
|
|
10
|
+
export const ROUTING_STORE_SNAPSHOT_FIELDS = Object.freeze([
|
|
11
|
+
'globalGeneration', 'projectGeneration', 'inventoryRevision', 'accessGraph', 'policy', 'catalog',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const PROFILE_MUTATION = 'concurrent routing profile mutation';
|
|
15
|
+
const CATALOG_MUTATION = 'concurrent evidence catalog mutation';
|
|
6
16
|
|
|
7
17
|
function object(value, field) {
|
|
8
18
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -198,6 +208,74 @@ export function commitRoutingEvidenceCache({
|
|
|
198
208
|
}, { now: refreshedAt });
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
function snapshotReader(source, field) {
|
|
212
|
+
if (typeof source[field] !== 'function') {
|
|
213
|
+
throw new TypeError(`routing store snapshot ${field} must be a function`);
|
|
214
|
+
}
|
|
215
|
+
return source[field];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The store-backed snapshot token: the revisions of the *persisted* routing
|
|
220
|
+
* inputs, taken by re-reading them. The Routing policy is recomputed from the
|
|
221
|
+
* generations and the inventory revision just read — a policy is derived and is
|
|
222
|
+
* never read back from a store — so a revision a caller merely states cannot
|
|
223
|
+
* authorize a dispatch, which comparing a resolver input with itself would allow.
|
|
224
|
+
*/
|
|
225
|
+
export async function captureRoutingStoreSnapshot(source) {
|
|
226
|
+
object(source, 'routing store snapshot source');
|
|
227
|
+
const [readProfile, readAccessGraph, readCatalog] = ['readProfile', 'readAccessGraph',
|
|
228
|
+
'readCatalog'].map((field) => snapshotReader(source, field));
|
|
229
|
+
const profile = await readProfile();
|
|
230
|
+
if (!profile?.global || !profile.composed) {
|
|
231
|
+
throw new Error('routing profile store carries no committed authorization');
|
|
232
|
+
}
|
|
233
|
+
const projectGeneration = profile.project?.generation ?? null;
|
|
234
|
+
const policy = deriveRoutingPolicy({
|
|
235
|
+
composed: profile.composed,
|
|
236
|
+
globalGeneration: profile.global.generation,
|
|
237
|
+
projectGeneration,
|
|
238
|
+
...(source.policyOptions ?? {}),
|
|
239
|
+
});
|
|
240
|
+
const accessGraph = await readAccessGraph();
|
|
241
|
+
const catalog = validateEvidenceCatalog(await readCatalog());
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
schemaVersion: ROUTING_STORE_SNAPSHOT_VERSION,
|
|
244
|
+
globalGeneration: profile.global.generation,
|
|
245
|
+
projectGeneration,
|
|
246
|
+
inventoryRevision: profile.composed.rosterState?.inventoryRevision
|
|
247
|
+
?? profile.composed.inventoryRevision ?? null,
|
|
248
|
+
accessGraph: accessGraph?.revision ?? null,
|
|
249
|
+
policy: policy.revision,
|
|
250
|
+
catalog: catalog.revision,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compare the named axes of two snapshot tokens. A moved axis names itself and
|
|
256
|
+
* fails the dispatch; the receipt reduces the message to a constant reason, so
|
|
257
|
+
* the compared values never leave this call.
|
|
258
|
+
*/
|
|
259
|
+
export function assertRoutingSnapshotMatches(expected, actual, fields) {
|
|
260
|
+
for (const token of [expected, actual]) object(token, 'routing store snapshot');
|
|
261
|
+
for (const field of fields) {
|
|
262
|
+
if (expected[field] === actual[field]) continue;
|
|
263
|
+
throw new Error(`${field === 'catalog' ? CATALOG_MUTATION : PROFILE_MUTATION}: `
|
|
264
|
+
+ `${field} changed from ${expected[field]} to ${actual[field]}`);
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The full-axis comparison a held lease runs before it releases the spawn. */
|
|
270
|
+
export function assertRoutingStoreSnapshotUnchanged(before, after) {
|
|
271
|
+
return assertRoutingSnapshotMatches(before, after, ROUTING_STORE_SNAPSHOT_FIELDS);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The in-process check over one caller's own resolver input. It only sees a
|
|
276
|
+
* mutation this process made to that object, so it is the degenerate case for a
|
|
277
|
+
* dispatch without a store-backed lease — never the authority over the store.
|
|
278
|
+
*/
|
|
201
279
|
export function captureRoutingProfileSnapshot({ accessGraph, policy }) {
|
|
202
280
|
const access = validateAccessGraph(accessGraph);
|
|
203
281
|
const routingPolicy = validateRoutingPolicy(policy);
|