@aiwg/cli 2026.8.2 → 2026.8.4
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/dist/src/a2a/hitl-driver.js +28 -1
- package/dist/src/a2a/hitl.js +32 -8
- package/dist/src/audit/operator-decision.js +180 -0
- package/dist/src/cli/handlers/mc.js +270 -73
- package/dist/src/cli/handlers/subcommands.js +1 -0
- package/dist/src/cli/handlers/use.js +3 -1
- package/dist/src/config/aiwg-config.js +5 -0
- package/dist/src/extensions/claude-hooks-installer.js +9 -5
- package/dist/src/extensions/project-local-doctor.js +10 -26
- package/dist/src/extensions/project-local-remove.js +42 -5
- package/dist/src/mcp/cli.mjs +12 -0
- package/dist/src/mcp/registry.js +14 -1
- package/dist/src/mcp/registry.mjs +15 -1
- package/dist/src/research/query-cli.js +94 -24
- package/dist/src/serve/shared-host-scheduler.js +260 -0
- package/dist/src/storage/backends/fortemi.js +95 -13
- package/dist/src/storage/cli.js +93 -6
- package/package.json +2 -2
- package/tools/plugin/package-plugins.mjs +170 -15
|
@@ -93,6 +93,34 @@ export async function hashBundleArtifacts(bundleAbsPath) {
|
|
|
93
93
|
}
|
|
94
94
|
return out;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Hash the provider-transformed files produced by a successful deployment.
|
|
98
|
+
* The source hash map supplies the canonical inventory/key shape; values are
|
|
99
|
+
* read from the first canonical provider path that exists.
|
|
100
|
+
*/
|
|
101
|
+
export async function hashDeployedBundleArtifacts(projectDir, provider, sourceHashes) {
|
|
102
|
+
const deployed = {};
|
|
103
|
+
for (const [sourceRel, sourceHash] of Object.entries(sourceHashes)) {
|
|
104
|
+
// Keep the complete inventory if a provider path unexpectedly cannot be
|
|
105
|
+
// resolved. Removal then fails safely instead of clearing the registry
|
|
106
|
+
// while leaving an untracked provider artifact behind.
|
|
107
|
+
deployed[sourceRel] = sourceHash;
|
|
108
|
+
for (const candidate of candidateDeployedPaths(projectDir, provider, sourceRel)) {
|
|
109
|
+
try {
|
|
110
|
+
deployed[sourceRel] = await sha256Hex(candidate);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Try translated/provider-alternate paths before leaving it absent.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return deployed;
|
|
119
|
+
}
|
|
120
|
+
/** Resolve current provider hashes while preserving old registry entries. */
|
|
121
|
+
export function artifactHashesForProvider(entry, provider) {
|
|
122
|
+
return entry.deployedArtifactHashes?.[provider] ?? entry.artifactHashes ?? {};
|
|
123
|
+
}
|
|
96
124
|
/** Resolve a recorded source-relative artifact through the canonical provider
|
|
97
125
|
* definition rather than assuming every artifact lives directly under the
|
|
98
126
|
* provider prefix (#1869). Returned paths are absolute for both project- and
|
|
@@ -111,6 +139,11 @@ export function candidateDeployedPaths(projectDir, provider, sourceRel) {
|
|
|
111
139
|
const tail = sourceRel.slice(separator + 1);
|
|
112
140
|
const absoluteRoot = isAbsolute(root) ? root : resolve(projectDir, root);
|
|
113
141
|
const candidates = [join(absoluteRoot, tail)];
|
|
142
|
+
// Codex discovers project skills from the shared native `.agents/skills`
|
|
143
|
+
// root while retaining `.codex` as its compatibility deployment surface.
|
|
144
|
+
if (provider === 'codex' && artifactType === 'skills') {
|
|
145
|
+
candidates.unshift(join(resolve(projectDir, '.agents', 'skills'), tail));
|
|
146
|
+
}
|
|
114
147
|
// Provider adapters may translate source extensions.
|
|
115
148
|
if (provider === 'cursor' && artifactType === 'rules' && tail.endsWith('.md')) {
|
|
116
149
|
candidates.push(join(absoluteRoot, `${tail.slice(0, -3)}.mdc`));
|
|
@@ -186,12 +219,10 @@ function resolveOwnership(config, selfBundleId, provider, sourceRel) {
|
|
|
186
219
|
continue;
|
|
187
220
|
if (entry.source !== 'project-local')
|
|
188
221
|
continue;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (sourceRel in entry.artifactHashes) {
|
|
222
|
+
const hashes = artifactHashesForProvider(entry, provider);
|
|
223
|
+
if (sourceRel in hashes) {
|
|
192
224
|
// Same source-rel path claimed by another project-local bundle —
|
|
193
225
|
// the deployed file (if present) is theirs, not ours.
|
|
194
|
-
void provider;
|
|
195
226
|
return name;
|
|
196
227
|
}
|
|
197
228
|
}
|
|
@@ -219,12 +250,12 @@ export async function removeProjectLocalBundle(config, projectDir, bundleId, opt
|
|
|
219
250
|
const { force = false, provider: onlyProvider, dryRun = false, keepRegistry = false } = opts;
|
|
220
251
|
const confirmMutation = opts.confirmMutation ?? (async () => false);
|
|
221
252
|
const installedEntry = entry;
|
|
222
|
-
const artifactHashes = installedEntry.artifactHashes ?? {};
|
|
223
253
|
const providers = Object.keys(installedEntry.deployedTo).filter(p => !onlyProvider || p === onlyProvider);
|
|
224
254
|
const outcomes = [];
|
|
225
255
|
const revertedProviders = [];
|
|
226
256
|
const partialProviders = [];
|
|
227
257
|
for (const provider of providers) {
|
|
258
|
+
const artifactHashes = artifactHashesForProvider(installedEntry, provider);
|
|
228
259
|
let providerHadSkip = false;
|
|
229
260
|
for (const sourceRel of Object.keys(artifactHashes)) {
|
|
230
261
|
const owner = resolveOwnership(config, bundleId, provider, sourceRel);
|
|
@@ -334,6 +365,12 @@ export async function removeProjectLocalBundle(config, projectDir, bundleId, opt
|
|
|
334
365
|
// Mutate registry for fully-reverted providers
|
|
335
366
|
if (!dryRun && !keepRegistry && !providerHadSkip) {
|
|
336
367
|
delete installedEntry.deployedTo[provider];
|
|
368
|
+
if (installedEntry.deployedArtifactHashes) {
|
|
369
|
+
delete installedEntry.deployedArtifactHashes[provider];
|
|
370
|
+
if (Object.keys(installedEntry.deployedArtifactHashes).length === 0) {
|
|
371
|
+
delete installedEntry.deployedArtifactHashes;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
337
374
|
}
|
|
338
375
|
}
|
|
339
376
|
// Top-level remove activity entry
|
package/dist/src/mcp/cli.mjs
CHANGED
|
@@ -47,6 +47,8 @@ Server Options (for add/update):
|
|
|
47
47
|
--args <a1,a2,...> Command arguments (comma-separated, for stdio)
|
|
48
48
|
--env <K=V,...> Environment variables (comma-separated K=V pairs)
|
|
49
49
|
--headers <K=V,...> HTTP headers (comma-separated K=V pairs)
|
|
50
|
+
--header-env <K=ENV,...>
|
|
51
|
+
Resolve HTTP header values from environment variables
|
|
50
52
|
--description <text> Optional description
|
|
51
53
|
|
|
52
54
|
Inject Options:
|
|
@@ -62,6 +64,8 @@ Serve Options:
|
|
|
62
64
|
Examples:
|
|
63
65
|
# Define MCP servers
|
|
64
66
|
aiwg mcp add fortemi --url https://memory.s9.internal/mcp --type http
|
|
67
|
+
aiwg mcp add fortemi-enterprise --url https://memory.example.internal/mcp --type http \
|
|
68
|
+
--header-env Authorization=AIWG_FORTEMI_TOKEN
|
|
65
69
|
aiwg mcp add gitea --url https://mcp-gitea.integrolabs.net/mcp
|
|
66
70
|
aiwg mcp add mytools --type stdio --command npx --args mcp-server-mytools
|
|
67
71
|
|
|
@@ -498,6 +502,7 @@ async function handleAdd(args) {
|
|
|
498
502
|
const argsStr = parseFlag(args, '--args');
|
|
499
503
|
const envStr = parseFlag(args, '--env');
|
|
500
504
|
const headersStr = parseFlag(args, '--headers');
|
|
505
|
+
const headerEnvStr = parseFlag(args, '--header-env');
|
|
501
506
|
const description = parseFlag(args, '--description');
|
|
502
507
|
|
|
503
508
|
if (type === 'stdio' && !command) {
|
|
@@ -518,6 +523,7 @@ async function handleAdd(args) {
|
|
|
518
523
|
args: argsStr ? argsStr.split(',') : undefined,
|
|
519
524
|
env: parseKVPairs(envStr),
|
|
520
525
|
headers: parseKVPairs(headersStr),
|
|
526
|
+
headerEnv: parseKVPairs(headerEnvStr),
|
|
521
527
|
description,
|
|
522
528
|
});
|
|
523
529
|
|
|
@@ -564,6 +570,7 @@ async function handleUpdate(args) {
|
|
|
564
570
|
const argsStr = parseFlag(args, '--args');
|
|
565
571
|
const envStr = parseFlag(args, '--env');
|
|
566
572
|
const headersStr = parseFlag(args, '--headers');
|
|
573
|
+
const headerEnvStr = parseFlag(args, '--header-env');
|
|
567
574
|
const description = parseFlag(args, '--description');
|
|
568
575
|
|
|
569
576
|
if (url !== undefined) updates.url = url;
|
|
@@ -572,6 +579,7 @@ async function handleUpdate(args) {
|
|
|
572
579
|
if (argsStr !== undefined) updates.args = argsStr.split(',');
|
|
573
580
|
if (envStr !== undefined) updates.env = parseKVPairs(envStr);
|
|
574
581
|
if (headersStr !== undefined) updates.headers = parseKVPairs(headersStr);
|
|
582
|
+
if (headerEnvStr !== undefined) updates.headerEnv = parseKVPairs(headerEnvStr);
|
|
575
583
|
if (description !== undefined) updates.description = description;
|
|
576
584
|
|
|
577
585
|
if (Object.keys(updates).length === 0) {
|
|
@@ -608,6 +616,10 @@ async function handleList() {
|
|
|
608
616
|
console.log(` Type: ${server.type}`);
|
|
609
617
|
if (server.url) console.log(` URL: ${server.url}`);
|
|
610
618
|
if (server.command) console.log(` Command: ${server.command}${server.args ? ' ' + server.args.join(' ') : ''}`);
|
|
619
|
+
if (server.headerEnv) {
|
|
620
|
+
const refs = Object.entries(server.headerEnv).map(([header, envName]) => `${header}←${envName}`);
|
|
621
|
+
console.log(` Credential refs: ${refs.join(', ')}`);
|
|
622
|
+
}
|
|
611
623
|
if (server.description) console.log(` Description: ${server.description}`);
|
|
612
624
|
if (server.injectedProviders && server.injectedProviders.length > 0) {
|
|
613
625
|
console.log(` Injected into: ${server.injectedProviders.join(', ')}`);
|
package/dist/src/mcp/registry.js
CHANGED
|
@@ -20,6 +20,16 @@ const DEFAULT_REGISTRY = {
|
|
|
20
20
|
kind: 'McpServerRegistry',
|
|
21
21
|
servers: {},
|
|
22
22
|
};
|
|
23
|
+
const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
function validateCredentialReferences(def) {
|
|
25
|
+
for (const [header, envName] of Object.entries(def.headerEnv ?? {})) {
|
|
26
|
+
if (!header.trim())
|
|
27
|
+
throw new Error('MCP header-env header name must not be empty');
|
|
28
|
+
if (!ENV_REFERENCE_NAME.test(envName)) {
|
|
29
|
+
throw new Error(`Invalid MCP header environment variable reference "${envName}"`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
export class McpServerRegistry {
|
|
24
34
|
configDir;
|
|
25
35
|
cache = null;
|
|
@@ -59,6 +69,7 @@ export class McpServerRegistry {
|
|
|
59
69
|
}
|
|
60
70
|
/** Add a new MCP server definition */
|
|
61
71
|
async add(def) {
|
|
72
|
+
validateCredentialReferences(def);
|
|
62
73
|
const data = await this.load();
|
|
63
74
|
if (data.servers[def.name]) {
|
|
64
75
|
throw new Error(`Server "${def.name}" already exists. Use "update" to modify it.`);
|
|
@@ -86,12 +97,14 @@ export class McpServerRegistry {
|
|
|
86
97
|
if (!data.servers[name]) {
|
|
87
98
|
throw new Error(`Server "${name}" not found.`);
|
|
88
99
|
}
|
|
89
|
-
|
|
100
|
+
const next = {
|
|
90
101
|
...data.servers[name],
|
|
91
102
|
...updates,
|
|
92
103
|
name, // preserve original name
|
|
93
104
|
updatedAt: new Date().toISOString(),
|
|
94
105
|
};
|
|
106
|
+
validateCredentialReferences(next);
|
|
107
|
+
data.servers[name] = next;
|
|
95
108
|
await this.save();
|
|
96
109
|
}
|
|
97
110
|
/** Get a specific server definition */
|
|
@@ -60,6 +60,17 @@ const DEFAULT_REGISTRY = {
|
|
|
60
60
|
servers: {},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
64
|
+
|
|
65
|
+
function validateCredentialReferences(def) {
|
|
66
|
+
for (const [header, envName] of Object.entries(def.headerEnv || {})) {
|
|
67
|
+
if (!header.trim()) throw new Error('MCP header-env header name must not be empty');
|
|
68
|
+
if (!ENV_REFERENCE_NAME.test(envName)) {
|
|
69
|
+
throw new Error(`Invalid MCP header environment variable reference "${envName}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
export class McpServerRegistry {
|
|
64
75
|
#configDir;
|
|
65
76
|
#cache = null;
|
|
@@ -99,6 +110,7 @@ export class McpServerRegistry {
|
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
async add(def) {
|
|
113
|
+
validateCredentialReferences(def);
|
|
102
114
|
const data = await this.load();
|
|
103
115
|
|
|
104
116
|
if (data.servers[def.name]) {
|
|
@@ -133,12 +145,14 @@ export class McpServerRegistry {
|
|
|
133
145
|
throw new Error(`Server "${name}" not found.`);
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
|
|
148
|
+
const next = {
|
|
137
149
|
...data.servers[name],
|
|
138
150
|
...updates,
|
|
139
151
|
name,
|
|
140
152
|
updatedAt: new Date().toISOString(),
|
|
141
153
|
};
|
|
154
|
+
validateCredentialReferences(next);
|
|
155
|
+
data.servers[name] = next;
|
|
142
156
|
|
|
143
157
|
await this.save();
|
|
144
158
|
}
|
|
@@ -40,7 +40,7 @@ function usage() {
|
|
|
40
40
|
return [
|
|
41
41
|
"Usage: aiwg research-query <question> [--backend fortemi-core|local] [--graph <name>]",
|
|
42
42
|
" [--depth quick|thorough] [--sources-only] [--max-sources N]",
|
|
43
|
-
" [--json] [--save]",
|
|
43
|
+
" [--include-diagnostics] [--json] [--save]",
|
|
44
44
|
].join("\n");
|
|
45
45
|
}
|
|
46
46
|
function flagValue(args, flag, errorMessage) {
|
|
@@ -73,7 +73,12 @@ function stripFlags(args) {
|
|
|
73
73
|
"--depth",
|
|
74
74
|
"--max-sources",
|
|
75
75
|
]);
|
|
76
|
-
const bareFlags = new Set([
|
|
76
|
+
const bareFlags = new Set([
|
|
77
|
+
"--sources-only",
|
|
78
|
+
"--include-diagnostics",
|
|
79
|
+
"--json",
|
|
80
|
+
"--save",
|
|
81
|
+
]);
|
|
77
82
|
const question = [];
|
|
78
83
|
for (let index = 0; index < args.length; index++) {
|
|
79
84
|
const arg = args[index];
|
|
@@ -96,8 +101,7 @@ function parseArgs(args) {
|
|
|
96
101
|
if (backend && backend !== "local" && backend !== "fortemi-core") {
|
|
97
102
|
throw new Error("--backend must be local or fortemi-core");
|
|
98
103
|
}
|
|
99
|
-
const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ??
|
|
100
|
-
"thorough");
|
|
104
|
+
const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ?? "thorough");
|
|
101
105
|
if (depth !== "quick" && depth !== "thorough") {
|
|
102
106
|
throw new Error("--depth must be quick or thorough");
|
|
103
107
|
}
|
|
@@ -113,6 +117,7 @@ function parseArgs(args) {
|
|
|
113
117
|
depth,
|
|
114
118
|
maxSources,
|
|
115
119
|
sourcesOnly: hasFlag(args, "--sources-only"),
|
|
120
|
+
includeDiagnostics: hasFlag(args, "--include-diagnostics"),
|
|
116
121
|
json: hasFlag(args, "--json"),
|
|
117
122
|
save: hasFlag(args, "--save"),
|
|
118
123
|
};
|
|
@@ -140,18 +145,40 @@ function entryId(entry) {
|
|
|
140
145
|
text.match(/\bPROF-[A-Z0-9-]+/i)?.[0]?.toUpperCase() ??
|
|
141
146
|
path.basename(entry.path).replace(/\.[^.]+$/, ""));
|
|
142
147
|
}
|
|
143
|
-
function
|
|
144
|
-
const normalized =
|
|
145
|
-
if (
|
|
148
|
+
function normalizedGrade(value) {
|
|
149
|
+
const normalized = value.trim().toUpperCase();
|
|
150
|
+
if (/^(VERY\s+LOW|D)$/.test(normalized))
|
|
146
151
|
return "VERY LOW";
|
|
147
|
-
if (
|
|
152
|
+
if (/^(HIGH|A(?:-)?)$/.test(normalized))
|
|
148
153
|
return "HIGH";
|
|
149
|
-
if (
|
|
154
|
+
if (/^(MODERATE|B(?:-)?)$/.test(normalized))
|
|
150
155
|
return "MODERATE";
|
|
151
|
-
if (
|
|
156
|
+
if (/^(LOW|C(?:-)?)$/.test(normalized))
|
|
152
157
|
return "LOW";
|
|
153
158
|
return "UNKNOWN";
|
|
154
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Extract only an explicitly declared research grade. Generic severity words
|
|
162
|
+
* in scan reports must never become evidence-quality signals.
|
|
163
|
+
*/
|
|
164
|
+
function gradeForEntry(entry, body) {
|
|
165
|
+
for (const tag of entry.tags) {
|
|
166
|
+
const tagged = tag.match(/^grade[-_: ](very[-_ ]low|high|moderate|low|[a-d](?:-)?)$/i);
|
|
167
|
+
if (tagged)
|
|
168
|
+
return normalizedGrade(tagged[1].replace(/[-_]/g, " "));
|
|
169
|
+
}
|
|
170
|
+
const text = [entry.summary, body].join("\n");
|
|
171
|
+
const declarations = [
|
|
172
|
+
/\bGRADE(?:\s+(?:quality|rating|level|assessment))?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
|
|
173
|
+
/##\s+GRADE[^\n]*\n(?:[^\n]*\n){0,3}?[^\n]*?(?:overall|rating|level)?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
|
|
174
|
+
];
|
|
175
|
+
for (const declaration of declarations) {
|
|
176
|
+
const match = declaration.exec(text);
|
|
177
|
+
if (match)
|
|
178
|
+
return normalizedGrade(match[1]);
|
|
179
|
+
}
|
|
180
|
+
return "UNKNOWN";
|
|
181
|
+
}
|
|
155
182
|
function relevance(score) {
|
|
156
183
|
if (score >= 0.55)
|
|
157
184
|
return "direct";
|
|
@@ -191,26 +218,58 @@ function isResearchEntry(entry) {
|
|
|
191
218
|
entryPath.includes("/research/") ||
|
|
192
219
|
entryPath.includes("/kb/"));
|
|
193
220
|
}
|
|
221
|
+
/** Generated diagnostics are searchable only by explicit operator opt-in. */
|
|
222
|
+
function isDiagnosticEntry(entry) {
|
|
223
|
+
const type = entry.type.toLowerCase();
|
|
224
|
+
const entryPath = entry.path.toLowerCase().replaceAll("\\", "/");
|
|
225
|
+
const tags = entry.tags.map((tag) => tag.toLowerCase());
|
|
226
|
+
return (entryPath.includes("/.aiwg/research/quarantine/") ||
|
|
227
|
+
entryPath.includes("/research/quarantine/") ||
|
|
228
|
+
/(?:^|\/)no-ref-[^/]*artifact-scan\.md$/.test(entryPath) ||
|
|
229
|
+
/(?:llm|integrity|artifact)-scan\.md$/.test(entryPath) ||
|
|
230
|
+
/(?:integrity|artifact|llm)[-_ ]scan|quarantine|diagnostic/.test(type) ||
|
|
231
|
+
tags.some((tag) => /^(?:integrity|artifact|llm)[-_ ]scan$|^quarantine$|^diagnostic$/.test(tag)));
|
|
232
|
+
}
|
|
194
233
|
function localEntries(cwd, graph) {
|
|
195
234
|
const index = loadGraphIndexFile(cwd, "metadata.json", graph);
|
|
196
|
-
return index ? Object.values(index.entries) : [];
|
|
235
|
+
return index ? Object.values(index.entries).map((entry) => ({ entry })) : [];
|
|
236
|
+
}
|
|
237
|
+
function cachedRecordBody(record) {
|
|
238
|
+
return [
|
|
239
|
+
record.search?.body,
|
|
240
|
+
record.text,
|
|
241
|
+
...(record.chunks ?? []).map((chunk) => chunk.text),
|
|
242
|
+
]
|
|
243
|
+
.filter((part) => Boolean(part))
|
|
244
|
+
.join("\n");
|
|
197
245
|
}
|
|
198
246
|
async function backendEntries(cwd, graph, backend) {
|
|
199
247
|
if (backend === "fortemi-core") {
|
|
200
|
-
const { loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
|
|
248
|
+
const { loadFortemiCoreExport, loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
|
|
201
249
|
const loaded = loadFortemiCoreMetadataEntries(cwd, graph);
|
|
202
|
-
|
|
250
|
+
if (loaded.reason)
|
|
251
|
+
return { entries: [], hint: loaded.reason };
|
|
252
|
+
const exported = loadFortemiCoreExport(cwd, graph);
|
|
253
|
+
if (!exported.exported)
|
|
254
|
+
return { entries: [], hint: exported.reason };
|
|
255
|
+
const records = new Map(exported.exported.items.map((record) => [record.source.path, record]));
|
|
256
|
+
return {
|
|
257
|
+
entries: loaded.entries.map((entry) => {
|
|
258
|
+
const record = records.get(entry.path);
|
|
259
|
+
return {
|
|
260
|
+
entry,
|
|
261
|
+
cachedBody: record ? cachedRecordBody(record) : "",
|
|
262
|
+
};
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
203
265
|
}
|
|
204
266
|
return { entries: localEntries(cwd, graph) };
|
|
205
267
|
}
|
|
206
|
-
function sourceForEntry(cwd,
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
entry.
|
|
210
|
-
|
|
211
|
-
entry.tags.join(" "),
|
|
212
|
-
body,
|
|
213
|
-
].join("\n");
|
|
268
|
+
function sourceForEntry(cwd, candidate, question, depth) {
|
|
269
|
+
const { entry } = candidate;
|
|
270
|
+
const body = depth === "thorough"
|
|
271
|
+
? (candidate.cachedBody ?? readEntryBody(cwd, entry.path))
|
|
272
|
+
: "";
|
|
214
273
|
const score = scoreEntry(entry, question, body, depth);
|
|
215
274
|
if (score <= 0)
|
|
216
275
|
return null;
|
|
@@ -219,7 +278,7 @@ function sourceForEntry(cwd, entry, question, depth) {
|
|
|
219
278
|
path: entry.path,
|
|
220
279
|
title: entry.title,
|
|
221
280
|
type: entry.type,
|
|
222
|
-
grade:
|
|
281
|
+
grade: gradeForEntry(entry, body),
|
|
223
282
|
relevance: relevance(score),
|
|
224
283
|
score: Math.round(score * 1000) / 1000,
|
|
225
284
|
summary: entry.summary,
|
|
@@ -236,13 +295,24 @@ export async function runResearchQuery(cwd, options) {
|
|
|
236
295
|
throw new Error(loaded.hint);
|
|
237
296
|
}
|
|
238
297
|
const sources = loaded.entries
|
|
239
|
-
.filter(isResearchEntry)
|
|
240
|
-
.
|
|
298
|
+
.filter(({ entry }) => isResearchEntry(entry))
|
|
299
|
+
.filter(({ entry }) => options.includeDiagnostics || !isDiagnosticEntry(entry))
|
|
300
|
+
.map((candidate) => sourceForEntry(cwd, candidate, options.question, depth))
|
|
241
301
|
.filter((source) => source !== null)
|
|
242
302
|
.sort((left, right) => {
|
|
243
303
|
const scoreCmp = right.score - left.score;
|
|
244
304
|
if (scoreCmp !== 0)
|
|
245
305
|
return scoreCmp;
|
|
306
|
+
const gradeOrder = [
|
|
307
|
+
"HIGH",
|
|
308
|
+
"MODERATE",
|
|
309
|
+
"LOW",
|
|
310
|
+
"VERY LOW",
|
|
311
|
+
"UNKNOWN",
|
|
312
|
+
];
|
|
313
|
+
const gradeCmp = gradeOrder.indexOf(left.grade) - gradeOrder.indexOf(right.grade);
|
|
314
|
+
if (gradeCmp !== 0)
|
|
315
|
+
return gradeCmp;
|
|
246
316
|
return left.path.localeCompare(right.path);
|
|
247
317
|
})
|
|
248
318
|
.slice(0, maxSources);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-orchestrator scheduling and admission for shared execution hosts.
|
|
3
|
+
*
|
|
4
|
+
* The store is the global serialization boundary. Multiple AIWG processes must
|
|
5
|
+
* use the same durable implementation; the in-memory store is for tests and
|
|
6
|
+
* single-process embedding only. Executor substrates report capacity and run
|
|
7
|
+
* admitted work, but do not own this policy.
|
|
8
|
+
*
|
|
9
|
+
* @implements #1566
|
|
10
|
+
*/
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
export class InMemoryAdmissionStore {
|
|
14
|
+
snapshot = { revision: 0, records: {} };
|
|
15
|
+
transact(mutate) {
|
|
16
|
+
const draft = structuredClone(this.snapshot);
|
|
17
|
+
const result = mutate(draft);
|
|
18
|
+
draft.revision += 1;
|
|
19
|
+
this.snapshot = draft;
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
read() {
|
|
23
|
+
return structuredClone(this.snapshot);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Durable cross-process store. The lock is deliberately non-blocking: a
|
|
27
|
+
* concurrent writer receives a conflict and retries through its control loop. */
|
|
28
|
+
export class FileAdmissionStore {
|
|
29
|
+
path;
|
|
30
|
+
constructor(path) {
|
|
31
|
+
this.path = path;
|
|
32
|
+
}
|
|
33
|
+
transact(mutate) {
|
|
34
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
35
|
+
const lockPath = `${this.path}.lock`;
|
|
36
|
+
let descriptor;
|
|
37
|
+
try {
|
|
38
|
+
descriptor = openSync(lockPath, 'wx', 0o600);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'EEXIST') {
|
|
42
|
+
throw new Error('shared-host admission store is busy; retry the request');
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const snapshot = this.readUnsafe();
|
|
48
|
+
const result = mutate(snapshot);
|
|
49
|
+
snapshot.revision += 1;
|
|
50
|
+
const temporary = `${this.path}.${process.pid}.tmp`;
|
|
51
|
+
writeFileSync(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
|
|
52
|
+
renameSync(temporary, this.path);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
closeSync(descriptor);
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(lockPath);
|
|
59
|
+
}
|
|
60
|
+
catch { /* best-effort lock cleanup */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
read() {
|
|
64
|
+
return structuredClone(this.readUnsafe());
|
|
65
|
+
}
|
|
66
|
+
readUnsafe() {
|
|
67
|
+
if (!existsSync(this.path))
|
|
68
|
+
return { revision: 0, records: {} };
|
|
69
|
+
return JSON.parse(readFileSync(this.path, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const terminalStates = new Set([
|
|
73
|
+
'denied', 'cancelled', 'timed-out', 'preempted',
|
|
74
|
+
]);
|
|
75
|
+
export class SharedHostScheduler {
|
|
76
|
+
store;
|
|
77
|
+
policy;
|
|
78
|
+
clock;
|
|
79
|
+
constructor(store, policy, clock = Date.now) {
|
|
80
|
+
this.store = store;
|
|
81
|
+
this.policy = policy;
|
|
82
|
+
this.clock = clock;
|
|
83
|
+
if (!Number.isInteger(policy.maxConcurrent) || policy.maxConcurrent < 1) {
|
|
84
|
+
throw new Error('maxConcurrent must be a positive integer');
|
|
85
|
+
}
|
|
86
|
+
if (policy.leaseTtlMs < 1 || policy.agingIntervalMs < 1) {
|
|
87
|
+
throw new Error('leaseTtlMs and agingIntervalMs must be positive');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
submit(request) {
|
|
91
|
+
this.validateRequest(request);
|
|
92
|
+
return this.store.transact(snapshot => {
|
|
93
|
+
const existing = snapshot.records[request.requestId];
|
|
94
|
+
if (existing) {
|
|
95
|
+
if (!sameRequest(existing, request)) {
|
|
96
|
+
throw new Error(`request '${request.requestId}' conflicts with an existing admission`);
|
|
97
|
+
}
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
snapshot.records[request.requestId] = {
|
|
101
|
+
...structuredClone(request),
|
|
102
|
+
state: 'queued',
|
|
103
|
+
revision: 1,
|
|
104
|
+
reason: 'awaiting shared-host capacity',
|
|
105
|
+
};
|
|
106
|
+
this.reconcile(snapshot);
|
|
107
|
+
return structuredClone(snapshot.records[request.requestId]);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
reconcileNow() {
|
|
111
|
+
return this.store.transact(snapshot => {
|
|
112
|
+
this.reconcile(snapshot);
|
|
113
|
+
return structuredClone(snapshot);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
renew(requestId) {
|
|
117
|
+
return this.store.transact(snapshot => {
|
|
118
|
+
const record = this.required(snapshot, requestId);
|
|
119
|
+
if (record.state !== 'admitted')
|
|
120
|
+
throw new Error(`cannot renew ${record.state} admission`);
|
|
121
|
+
record.leaseExpiresAt = new Date(this.clock() + this.policy.leaseTtlMs).toISOString();
|
|
122
|
+
record.revision += 1;
|
|
123
|
+
record.reason = 'lease renewed';
|
|
124
|
+
return structuredClone(record);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
release(requestId) {
|
|
128
|
+
return this.store.transact(snapshot => {
|
|
129
|
+
const record = this.required(snapshot, requestId);
|
|
130
|
+
delete snapshot.records[requestId];
|
|
131
|
+
if (record.state === 'admitted')
|
|
132
|
+
this.reconcile(snapshot);
|
|
133
|
+
return structuredClone(snapshot);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
cancel(requestId) {
|
|
137
|
+
return this.store.transact(snapshot => {
|
|
138
|
+
const record = this.required(snapshot, requestId);
|
|
139
|
+
if (terminalStates.has(record.state))
|
|
140
|
+
return structuredClone(record);
|
|
141
|
+
record.state = 'cancelled';
|
|
142
|
+
record.reason = 'cancelled by orchestrator';
|
|
143
|
+
record.finishedAt = new Date(this.clock()).toISOString();
|
|
144
|
+
record.revision += 1;
|
|
145
|
+
this.reconcile(snapshot);
|
|
146
|
+
return structuredClone(record);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
snapshot() {
|
|
150
|
+
return this.store.read();
|
|
151
|
+
}
|
|
152
|
+
reconcile(snapshot) {
|
|
153
|
+
const now = this.clock();
|
|
154
|
+
for (const record of Object.values(snapshot.records)) {
|
|
155
|
+
if (record.state === 'admitted' && Date.parse(record.leaseExpiresAt ?? '') <= now) {
|
|
156
|
+
record.state = 'timed-out';
|
|
157
|
+
record.reason = 'admission lease expired; capacity recovered';
|
|
158
|
+
record.finishedAt = new Date(now).toISOString();
|
|
159
|
+
record.revision += 1;
|
|
160
|
+
}
|
|
161
|
+
else if (record.state === 'queued' && Date.parse(record.submittedAt) + record.queueTimeoutMs <= now) {
|
|
162
|
+
record.state = 'timed-out';
|
|
163
|
+
record.reason = 'queue deadline elapsed';
|
|
164
|
+
record.finishedAt = new Date(now).toISOString();
|
|
165
|
+
record.revision += 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let queued = Object.values(snapshot.records)
|
|
169
|
+
.filter(record => record.state === 'queued')
|
|
170
|
+
.sort((a, b) => this.compare(a, b, now));
|
|
171
|
+
for (const candidate of queued) {
|
|
172
|
+
if (!this.hasCapacity(snapshot, candidate)) {
|
|
173
|
+
if (this.policy.allowPreemption)
|
|
174
|
+
this.tryPreempt(snapshot, candidate, now);
|
|
175
|
+
}
|
|
176
|
+
if (!this.hasCapacity(snapshot, candidate))
|
|
177
|
+
continue;
|
|
178
|
+
candidate.state = 'admitted';
|
|
179
|
+
candidate.reason = 'admitted by shared-host policy';
|
|
180
|
+
candidate.admittedAt = new Date(now).toISOString();
|
|
181
|
+
candidate.leaseExpiresAt = new Date(now + this.policy.leaseTtlMs).toISOString();
|
|
182
|
+
candidate.revision += 1;
|
|
183
|
+
}
|
|
184
|
+
queued = [];
|
|
185
|
+
}
|
|
186
|
+
tryPreempt(snapshot, candidate, now) {
|
|
187
|
+
const victims = Object.values(snapshot.records)
|
|
188
|
+
.filter(record => record.state === 'admitted' && record.preemptible === true)
|
|
189
|
+
.sort((a, b) => this.compare(b, a, now));
|
|
190
|
+
const victim = victims.find(record => {
|
|
191
|
+
if (this.effectivePriority(record, now) >= this.effectivePriority(candidate, now))
|
|
192
|
+
return false;
|
|
193
|
+
const priorState = record.state;
|
|
194
|
+
record.state = 'preempted';
|
|
195
|
+
const freesRequiredCapacity = this.hasCapacity(snapshot, candidate);
|
|
196
|
+
record.state = priorState;
|
|
197
|
+
return freesRequiredCapacity;
|
|
198
|
+
});
|
|
199
|
+
if (!victim)
|
|
200
|
+
return;
|
|
201
|
+
victim.state = 'preempted';
|
|
202
|
+
victim.reason = `preempted by higher-priority request '${candidate.requestId}'`;
|
|
203
|
+
victim.preemptedBy = candidate.requestId;
|
|
204
|
+
victim.finishedAt = new Date(now).toISOString();
|
|
205
|
+
victim.revision += 1;
|
|
206
|
+
}
|
|
207
|
+
hasCapacity(snapshot, candidate) {
|
|
208
|
+
const active = Object.values(snapshot.records).filter(record => record.state === 'admitted');
|
|
209
|
+
if (active.length >= this.policy.maxConcurrent)
|
|
210
|
+
return false;
|
|
211
|
+
if (!belowQuota(active, 'environment', candidate.environment, this.policy.environmentQuotas))
|
|
212
|
+
return false;
|
|
213
|
+
if (!belowQuota(active, 'provider', candidate.provider, this.policy.providerQuotas))
|
|
214
|
+
return false;
|
|
215
|
+
const runtimeQuotas = {
|
|
216
|
+
host: this.policy.defaultHostQuota ?? 1,
|
|
217
|
+
...this.policy.runtimeQuotas,
|
|
218
|
+
};
|
|
219
|
+
return belowQuota(active, 'runtimeKind', candidate.runtimeKind, runtimeQuotas);
|
|
220
|
+
}
|
|
221
|
+
effectivePriority(record, now) {
|
|
222
|
+
const waited = Math.max(0, now - Date.parse(record.submittedAt));
|
|
223
|
+
return record.priority + Math.floor(waited / this.policy.agingIntervalMs);
|
|
224
|
+
}
|
|
225
|
+
compare(a, b, now) {
|
|
226
|
+
return this.effectivePriority(b, now) - this.effectivePriority(a, now)
|
|
227
|
+
|| Date.parse(a.submittedAt) - Date.parse(b.submittedAt)
|
|
228
|
+
|| a.requestId.localeCompare(b.requestId);
|
|
229
|
+
}
|
|
230
|
+
required(snapshot, requestId) {
|
|
231
|
+
const record = snapshot.records[requestId];
|
|
232
|
+
if (!record)
|
|
233
|
+
throw new Error(`unknown admission request '${requestId}'`);
|
|
234
|
+
return record;
|
|
235
|
+
}
|
|
236
|
+
validateRequest(request) {
|
|
237
|
+
if (!request.requestId || !request.orchestratorId || !request.environment || !request.provider) {
|
|
238
|
+
throw new Error('request identity, orchestrator, environment, and provider are required');
|
|
239
|
+
}
|
|
240
|
+
if (!Number.isFinite(request.priority))
|
|
241
|
+
throw new Error('priority must be finite');
|
|
242
|
+
if (!Number.isFinite(Date.parse(request.submittedAt)))
|
|
243
|
+
throw new Error('submittedAt must be a timestamp');
|
|
244
|
+
if (request.queueTimeoutMs < 1)
|
|
245
|
+
throw new Error('queueTimeoutMs must be positive');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function belowQuota(active, field, value, quotas) {
|
|
249
|
+
const quota = quotas?.[String(value)];
|
|
250
|
+
if (quota === undefined)
|
|
251
|
+
return true;
|
|
252
|
+
return active.filter(record => record[field] === value).length < quota;
|
|
253
|
+
}
|
|
254
|
+
function sameRequest(record, request) {
|
|
255
|
+
return record.orchestratorId === request.orchestratorId
|
|
256
|
+
&& record.environment === request.environment
|
|
257
|
+
&& record.provider === request.provider
|
|
258
|
+
&& record.runtimeKind === request.runtimeKind;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=shared-host-scheduler.js.map
|