@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -9,16 +9,51 @@ import {
|
|
|
9
9
|
detectAgentSurfaces,
|
|
10
10
|
surfaceById,
|
|
11
11
|
} from './agentSurfaceRegistry.mjs';
|
|
12
|
+
import {
|
|
13
|
+
commitRoutingProfileGenerations,
|
|
14
|
+
readCommittedRoutingProfilePair,
|
|
15
|
+
resolveProjectIdentity,
|
|
16
|
+
} from './routingProfileStorage.mjs';
|
|
17
|
+
import { loadRoutingInventory, presentInventory } from './routingInventory.mjs';
|
|
12
18
|
|
|
13
|
-
export const ROUTING_PROFILE_VERSION =
|
|
19
|
+
export const ROUTING_PROFILE_VERSION = 2;
|
|
14
20
|
export const ROUTING_PROFILE_PATH = 'routing-profile.json';
|
|
21
|
+
/** The workload classes a Standard route is nominated for — the resolver's vocabulary. */
|
|
22
|
+
export const STANDARD_ROUTE_CLASSES = Object.freeze(['mechanical', 'development', 'judgment']);
|
|
23
|
+
/**
|
|
24
|
+
* Per-pair roster state. `admitted` is the positive list a dispatch may pick
|
|
25
|
+
* from; `declined` is a user decision that must never prompt again; `withdrawn`
|
|
26
|
+
* is derived the moment the inventory no longer lists an admitted pair.
|
|
27
|
+
*/
|
|
28
|
+
export const ROSTER_PAIR_STATES = Object.freeze(['admitted', 'declined', 'withdrawn']);
|
|
29
|
+
/** A Standard route is either nominated and authorized, or knowingly broken. */
|
|
30
|
+
export const STANDARD_ROUTE_STATES = Object.freeze(['configured', 'unresolved']);
|
|
15
31
|
|
|
32
|
+
/** Loosest first: a project narrowing may only move toward the end of this list. */
|
|
16
33
|
const SWITCHING = Object.freeze(['automatic', 'ask', 'current-surface-only']);
|
|
17
34
|
const ACTIVATION = Object.freeze(['approve', 'back', 'advanced', 'safe-current-surface', 'decline']);
|
|
35
|
+
/** The transport by which a surface drives its own runtime, as the registry names it. */
|
|
36
|
+
const NATIVE_TRANSPORT = 'native';
|
|
37
|
+
const SHARED_FIELDS = [
|
|
38
|
+
'schemaVersion', 'registryRevision', 'selectedSurfaces', 'consideredSurfaces', 'switching',
|
|
39
|
+
'advanced',
|
|
40
|
+
];
|
|
18
41
|
const PROFILE_FIELDS = new Set([
|
|
19
|
-
'
|
|
20
|
-
|
|
42
|
+
...SHARED_FIELDS, 'authorizedTransports', 'roster', 'inventoryRevision', 'standardRoutes',
|
|
43
|
+
]);
|
|
44
|
+
const PROFILE_FIELDS_V1 = new Set(SHARED_FIELDS);
|
|
45
|
+
/** A project document owns nothing; every field it may carry is a narrowing axis. */
|
|
46
|
+
const NARROWING_FIELDS = new Set([
|
|
47
|
+
'schemaVersion', 'selectedSurfaces', 'authorizedTransports', 'switching', 'roster',
|
|
48
|
+
'standardRoutes',
|
|
21
49
|
]);
|
|
50
|
+
const ADVANCED_FIELDS = new Set(['legacy']);
|
|
51
|
+
const PAIR_FIELDS = new Set(['model', 'effort']);
|
|
52
|
+
/** A pair plus the state it is recorded in — the shape of a roster entry and of a Standard route. */
|
|
53
|
+
const STATED_PAIR_FIELDS = new Set(['model', 'effort', 'state']);
|
|
54
|
+
const TRANSPORT_FIELDS = new Set(['surface', 'transport']);
|
|
55
|
+
/** A model id may carry a context variant (`opus[1m]`); the attested channel drops it. */
|
|
56
|
+
const CONTEXT_VARIANT = /\[[^\]]*\]$/;
|
|
22
57
|
|
|
23
58
|
function uniqueStrings(value, field) {
|
|
24
59
|
if (!Array.isArray(value) ||
|
|
@@ -28,15 +63,206 @@ function uniqueStrings(value, field) {
|
|
|
28
63
|
return [...new Set(value)];
|
|
29
64
|
}
|
|
30
65
|
|
|
31
|
-
|
|
32
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
33
|
-
throw new TypeError('routing profile must be an object');
|
|
34
|
-
}
|
|
66
|
+
function assertFields(input, allowed, label) {
|
|
35
67
|
for (const key of Object.keys(input)) {
|
|
36
|
-
if (!
|
|
68
|
+
if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`);
|
|
37
69
|
}
|
|
38
|
-
|
|
39
|
-
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function plainObject(value, field) {
|
|
73
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
74
|
+
throw new TypeError(`${field} must be an object`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The roster identity rule: trim and drop the context variant. */
|
|
80
|
+
export function normalizeRosterModelId(value, field = 'model') {
|
|
81
|
+
if (typeof value !== 'string') throw new TypeError(`${field} must be a non-empty string`);
|
|
82
|
+
const model = value.trim().replace(CONTEXT_VARIANT, '').trim();
|
|
83
|
+
if (!model) throw new TypeError(`${field} must be a non-empty string`);
|
|
84
|
+
return model;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A model-and-effort pair. Effort domains are per model, so effort is always
|
|
89
|
+
* explicit: a value the model supports, or `null` for a model with no effort
|
|
90
|
+
* axis. A pair is authorized as a pair, never as a model with an effort
|
|
91
|
+
* attached afterwards.
|
|
92
|
+
*/
|
|
93
|
+
function validatePair(input, field) {
|
|
94
|
+
plainObject(input, field);
|
|
95
|
+
assertFields(input, PAIR_FIELDS, field);
|
|
96
|
+
const model = normalizeRosterModelId(input.model, `${field}.model`);
|
|
97
|
+
if (!('effort' in input) || (input.effort !== null &&
|
|
98
|
+
(typeof input.effort !== 'string' || input.effort.trim() === ''))) {
|
|
99
|
+
throw new TypeError(`${field}.effort must be a non-empty string or null (no effort axis)`);
|
|
100
|
+
}
|
|
101
|
+
return { model, effort: input.effort === null ? null : input.effort.trim() };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pairKey({ model, effort }) {
|
|
105
|
+
return `${model}\u0000${effort ?? ''}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The pair a diagnostic names, in the one form a human and a test both read. */
|
|
109
|
+
function pairLabel({ model, effort }) {
|
|
110
|
+
return `${model}/${effort ?? 'no-effort'}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const barePair = ({ model, effort }) => ({ model, effort });
|
|
114
|
+
const admittedPairs = (roster) => roster.filter(({ state }) => state === 'admitted').map(barePair);
|
|
115
|
+
|
|
116
|
+
/** A transport authorization is a `(surface, transport)` pair, never a bare transport. */
|
|
117
|
+
function validateTransport(input, field) {
|
|
118
|
+
plainObject(input, field);
|
|
119
|
+
assertFields(input, TRANSPORT_FIELDS, field);
|
|
120
|
+
for (const key of ['surface', 'transport']) {
|
|
121
|
+
if (typeof input[key] !== 'string' || input[key].trim() === '') {
|
|
122
|
+
throw new TypeError(`${field}.${key} must be a non-empty string`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { surface: input.surface.trim(), transport: input.transport.trim() };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const transportKey = ({ surface, transport }) => `${surface}\u0000${transport}`;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Selecting two agent apps does not authorize either to drive the other's CLI,
|
|
132
|
+
* so every authorized transport names the surface it belongs to and that surface
|
|
133
|
+
* must be one the document selected.
|
|
134
|
+
*/
|
|
135
|
+
function validateTransports(value, field, surfaces) {
|
|
136
|
+
if (!Array.isArray(value)) {
|
|
137
|
+
throw new TypeError(`${field} must be an array of {surface, transport} pairs`);
|
|
138
|
+
}
|
|
139
|
+
const authorized = new Map();
|
|
140
|
+
value.forEach((input, index) => {
|
|
141
|
+
const transport = validateTransport(input, `${field}[${index}]`);
|
|
142
|
+
if (surfaces && !surfaces.includes(transport.surface)) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
`${field}[${index}].surface must be a selected surface: ${transport.surface}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
authorized.set(transportKey(transport), transport);
|
|
148
|
+
});
|
|
149
|
+
return [...authorized.values()];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateRosterEntry(input, field) {
|
|
153
|
+
plainObject(input, field);
|
|
154
|
+
assertFields(input, STATED_PAIR_FIELDS, field);
|
|
155
|
+
const pair = validatePair({ model: input.model, effort: input.effort }, field);
|
|
156
|
+
if (!ROSTER_PAIR_STATES.includes(input.state)) {
|
|
157
|
+
throw new TypeError(`${field}.state must be one of: ${ROSTER_PAIR_STATES.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
return { ...pair, state: input.state };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The recorded roster: one state per pair. Two states for one pair is ambiguous. */
|
|
163
|
+
function validateRoster(value, field = 'roster') {
|
|
164
|
+
if (!Array.isArray(value)) throw new TypeError(`${field} must be an array of roster entries`);
|
|
165
|
+
const entries = new Map();
|
|
166
|
+
value.forEach((input, index) => {
|
|
167
|
+
const entry = validateRosterEntry(input, `${field}[${index}]`);
|
|
168
|
+
const prior = entries.get(pairKey(entry));
|
|
169
|
+
if (prior && prior.state !== entry.state) {
|
|
170
|
+
throw new TypeError(
|
|
171
|
+
`${field} records the same pair twice with different states: ${pairLabel(entry)}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
entries.set(pairKey(entry), entry);
|
|
175
|
+
});
|
|
176
|
+
return [...entries.values()];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A narrowing does not decline a pair — it names the subset a project may use. */
|
|
180
|
+
function validateNarrowingRoster(value) {
|
|
181
|
+
if (!Array.isArray(value)) throw new TypeError('roster must be an array of pairs');
|
|
182
|
+
const pairs = new Map();
|
|
183
|
+
value.forEach((input, index) => {
|
|
184
|
+
const pair = validatePair(input, `roster[${index}]`);
|
|
185
|
+
pairs.set(pairKey(pair), pair);
|
|
186
|
+
});
|
|
187
|
+
return [...pairs.values()];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateStandardRouteEntry(input, field) {
|
|
191
|
+
plainObject(input, field);
|
|
192
|
+
assertFields(input, STATED_PAIR_FIELDS, field);
|
|
193
|
+
const pair = validatePair({ model: input.model, effort: input.effort }, field);
|
|
194
|
+
if (!STANDARD_ROUTE_STATES.includes(input.state)) {
|
|
195
|
+
throw new TypeError(`${field}.state must be one of: ${STANDARD_ROUTE_STATES.join(', ')}`);
|
|
196
|
+
}
|
|
197
|
+
return { ...pair, state: input.state };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The global document nominates every workload class explicitly. A `configured`
|
|
202
|
+
* route must name an admitted pair; an `unresolved` one is a knowingly broken
|
|
203
|
+
* route the profile keeps rather than a profile the Kit calls invalid.
|
|
204
|
+
*/
|
|
205
|
+
function validateStandardRoutes(value, roster) {
|
|
206
|
+
plainObject(value, 'standardRoutes');
|
|
207
|
+
assertFields(value, new Set(STANDARD_ROUTE_CLASSES), 'standardRoutes');
|
|
208
|
+
const authorized = new Set(admittedPairs(roster).map(pairKey));
|
|
209
|
+
const routes = {};
|
|
210
|
+
for (const workload of STANDARD_ROUTE_CLASSES) {
|
|
211
|
+
if (!(workload in value)) {
|
|
212
|
+
throw new TypeError(
|
|
213
|
+
`standardRoutes must name every workload class: ${STANDARD_ROUTE_CLASSES.join(', ')}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (value[workload] === null) {
|
|
217
|
+
routes[workload] = null;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const entry = validateStandardRouteEntry(value[workload], `standardRoutes.${workload}`);
|
|
221
|
+
if (entry.state === 'configured' && !authorized.has(pairKey(entry))) {
|
|
222
|
+
throw new TypeError(`standardRoutes.${workload} must name an admitted roster pair`);
|
|
223
|
+
}
|
|
224
|
+
routes[workload] = entry;
|
|
225
|
+
}
|
|
226
|
+
return routes;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A project overrides the classes it names and inherits the rest. */
|
|
230
|
+
function validateNarrowingStandardRoutes(value) {
|
|
231
|
+
plainObject(value, 'standardRoutes');
|
|
232
|
+
assertFields(value, new Set(STANDARD_ROUTE_CLASSES), 'standardRoutes');
|
|
233
|
+
const routes = {};
|
|
234
|
+
for (const workload of STANDARD_ROUTE_CLASSES) {
|
|
235
|
+
if (!(workload in value)) continue;
|
|
236
|
+
routes[workload] = value[workload] === null
|
|
237
|
+
? null
|
|
238
|
+
: validateStandardRouteEntry(value[workload], `standardRoutes.${workload}`);
|
|
239
|
+
}
|
|
240
|
+
return routes;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The inventory revision the roster was reconciled against; never a profile revision. */
|
|
244
|
+
function validateInventoryRevision(value) {
|
|
245
|
+
if (value === null || value === undefined) return null;
|
|
246
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
247
|
+
throw new TypeError('inventoryRevision must be a non-empty string or null');
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** v2 `advanced` carries preserved legacy evidence only — the optimization dial is gone. */
|
|
253
|
+
function validateAdvanced(value) {
|
|
254
|
+
if (value === null) return null;
|
|
255
|
+
plainObject(value, 'advanced');
|
|
256
|
+
assertFields(value, ADVANCED_FIELDS, 'advanced');
|
|
257
|
+
if (!('legacy' in value)) return null;
|
|
258
|
+
return { legacy: structuredClone(plainObject(value.legacy, 'advanced.legacy')) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function validateSharedFields(input, fields, version) {
|
|
262
|
+
plainObject(input, 'routing profile');
|
|
263
|
+
assertFields(input, fields, 'routing profile');
|
|
264
|
+
if (input.schemaVersion !== version) {
|
|
265
|
+
throw new TypeError(`routing profile schemaVersion must be ${version}`);
|
|
40
266
|
}
|
|
41
267
|
if (!Number.isInteger(input.registryRevision) || input.registryRevision < 0) {
|
|
42
268
|
throw new TypeError('routing profile registryRevision must be a non-negative integer');
|
|
@@ -53,25 +279,553 @@ export function validateRoutingProfile(input) {
|
|
|
53
279
|
if (!SWITCHING.includes(input.switching)) {
|
|
54
280
|
throw new TypeError(`switching must be one of: ${SWITCHING.join(', ')}`);
|
|
55
281
|
}
|
|
56
|
-
if (input.advanced !== null &&
|
|
57
|
-
(!input.advanced || typeof input.advanced !== 'object' || Array.isArray(input.advanced))) {
|
|
58
|
-
throw new TypeError('advanced must be an object or null');
|
|
59
|
-
}
|
|
60
282
|
return {
|
|
61
|
-
schemaVersion:
|
|
283
|
+
schemaVersion: version,
|
|
62
284
|
registryRevision: input.registryRevision,
|
|
63
285
|
selectedSurfaces,
|
|
64
286
|
consideredSurfaces,
|
|
65
287
|
switching: input.switching,
|
|
66
|
-
advanced: input.advanced === null ? null : structuredClone(input.advanced),
|
|
67
288
|
};
|
|
68
289
|
}
|
|
69
290
|
|
|
70
|
-
|
|
71
|
-
|
|
291
|
+
/** The global document: the authorization every project narrowing is derived against. */
|
|
292
|
+
export function validateRoutingProfile(input) {
|
|
293
|
+
const shared = validateSharedFields(input, PROFILE_FIELDS, ROUTING_PROFILE_VERSION);
|
|
294
|
+
const roster = validateRoster(input.roster);
|
|
295
|
+
return {
|
|
296
|
+
...shared,
|
|
297
|
+
authorizedTransports: validateTransports(
|
|
298
|
+
input.authorizedTransports, 'authorizedTransports', shared.selectedSurfaces,
|
|
299
|
+
),
|
|
300
|
+
roster,
|
|
301
|
+
inventoryRevision: validateInventoryRevision(input.inventoryRevision),
|
|
302
|
+
standardRoutes: validateStandardRoutes(input.standardRoutes, roster),
|
|
303
|
+
advanced: validateAdvanced(input.advanced ?? null),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The project document: every field is a narrowing axis and `null` means "this
|
|
309
|
+
* project narrows nothing here". A project owns no surfaces, no switching
|
|
310
|
+
* autonomy and no roster of its own — it only ever subtracts from the global
|
|
311
|
+
* authorization, which is why none of the global-only fields are accepted.
|
|
312
|
+
*/
|
|
313
|
+
export function validateRoutingNarrowing(input) {
|
|
314
|
+
plainObject(input, 'routing narrowing');
|
|
315
|
+
assertFields(input, NARROWING_FIELDS, 'routing narrowing');
|
|
316
|
+
if (input.schemaVersion !== ROUTING_PROFILE_VERSION) {
|
|
317
|
+
throw new TypeError(`routing narrowing schemaVersion must be ${ROUTING_PROFILE_VERSION}`);
|
|
318
|
+
}
|
|
319
|
+
const selectedSurfaces = input.selectedSurfaces == null
|
|
320
|
+
? null
|
|
321
|
+
: uniqueStrings(input.selectedSurfaces, 'selectedSurfaces');
|
|
322
|
+
if (selectedSurfaces && !selectedSurfaces.length) {
|
|
323
|
+
throw new TypeError('selectedSurfaces must not be empty (use null to narrow no surface)');
|
|
324
|
+
}
|
|
325
|
+
if (input.switching != null && !SWITCHING.includes(input.switching)) {
|
|
326
|
+
throw new TypeError(`switching must be one of: ${SWITCHING.join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
schemaVersion: ROUTING_PROFILE_VERSION,
|
|
330
|
+
selectedSurfaces,
|
|
331
|
+
authorizedTransports: input.authorizedTransports == null
|
|
332
|
+
? null
|
|
333
|
+
: validateTransports(input.authorizedTransports, 'authorizedTransports', selectedSurfaces),
|
|
334
|
+
switching: input.switching ?? null,
|
|
335
|
+
roster: input.roster == null ? null : validateNarrowingRoster(input.roster),
|
|
336
|
+
standardRoutes: input.standardRoutes == null
|
|
337
|
+
? null
|
|
338
|
+
: validateNarrowingStandardRoutes(input.standardRoutes),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function validateRoutingProfileV1(input) {
|
|
343
|
+
const shared = validateSharedFields(input, PROFILE_FIELDS_V1, 1);
|
|
344
|
+
const advanced = input.advanced;
|
|
345
|
+
if (advanced !== null && (!advanced || typeof advanced !== 'object' || Array.isArray(advanced))) {
|
|
346
|
+
throw new TypeError('advanced must be an object or null');
|
|
347
|
+
}
|
|
348
|
+
return { ...shared, advanced: advanced === null ? null : structuredClone(advanced) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function emptyStandardRoutes() {
|
|
352
|
+
return Object.fromEntries(STANDARD_ROUTE_CLASSES.map((workload) => [workload, null]));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Read one v1 model preference. Never throws: junk evidence is reported, not fatal. */
|
|
356
|
+
function readModelPreference(entry) {
|
|
357
|
+
try {
|
|
358
|
+
if (typeof entry === 'string') return { model: normalizeRosterModelId(entry), pair: null };
|
|
359
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry) && 'effort' in entry) {
|
|
360
|
+
return { model: null, pair: validatePair({ model: entry.model, effort: entry.effort }, 'p') };
|
|
361
|
+
}
|
|
362
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
363
|
+
return { model: normalizeRosterModelId(entry.model), pair: null };
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
return { model: null, pair: null };
|
|
367
|
+
}
|
|
368
|
+
return { model: null, pair: null };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Reconcile v1 `advanced` explicitly: model preferences become roster pairs
|
|
373
|
+
* where the evidence authorizes a pair, the removed dial and every unreconciled
|
|
374
|
+
* preference are recorded, and the whole original object survives as legacy
|
|
375
|
+
* evidence. Nothing the user chose is dropped.
|
|
376
|
+
*/
|
|
377
|
+
function reconcileV1Advanced(advanced) {
|
|
378
|
+
const notes = [];
|
|
379
|
+
const roster = [];
|
|
380
|
+
if (!advanced) return { roster, notes, preserved: null };
|
|
381
|
+
if ('optimization' in advanced) {
|
|
382
|
+
notes.push({ code: 'optimization-removed', value: advanced.optimization });
|
|
383
|
+
}
|
|
384
|
+
const preferences = Array.isArray(advanced.preferredModels) ? advanced.preferredModels : [];
|
|
385
|
+
preferences.forEach((entry, index) => {
|
|
386
|
+
const { model, pair } = readModelPreference(entry);
|
|
387
|
+
if (pair) {
|
|
388
|
+
roster.push({ ...pair, state: 'admitted' });
|
|
389
|
+
notes.push({ code: 'roster-pair-admitted', model: pair.model, effort: pair.effort });
|
|
390
|
+
} else if (model) {
|
|
391
|
+
notes.push({ code: 'model-preference-needs-effort', model });
|
|
392
|
+
} else {
|
|
393
|
+
notes.push({ code: 'model-preference-unreadable', index });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
const preserved = Object.keys(advanced).length ? { legacy: structuredClone(advanced) } : null;
|
|
397
|
+
return { roster, notes, preserved };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function migrateRoutingProfileV1(document) {
|
|
401
|
+
const { roster, notes, preserved } = reconcileV1Advanced(document.advanced);
|
|
402
|
+
// v1 never asked a transport question. Reading the surfaces the user selected
|
|
403
|
+
// as "may drive its own runtime" preserves what v1 meant without inventing a
|
|
404
|
+
// cross-provider authorization, which stays the user's explicit decision.
|
|
405
|
+
const authorizedTransports = document.selectedSurfaces
|
|
406
|
+
.map((surface) => ({ surface, transport: NATIVE_TRANSPORT }));
|
|
407
|
+
notes.push({
|
|
408
|
+
code: 'transport-authorization-defaulted-to-native',
|
|
409
|
+
surfaces: [...document.selectedSurfaces],
|
|
410
|
+
});
|
|
411
|
+
const profile = validateRoutingProfile({
|
|
412
|
+
schemaVersion: ROUTING_PROFILE_VERSION,
|
|
413
|
+
registryRevision: document.registryRevision,
|
|
414
|
+
selectedSurfaces: document.selectedSurfaces,
|
|
415
|
+
consideredSurfaces: document.consideredSurfaces,
|
|
416
|
+
authorizedTransports,
|
|
417
|
+
switching: document.switching,
|
|
418
|
+
roster,
|
|
419
|
+
inventoryRevision: null,
|
|
420
|
+
standardRoutes: emptyStandardRoutes(),
|
|
421
|
+
advanced: preserved,
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
profile,
|
|
425
|
+
migration: {
|
|
426
|
+
from: 1,
|
|
427
|
+
to: ROUTING_PROFILE_VERSION,
|
|
428
|
+
notes,
|
|
429
|
+
backup: structuredClone(document),
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const DECODERS = new Map([
|
|
435
|
+
[1, (document) => migrateRoutingProfileV1(validateRoutingProfileV1(document))],
|
|
436
|
+
[2, (document) => ({ profile: validateRoutingProfile(document), migration: null })],
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Version-aware decode: read `schemaVersion` first, decode with that version's
|
|
441
|
+
* own decoder, then migrate. An older document is never collapsed to invalid.
|
|
442
|
+
*/
|
|
443
|
+
export function decodeRoutingProfile(document) {
|
|
444
|
+
plainObject(document, 'routing profile');
|
|
445
|
+
const decode = DECODERS.get(document.schemaVersion);
|
|
446
|
+
if (!decode) {
|
|
447
|
+
throw new TypeError(
|
|
448
|
+
`unsupported routing profile schemaVersion: ${JSON.stringify(document.schemaVersion)}`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return decode(document);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Version-aware decode of a project narrowing. The two-level store is new, so
|
|
456
|
+
* there is exactly one narrowing version — an unknown one fails closed instead
|
|
457
|
+
* of being read as if it were the current shape.
|
|
458
|
+
*/
|
|
459
|
+
export function decodeRoutingNarrowing(document) {
|
|
460
|
+
plainObject(document, 'routing narrowing');
|
|
461
|
+
if (document.schemaVersion !== ROUTING_PROFILE_VERSION) {
|
|
462
|
+
throw new TypeError(
|
|
463
|
+
`unsupported routing narrowing schemaVersion: ${JSON.stringify(document.schemaVersion)}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return { narrowing: validateRoutingNarrowing(document) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const strictness = (switching) => SWITCHING.indexOf(switching);
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Every way a project document could widen the global authorization, named. A
|
|
473
|
+
* narrowing may subtract from a set and move switching toward stricter — nothing
|
|
474
|
+
* else. This is an authorization check only: whether a pair can actually be
|
|
475
|
+
* reached is decided at dispatch time against the Access graph, not here.
|
|
476
|
+
*/
|
|
477
|
+
export function narrowingViolations(globalDocument, narrowingDocument) {
|
|
478
|
+
const authorization = validateRoutingProfile(globalDocument);
|
|
479
|
+
const narrowing = validateRoutingNarrowing(narrowingDocument);
|
|
480
|
+
const violations = [];
|
|
481
|
+
const surfaces = new Set(authorization.selectedSurfaces);
|
|
482
|
+
for (const surface of narrowing.selectedSurfaces ?? []) {
|
|
483
|
+
if (!surfaces.has(surface)) violations.push({ code: 'surface-not-authorized', surface });
|
|
484
|
+
}
|
|
485
|
+
const transports = new Set(authorization.authorizedTransports.map(transportKey));
|
|
486
|
+
for (const transport of narrowing.authorizedTransports ?? []) {
|
|
487
|
+
if (!transports.has(transportKey(transport))) {
|
|
488
|
+
violations.push({ code: 'transport-not-authorized', ...transport });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const admitted = new Set(admittedPairs(authorization.roster).map(pairKey));
|
|
492
|
+
for (const pair of narrowing.roster ?? []) {
|
|
493
|
+
if (!admitted.has(pairKey(pair))) violations.push({ code: 'pair-not-authorized', ...pair });
|
|
494
|
+
}
|
|
495
|
+
if (narrowing.switching && strictness(narrowing.switching) < strictness(authorization.switching)) {
|
|
496
|
+
violations.push({
|
|
497
|
+
code: 'switching-loosened', from: authorization.switching, to: narrowing.switching,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
// A replacement route must sit inside the roster this narrowing leaves behind,
|
|
501
|
+
// not merely inside the global one.
|
|
502
|
+
const effective = narrowing.roster
|
|
503
|
+
? new Set(narrowing.roster.map(pairKey).filter((key) => admitted.has(key)))
|
|
504
|
+
: admitted;
|
|
505
|
+
for (const [workload, entry] of Object.entries(narrowing.standardRoutes ?? {})) {
|
|
506
|
+
if (entry && !effective.has(pairKey(entry))) {
|
|
507
|
+
violations.push({
|
|
508
|
+
code: 'standard-route-not-in-effective-roster',
|
|
509
|
+
workload, model: entry.model, effort: entry.effort,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return violations;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** One line per violation, so a rejection names what it refused and why. */
|
|
517
|
+
function describeNarrowingViolations(violations) {
|
|
518
|
+
return violations.map((violation) => {
|
|
519
|
+
if (violation.code === 'surface-not-authorized') return `${violation.code}:${violation.surface}`;
|
|
520
|
+
if (violation.code === 'transport-not-authorized') {
|
|
521
|
+
return `${violation.code}:${transportKey(violation).replace('\u0000', '/')}`;
|
|
522
|
+
}
|
|
523
|
+
if (violation.code === 'switching-loosened') {
|
|
524
|
+
return `${violation.code}:${violation.from}->${violation.to}`;
|
|
525
|
+
}
|
|
526
|
+
if (violation.code === 'standard-route-not-in-effective-roster') {
|
|
527
|
+
return `${violation.code}:${violation.workload}=${pairLabel(violation)}`;
|
|
528
|
+
}
|
|
529
|
+
return `${violation.code}:${pairLabel(violation)}`;
|
|
530
|
+
}).join(', ');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The pinned inventory the roster is reconciled against. An explicit
|
|
535
|
+
* `inventory` — including an explicit `null` — wins; otherwise the shipped
|
|
536
|
+
* snapshots are read, and an unreadable snapshot yields no inventory plus the
|
|
537
|
+
* reason, rather than failing an update that has nothing to do with the roster.
|
|
538
|
+
*/
|
|
539
|
+
async function resolveInventory(options) {
|
|
540
|
+
if (options.inventory !== undefined) return { inventory: options.inventory, error: null };
|
|
541
|
+
try {
|
|
542
|
+
return { inventory: await (options.loadInventory ?? loadRoutingInventory)(), error: null };
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return { inventory: null, error: error.message };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The pairs an inventory knows, keyed by pair identity after normalization. */
|
|
549
|
+
function knownInventoryPairs(inventory) {
|
|
550
|
+
const known = new Map();
|
|
551
|
+
for (const candidate of inventory.pairs) {
|
|
552
|
+
const pair = {
|
|
553
|
+
model: normalizeRosterModelId(candidate.modelId, 'inventory modelId'),
|
|
554
|
+
effort: candidate.effort ?? null,
|
|
555
|
+
};
|
|
556
|
+
if (!known.has(pairKey(pair))) {
|
|
557
|
+
known.set(pairKey(pair), { ...pair, surface: candidate.surface, provider: candidate.provider });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return known;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The roster state machine, run against one recorded inventory revision.
|
|
565
|
+
*
|
|
566
|
+
* Transitions: a pair the inventory adds and the roster does not record is
|
|
567
|
+
* `pending` and the interview asks about it once; an `admitted` pair the
|
|
568
|
+
* inventory no longer lists is `withdrawn` immediately, derived rather than
|
|
569
|
+
* waiting for a write; a `declined` pair keeps its decline even when the pair
|
|
570
|
+
* disappears, so a user decision is never re-asked when it comes back; and a
|
|
571
|
+
* `withdrawn` pair the inventory lists again is `reopenable` — offered, never
|
|
572
|
+
* silently re-admitted.
|
|
573
|
+
*/
|
|
574
|
+
export function reconcileRosterState({ roster = [], inventoryRevision = null, inventory }) {
|
|
575
|
+
if (!inventory || typeof inventory.revision !== 'string' || !Array.isArray(inventory.pairs)) {
|
|
576
|
+
throw new TypeError('a roster reconcile needs a loaded inventory with a revision and pairs');
|
|
577
|
+
}
|
|
578
|
+
const known = knownInventoryPairs(inventory);
|
|
579
|
+
const recorded = new Set();
|
|
580
|
+
const entries = [];
|
|
581
|
+
const newlyWithdrawn = [];
|
|
582
|
+
for (const entry of validateRoster(roster)) {
|
|
583
|
+
const withdrawn = entry.state === 'admitted' && !known.has(pairKey(entry));
|
|
584
|
+
if (withdrawn) newlyWithdrawn.push(barePair(entry));
|
|
585
|
+
entries.push(withdrawn ? { ...entry, state: 'withdrawn' } : entry);
|
|
586
|
+
recorded.add(pairKey(entry));
|
|
587
|
+
}
|
|
588
|
+
const inState = (state) => entries.filter((entry) => entry.state === state).map(barePair);
|
|
589
|
+
const withdrawn = inState('withdrawn');
|
|
590
|
+
return Object.freeze({
|
|
591
|
+
inventoryRevision: inventory.revision,
|
|
592
|
+
recordedInventoryRevision: inventoryRevision,
|
|
593
|
+
stale: inventoryRevision !== inventory.revision,
|
|
594
|
+
entries: Object.freeze(entries),
|
|
595
|
+
admitted: Object.freeze(inState('admitted')),
|
|
596
|
+
declined: Object.freeze(inState('declined')),
|
|
597
|
+
withdrawn: Object.freeze(withdrawn),
|
|
598
|
+
newlyWithdrawn: Object.freeze(newlyWithdrawn),
|
|
599
|
+
reopenable: Object.freeze(withdrawn.filter((pair) => known.has(pairKey(pair)))),
|
|
600
|
+
pending: Object.freeze([...known.values()]
|
|
601
|
+
.filter((pair) => !recorded.has(pairKey(pair))).map(barePair)),
|
|
602
|
+
known: Object.freeze([...known.values()]),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Intersect one narrowing axis with its authorization, naming what fell out. */
|
|
607
|
+
function intersectAxis(authorized, narrowed, { axis, key, label }, notes) {
|
|
608
|
+
if (!narrowed) return [...authorized];
|
|
609
|
+
const available = new Map(authorized.map((value) => [key(value), value]));
|
|
610
|
+
const wanted = new Set(narrowed.map(key));
|
|
611
|
+
for (const value of narrowed) {
|
|
612
|
+
if (!available.has(key(value))) {
|
|
613
|
+
notes.push({ code: 'narrowing-dropped-by-global-contraction', axis, value: label(value) });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return authorized.filter((value) => wanted.has(key(value)));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function composeStandardRoutes(authorization, narrowing, authorized, notes) {
|
|
620
|
+
const routes = {};
|
|
621
|
+
const blocked = [];
|
|
622
|
+
for (const workload of STANDARD_ROUTE_CLASSES) {
|
|
623
|
+
const override = narrowing?.standardRoutes && workload in narrowing.standardRoutes;
|
|
624
|
+
const entry = override ? narrowing.standardRoutes[workload] : authorization.standardRoutes[workload];
|
|
625
|
+
if (!entry) {
|
|
626
|
+
routes[workload] = null;
|
|
627
|
+
blocked.push({ workload, reason: 'standard-route-missing' });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (entry.state === 'unresolved' || !authorized.has(pairKey(entry))) {
|
|
631
|
+
if (entry.state !== 'unresolved') {
|
|
632
|
+
notes.push({
|
|
633
|
+
code: 'standard-route-derived-unresolved',
|
|
634
|
+
workload, model: entry.model, effort: entry.effort,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
routes[workload] = { ...barePair(entry), state: 'unresolved' };
|
|
638
|
+
blocked.push({ workload, reason: 'standard-route-unresolved' });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
routes[workload] = { ...barePair(entry), state: 'configured' };
|
|
642
|
+
}
|
|
643
|
+
return { routes, blocked };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Compose the global authorization with this project's narrowing: intersection
|
|
648
|
+
* for surfaces, transports and roster, strictest-wins for switching.
|
|
649
|
+
*
|
|
650
|
+
* Composition answers exactly one question — what is authorized. It never
|
|
651
|
+
* consults the Access graph, so an unreachable or untested pair still composes;
|
|
652
|
+
* executability is decided at dispatch time. A global contraction therefore
|
|
653
|
+
* never invalidates an older narrowing: the elements it lost are dropped with a
|
|
654
|
+
* note, and a Standard route that named one of them is derived `unresolved` and
|
|
655
|
+
* blocks its workload class. The Kit picks no replacement.
|
|
656
|
+
*/
|
|
657
|
+
export function composeRoutingProfile({ global, project = null, inventory = null }) {
|
|
658
|
+
const authorization = validateRoutingProfile(global);
|
|
659
|
+
const narrowing = project ? validateRoutingNarrowing(project) : null;
|
|
660
|
+
const notes = [];
|
|
661
|
+
const rosterState = inventory
|
|
662
|
+
? reconcileRosterState({
|
|
663
|
+
roster: authorization.roster,
|
|
664
|
+
inventoryRevision: authorization.inventoryRevision,
|
|
665
|
+
inventory,
|
|
666
|
+
})
|
|
667
|
+
: null;
|
|
668
|
+
const admitted = rosterState ? [...rosterState.admitted] : admittedPairs(authorization.roster);
|
|
669
|
+
const selectedSurfaces = intersectAxis(authorization.selectedSurfaces, narrowing?.selectedSurfaces,
|
|
670
|
+
{ axis: 'surface', key: (id) => id, label: (id) => id }, notes);
|
|
671
|
+
const authorizedTransports = intersectAxis(
|
|
672
|
+
authorization.authorizedTransports, narrowing?.authorizedTransports,
|
|
673
|
+
{ axis: 'transport', key: transportKey, label: ({ surface, transport }) => `${surface}/${transport}` },
|
|
674
|
+
notes,
|
|
675
|
+
).filter(({ surface }) => selectedSurfaces.includes(surface));
|
|
676
|
+
const roster = intersectAxis(admitted, narrowing?.roster,
|
|
677
|
+
{ axis: 'pair', key: pairKey, label: pairLabel }, notes);
|
|
678
|
+
const { routes, blocked } = composeStandardRoutes(
|
|
679
|
+
authorization, narrowing, new Set(roster.map(pairKey)), notes,
|
|
680
|
+
);
|
|
681
|
+
return Object.freeze({
|
|
682
|
+
selectedSurfaces: Object.freeze(selectedSurfaces),
|
|
683
|
+
authorizedTransports: Object.freeze(authorizedTransports),
|
|
684
|
+
switching: strictness(narrowing?.switching) > strictness(authorization.switching)
|
|
685
|
+
? narrowing.switching
|
|
686
|
+
: authorization.switching,
|
|
687
|
+
roster: Object.freeze(roster),
|
|
688
|
+
standardRoutes: Object.freeze(routes),
|
|
689
|
+
inventoryRevision: authorization.inventoryRevision,
|
|
690
|
+
rosterState,
|
|
691
|
+
blocked: Object.freeze(blocked),
|
|
692
|
+
notes: Object.freeze(notes),
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function stateRoot(profileRoot) {
|
|
697
|
+
return profileRoot ??
|
|
72
698
|
join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'agent-workflow-kit');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function routingProfilePath(consumerRoot, profileRoot) {
|
|
73
702
|
const consumerKey = createHash('sha256').update(resolve(consumerRoot)).digest('hex').slice(0, 20);
|
|
74
|
-
return join(
|
|
703
|
+
return join(stateRoot(profileRoot), 'profiles', consumerKey, ROUTING_PROFILE_PATH);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Where the two-level store keeps its generations: one global, one per project key. */
|
|
707
|
+
export function routingProfileStorageRoot(profileRoot) {
|
|
708
|
+
return join(stateRoot(profileRoot), 'routing');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function projectIdentity({ identity, projectRoot, runGit }) {
|
|
712
|
+
if (identity) return identity;
|
|
713
|
+
return resolveProjectIdentity({ projectRoot, runGit });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function globalGeneration(envelope) {
|
|
717
|
+
if (!envelope) return null;
|
|
718
|
+
const { profile, migration } = decodeRoutingProfile(envelope.document);
|
|
719
|
+
return {
|
|
720
|
+
generation: envelope.generation,
|
|
721
|
+
committedAt: envelope.committedAt,
|
|
722
|
+
profile,
|
|
723
|
+
migration,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function projectGeneration(envelope) {
|
|
728
|
+
if (!envelope) return null;
|
|
729
|
+
return {
|
|
730
|
+
generation: envelope.generation,
|
|
731
|
+
committedAt: envelope.committedAt,
|
|
732
|
+
authoredAgainstGlobalGeneration: envelope.authoredAgainstGlobalGeneration ?? null,
|
|
733
|
+
narrowing: decodeRoutingNarrowing(envelope.document).narrowing,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Read the latest committed global generation plus this project's own narrowing
|
|
739
|
+
* and compose them, so a global choice made after the narrowing is never
|
|
740
|
+
* invisible to the project. The generation a narrowing was authored against is
|
|
741
|
+
* reported for diagnostics, never used as the read key, and a project without a
|
|
742
|
+
* narrowing is a normal, safe state rather than an error.
|
|
743
|
+
*/
|
|
744
|
+
export async function readComposedRoutingProfile(options) {
|
|
745
|
+
const { profileRoot, projectRoot, identity, runGit } = options;
|
|
746
|
+
const resolved = await projectIdentity({ identity, projectRoot, runGit });
|
|
747
|
+
const pair = await readCommittedRoutingProfilePair({
|
|
748
|
+
root: routingProfileStorageRoot(profileRoot),
|
|
749
|
+
projectKey: resolved.key,
|
|
750
|
+
});
|
|
751
|
+
const reasons = [];
|
|
752
|
+
if (!pair.global) reasons.push('no-global-authorization');
|
|
753
|
+
if (!pair.project) reasons.push('no-project-narrowing');
|
|
754
|
+
const global = globalGeneration(pair.global);
|
|
755
|
+
const project = projectGeneration(pair.project);
|
|
756
|
+
const { inventory } = await resolveInventory(options);
|
|
757
|
+
return {
|
|
758
|
+
identity: resolved,
|
|
759
|
+
global,
|
|
760
|
+
project,
|
|
761
|
+
composed: global
|
|
762
|
+
? composeRoutingProfile({
|
|
763
|
+
global: global.profile, project: project?.narrowing ?? null, inventory,
|
|
764
|
+
})
|
|
765
|
+
: null,
|
|
766
|
+
reasons,
|
|
767
|
+
pendingTransactionId: pair.pendingTransactionId,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The authorization a project narrowing is derived against: the pair being committed, else the store's. */
|
|
772
|
+
async function authorizingGlobal(root, projectKey, globalDocument) {
|
|
773
|
+
if (globalDocument) return { authorization: globalDocument, generation: undefined };
|
|
774
|
+
const committed = await readCommittedRoutingProfilePair({ root, projectKey });
|
|
775
|
+
if (!committed.global) {
|
|
776
|
+
throw new Error('a project narrowing needs a committed global authorization: '
|
|
777
|
+
+ 'no-global-authorization');
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
authorization: decodeRoutingProfile(committed.global.document).profile,
|
|
781
|
+
generation: committed.global.generation,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Commit the global authorization, the project narrowing, or both as one
|
|
787
|
+
* transaction. Each document is validated against its own schema before it
|
|
788
|
+
* reaches the store — the envelope carries the generation, the documents never
|
|
789
|
+
* do — and a project document that would widen the authorization it narrows is
|
|
790
|
+
* refused here, naming every axis it widened.
|
|
791
|
+
*/
|
|
792
|
+
export async function commitRoutingProfilePair({
|
|
793
|
+
profileRoot, projectRoot, identity, runGit, global = null, project = null,
|
|
794
|
+
expectedGlobalGeneration, expectedProjectGeneration, now,
|
|
795
|
+
}) {
|
|
796
|
+
const resolved = await projectIdentity({ identity, projectRoot, runGit });
|
|
797
|
+
const root = routingProfileStorageRoot(profileRoot);
|
|
798
|
+
const globalDocument = global ? validateRoutingProfile(global) : null;
|
|
799
|
+
const projectDocument = project ? validateRoutingNarrowing(project) : null;
|
|
800
|
+
let expectedGlobal = expectedGlobalGeneration;
|
|
801
|
+
if (projectDocument) {
|
|
802
|
+
const { authorization, generation } = await authorizingGlobal(
|
|
803
|
+
root, resolved.key, globalDocument,
|
|
804
|
+
);
|
|
805
|
+
const violations = narrowingViolations(authorization, projectDocument);
|
|
806
|
+
if (violations.length) {
|
|
807
|
+
throw new Error('project narrowing widens the global authorization: '
|
|
808
|
+
+ describeNarrowingViolations(violations));
|
|
809
|
+
}
|
|
810
|
+
// The narrowing was checked against the generation just read: bind the commit
|
|
811
|
+
// to it so a global that moved in between fails instead of racing.
|
|
812
|
+
if (expectedGlobal === undefined && generation !== undefined) expectedGlobal = generation;
|
|
813
|
+
}
|
|
814
|
+
return commitRoutingProfileGenerations({
|
|
815
|
+
root,
|
|
816
|
+
identity: resolved,
|
|
817
|
+
globalDocument,
|
|
818
|
+
projectDocument,
|
|
819
|
+
expectedGlobalGeneration: expectedGlobal,
|
|
820
|
+
expectedProjectGeneration,
|
|
821
|
+
now,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Where the pre-migration document is preserved before a migrated profile is written. */
|
|
826
|
+
export function routingProfileBackupPath(consumerRoot, profileRoot, fromVersion) {
|
|
827
|
+
const path = routingProfilePath(consumerRoot, profileRoot);
|
|
828
|
+
return join(dirname(path), `routing-profile.v${fromVersion}.backup.json`);
|
|
75
829
|
}
|
|
76
830
|
|
|
77
831
|
async function readProfileSnapshot(consumerRoot, profileRoot) {
|
|
@@ -80,12 +834,15 @@ async function readProfileSnapshot(consumerRoot, profileRoot) {
|
|
|
80
834
|
const bytes = await readFile(path, 'utf8');
|
|
81
835
|
const fingerprint = createHash('sha256').update(bytes).digest('hex');
|
|
82
836
|
try {
|
|
83
|
-
|
|
837
|
+
const { profile, migration } = decodeRoutingProfile(JSON.parse(bytes));
|
|
838
|
+
return { profile, migration, bytes, fingerprint, invalid: false };
|
|
84
839
|
} catch {
|
|
85
|
-
return { profile: null, fingerprint, invalid: true };
|
|
840
|
+
return { profile: null, migration: null, bytes, fingerprint, invalid: true };
|
|
86
841
|
}
|
|
87
842
|
} catch (error) {
|
|
88
|
-
if (error.code === 'ENOENT')
|
|
843
|
+
if (error.code === 'ENOENT') {
|
|
844
|
+
return { profile: null, migration: null, bytes: null, fingerprint: null, invalid: false };
|
|
845
|
+
}
|
|
89
846
|
throw error;
|
|
90
847
|
}
|
|
91
848
|
}
|
|
@@ -102,55 +859,102 @@ async function resolvedDetectedSurfaceIds(options) {
|
|
|
102
859
|
return detected.filter((surface) => surface.detected).map((surface) => surface.id);
|
|
103
860
|
}
|
|
104
861
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
862
|
+
/**
|
|
863
|
+
* What the roster and the Standard routes still owe the user, and nothing more.
|
|
864
|
+
*
|
|
865
|
+
* A pair the roster never recorded, an admitted pair the inventory dropped, and
|
|
866
|
+
* a withdrawn pair that came back each ask exactly one question. A bare
|
|
867
|
+
* inventory revision bump with no pair delta asks nothing — it rides along with
|
|
868
|
+
* the next authorized write, which is what keeps this from becoming the endless
|
|
869
|
+
* reconcile prompt the plan set out to remove. A Standard route already stored
|
|
870
|
+
* `unresolved` is a settled state and stays silent too; only a stored
|
|
871
|
+
* `configured` route whose pair is no longer authorized is a new fact.
|
|
872
|
+
*/
|
|
873
|
+
function rosterDelta(profile, inventory, inventoryError) {
|
|
874
|
+
if (!inventory) {
|
|
111
875
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
delta: {
|
|
115
|
-
|
|
116
|
-
fingerprint,
|
|
117
|
-
detectedSurfaceIds,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
if (!profile) {
|
|
121
|
-
return {
|
|
122
|
-
status: 'needs-reconcile',
|
|
123
|
-
reasons: ['missing'],
|
|
124
|
-
delta: { type: 'missing-profile' },
|
|
125
|
-
profile: null,
|
|
126
|
-
fingerprint,
|
|
127
|
-
detectedSurfaceIds,
|
|
876
|
+
reasons: inventoryError ? ['roster-inventory-unreadable'] : [],
|
|
877
|
+
state: null,
|
|
878
|
+
delta: { inventoryUnreadable: Boolean(inventoryError) },
|
|
879
|
+
blocked: [],
|
|
128
880
|
};
|
|
129
881
|
}
|
|
882
|
+
const state = reconcileRosterState({
|
|
883
|
+
roster: profile.roster, inventoryRevision: profile.inventoryRevision, inventory,
|
|
884
|
+
});
|
|
885
|
+
const composed = composeRoutingProfile({ global: profile, inventory });
|
|
886
|
+
const unresolved = composed.notes.filter(({ code }) => code === 'standard-route-derived-unresolved');
|
|
887
|
+
const reasons = [];
|
|
888
|
+
if (state.pending.length) reasons.push('roster-pairs-unrecorded');
|
|
889
|
+
if (state.newlyWithdrawn.length) reasons.push('roster-pair-withdrawn');
|
|
890
|
+
if (state.reopenable.length) reasons.push('roster-pair-reopenable');
|
|
891
|
+
if (unresolved.length) reasons.push('standard-route-unresolved');
|
|
892
|
+
return {
|
|
893
|
+
reasons,
|
|
894
|
+
state,
|
|
895
|
+
blocked: composed.blocked,
|
|
896
|
+
delta: {
|
|
897
|
+
inventoryRevision: { from: state.recordedInventoryRevision, to: state.inventoryRevision },
|
|
898
|
+
pending: [...state.pending],
|
|
899
|
+
withdrawn: [...state.newlyWithdrawn],
|
|
900
|
+
reopenable: [...state.reopenable],
|
|
901
|
+
unresolvedRoutes: unresolved.map(({ workload, model, effort }) => ({ workload, model, effort })),
|
|
902
|
+
inventoryUnreadable: false,
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Which surfaces the registry gained or lost since the profile was written. */
|
|
908
|
+
function surfaceDelta(profile, registry, detectedSurfaceIds) {
|
|
130
909
|
const known = new Set(registry.map(({ id }) => id));
|
|
131
910
|
const considered = new Set(profile.consideredSurfaces);
|
|
132
|
-
const reasons = [];
|
|
133
911
|
const removedSurfaceIds = profile.selectedSurfaces.filter((id) => !known.has(id));
|
|
134
912
|
const newSurfaceIds = detectedSurfaceIds.filter((id) => known.has(id) && !considered.has(id));
|
|
913
|
+
const reasons = [];
|
|
135
914
|
if (profile.registryRevision < AGENT_SURFACE_REGISTRY_REVISION) reasons.push('materially-stale');
|
|
136
915
|
if (removedSurfaceIds.length) reasons.push('removed-route');
|
|
137
916
|
if (newSurfaceIds.length) reasons.push('new-meaningful-surface');
|
|
138
917
|
return {
|
|
139
|
-
status: reasons.length ? 'needs-reconcile' : 'still valid',
|
|
140
918
|
reasons,
|
|
141
919
|
delta: {
|
|
142
|
-
|
|
143
|
-
registryRevision: {
|
|
144
|
-
from: profile.registryRevision,
|
|
145
|
-
to: AGENT_SURFACE_REGISTRY_REVISION,
|
|
146
|
-
},
|
|
920
|
+
registryRevision: { from: profile.registryRevision, to: AGENT_SURFACE_REGISTRY_REVISION },
|
|
147
921
|
removedSurfaces: removedSurfaceIds.map((id) => ({ id, label: id })),
|
|
148
|
-
newSurfaces: newSurfaceIds.map((id) => {
|
|
149
|
-
const surface = surfaceById(id, registry);
|
|
150
|
-
return { id, label: surface.label };
|
|
151
|
-
}),
|
|
922
|
+
newSurfaces: newSurfaceIds.map((id) => ({ id, label: surfaceById(id, registry).label })),
|
|
152
923
|
},
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export async function inspectRoutingProfile(options) {
|
|
928
|
+
const { consumerRoot, registry = AGENT_SURFACE_REGISTRY } = options;
|
|
929
|
+
const detectedSurfaceIds = await resolvedDetectedSurfaceIds(options);
|
|
930
|
+
const snapshot = await readProfileSnapshot(consumerRoot, options.profileRoot);
|
|
931
|
+
const { profile, fingerprint, migration } = snapshot;
|
|
932
|
+
const unusable = snapshot.invalid ? 'invalid' : (profile ? null : 'missing');
|
|
933
|
+
if (unusable) {
|
|
934
|
+
return {
|
|
935
|
+
status: 'needs-reconcile',
|
|
936
|
+
reasons: [unusable],
|
|
937
|
+
delta: { type: `${unusable}-profile` },
|
|
938
|
+
profile: null,
|
|
939
|
+
migration: null,
|
|
940
|
+
rosterState: null,
|
|
941
|
+
fingerprint,
|
|
942
|
+
detectedSurfaceIds,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const surfaces = surfaceDelta(profile, registry, detectedSurfaceIds);
|
|
946
|
+
const { inventory, error } = await resolveInventory(options);
|
|
947
|
+
const roster = rosterDelta(profile, inventory, error);
|
|
948
|
+
const reasons = [...surfaces.reasons, ...roster.reasons];
|
|
949
|
+
return {
|
|
950
|
+
status: reasons.length ? 'needs-reconcile' : 'still valid',
|
|
951
|
+
reasons,
|
|
952
|
+
delta: { type: 'profile-delta', ...surfaces.delta, roster: roster.delta },
|
|
153
953
|
profile,
|
|
954
|
+
migration,
|
|
955
|
+
rosterState: roster.state,
|
|
956
|
+
blocked: roster.blocked,
|
|
957
|
+
inventory,
|
|
154
958
|
fingerprint,
|
|
155
959
|
detectedSurfaceIds,
|
|
156
960
|
};
|
|
@@ -177,12 +981,263 @@ function autonomyQuestion() {
|
|
|
177
981
|
};
|
|
178
982
|
}
|
|
179
983
|
|
|
984
|
+
/** Every `(surface, transport)` the selected apps declare, native first. */
|
|
985
|
+
function transportChoices(selectedSurfaces, registry) {
|
|
986
|
+
return selectedSurfaces.flatMap((id) => {
|
|
987
|
+
const surface = surfaceById(id, registry);
|
|
988
|
+
if (!surface) return [];
|
|
989
|
+
return [...surface.adapter.transports]
|
|
990
|
+
.sort((left, right) => Number(right === NATIVE_TRANSPORT) - Number(left === NATIVE_TRANSPORT))
|
|
991
|
+
.map((transport) => ({
|
|
992
|
+
surface: id,
|
|
993
|
+
surfaceLabel: surface.label,
|
|
994
|
+
transport,
|
|
995
|
+
native: transport === NATIVE_TRANSPORT,
|
|
996
|
+
}));
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function transportQuestion(choices) {
|
|
1001
|
+
return {
|
|
1002
|
+
kind: 'transports',
|
|
1003
|
+
message: 'Which runtime may each agent app drive?',
|
|
1004
|
+
options: choices,
|
|
1005
|
+
preselected: choices.filter(({ native }) => native)
|
|
1006
|
+
.map(({ surface, transport }) => ({ surface, transport })),
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** How many entries a long list shows at once before it scrolls. */
|
|
1011
|
+
const ROSTER_PAGE_SIZE = 10;
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The roster question: the inventory grouped per agent app, detected apps first,
|
|
1015
|
+
* with the pair count and a page size, so a list that grows to dozens of pairs
|
|
1016
|
+
* stays navigable instead of scrolling off the screen.
|
|
1017
|
+
*/
|
|
1018
|
+
function rosterQuestion({ inventory, detectedSurfaceIds, registry }, selectable, message) {
|
|
1019
|
+
const wanted = new Set(selectable.map(pairKey));
|
|
1020
|
+
const seen = new Set();
|
|
1021
|
+
const groups = [];
|
|
1022
|
+
for (const candidate of presentInventory(inventory, detectedSurfaceIds).pairs) {
|
|
1023
|
+
const pair = {
|
|
1024
|
+
model: normalizeRosterModelId(candidate.modelId, 'inventory modelId'),
|
|
1025
|
+
effort: candidate.effort ?? null,
|
|
1026
|
+
};
|
|
1027
|
+
if (!wanted.has(pairKey(pair)) || seen.has(pairKey(pair))) continue;
|
|
1028
|
+
seen.add(pairKey(pair));
|
|
1029
|
+
const label = surfaceById(candidate.surface, registry)?.label ?? candidate.surface;
|
|
1030
|
+
let group = groups.find(({ surface }) => surface === candidate.surface);
|
|
1031
|
+
if (!group) {
|
|
1032
|
+
group = {
|
|
1033
|
+
surface: candidate.surface,
|
|
1034
|
+
label,
|
|
1035
|
+
detected: detectedSurfaceIds.includes(candidate.surface),
|
|
1036
|
+
pairs: [],
|
|
1037
|
+
};
|
|
1038
|
+
groups.push(group);
|
|
1039
|
+
}
|
|
1040
|
+
group.pairs.push(pair);
|
|
1041
|
+
}
|
|
1042
|
+
return { kind: 'roster', message, groups, total: seen.size, preselected: [] };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function standardRouteQuestion(workload, admitted, current) {
|
|
1046
|
+
return {
|
|
1047
|
+
kind: 'standard-route',
|
|
1048
|
+
workload,
|
|
1049
|
+
message: `Which pair decides ${workload} work when no evidence covers it?`,
|
|
1050
|
+
options: admitted,
|
|
1051
|
+
current: current ?? null,
|
|
1052
|
+
pageSize: ROSTER_PAGE_SIZE,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* The interview, in order, with what the user sees at each stage. The loop below
|
|
1058
|
+
* is driven by this table, so the sequence a reader finds here is the sequence
|
|
1059
|
+
* that runs.
|
|
1060
|
+
*/
|
|
1061
|
+
export const ROUTING_INTERVIEW_SEQUENCE = Object.freeze([
|
|
1062
|
+
Object.freeze({
|
|
1063
|
+
id: 'surfaces',
|
|
1064
|
+
kinds: Object.freeze(['surfaces']),
|
|
1065
|
+
asks: 'which agent apps you use',
|
|
1066
|
+
shows: 'every registered app, the detected entries preselected, one hint per app',
|
|
1067
|
+
}),
|
|
1068
|
+
Object.freeze({
|
|
1069
|
+
id: 'transports',
|
|
1070
|
+
kinds: Object.freeze(['transports']),
|
|
1071
|
+
asks: 'which runtime each selected app may drive',
|
|
1072
|
+
shows: 'one entry per app and transport, native preselected, cross-app CLI control spelled out',
|
|
1073
|
+
skippedWhen: 'no selected app declares a transport',
|
|
1074
|
+
}),
|
|
1075
|
+
Object.freeze({
|
|
1076
|
+
id: 'switching',
|
|
1077
|
+
kinds: Object.freeze(['autonomy']),
|
|
1078
|
+
asks: 'whether the Kit may move a task to another app',
|
|
1079
|
+
shows: 'the three switching modes with what each one does',
|
|
1080
|
+
skippedWhen: 'a single app is selected — there is nothing to switch to',
|
|
1081
|
+
}),
|
|
1082
|
+
Object.freeze({
|
|
1083
|
+
id: 'roster',
|
|
1084
|
+
kinds: Object.freeze(['roster']),
|
|
1085
|
+
asks: 'which model-and-effort pairs the Kit may pick from',
|
|
1086
|
+
shows: 'the pinned inventory grouped per app, detected apps first, with the pair count, '
|
|
1087
|
+
+ 'a page size, and the warning that an unselected pair is recorded as declined',
|
|
1088
|
+
skippedWhen: 'the pinned inventory is unreadable or has nothing left to decide',
|
|
1089
|
+
}),
|
|
1090
|
+
Object.freeze({
|
|
1091
|
+
id: 'standardRoutes',
|
|
1092
|
+
kinds: Object.freeze(['standard-route']),
|
|
1093
|
+
asks: 'which admitted pair decides a workload class without decisive evidence',
|
|
1094
|
+
shows: 'one question per workload class over the admitted pairs, each answerable with none',
|
|
1095
|
+
skippedWhen: 'no pair is admitted, so there is nothing to nominate',
|
|
1096
|
+
}),
|
|
1097
|
+
Object.freeze({
|
|
1098
|
+
id: 'activation',
|
|
1099
|
+
kinds: Object.freeze(['activation', 'advanced']),
|
|
1100
|
+
asks: 'whether to store the reviewed profile',
|
|
1101
|
+
shows: 'the whole draft — apps, transports, switching, roster counts, Standard routes, '
|
|
1102
|
+
+ 'advanced draft — plus what each action does',
|
|
1103
|
+
}),
|
|
1104
|
+
]);
|
|
1105
|
+
|
|
1106
|
+
async function askSurfaces(context, draft) {
|
|
1107
|
+
const selected = uniqueStrings(
|
|
1108
|
+
await context.prompt(surfaceQuestion(context.registry, context.preselected)),
|
|
1109
|
+
'selected surfaces',
|
|
1110
|
+
).filter((id) => context.knownSurfaces.has(id));
|
|
1111
|
+
if (!selected.length) selected.push(context.currentSurface);
|
|
1112
|
+
draft.selectedSurfaces = selected;
|
|
1113
|
+
return 'ok';
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function askTransports(context, draft) {
|
|
1117
|
+
const choices = transportChoices(draft.selectedSurfaces, context.registry);
|
|
1118
|
+
if (!choices.length) {
|
|
1119
|
+
draft.authorizedTransports = [];
|
|
1120
|
+
return 'ok';
|
|
1121
|
+
}
|
|
1122
|
+
const offered = new Map(choices.map((choice) => [
|
|
1123
|
+
transportKey(choice), { surface: choice.surface, transport: choice.transport },
|
|
1124
|
+
]));
|
|
1125
|
+
const answer = await context.prompt(transportQuestion(choices));
|
|
1126
|
+
draft.authorizedTransports = (Array.isArray(answer) ? answer : [])
|
|
1127
|
+
.filter((entry) => entry && offered.has(transportKey(entry)))
|
|
1128
|
+
.map((entry) => offered.get(transportKey(entry)));
|
|
1129
|
+
return 'ok';
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function askSwitching(context, draft) {
|
|
1133
|
+
if (draft.selectedSurfaces.length === 1) {
|
|
1134
|
+
draft.switching = 'current-surface-only';
|
|
1135
|
+
return 'ok';
|
|
1136
|
+
}
|
|
1137
|
+
const chosen = await context.prompt(autonomyQuestion());
|
|
1138
|
+
if (!SWITCHING.includes(chosen)) throw new TypeError('invalid autonomy choice');
|
|
1139
|
+
draft.switching = chosen;
|
|
1140
|
+
return 'ok';
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Record one answer per offered pair: what the user picked is `admitted`, what
|
|
1145
|
+
* they left is `declined`. A decline is a durable decision, which is exactly why
|
|
1146
|
+
* the pair is never offered again until the user reopens it.
|
|
1147
|
+
*/
|
|
1148
|
+
function mergeRosterAnswer(entries, selectable, answer) {
|
|
1149
|
+
const offered = new Set(selectable.map(pairKey));
|
|
1150
|
+
const chosen = new Set((Array.isArray(answer) ? answer : [])
|
|
1151
|
+
.filter((pair) => pair && offered.has(pairKey(pair)))
|
|
1152
|
+
.map(pairKey));
|
|
1153
|
+
const decided = new Map(entries.map((entry) => [pairKey(entry), entry]));
|
|
1154
|
+
for (const pair of selectable) {
|
|
1155
|
+
decided.set(pairKey(pair), {
|
|
1156
|
+
...pair, state: chosen.has(pairKey(pair)) ? 'admitted' : 'declined',
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
return [...decided.values()];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function askRoster(context, draft) {
|
|
1163
|
+
if (!context.inventory) {
|
|
1164
|
+
draft.roster = [];
|
|
1165
|
+
draft.inventoryRevision = null;
|
|
1166
|
+
return 'ok';
|
|
1167
|
+
}
|
|
1168
|
+
const state = reconcileRosterState({ roster: [], inventoryRevision: null, inventory: context.inventory });
|
|
1169
|
+
draft.inventoryRevision = state.inventoryRevision;
|
|
1170
|
+
draft.roster = state.pending.length
|
|
1171
|
+
? mergeRosterAnswer([], [...state.pending],
|
|
1172
|
+
await context.prompt(rosterQuestion(context, state.pending,
|
|
1173
|
+
'Which model-and-effort pairs may the Kit use?')))
|
|
1174
|
+
: [];
|
|
1175
|
+
return 'ok';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Match an answer back onto the offered pairs; anything else nominates nothing. */
|
|
1179
|
+
function matchOffered(answer, offered) {
|
|
1180
|
+
const known = new Map(offered.map((pair) => [pairKey(pair), pair]));
|
|
1181
|
+
return answer && known.get(pairKey(answer)) ? known.get(pairKey(answer)) : null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function askStandardRoutes(context, draft) {
|
|
1185
|
+
draft.standardRoutes = emptyStandardRoutes();
|
|
1186
|
+
const admitted = admittedPairs(draft.roster);
|
|
1187
|
+
if (!admitted.length) return 'ok';
|
|
1188
|
+
for (const workload of STANDARD_ROUTE_CLASSES) {
|
|
1189
|
+
const chosen = matchOffered(
|
|
1190
|
+
await context.prompt(standardRouteQuestion(workload, admitted, null)), admitted,
|
|
1191
|
+
);
|
|
1192
|
+
draft.standardRoutes[workload] = chosen ? { ...chosen, state: 'configured' } : null;
|
|
1193
|
+
}
|
|
1194
|
+
return 'ok';
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** One activation review: back, decline, optional advanced draft, or activate. */
|
|
1198
|
+
async function askActivation(context, draft) {
|
|
1199
|
+
while (true) {
|
|
1200
|
+
const action = await context.prompt({
|
|
1201
|
+
kind: 'activation',
|
|
1202
|
+
message: 'Review routing activation',
|
|
1203
|
+
selectedSurfaces: draft.selectedSurfaces,
|
|
1204
|
+
authorizedTransports: draft.authorizedTransports,
|
|
1205
|
+
switching: draft.switching,
|
|
1206
|
+
roster: draft.roster,
|
|
1207
|
+
standardRoutes: draft.standardRoutes,
|
|
1208
|
+
advancedDraft: draft.advanced,
|
|
1209
|
+
actions: ACTIVATION,
|
|
1210
|
+
});
|
|
1211
|
+
if (!ACTIVATION.includes(action)) throw new TypeError('invalid activation choice');
|
|
1212
|
+
if (action === 'back') return 'back';
|
|
1213
|
+
if (action === 'decline') return 'declined';
|
|
1214
|
+
if (action === 'advanced') {
|
|
1215
|
+
draft.advanced = await context.prompt({
|
|
1216
|
+
kind: 'advanced',
|
|
1217
|
+
message: 'Optional model and optimization preferences',
|
|
1218
|
+
draft: draft.advanced,
|
|
1219
|
+
});
|
|
1220
|
+
if (!draft.advanced || typeof draft.advanced !== 'object' || Array.isArray(draft.advanced)) {
|
|
1221
|
+
throw new TypeError('advanced choice must be an object');
|
|
1222
|
+
}
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (action === 'safe-current-surface') draft.switching = 'current-surface-only';
|
|
1226
|
+
return 'ok';
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const STAGE_HANDLERS = Object.freeze({
|
|
1231
|
+
surfaces: askSurfaces,
|
|
1232
|
+
transports: askTransports,
|
|
1233
|
+
switching: askSwitching,
|
|
1234
|
+
roster: askRoster,
|
|
1235
|
+
standardRoutes: askStandardRoutes,
|
|
1236
|
+
activation: askActivation,
|
|
1237
|
+
});
|
|
1238
|
+
|
|
180
1239
|
export async function setupRoutingProfile(options) {
|
|
181
|
-
const {
|
|
182
|
-
consumerRoot,
|
|
183
|
-
prompt,
|
|
184
|
-
registry = AGENT_SURFACE_REGISTRY,
|
|
185
|
-
} = options;
|
|
1240
|
+
const { consumerRoot, prompt, registry = AGENT_SURFACE_REGISTRY } = options;
|
|
186
1241
|
if (typeof prompt !== 'function') {
|
|
187
1242
|
return { status: 'needs-reconcile', reasons: ['personal-choice-required'] };
|
|
188
1243
|
}
|
|
@@ -192,66 +1247,53 @@ export async function setupRoutingProfile(options) {
|
|
|
192
1247
|
const expectedFingerprint = options.expectedFingerprint === undefined
|
|
193
1248
|
? (await readProfileSnapshot(consumerRoot, options.profileRoot)).fingerprint
|
|
194
1249
|
: options.expectedFingerprint;
|
|
195
|
-
const
|
|
196
|
-
const preselected = detectedSurfaceIds.filter((id) =>
|
|
1250
|
+
const knownSurfaces = new Set(registry.map(({ id }) => id));
|
|
1251
|
+
const preselected = detectedSurfaceIds.filter((id) => knownSurfaces.has(id));
|
|
197
1252
|
if (!preselected.includes(currentSurface)) preselected.unshift(currentSurface);
|
|
1253
|
+
const { inventory } = await resolveInventory(options);
|
|
1254
|
+
const context = {
|
|
1255
|
+
prompt, registry, knownSurfaces, preselected, currentSurface, detectedSurfaceIds, inventory,
|
|
1256
|
+
};
|
|
198
1257
|
|
|
199
1258
|
while (true) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
advancedDraft: advanced,
|
|
219
|
-
actions: ACTIVATION,
|
|
220
|
-
};
|
|
221
|
-
const action = await prompt(summary);
|
|
222
|
-
if (!ACTIVATION.includes(action)) throw new TypeError('invalid activation choice');
|
|
223
|
-
if (action === 'back') {
|
|
224
|
-
revisitChoices = true;
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
if (action === 'decline') return { status: 'declined' };
|
|
228
|
-
if (action === 'advanced') {
|
|
229
|
-
advanced = await prompt({
|
|
230
|
-
kind: 'advanced',
|
|
231
|
-
message: 'Optional model and optimization preferences',
|
|
232
|
-
draft: advanced,
|
|
233
|
-
});
|
|
234
|
-
if (!advanced || typeof advanced !== 'object' || Array.isArray(advanced)) {
|
|
235
|
-
throw new TypeError('advanced choice must be an object');
|
|
236
|
-
}
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
if (action === 'safe-current-surface') switching = 'current-surface-only';
|
|
240
|
-
const profile = validateRoutingProfile({
|
|
241
|
-
schemaVersion: ROUTING_PROFILE_VERSION,
|
|
242
|
-
registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
|
|
243
|
-
selectedSurfaces,
|
|
244
|
-
consideredSurfaces: [...new Set([...preselected, ...selectedSurfaces])],
|
|
245
|
-
switching,
|
|
246
|
-
advanced,
|
|
247
|
-
});
|
|
248
|
-
await writeProfileExpected(options, profile, expectedFingerprint);
|
|
249
|
-
return { status: 'activated', profile };
|
|
250
|
-
}
|
|
251
|
-
if (revisitChoices) continue;
|
|
1259
|
+
const draft = { advanced: null };
|
|
1260
|
+
const outcome = await runInterview(context, draft);
|
|
1261
|
+
if (outcome === 'back') continue;
|
|
1262
|
+
if (outcome === 'declined') return { status: 'declined' };
|
|
1263
|
+
const profile = validateRoutingProfile({
|
|
1264
|
+
schemaVersion: ROUTING_PROFILE_VERSION,
|
|
1265
|
+
registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
|
|
1266
|
+
selectedSurfaces: draft.selectedSurfaces,
|
|
1267
|
+
consideredSurfaces: [...new Set([...preselected, ...draft.selectedSurfaces])],
|
|
1268
|
+
authorizedTransports: draft.authorizedTransports,
|
|
1269
|
+
switching: draft.switching,
|
|
1270
|
+
roster: draft.roster,
|
|
1271
|
+
inventoryRevision: draft.inventoryRevision,
|
|
1272
|
+
standardRoutes: draft.standardRoutes,
|
|
1273
|
+
advanced: legacyAdvanced(draft.advanced),
|
|
1274
|
+
});
|
|
1275
|
+
await writeProfileExpected(options, profile, expectedFingerprint);
|
|
1276
|
+
return { status: 'activated', profile };
|
|
252
1277
|
}
|
|
253
1278
|
}
|
|
254
1279
|
|
|
1280
|
+
/** Run the declared sequence once; `back` restarts it from the first stage. */
|
|
1281
|
+
async function runInterview(context, draft) {
|
|
1282
|
+
for (const stage of ROUTING_INTERVIEW_SEQUENCE) {
|
|
1283
|
+
const outcome = await STAGE_HANDLERS[stage.id](context, draft);
|
|
1284
|
+
if (outcome !== 'ok') return outcome;
|
|
1285
|
+
}
|
|
1286
|
+
return 'ok';
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** An advanced draft the v2 schema does not model is kept verbatim as legacy evidence. */
|
|
1290
|
+
function legacyAdvanced(draft) {
|
|
1291
|
+
if (!draft || !Object.keys(draft).length) return null;
|
|
1292
|
+
const keys = Object.keys(draft);
|
|
1293
|
+
if (keys.length === 1 && keys[0] === 'legacy') return validateAdvanced(draft);
|
|
1294
|
+
return { legacy: structuredClone(draft) };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
255
1297
|
export async function reconcileRoutingProfile(options, inspection) {
|
|
256
1298
|
if (inspection.status === 'still valid') return { status: 'still valid' };
|
|
257
1299
|
if (typeof options.prompt !== 'function') {
|
|
@@ -277,6 +1319,27 @@ export async function reconcileRoutingProfile(options, inspection) {
|
|
|
277
1319
|
return { status: 'declined', reasons: inspection.reasons };
|
|
278
1320
|
}
|
|
279
1321
|
|
|
1322
|
+
const surfaces = reconcileSurfaces(options, inspection, choice);
|
|
1323
|
+
const roster = await reconcileRoster(options, inspection);
|
|
1324
|
+
const profile = validateRoutingProfile({
|
|
1325
|
+
...inspection.profile,
|
|
1326
|
+
registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
|
|
1327
|
+
selectedSurfaces: surfaces.selectedSurfaces,
|
|
1328
|
+
consideredSurfaces: surfaces.consideredSurfaces,
|
|
1329
|
+
// A transport belongs to a surface: dropping the surface drops its authorization.
|
|
1330
|
+
authorizedTransports: inspection.profile.authorizedTransports
|
|
1331
|
+
.filter(({ surface }) => surfaces.selectedSurfaces.includes(surface)),
|
|
1332
|
+
switching: surfaces.switching,
|
|
1333
|
+
roster: roster.entries,
|
|
1334
|
+
inventoryRevision: roster.inventoryRevision,
|
|
1335
|
+
standardRoutes: await reconcileStandardRoutes(options, inspection, roster.entries),
|
|
1336
|
+
});
|
|
1337
|
+
await writeProfileExpected(options, profile, inspection.fingerprint);
|
|
1338
|
+
return { status: 'reconciled', reasons: inspection.reasons, profile };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** The surface half of a reconcile: only the surfaced delta may change a choice. */
|
|
1342
|
+
function reconcileSurfaces(options, inspection, choice) {
|
|
280
1343
|
const removed = new Set(inspection.delta.removedSurfaces.map(({ id }) => id));
|
|
281
1344
|
const requestedAdds = new Set(choice.addSurfaceIds ?? []);
|
|
282
1345
|
const knownAdds = inspection.delta.newSurfaces
|
|
@@ -294,18 +1357,82 @@ export async function reconcileRoutingProfile(options, inspection) {
|
|
|
294
1357
|
selectedSurfaces.push(safeSurface);
|
|
295
1358
|
switching = 'current-surface-only';
|
|
296
1359
|
}
|
|
297
|
-
|
|
298
|
-
...inspection.profile,
|
|
299
|
-
registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
|
|
1360
|
+
return {
|
|
300
1361
|
selectedSurfaces,
|
|
301
1362
|
consideredSurfaces: [
|
|
302
1363
|
...inspection.profile.consideredSurfaces.filter((id) => !removed.has(id)),
|
|
303
1364
|
...inspection.delta.newSurfaces.map(({ id }) => id),
|
|
304
1365
|
],
|
|
305
1366
|
switching,
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* The roster half of a reconcile: ask only about the pairs this run actually
|
|
1372
|
+
* changed. With nothing new to decide, the derived states are persisted and the
|
|
1373
|
+
* recorded inventory revision rides along with this authorized write.
|
|
1374
|
+
*/
|
|
1375
|
+
async function reconcileRoster(options, inspection) {
|
|
1376
|
+
const state = inspection.rosterState;
|
|
1377
|
+
if (!state) {
|
|
1378
|
+
return {
|
|
1379
|
+
entries: inspection.profile.roster,
|
|
1380
|
+
inventoryRevision: inspection.profile.inventoryRevision,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const selectable = [...state.pending, ...state.reopenable];
|
|
1384
|
+
if (!selectable.length) {
|
|
1385
|
+
return { entries: [...state.entries], inventoryRevision: state.inventoryRevision };
|
|
1386
|
+
}
|
|
1387
|
+
const context = {
|
|
1388
|
+
inventory: inspection.inventory,
|
|
1389
|
+
detectedSurfaceIds: inspection.detectedSurfaceIds,
|
|
1390
|
+
registry: options.registry ?? AGENT_SURFACE_REGISTRY,
|
|
1391
|
+
};
|
|
1392
|
+
const answer = await options.prompt(rosterQuestion(
|
|
1393
|
+
context, selectable, 'Your Model roster changed — which pairs may the Kit use?',
|
|
1394
|
+
));
|
|
1395
|
+
return {
|
|
1396
|
+
entries: mergeRosterAnswer([...state.entries], selectable, answer),
|
|
1397
|
+
inventoryRevision: state.inventoryRevision,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* A Standard route whose pair is no longer admitted is persisted `unresolved`,
|
|
1403
|
+
* and the user is asked once for a replacement. Answering nothing leaves the
|
|
1404
|
+
* route unresolved and its workload class blocked — the Kit never picks a
|
|
1405
|
+
* replacement, and an already-unresolved route is not asked about again.
|
|
1406
|
+
*/
|
|
1407
|
+
async function reconcileStandardRoutes(options, inspection, roster) {
|
|
1408
|
+
const admitted = admittedPairs(roster);
|
|
1409
|
+
const authorized = new Set(admitted.map(pairKey));
|
|
1410
|
+
const routes = { ...inspection.profile.standardRoutes };
|
|
1411
|
+
for (const workload of STANDARD_ROUTE_CLASSES) {
|
|
1412
|
+
const entry = routes[workload];
|
|
1413
|
+
if (!entry || entry.state !== 'configured' || authorized.has(pairKey(entry))) continue;
|
|
1414
|
+
routes[workload] = { ...barePair(entry), state: 'unresolved' };
|
|
1415
|
+
if (!admitted.length) continue;
|
|
1416
|
+
const chosen = matchOffered(
|
|
1417
|
+
await options.prompt(standardRouteQuestion(workload, admitted, routes[workload])), admitted,
|
|
1418
|
+
);
|
|
1419
|
+
if (chosen) routes[workload] = { ...chosen, state: 'configured' };
|
|
1420
|
+
}
|
|
1421
|
+
return routes;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Preserve the pre-migration document before a migrated profile replaces it.
|
|
1426
|
+
* The earliest preserved evidence wins: an existing backup is never overwritten.
|
|
1427
|
+
*/
|
|
1428
|
+
async function backupLegacyDocument(options, snapshot) {
|
|
1429
|
+
const path = routingProfileBackupPath(
|
|
1430
|
+
options.consumerRoot,
|
|
1431
|
+
options.profileRoot,
|
|
1432
|
+
snapshot.migration.from,
|
|
1433
|
+
);
|
|
1434
|
+
if (await access(path).then(() => true, () => false)) return;
|
|
1435
|
+
await writeAtomic(path, snapshot.bytes);
|
|
309
1436
|
}
|
|
310
1437
|
|
|
311
1438
|
async function writeProfileExpected(options, profile, expectedFingerprint) {
|
|
@@ -322,10 +1449,11 @@ async function writeProfileExpected(options, profile, expectedFingerprint) {
|
|
|
322
1449
|
throw error;
|
|
323
1450
|
}
|
|
324
1451
|
try {
|
|
325
|
-
const
|
|
326
|
-
if (
|
|
1452
|
+
const snapshot = await readProfileSnapshot(options.consumerRoot, options.profileRoot);
|
|
1453
|
+
if (snapshot.fingerprint !== expectedFingerprint) {
|
|
327
1454
|
throw new Error('concurrent routing profile mutation: profile changed during decision');
|
|
328
1455
|
}
|
|
1456
|
+
if (snapshot.migration) await backupLegacyDocument(options, snapshot);
|
|
329
1457
|
await writeAtomic(path, `${JSON.stringify(profile, null, 2)}\n`);
|
|
330
1458
|
} finally {
|
|
331
1459
|
await lock.close();
|