@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deriving the Routing policy from a composed Routing profile.
|
|
3
|
+
*
|
|
4
|
+
* A Routing profile is the stored personal choice and carries no revision; the
|
|
5
|
+
* Routing policy is the revisioned constraint object derived from it for one
|
|
6
|
+
* dispatch. Derivation is a pure function: the same composed profile, the same
|
|
7
|
+
* two document generations and the same inventory revision always produce the
|
|
8
|
+
* same policy and the same revision, so a Dispatch receipt can name one
|
|
9
|
+
* revision and a later re-derivation can prove the constraints did not move.
|
|
10
|
+
*
|
|
11
|
+
* The revision is a canonical hash over exactly three facts — the global
|
|
12
|
+
* generation, the project generation, and the inventory revision. Those three
|
|
13
|
+
* are what a composed authorization is a function of: every profile change
|
|
14
|
+
* commits a new immutable generation, and every roster reconcile names an
|
|
15
|
+
* inventory revision. Hashing the composed values themselves would add nothing
|
|
16
|
+
* and would stop the revision being a token a store can compare.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here consults the Access graph, the Evidence catalog, the resolver or
|
|
19
|
+
* a dispatcher. Composition and derivation answer "what is authorized";
|
|
20
|
+
* executability is decided at decision time.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
import { STANDARD_ROUTE_CLASSES } from './routingProfile.mjs';
|
|
25
|
+
import { ROUTING_POLICY_VERSION, validateRoutingPolicy } from './routingPolicy.mjs';
|
|
26
|
+
|
|
27
|
+
/** The revision inputs — those three, nothing else. */
|
|
28
|
+
export const ROUTING_POLICY_REVISION_INPUTS = Object.freeze([
|
|
29
|
+
'globalGeneration',
|
|
30
|
+
'projectGeneration',
|
|
31
|
+
'inventoryRevision',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** The fail-closed fallbacks a profile does not carry and a caller may override. */
|
|
35
|
+
const DEFAULT_UNREACHABLE = 'block';
|
|
36
|
+
const DEFAULT_MISSING_INFRASTRUCTURE = 'block';
|
|
37
|
+
|
|
38
|
+
const COMPOSED_FIELDS = ['selectedSurfaces', 'authorizedTransports', 'switching', 'roster',
|
|
39
|
+
'standardRoutes'];
|
|
40
|
+
|
|
41
|
+
function object(value, field) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
43
|
+
throw new TypeError(`${field} must be an object`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertFields(input, allowed, label) {
|
|
49
|
+
for (const key of Object.keys(input)) {
|
|
50
|
+
if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Key order is fixed, so the same three facts always hash to the same digest. */
|
|
55
|
+
function canonical(value) {
|
|
56
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
57
|
+
if (value && typeof value === 'object') {
|
|
58
|
+
return `{${Object.keys(value).sort()
|
|
59
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(value ?? null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sha256 = (text) => `sha256-${createHash('sha256').update(text).digest('base64url')}`;
|
|
65
|
+
|
|
66
|
+
function generation(value, field, { required }) {
|
|
67
|
+
if (value === null || value === undefined) {
|
|
68
|
+
if (required) throw new TypeError(`${field} must be a non-negative integer`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
72
|
+
throw new TypeError(`${field} must be a non-negative integer`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The policy revision: a canonical hash over exactly the global generation, the
|
|
79
|
+
* project generation and the inventory revision. A policy without a committed
|
|
80
|
+
* global authorization cannot exist, so the global generation is required; a
|
|
81
|
+
* project without a narrowing is a normal state and contributes `null`.
|
|
82
|
+
*/
|
|
83
|
+
export function routingPolicyRevision(input) {
|
|
84
|
+
object(input, 'routing policy revision input');
|
|
85
|
+
assertFields(input, new Set(ROUTING_POLICY_REVISION_INPUTS), 'routing policy revision input');
|
|
86
|
+
const inventoryRevision = input.inventoryRevision ?? null;
|
|
87
|
+
if (inventoryRevision !== null
|
|
88
|
+
&& (typeof inventoryRevision !== 'string' || inventoryRevision.trim() === '')) {
|
|
89
|
+
throw new TypeError('inventoryRevision must be a non-empty string or null');
|
|
90
|
+
}
|
|
91
|
+
return sha256(canonical({
|
|
92
|
+
globalGeneration: generation(input.globalGeneration, 'globalGeneration', { required: true }),
|
|
93
|
+
projectGeneration: generation(input.projectGeneration, 'projectGeneration', { required: false }),
|
|
94
|
+
inventoryRevision,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Project a composed profile onto the policy's axes, without reinterpreting it. */
|
|
99
|
+
function authorizationOf(composed) {
|
|
100
|
+
object(composed, 'composed routing profile');
|
|
101
|
+
for (const field of COMPOSED_FIELDS) {
|
|
102
|
+
if (composed[field] === undefined) {
|
|
103
|
+
throw new TypeError(`composed routing profile is missing ${field}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(composed.authorizedTransports)) {
|
|
107
|
+
throw new TypeError('composed routing profile authorizedTransports must be an array');
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
allowedSurfaces: [...composed.selectedSurfaces],
|
|
111
|
+
// The resolver matches a path's transport id, so the policy carries ids.
|
|
112
|
+
allowedTransports: composed.authorizedTransports.map(({ transport }) => transport),
|
|
113
|
+
roster: composed.roster.map(({ model, effort }) => ({ model, effort })),
|
|
114
|
+
standardRoutes: Object.fromEntries(
|
|
115
|
+
STANDARD_ROUTE_CLASSES.map((cls) => [cls, composed.standardRoutes?.[cls] ?? null]),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Derive the Routing policy for one dispatch. The inventory revision defaults to
|
|
122
|
+
* the one the composition actually reconciled the roster against, so the
|
|
123
|
+
* revision names the inventory that produced the effective roster rather than a
|
|
124
|
+
* separately supplied one. `unreachable` and `missingInfrastructure` are not
|
|
125
|
+
* profile fields; they default fail-closed and every explicit value is
|
|
126
|
+
* validated. A composed profile that authorizes nothing throws with the named
|
|
127
|
+
* reason instead of yielding an empty policy.
|
|
128
|
+
*/
|
|
129
|
+
export function deriveRoutingPolicy({
|
|
130
|
+
composed,
|
|
131
|
+
globalGeneration,
|
|
132
|
+
projectGeneration = null,
|
|
133
|
+
inventoryRevision,
|
|
134
|
+
unreachable = DEFAULT_UNREACHABLE,
|
|
135
|
+
missingInfrastructure = DEFAULT_MISSING_INFRASTRUCTURE,
|
|
136
|
+
}) {
|
|
137
|
+
const authorization = authorizationOf(composed);
|
|
138
|
+
const reconciled = inventoryRevision === undefined
|
|
139
|
+
? (composed.rosterState?.inventoryRevision ?? composed.inventoryRevision ?? null)
|
|
140
|
+
: inventoryRevision;
|
|
141
|
+
return validateRoutingPolicy({
|
|
142
|
+
schemaVersion: ROUTING_POLICY_VERSION,
|
|
143
|
+
revision: routingPolicyRevision({
|
|
144
|
+
globalGeneration,
|
|
145
|
+
projectGeneration,
|
|
146
|
+
inventoryRevision: reconciled,
|
|
147
|
+
}),
|
|
148
|
+
allowedSurfaces: authorization.allowedSurfaces,
|
|
149
|
+
allowedTransports: authorization.allowedTransports,
|
|
150
|
+
switching: composed.switching,
|
|
151
|
+
roster: authorization.roster,
|
|
152
|
+
standardRoutes: authorization.standardRoutes,
|
|
153
|
+
unreachable,
|
|
154
|
+
missingInfrastructure,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-level Routing profile store: one immutable generation per document,
|
|
3
|
+
* global and project independent. The generation id lives in the storage
|
|
4
|
+
* envelope *around* a document, never in the Routing profile schema — a profile
|
|
5
|
+
* carries no revision. Composition takes the latest committed global generation
|
|
6
|
+
* plus this project's own narrowing, so a global choice made after a narrowing
|
|
7
|
+
* is never invisible to that project; the generation a narrowing was authored
|
|
8
|
+
* against travels with it for diagnostics, never as the read key.
|
|
9
|
+
*
|
|
10
|
+
* A transaction descriptor is written only when one interview changes both
|
|
11
|
+
* documents, and exists solely so recovery can discard a half-written pair: a
|
|
12
|
+
* generation the descriptor names is not committed, reads skip it, recovery
|
|
13
|
+
* deletes it — leaving the last committed pair.
|
|
14
|
+
*
|
|
15
|
+
* The project key is a deliberately opaque identity: a UUID in a marker
|
|
16
|
+
* file inside the git common directory, so every worktree of one repository
|
|
17
|
+
* narrows once and the marker stays outside `git status` and outside the
|
|
18
|
+
* consumer's project layer. A fresh clone is a project without a narrowing yet.
|
|
19
|
+
* Without git the canonical project-root path keys it at lower confidence,
|
|
20
|
+
* because a path does not survive a move.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { access, mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { promisify } from 'node:util';
|
|
27
|
+
|
|
28
|
+
import { writeAtomic } from './atomicWrite.mjs';
|
|
29
|
+
|
|
30
|
+
export const ROUTING_PROFILE_ENVELOPE_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
/** User-local routing evidence: owner-only, like every other routing document. */
|
|
33
|
+
const STORE_MODE = 0o600;
|
|
34
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
35
|
+
const LOCK_POLL_MS = 10;
|
|
36
|
+
const GENERATION_FILE = /^generation-(\d+)\.json$/;
|
|
37
|
+
const PENDING_FILE = 'pending-transaction.json';
|
|
38
|
+
const MARKER_SEGMENTS = ['agent-workflow-kit', 'project-id'];
|
|
39
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
40
|
+
|
|
41
|
+
const execFileAsync = promisify(execFile);
|
|
42
|
+
const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
43
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
44
|
+
/** Missing is a state, not a failure; anything else stays an error. */
|
|
45
|
+
const readTextOrNull = (file) => readFile(file, 'utf8').catch((error) => {
|
|
46
|
+
if (error.code === 'ENOENT') return null;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Git is evidence, not a dependency: an unavailable git yields no common dir. */
|
|
51
|
+
const defaultRunGit = (args, cwd) => execFileAsync('git', args, { cwd, encoding: 'utf8' })
|
|
52
|
+
.then(({ stdout }) => stdout, () => null);
|
|
53
|
+
|
|
54
|
+
/** Where one generation of one document lives. Immutable once written. */
|
|
55
|
+
export function routingProfileGenerationPath({ root, scope, projectKey = null, generation }) {
|
|
56
|
+
if (scope !== 'global' && !projectKey) throw new TypeError('a project generation needs a key');
|
|
57
|
+
const dir = scope === 'global' ? join(root, 'global') : join(root, 'projects', projectKey);
|
|
58
|
+
return join(dir, `generation-${generation}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const writeJson = (file, value) =>
|
|
62
|
+
writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, STORE_MODE);
|
|
63
|
+
|
|
64
|
+
async function readJson(file, label) {
|
|
65
|
+
const raw = await readTextOrNull(file);
|
|
66
|
+
if (raw === null) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`${label} is not valid JSON: ${file}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateEnvelope(value, { scope, generation, file }) {
|
|
75
|
+
const object = (candidate) => Boolean(candidate) && typeof candidate === 'object'
|
|
76
|
+
&& !Array.isArray(candidate);
|
|
77
|
+
if (!object(value) || value.envelopeVersion !== ROUTING_PROFILE_ENVELOPE_VERSION
|
|
78
|
+
|| value.scope !== scope || value.generation !== generation) {
|
|
79
|
+
throw new Error(`routing profile envelope does not match its generation file: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (!object(value.document)) {
|
|
82
|
+
throw new Error(`routing profile envelope carries no document: ${file}`);
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({ ...value, file });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listGenerations(root, scope, projectKey) {
|
|
88
|
+
const dir = dirname(routingProfileGenerationPath({ root, scope, projectKey, generation: 0 }));
|
|
89
|
+
const names = await readdir(dir).catch((error) => {
|
|
90
|
+
if (error.code === 'ENOENT') return [];
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
return names.map((name) => GENERATION_FILE.exec(name)).filter(Boolean)
|
|
94
|
+
.map((match) => Number(match[1])).sort((a, b) => a - b);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Generations a pending descriptor names are not committed. */
|
|
98
|
+
function pendingGenerations(pending, scope, projectKey) {
|
|
99
|
+
const entries = Array.isArray(pending?.entries) ? pending.entries : [];
|
|
100
|
+
return new Set(entries
|
|
101
|
+
.filter((entry) => entry.scope === scope
|
|
102
|
+
&& (scope === 'global' || entry.projectKey === projectKey))
|
|
103
|
+
.map((entry) => entry.generation));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readLatestCommitted(root, scope, projectKey, pending) {
|
|
107
|
+
const uncommitted = pendingGenerations(pending, scope, projectKey);
|
|
108
|
+
const generation = (await listGenerations(root, scope, projectKey))
|
|
109
|
+
.filter((candidate) => !uncommitted.has(candidate)).at(-1);
|
|
110
|
+
if (generation === undefined) return null;
|
|
111
|
+
const file = routingProfileGenerationPath({ root, scope, projectKey, generation });
|
|
112
|
+
const document = await readJson(file, 'routing profile envelope');
|
|
113
|
+
return validateEnvelope(document, { scope, generation, file });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The committed pair: the latest committed global generation plus this project's
|
|
118
|
+
* own narrowing. No project document is a normal, safe state — a fresh clone
|
|
119
|
+
* simply falls back to the global authorization.
|
|
120
|
+
*/
|
|
121
|
+
export async function readCommittedRoutingProfilePair({ root, projectKey = null }) {
|
|
122
|
+
const pending = await readJson(join(root, PENDING_FILE), 'routing profile transaction');
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
global: await readLatestCommitted(root, 'global', null, pending),
|
|
125
|
+
project: projectKey ? await readLatestCommitted(root, 'project', projectKey, pending) : null,
|
|
126
|
+
pendingTransactionId: pending?.id ?? null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function withStorageLock(root, lockTimeoutMs, run) {
|
|
131
|
+
await mkdir(root, { recursive: true });
|
|
132
|
+
const lock = join(root, '.lock');
|
|
133
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try { await mkdir(lock); break; } catch (error) {
|
|
136
|
+
if (error.code !== 'EEXIST') throw error;
|
|
137
|
+
if (Date.now() >= deadline) throw new Error(`routing profile store is locked: ${lock}`);
|
|
138
|
+
await sleep(LOCK_POLL_MS);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return await run();
|
|
143
|
+
} finally {
|
|
144
|
+
await rmdir(lock).catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function discardPendingTransaction(root) {
|
|
149
|
+
const pending = await readJson(join(root, PENDING_FILE), 'routing profile transaction');
|
|
150
|
+
if (!pending) return Object.freeze({ transactionId: null, discarded: [] });
|
|
151
|
+
const discarded = [];
|
|
152
|
+
for (const entry of pending.entries ?? []) {
|
|
153
|
+
const file = routingProfileGenerationPath({
|
|
154
|
+
root, scope: entry.scope, projectKey: entry.projectKey ?? null, generation: entry.generation,
|
|
155
|
+
});
|
|
156
|
+
if (await exists(file)) {
|
|
157
|
+
await rm(file, { recursive: true, force: true });
|
|
158
|
+
discarded.push(file);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await rm(join(root, PENDING_FILE), { force: true });
|
|
162
|
+
return Object.freeze({ transactionId: pending.id ?? null, discarded });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Discard a half-written pair: the store reads back as the last committed pair. */
|
|
166
|
+
export async function recoverRoutingProfileStorage({ root, lockTimeoutMs = LOCK_TIMEOUT_MS }) {
|
|
167
|
+
return withStorageLock(root, lockTimeoutMs, () => discardPendingTransaction(root));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Never rewrite a generation: the counter only moves forward, and a caller that
|
|
172
|
+
* states the generation it read is rejected once the store moved on.
|
|
173
|
+
*/
|
|
174
|
+
function nextGeneration(current, document, expected) {
|
|
175
|
+
const committed = current?.generation ?? null;
|
|
176
|
+
if (expected !== undefined && expected !== committed) {
|
|
177
|
+
throw new Error(`stale routing profile generation: expected ${expected ?? 'none'}, `
|
|
178
|
+
+ `found ${committed ?? 'none'}`);
|
|
179
|
+
}
|
|
180
|
+
return document ? (committed ?? 0) + 1 : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const writeGeneration = (root, envelope) => writeJson(routingProfileGenerationPath({
|
|
184
|
+
root, scope: envelope.scope, projectKey: envelope.projectKey ?? null,
|
|
185
|
+
generation: envelope.generation,
|
|
186
|
+
}), envelope);
|
|
187
|
+
|
|
188
|
+
async function commitUnderLock({
|
|
189
|
+
root, identity, globalDocument, projectDocument,
|
|
190
|
+
expectedGlobalGeneration, expectedProjectGeneration, now,
|
|
191
|
+
}) {
|
|
192
|
+
const projectKey = identity?.key ?? null;
|
|
193
|
+
await discardPendingTransaction(root);
|
|
194
|
+
const current = await readCommittedRoutingProfilePair({ root, projectKey });
|
|
195
|
+
const global = nextGeneration(current.global, globalDocument, expectedGlobalGeneration);
|
|
196
|
+
const project = nextGeneration(current.project, projectDocument, expectedProjectGeneration);
|
|
197
|
+
const committedAt = now();
|
|
198
|
+
const paired = Boolean(globalDocument && projectDocument);
|
|
199
|
+
const transactionId = paired ? randomUUID() : null;
|
|
200
|
+
if (paired) {
|
|
201
|
+
await writeJson(join(root, PENDING_FILE), {
|
|
202
|
+
id: transactionId,
|
|
203
|
+
startedAt: committedAt,
|
|
204
|
+
entries: [
|
|
205
|
+
{ scope: 'global', projectKey: null, generation: global },
|
|
206
|
+
{ scope: 'project', projectKey, generation: project },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (globalDocument) {
|
|
211
|
+
await writeGeneration(root, {
|
|
212
|
+
envelopeVersion: ROUTING_PROFILE_ENVELOPE_VERSION, scope: 'global',
|
|
213
|
+
generation: global, committedAt, document: globalDocument,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (projectDocument) {
|
|
217
|
+
await writeGeneration(root, {
|
|
218
|
+
envelopeVersion: ROUTING_PROFILE_ENVELOPE_VERSION, scope: 'project',
|
|
219
|
+
generation: project, committedAt, projectKey,
|
|
220
|
+
identity: { source: identity.source, confidence: identity.confidence },
|
|
221
|
+
authoredAgainstGlobalGeneration: global ?? current.global?.generation ?? null,
|
|
222
|
+
document: projectDocument,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (paired) await rm(join(root, PENDING_FILE), { force: true });
|
|
226
|
+
return Object.freeze({ transactionId, globalGeneration: global, projectGeneration: project });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Commit a global authorization, a project narrowing, or both. Both together are
|
|
231
|
+
* one transaction: the descriptor names the generations being written and only
|
|
232
|
+
* its removal commits them.
|
|
233
|
+
*/
|
|
234
|
+
export async function commitRoutingProfileGenerations(options) {
|
|
235
|
+
const { root, identity = null, globalDocument = null, projectDocument = null,
|
|
236
|
+
now = () => new Date().toISOString(), lockTimeoutMs = LOCK_TIMEOUT_MS } = options;
|
|
237
|
+
if (!globalDocument && !projectDocument) {
|
|
238
|
+
throw new TypeError('a routing profile commit must carry a global or a project document');
|
|
239
|
+
}
|
|
240
|
+
if (projectDocument && !identity?.key) {
|
|
241
|
+
throw new TypeError('a project narrowing needs a resolved project identity');
|
|
242
|
+
}
|
|
243
|
+
return withStorageLock(root, lockTimeoutMs, () => commitUnderLock({
|
|
244
|
+
...options, identity, globalDocument, projectDocument, now,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readMarker(markerPath) {
|
|
249
|
+
const raw = await readTextOrNull(markerPath);
|
|
250
|
+
if (raw === null) return null;
|
|
251
|
+
const value = raw.trim();
|
|
252
|
+
if (!UUID.test(value)) {
|
|
253
|
+
throw new Error(`routing project identity marker is unreadable: ${markerPath}`);
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Write the marker once. A concurrent writer that got there first wins. */
|
|
259
|
+
async function createMarker(markerPath) {
|
|
260
|
+
await mkdir(dirname(markerPath), { recursive: true });
|
|
261
|
+
const id = randomUUID();
|
|
262
|
+
try {
|
|
263
|
+
await writeFile(markerPath, `${id}\n`, { encoding: 'utf8', flag: 'wx', mode: STORE_MODE });
|
|
264
|
+
return id;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error.code !== 'EEXIST') throw error;
|
|
267
|
+
return readMarker(markerPath);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pathIdentity(projectRoot) {
|
|
272
|
+
const digest = createHash('sha256').update(projectRoot).digest('hex').slice(0, 20);
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
key: `path-${digest}`, value: projectRoot,
|
|
275
|
+
source: 'project-path', confidence: 'lower', markerPath: null,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the project identity that keys the narrowing: the UUID marker in the
|
|
281
|
+
* git common directory, shared by every worktree of one repository, or the
|
|
282
|
+
* canonical project-root path at lower confidence outside git.
|
|
283
|
+
*/
|
|
284
|
+
export async function resolveProjectIdentity({ projectRoot, runGit = defaultRunGit }) {
|
|
285
|
+
if (typeof projectRoot !== 'string' || projectRoot.trim() === '') {
|
|
286
|
+
throw new TypeError('a project identity needs a project root');
|
|
287
|
+
}
|
|
288
|
+
const root = resolve(projectRoot);
|
|
289
|
+
const stdout = await runGit(['rev-parse', '--git-common-dir'], root);
|
|
290
|
+
const commonDir = typeof stdout === 'string' && stdout.trim()
|
|
291
|
+
? resolve(root, stdout.trim())
|
|
292
|
+
: null;
|
|
293
|
+
if (!commonDir) return pathIdentity(root);
|
|
294
|
+
const markerPath = join(commonDir, ...MARKER_SEGMENTS);
|
|
295
|
+
const key = (await readMarker(markerPath)) ?? (await createMarker(markerPath));
|
|
296
|
+
return Object.freeze({
|
|
297
|
+
key, value: key, source: 'git-marker', confidence: 'stable', markerPath,
|
|
298
|
+
});
|
|
299
|
+
}
|