@abelxiaoxing/cadence 1.2.4 → 1.4.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/README.md +155 -10
- package/package.json +5 -3
- package/prompts/abel-design.md +71 -17
- package/prompts/abel-diagnose.md +10 -1
- package/prompts/abel-implement.md +118 -20
- package/prompts/abel-init.md +9 -2
- package/src/apply-transaction.ts +16 -14
- package/src/artifact-store.ts +17 -0
- package/src/candidate-patch.ts +2 -1
- package/src/canonical.ts +16 -0
- package/src/change-contract.ts +268 -0
- package/src/child-session.ts +4 -3
- package/src/contracts.ts +141 -33
- package/src/control-contracts.ts +51 -1
- package/src/delivery-compiler.ts +148 -31
- package/src/design-control.ts +83 -15
- package/src/design-journal.ts +216 -72
- package/src/durable-workflow.ts +4385 -0
- package/src/implement-graph.ts +11 -16
- package/src/index.ts +399 -1079
- package/src/isolation-backend.ts +84 -23
- package/src/openspec-cli.ts +494 -0
- package/src/package-delivery.ts +486 -0
- package/src/package-verification.ts +509 -0
- package/src/route-policy.ts +1 -11
- package/src/run-state.ts +4 -16
- package/src/run-store.ts +5 -144
- package/src/scoped-grep-worker.mjs +23 -0
- package/src/scoped-tools.ts +312 -116
- package/src/sqlite-schema.ts +171 -0
- package/src/storage-schema.ts +352 -0
- package/src/subagent-activity.ts +14 -1
- package/src/submit-tool.ts +20 -2
- package/src/task-ledger.ts +14 -55
- package/src/verification-capability.ts +298 -102
- package/src/verification-environment.ts +213 -0
- package/src/verification-identity.ts +77 -0
- package/src/worker-broker.ts +97 -34
- package/src/workflow-engine.ts +28 -8285
- package/src/workflow-policy.ts +1427 -0
- package/src/workflow-state-machine.ts +4478 -0
- package/src/workspace-io-entry.mjs +58 -0
- package/src/workspace-io-worker.mjs +984 -0
- package/src/workspace-io.ts +84 -0
- package/src/workspace-store.ts +125 -19
- package/skills/abel-workflow/SKILL.md +0 -205
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readlinkSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
type StructuredVerificationContract,
|
|
15
|
+
VERIFICATION_CONFIGURATION_PATHS,
|
|
16
|
+
} from "./contracts.ts";
|
|
17
|
+
import type { IsolationMount } from "./isolation-backend.ts";
|
|
18
|
+
import {
|
|
19
|
+
resolveVerificationRunner,
|
|
20
|
+
type VerificationRunnerBinding,
|
|
21
|
+
validateVerificationAdapterCapability,
|
|
22
|
+
verificationRunnerFiles,
|
|
23
|
+
} from "./verification-capability.ts";
|
|
24
|
+
import { runWorkspaceIo } from "./workspace-io.ts";
|
|
25
|
+
|
|
26
|
+
export async function captureVerificationEnvironmentIdentity(
|
|
27
|
+
dependencyOwner: string,
|
|
28
|
+
verifications: readonly StructuredVerificationContract[],
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const roots = [path.join(dependencyOwner, "node_modules")];
|
|
32
|
+
const node = resolveVerificationRunner("node");
|
|
33
|
+
if (node) roots.push(realpathSync(node.executablePath));
|
|
34
|
+
for (const verification of verifications) {
|
|
35
|
+
// Identity describes installed tools; admission/execution separately owns config authorization.
|
|
36
|
+
const capability = validateVerificationAdapterCapability(
|
|
37
|
+
dependencyOwner,
|
|
38
|
+
verification,
|
|
39
|
+
{ executionWritePaths: VERIFICATION_CONFIGURATION_PATHS },
|
|
40
|
+
);
|
|
41
|
+
if (!capability.ok) throw new Error("verification-environment-unavailable");
|
|
42
|
+
roots.push(
|
|
43
|
+
...verificationRunnerFiles(
|
|
44
|
+
dependencyOwner,
|
|
45
|
+
verification,
|
|
46
|
+
capability.runnerBindings,
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
for (const binding of capability.runnerBindings)
|
|
50
|
+
if (binding.mountSource) roots.push(binding.mountSource);
|
|
51
|
+
}
|
|
52
|
+
return runWorkspaceIo<string>({
|
|
53
|
+
root: dependencyOwner,
|
|
54
|
+
artifactRoot: dependencyOwner,
|
|
55
|
+
operation: "verificationIdentity",
|
|
56
|
+
args: [roots],
|
|
57
|
+
signal,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CACHES = [".vite", ".vite-temp"];
|
|
62
|
+
function within(root: string, candidate: string): boolean {
|
|
63
|
+
const relative = path.relative(root, candidate);
|
|
64
|
+
return (
|
|
65
|
+
relative !== ".." &&
|
|
66
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
67
|
+
!path.isAbsolute(relative)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A disposable dependency view: immutable packages and independently writable caches. */
|
|
72
|
+
export function prepareVerificationEnvironment(
|
|
73
|
+
root: string,
|
|
74
|
+
dependencyOwner: string,
|
|
75
|
+
runnerBindings: readonly VerificationRunnerBinding[],
|
|
76
|
+
) {
|
|
77
|
+
const privateRoot = mkdtempSync(
|
|
78
|
+
path.join(path.dirname(root), ".cadence-verification-"),
|
|
79
|
+
);
|
|
80
|
+
const cleanup = () => rmSync(privateRoot, { recursive: true, force: true });
|
|
81
|
+
try {
|
|
82
|
+
const mounts: IsolationMount[] = [
|
|
83
|
+
{ source: privateRoot, target: "/cadence", writable: true },
|
|
84
|
+
];
|
|
85
|
+
for (const directory of [
|
|
86
|
+
"home",
|
|
87
|
+
"cache",
|
|
88
|
+
"tmp",
|
|
89
|
+
"reports",
|
|
90
|
+
"dependencies",
|
|
91
|
+
]) {
|
|
92
|
+
mkdirSync(path.join(privateRoot, directory), { mode: 0o700 });
|
|
93
|
+
}
|
|
94
|
+
writeFileSync(path.join(privateRoot, "home/user.npmrc"), "");
|
|
95
|
+
writeFileSync(path.join(privateRoot, "home/global.npmrc"), "");
|
|
96
|
+
const dependencies = path.join(dependencyOwner, "node_modules");
|
|
97
|
+
const stat = lstatSync(dependencies, { throwIfNoEntry: false });
|
|
98
|
+
if (stat) {
|
|
99
|
+
const target = path.join(root, "node_modules");
|
|
100
|
+
const targetStat = lstatSync(target, { throwIfNoEntry: false });
|
|
101
|
+
if (
|
|
102
|
+
!stat.isDirectory() ||
|
|
103
|
+
stat.isSymbolicLink() ||
|
|
104
|
+
(targetStat &&
|
|
105
|
+
(!targetStat.isDirectory() || targetStat.isSymbolicLink()))
|
|
106
|
+
) {
|
|
107
|
+
throw new Error("dependency-path-unsafe");
|
|
108
|
+
}
|
|
109
|
+
if (!targetStat) mkdirSync(target, { mode: 0o700 });
|
|
110
|
+
const view = path.join(privateRoot, "dependencies");
|
|
111
|
+
mounts.push({ source: view, target: "/workspace/node_modules" });
|
|
112
|
+
const entries = readdirSync(dependencies);
|
|
113
|
+
if (entries.length > 20_000) throw new Error("dependency-view-too-large");
|
|
114
|
+
const canonical = realpathSync(dependencies);
|
|
115
|
+
for (const name of entries) {
|
|
116
|
+
if (CACHES.includes(name)) continue;
|
|
117
|
+
const source = path.join(dependencies, name);
|
|
118
|
+
const placeholder = path.join(view, name);
|
|
119
|
+
const entry = lstatSync(source);
|
|
120
|
+
if (entry.isSymbolicLink()) {
|
|
121
|
+
const link = readlinkSync(source);
|
|
122
|
+
if (
|
|
123
|
+
path.isAbsolute(link) ||
|
|
124
|
+
!within(canonical, realpathSync(source))
|
|
125
|
+
) {
|
|
126
|
+
throw new Error("dependency-path-unsafe");
|
|
127
|
+
}
|
|
128
|
+
symlinkSync(link, placeholder);
|
|
129
|
+
} else {
|
|
130
|
+
if (entry.isDirectory()) mkdirSync(placeholder);
|
|
131
|
+
else if (entry.isFile()) writeFileSync(placeholder, "");
|
|
132
|
+
else throw new Error("dependency-path-unsafe");
|
|
133
|
+
mounts.push({ source, target: `/workspace/node_modules/${name}` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const cache of CACHES) {
|
|
137
|
+
mkdirSync(path.join(view, cache));
|
|
138
|
+
const source = path.join(privateRoot, "cache", cache);
|
|
139
|
+
mkdirSync(source);
|
|
140
|
+
mounts.push({
|
|
141
|
+
source,
|
|
142
|
+
target: `/workspace/node_modules/${cache}`,
|
|
143
|
+
writable: true,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const sources = new Map<string, string>();
|
|
148
|
+
const bindings = runnerBindings.map((binding) => {
|
|
149
|
+
if (!binding.mountSource) return { ...binding };
|
|
150
|
+
const source = path.resolve(binding.mountSource);
|
|
151
|
+
const stat = lstatSync(source);
|
|
152
|
+
const executable = realpathSync(binding.executablePath);
|
|
153
|
+
if (
|
|
154
|
+
!stat.isDirectory() ||
|
|
155
|
+
stat.isSymbolicLink() ||
|
|
156
|
+
!within(source, executable)
|
|
157
|
+
) {
|
|
158
|
+
throw new Error("runner-mount-unavailable");
|
|
159
|
+
}
|
|
160
|
+
let target = sources.get(source);
|
|
161
|
+
if (!target) {
|
|
162
|
+
target = `/cadence-runners/${sources.size}`;
|
|
163
|
+
sources.set(source, target);
|
|
164
|
+
mounts.push({ source, target });
|
|
165
|
+
}
|
|
166
|
+
const translate = (file: string) =>
|
|
167
|
+
path.posix.join(target, ...path.relative(source, file).split(path.sep));
|
|
168
|
+
return {
|
|
169
|
+
...binding,
|
|
170
|
+
executablePath: translate(executable),
|
|
171
|
+
...(binding.fixedArgs
|
|
172
|
+
? {
|
|
173
|
+
fixedArgs: binding.fixedArgs.map((arg) =>
|
|
174
|
+
path.isAbsolute(arg) && within(source, arg)
|
|
175
|
+
? translate(arg)
|
|
176
|
+
: arg,
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
privateRoot,
|
|
184
|
+
mounts,
|
|
185
|
+
bindings,
|
|
186
|
+
cleanup,
|
|
187
|
+
environment: {
|
|
188
|
+
CI: "1",
|
|
189
|
+
HOME: "/cadence/home",
|
|
190
|
+
XDG_CACHE_HOME: "/cadence/cache",
|
|
191
|
+
TMPDIR: "/cadence/tmp",
|
|
192
|
+
npm_config_cache: "/cadence/cache/npm",
|
|
193
|
+
npm_config_userconfig: "/cadence/home/user.npmrc",
|
|
194
|
+
npm_config_globalconfig: "/cadence/home/global.npmrc",
|
|
195
|
+
PATH: [
|
|
196
|
+
...new Set(
|
|
197
|
+
// Honor explicitly resolved private runtimes before system fallbacks.
|
|
198
|
+
[
|
|
199
|
+
...bindings.filter((binding) => binding.mountSource),
|
|
200
|
+
...bindings.filter((binding) => !binding.mountSource),
|
|
201
|
+
].map((binding) => path.posix.dirname(binding.executablePath)),
|
|
202
|
+
),
|
|
203
|
+
"/usr/local/bin",
|
|
204
|
+
"/usr/bin",
|
|
205
|
+
"/bin",
|
|
206
|
+
].join(":"),
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
} catch (error) {
|
|
210
|
+
cleanup();
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readlinkSync,
|
|
10
|
+
readSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Content identity, including executable modes and links. Never follows dependency links. */
|
|
15
|
+
export function verificationEnvironmentDigest(
|
|
16
|
+
roots: readonly string[],
|
|
17
|
+
checkCancelled: () => void = () => {},
|
|
18
|
+
) {
|
|
19
|
+
const digest = createHash("sha256");
|
|
20
|
+
let entries = 0;
|
|
21
|
+
let bytes = 0;
|
|
22
|
+
const started = Date.now();
|
|
23
|
+
const buffer = Buffer.alloc(256 * 1024);
|
|
24
|
+
const visit = (file: string, relative: string, dependencyRoot: boolean) => {
|
|
25
|
+
checkCancelled();
|
|
26
|
+
if (++entries > 500_000 || Date.now() - started > 60_000)
|
|
27
|
+
throw new Error("verification-environment-limit");
|
|
28
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
29
|
+
digest.update(JSON.stringify([relative, stat ? stat.mode & 0o777 : null]));
|
|
30
|
+
if (!stat) {
|
|
31
|
+
digest.update("absent");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (stat.isSymbolicLink()) {
|
|
35
|
+
digest.update(JSON.stringify(["link", readlinkSync(file)]));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (stat.isDirectory()) {
|
|
39
|
+
digest.update("directory");
|
|
40
|
+
for (const name of readdirSync(file).sort()) {
|
|
41
|
+
if (dependencyRoot && [".vite", ".vite-temp"].includes(name)) continue;
|
|
42
|
+
visit(path.join(file, name), `${relative}/${name}`, false);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!stat.isFile()) throw new Error("verification-environment-unsafe");
|
|
47
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
48
|
+
try {
|
|
49
|
+
const before = fstatSync(fd);
|
|
50
|
+
if (before.ino !== stat.ino || before.dev !== stat.dev)
|
|
51
|
+
throw new Error("verification-environment-changed");
|
|
52
|
+
const content = createHash("sha256");
|
|
53
|
+
for (;;) {
|
|
54
|
+
const count = readSync(fd, buffer, 0, buffer.length, null);
|
|
55
|
+
if (count === 0) break;
|
|
56
|
+
checkCancelled();
|
|
57
|
+
bytes += count;
|
|
58
|
+
if (bytes > 16 * 1024 ** 3 || Date.now() - started > 60_000)
|
|
59
|
+
throw new Error("verification-environment-limit");
|
|
60
|
+
content.update(buffer.subarray(0, count));
|
|
61
|
+
}
|
|
62
|
+
const after = fstatSync(fd);
|
|
63
|
+
if (
|
|
64
|
+
before.size !== after.size ||
|
|
65
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
66
|
+
before.ctimeMs !== after.ctimeMs
|
|
67
|
+
)
|
|
68
|
+
throw new Error("verification-environment-changed");
|
|
69
|
+
digest.update(JSON.stringify(["file", content.digest("hex")]));
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (const root of [...new Set(roots)].sort())
|
|
75
|
+
visit(root, root, path.basename(root) === "node_modules");
|
|
76
|
+
return digest.digest("hex");
|
|
77
|
+
}
|
package/src/worker-broker.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface RouteRequirements {
|
|
|
21
21
|
dialects?: RouteDialect[];
|
|
22
22
|
minContextWindow?: number;
|
|
23
23
|
minOutputTokens?: number;
|
|
24
|
+
preferredContextWindow?: number;
|
|
25
|
+
preferredOutputTokens?: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface RouteHealth {
|
|
@@ -56,6 +58,7 @@ export interface WorkerBrokerOptions {
|
|
|
56
58
|
|
|
57
59
|
export interface RunWorkerAttemptInput<T> {
|
|
58
60
|
runId: string;
|
|
61
|
+
taskId?: string;
|
|
59
62
|
operationId: string;
|
|
60
63
|
role: WorkerRole;
|
|
61
64
|
requirements?: RouteRequirements;
|
|
@@ -126,7 +129,13 @@ function validRequirements(value: unknown): value is RouteRequirements {
|
|
|
126
129
|
if (
|
|
127
130
|
Object.keys(candidate).some(
|
|
128
131
|
(key) =>
|
|
129
|
-
![
|
|
132
|
+
![
|
|
133
|
+
"dialects",
|
|
134
|
+
"minContextWindow",
|
|
135
|
+
"minOutputTokens",
|
|
136
|
+
"preferredContextWindow",
|
|
137
|
+
"preferredOutputTokens",
|
|
138
|
+
].includes(key),
|
|
130
139
|
)
|
|
131
140
|
) {
|
|
132
141
|
return false;
|
|
@@ -147,7 +156,12 @@ function validRequirements(value: unknown): value is RouteRequirements {
|
|
|
147
156
|
(candidate.minContextWindow as number) > 0)) &&
|
|
148
157
|
(candidate.minOutputTokens === undefined ||
|
|
149
158
|
(Number.isSafeInteger(candidate.minOutputTokens) &&
|
|
150
|
-
(candidate.minOutputTokens as number) > 0))
|
|
159
|
+
(candidate.minOutputTokens as number) > 0)) &&
|
|
160
|
+
[candidate.preferredContextWindow, candidate.preferredOutputTokens].every(
|
|
161
|
+
(value) =>
|
|
162
|
+
value === undefined ||
|
|
163
|
+
(Number.isSafeInteger(value) && (value as number) > 0),
|
|
164
|
+
)
|
|
151
165
|
);
|
|
152
166
|
}
|
|
153
167
|
|
|
@@ -176,7 +190,6 @@ export class WorkerBroker {
|
|
|
176
190
|
readonly #policy: RoutePolicy;
|
|
177
191
|
readonly #now: () => number;
|
|
178
192
|
readonly #healthStore: RouteHealthStore;
|
|
179
|
-
readonly #health = new Map<string, RouteHealth>();
|
|
180
193
|
|
|
181
194
|
constructor(policy: RoutePolicy, options: WorkerBrokerOptions = {}) {
|
|
182
195
|
this.#policy = structuredClone(policy);
|
|
@@ -187,7 +200,6 @@ export class WorkerBroker {
|
|
|
187
200
|
#routeHealth(routeId: string): RouteHealth {
|
|
188
201
|
const route = this.#policy.routes[routeId];
|
|
189
202
|
return (
|
|
190
|
-
this.#health.get(routeId) ??
|
|
191
203
|
(route ? this.#healthStore.get(route.fingerprint) : undefined) ?? {
|
|
192
204
|
state: "healthy",
|
|
193
205
|
}
|
|
@@ -197,7 +209,6 @@ export class WorkerBroker {
|
|
|
197
209
|
#setRouteHealth(routeId: string, health: RouteHealth): void {
|
|
198
210
|
const route = this.#policy.routes[routeId];
|
|
199
211
|
if (!route) throw new Error("route-not-declared");
|
|
200
|
-
this.#health.set(routeId, structuredClone(health));
|
|
201
212
|
this.#healthStore.set(route.fingerprint, health);
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -229,14 +240,13 @@ export class WorkerBroker {
|
|
|
229
240
|
});
|
|
230
241
|
}
|
|
231
242
|
|
|
232
|
-
select(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}):
|
|
243
|
+
select(
|
|
244
|
+
input: RouteRequirements & {
|
|
245
|
+
role: WorkerRole;
|
|
246
|
+
exclude?: readonly string[];
|
|
247
|
+
routeId?: string;
|
|
248
|
+
},
|
|
249
|
+
):
|
|
240
250
|
| { ok: true; route: WorkerRoutePolicy; health: RouteHealthState }
|
|
241
251
|
| { ok: false; code: "endpoint-unavailable" | "route-not-declared" } {
|
|
242
252
|
const requirements: RouteRequirements = {
|
|
@@ -247,6 +257,12 @@ export class WorkerBroker {
|
|
|
247
257
|
...(input.minOutputTokens === undefined
|
|
248
258
|
? {}
|
|
249
259
|
: { minOutputTokens: input.minOutputTokens }),
|
|
260
|
+
...(input.preferredContextWindow === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { preferredContextWindow: input.preferredContextWindow }),
|
|
263
|
+
...(input.preferredOutputTokens === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: { preferredOutputTokens: input.preferredOutputTokens }),
|
|
250
266
|
};
|
|
251
267
|
if (!isRole(input.role) || !validRequirements(requirements)) {
|
|
252
268
|
return { ok: false, code: "endpoint-unavailable" };
|
|
@@ -261,7 +277,22 @@ export class WorkerBroker {
|
|
|
261
277
|
return { ok: false, code: "route-not-declared" };
|
|
262
278
|
}
|
|
263
279
|
const excluded = new Set(input.exclude ?? []);
|
|
264
|
-
|
|
280
|
+
// Stable preference tiers preserve declared order within each tier. Health,
|
|
281
|
+
// hard minima and explicit pins still determine eligibility.
|
|
282
|
+
const preferred = (routeId: string) => {
|
|
283
|
+
const route = this.#policy.routes[routeId];
|
|
284
|
+
return route &&
|
|
285
|
+
route.capabilities.contextWindow >=
|
|
286
|
+
(requirements.preferredContextWindow ?? 0) &&
|
|
287
|
+
route.capabilities.maxTokens >=
|
|
288
|
+
(requirements.preferredOutputTokens ?? 0)
|
|
289
|
+
? 1
|
|
290
|
+
: 0;
|
|
291
|
+
};
|
|
292
|
+
const ordered = [...candidates].sort(
|
|
293
|
+
(left, right) => preferred(right) - preferred(left),
|
|
294
|
+
);
|
|
295
|
+
for (const routeId of ordered) {
|
|
265
296
|
if (excluded.has(routeId)) continue;
|
|
266
297
|
const route = this.#policy.routes[routeId];
|
|
267
298
|
if (!route || !capable(route, input.role, requirements)) continue;
|
|
@@ -606,7 +637,8 @@ export class WorkerBroker {
|
|
|
606
637
|
}
|
|
607
638
|
|
|
608
639
|
/**
|
|
609
|
-
* Adds
|
|
640
|
+
* Adds run/role defaults and independent task bindings to route attempts.
|
|
641
|
+
* All bound brokers consult the same health store.
|
|
610
642
|
* Attempt credentials and provider objects remain disposable and are never
|
|
611
643
|
* retained by this facade.
|
|
612
644
|
*/
|
|
@@ -622,16 +654,21 @@ export class RunWorkerBroker {
|
|
|
622
654
|
|
|
623
655
|
constructor(policy: RoutePolicy, options: WorkerBrokerOptions = {}) {
|
|
624
656
|
this.#policy = structuredClone(policy);
|
|
625
|
-
this.#options =
|
|
626
|
-
|
|
657
|
+
this.#options = {
|
|
658
|
+
...options,
|
|
659
|
+
healthStore: options.healthStore ?? new MemoryRouteHealthStore(),
|
|
660
|
+
};
|
|
661
|
+
this.#defaultBroker = new WorkerBroker(this.#policy, this.#options);
|
|
627
662
|
}
|
|
628
663
|
|
|
629
|
-
#bindingKey(runId: string, role: WorkerRole): string {
|
|
630
|
-
return
|
|
664
|
+
#bindingKey(runId: string, role: WorkerRole, taskId?: string): string {
|
|
665
|
+
return JSON.stringify([runId, role, taskId ?? null]);
|
|
631
666
|
}
|
|
632
667
|
|
|
633
|
-
#brokerFor(runId: string, role: WorkerRole): WorkerBroker {
|
|
634
|
-
const binding =
|
|
668
|
+
#brokerFor(runId: string, role: WorkerRole, taskId?: string): WorkerBroker {
|
|
669
|
+
const binding =
|
|
670
|
+
this.#bindings.get(this.#bindingKey(runId, role, taskId)) ??
|
|
671
|
+
this.#bindings.get(this.#bindingKey(runId, role));
|
|
635
672
|
if (!binding) return this.#defaultBroker;
|
|
636
673
|
const routeId = binding.routeId;
|
|
637
674
|
const key = `${role}\0${routeId}`;
|
|
@@ -657,6 +694,8 @@ export class RunWorkerBroker {
|
|
|
657
694
|
if (
|
|
658
695
|
typeof input.runId !== "string" ||
|
|
659
696
|
input.runId.length === 0 ||
|
|
697
|
+
(input.taskId !== undefined &&
|
|
698
|
+
(typeof input.taskId !== "string" || input.taskId.length === 0)) ||
|
|
660
699
|
typeof input.operationId !== "string" ||
|
|
661
700
|
input.operationId.length === 0
|
|
662
701
|
) {
|
|
@@ -667,7 +706,11 @@ export class RunWorkerBroker {
|
|
|
667
706
|
attempts: [],
|
|
668
707
|
};
|
|
669
708
|
}
|
|
670
|
-
const result = await this.#brokerFor(
|
|
709
|
+
const result = await this.#brokerFor(
|
|
710
|
+
input.runId,
|
|
711
|
+
input.role,
|
|
712
|
+
input.taskId,
|
|
713
|
+
).run({
|
|
671
714
|
operationId: input.operationId,
|
|
672
715
|
role: input.role,
|
|
673
716
|
...(input.requirements ? { requirements: input.requirements } : {}),
|
|
@@ -679,39 +722,54 @@ export class RunWorkerBroker {
|
|
|
679
722
|
if (result.ok) {
|
|
680
723
|
const route = this.#policy.routes[result.routeId];
|
|
681
724
|
if (!route) throw new Error("broker-route-result-invalid");
|
|
682
|
-
this.#bindings.set(
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
725
|
+
this.#bindings.set(
|
|
726
|
+
this.#bindingKey(input.runId, input.role, input.taskId),
|
|
727
|
+
{
|
|
728
|
+
routeId: route.id,
|
|
729
|
+
fingerprint: route.fingerprint,
|
|
730
|
+
},
|
|
731
|
+
);
|
|
686
732
|
}
|
|
687
733
|
return result;
|
|
688
734
|
}
|
|
689
735
|
|
|
690
736
|
rebind(input: {
|
|
691
737
|
runId: string;
|
|
738
|
+
taskId?: string;
|
|
692
739
|
role: WorkerRole;
|
|
693
740
|
routeId: string;
|
|
694
741
|
requirements?: RouteRequirements;
|
|
695
742
|
}) {
|
|
696
|
-
const
|
|
743
|
+
const { taskId, ...routeInput } = input;
|
|
744
|
+
if (
|
|
745
|
+
taskId !== undefined &&
|
|
746
|
+
(typeof taskId !== "string" || taskId.length === 0)
|
|
747
|
+
) {
|
|
748
|
+
return { ok: false as const, code: "approval-boundary" as const };
|
|
749
|
+
}
|
|
750
|
+
const result = this.#defaultBroker.rebind(routeInput);
|
|
697
751
|
if (result.ok) {
|
|
698
|
-
this.#bindings.set(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
752
|
+
this.#bindings.set(
|
|
753
|
+
this.#bindingKey(input.runId, input.role, input.taskId),
|
|
754
|
+
{
|
|
755
|
+
routeId: result.route.id,
|
|
756
|
+
fingerprint: result.route.fingerprint,
|
|
757
|
+
},
|
|
758
|
+
);
|
|
702
759
|
}
|
|
703
760
|
return result;
|
|
704
761
|
}
|
|
705
762
|
|
|
706
763
|
resumeBinding(input: {
|
|
707
764
|
runId: string;
|
|
765
|
+
taskId?: string;
|
|
708
766
|
role: WorkerRole;
|
|
709
767
|
routeId: string;
|
|
710
768
|
expectedFingerprint?: string;
|
|
711
769
|
requirements?: RouteRequirements;
|
|
712
770
|
}) {
|
|
713
771
|
const binding = this.#bindings.get(
|
|
714
|
-
this.#bindingKey(input.runId, input.role),
|
|
772
|
+
this.#bindingKey(input.runId, input.role, input.taskId),
|
|
715
773
|
);
|
|
716
774
|
const route = this.#policy.routes[input.routeId];
|
|
717
775
|
if (!binding) {
|
|
@@ -724,6 +782,7 @@ export class RunWorkerBroker {
|
|
|
724
782
|
}
|
|
725
783
|
return this.rebind({
|
|
726
784
|
runId: input.runId,
|
|
785
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
727
786
|
role: input.role,
|
|
728
787
|
routeId: input.routeId,
|
|
729
788
|
...(input.requirements ? { requirements: input.requirements } : {}),
|
|
@@ -751,8 +810,12 @@ export class RunWorkerBroker {
|
|
|
751
810
|
this.#boundBrokers.clear();
|
|
752
811
|
}
|
|
753
812
|
|
|
754
|
-
binding(
|
|
755
|
-
|
|
813
|
+
binding(
|
|
814
|
+
runId: string,
|
|
815
|
+
role: WorkerRole,
|
|
816
|
+
taskId?: string,
|
|
817
|
+
): string | undefined {
|
|
818
|
+
return this.#bindings.get(this.#bindingKey(runId, role, taskId))?.routeId;
|
|
756
819
|
}
|
|
757
820
|
|
|
758
821
|
status(): Record<string, unknown> {
|