@aiwg/cli 2026.10.8 → 2026.10.10
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/agentic/code/providers/model-capabilities.v1.json +197 -3
- package/bin/aiwg.mjs +8 -0
- package/dist/src/artifacts/cli.js +9 -5
- package/dist/src/auth/config.js +3 -8
- package/dist/src/auth/resource-credentials.js +35 -2
- package/dist/src/cli/handlers/diagnose.js +13 -0
- package/dist/src/cli/handlers/mc-admission.js +39 -0
- package/dist/src/cli/handlers/mc.js +2 -31
- package/dist/src/cli/handlers/models-help.js +52 -0
- package/dist/src/cli/handlers/models.js +11 -81
- package/dist/src/cli/handlers/resource-versions.js +8 -5
- package/dist/src/cli/handlers/script-runner.js +13 -1
- package/dist/src/cli/handlers/use-addon-profile.js +72 -0
- package/dist/src/cli/handlers/use.js +209 -233
- package/dist/src/cli/ui.js +1 -0
- package/dist/src/config/aiwg-config.js +38 -184
- package/dist/src/config/cli-help.js +54 -0
- package/dist/src/config/cli.js +5 -69
- package/dist/src/config/config-normalization.js +10 -0
- package/dist/src/config/worker-activation-gate.js +26 -0
- package/dist/src/config/worker-adoption-cli.js +102 -0
- package/dist/src/config/worker-adoption.js +368 -0
- package/dist/src/config/worker-config-digest.js +14 -0
- package/dist/src/config/worker-group-templates.js +150 -0
- package/dist/src/config/worker-guidance-warning.js +28 -0
- package/dist/src/config/worker-owner-lock.js +152 -0
- package/dist/src/config/worker-parallelism.js +154 -0
- package/dist/src/config/worker-write.js +75 -0
- package/dist/src/decision/telemetry/exporter.js +2 -1
- package/dist/src/decision/telemetry/index.js +1 -0
- package/dist/src/decision/telemetry/mapping.js +43 -0
- package/dist/src/decision/telemetry/otlp-http.js +5 -2
- package/dist/src/decision/telemetry/redaction.js +6 -1
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/telemetry/worker.js +69 -0
- package/dist/src/lint/rule-activation.js +15 -7
- package/dist/src/lint/runner.js +2 -2
- package/dist/src/mcp/server.mjs +2 -11
- package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
- package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
- package/dist/src/mcp/tools/worker-invoke.mjs +6 -6
- package/dist/src/mission-protocol/codecs.js +8 -38
- package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
- package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
- package/dist/src/models/conductor-data-policy.d.mts +8 -0
- package/dist/src/models/conductor-data-policy.mjs +190 -0
- package/dist/src/models/config-loader.js +19 -3
- package/dist/src/models/config-writer.js +27 -0
- package/dist/src/models/model-capabilities.v1.json +197 -3
- package/dist/src/models/provider-policy.js +7 -155
- package/dist/src/models/resolver.js +2 -293
- package/dist/src/models/worker-artifact-binding.mjs +12 -0
- package/dist/src/models/worker-direct-readiness-policy.js +155 -0
- package/dist/src/models/worker-direct-readiness.js +9 -218
- package/dist/src/models/worker-health-readiness.js +105 -26
- package/dist/src/models/worker-installation.mjs +12 -14
- package/dist/src/models/worker-member-references.js +64 -0
- package/dist/src/models/worker-plan.js +317 -0
- package/dist/src/models/worker-selection-policy.js +217 -0
- package/dist/src/models/wrapper-deployment.js +1 -1
- package/dist/src/resources/web-release.js +76 -65
- package/dist/src/serve/shared-host-admission-checks.js +116 -0
- package/dist/src/serve/shared-host-scheduler.js +25 -107
- package/dist/src/serve/shared-host-starvation.js +73 -0
- package/dist/src/serve/worker-artifacts.d.mts +4 -0
- package/dist/src/serve/worker-artifacts.mjs +56 -0
- package/dist/src/serve/worker-command-request.js +68 -0
- package/dist/src/serve/worker-invocation-paths.js +144 -0
- package/dist/src/serve/worker-invocation-preparation.js +169 -0
- package/dist/src/serve/worker-invocation.js +24 -291
- package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
- package/dist/src/serve/worker-stdin-frame.mjs +6 -6
- package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
- package/dist/src/storage/backends/fortemi.js +33 -29
- package/dist/src/storage/backends/logseq.js +16 -3
- package/dist/src/storage/backends/postgres.js +74 -33
- package/dist/src/storage/backends/postgrest.js +37 -13
- package/dist/src/storage/config.js +6 -1
- package/dist/src/uhp/config.js +4 -0
- package/package.json +9 -1
- package/schemas/mission-v1.schema.json +20 -19
- package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
- package/tools/agents/providers/claude-post-deploy.mjs +35 -0
- package/tools/agents/providers/claude.mjs +3 -30
- package/tools/agents/providers/codex-post-deploy.mjs +13 -0
- package/tools/agents/providers/codex.mjs +2 -8
- package/tools/agents/providers/conductor-data-guard.mjs +61 -0
- package/tools/agents/providers/rule-delivery.mjs +3 -16
- package/tools/agents/providers/rule-presentation.mjs +38 -0
- package/tools/plugin/package-plugins.mjs +1 -1
- package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
- package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
- package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
- package/tools/ralph-external/lib/session-attempt.mjs +85 -0
- package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
- package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
- package/tools/ralph-external/lib/worker-final.mjs +143 -0
- package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
- package/tools/ralph-external/lib/worker-isolation.mjs +2 -44
- package/tools/ralph-external/lib/worker-launch-cli.mjs +47 -0
- package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
- package/tools/ralph-external/session-launcher.mjs +73 -217
- package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Publishes complete owner identities atomically and fences dead-owner takeover by generation.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { mkdir, writeFile, symlink, readlink, unlink, rm, readdir } from 'node:fs/promises';
|
|
6
|
+
import { readFileSync, readlinkSync } from 'node:fs';
|
|
7
|
+
import { projectControlPath } from './project-artifacts.js';
|
|
8
|
+
export const workerWriteLock = (project) => projectControlPath(project, 'aiwg.config.apply-lock');
|
|
9
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
10
|
+
function processStart(pid) {
|
|
11
|
+
try {
|
|
12
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
13
|
+
return `${readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function identity() {
|
|
20
|
+
return { generation: randomUUID(), host: hostname(), pid: process.pid, start: processStart(process.pid) };
|
|
21
|
+
}
|
|
22
|
+
function provablyDead(owner) {
|
|
23
|
+
if (owner.released)
|
|
24
|
+
return true; // The operation ended and explicitly relinquished exclusion.
|
|
25
|
+
if (owner.host !== hostname() || !Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(owner.pid, 0);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return error.code === 'ESRCH';
|
|
32
|
+
}
|
|
33
|
+
const start = processStart(owner.pid);
|
|
34
|
+
return typeof owner.start === 'string' && owner.start.length > 0 && start !== null && owner.start !== start;
|
|
35
|
+
}
|
|
36
|
+
function latest(directory) {
|
|
37
|
+
let owner = JSON.parse(readFileSync(join(directory, 'owner.json'), 'utf8'));
|
|
38
|
+
// Immutable successor links are a compare-and-swap: only one contender can claim a generation.
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (!/^[a-f0-9-]{36}$/.test(owner.generation))
|
|
41
|
+
throw new Error('Invalid worker lock generation');
|
|
42
|
+
try {
|
|
43
|
+
owner = JSON.parse(readlinkSync(join(directory, `next-${owner.generation}`)));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (missing(error))
|
|
47
|
+
return owner;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function sweepOrphanedOwners(path) {
|
|
53
|
+
const parent = dirname(path);
|
|
54
|
+
const prefix = `${basename(path)}.`;
|
|
55
|
+
for (const entry of await readdir(parent, { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix) || !/^[a-f0-9-]{36}$/.test(entry.name.slice(prefix.length)))
|
|
57
|
+
continue;
|
|
58
|
+
const directory = join(parent, entry.name);
|
|
59
|
+
let owner;
|
|
60
|
+
try {
|
|
61
|
+
owner = latest(directory);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
} // Incomplete or unreadable identities cannot prove death.
|
|
66
|
+
if (!provablyDead(owner))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
if (resolve(parent, await readlink(path)) === directory)
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!missing(error))
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
await rm(directory, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function lease(path, directory, owner) {
|
|
80
|
+
const assertOwned = () => {
|
|
81
|
+
try {
|
|
82
|
+
if (readlinkSync(path) === basename(directory) && latest(directory).generation === owner.generation)
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
catch { /* Missing or replaced ownership must never authorize another write. */ }
|
|
86
|
+
throw new Error(`Worker configuration lock ownership lost (${owner.generation}); dispatch remains closed at ${path}`);
|
|
87
|
+
};
|
|
88
|
+
return { path, generation: owner.generation, assertOwned, release: async (keepBlocked = false) => {
|
|
89
|
+
assertOwned();
|
|
90
|
+
if (keepBlocked) {
|
|
91
|
+
// A caught failure can be retried even while this embedding process stays alive.
|
|
92
|
+
await symlink(JSON.stringify({ ...identity(), released: true }), join(directory, `next-${owner.generation}`));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// A live owner cannot be reclaimed; unlink only while we still own this exact generation.
|
|
96
|
+
await unlink(path);
|
|
97
|
+
await rm(directory, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
} };
|
|
100
|
+
}
|
|
101
|
+
export async function acquireWorkerWriteLock(project, block = false, proposalDigest) {
|
|
102
|
+
const path = workerWriteLock(project);
|
|
103
|
+
const owner = { ...identity(), ...(proposalDigest ? { proposalDigest } : {}) };
|
|
104
|
+
const directory = `${path}.${owner.generation}`;
|
|
105
|
+
await mkdir(dirname(path), { recursive: true });
|
|
106
|
+
await sweepOrphanedOwners(path);
|
|
107
|
+
await mkdir(directory);
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(join(directory, 'owner.json'), JSON.stringify(owner), { mode: 0o600 });
|
|
110
|
+
if (block)
|
|
111
|
+
await writeFile(join(directory, 'blocked'), 'Worker policy apply/recovery in progress.\n', { mode: 0o600 });
|
|
112
|
+
// No visible lock can exist without an owner, even if initialization is interrupted.
|
|
113
|
+
await symlink(basename(directory), path);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
await rm(directory, { recursive: true, force: true });
|
|
117
|
+
if (error.code === 'EEXIST')
|
|
118
|
+
throw new Error(`Worker configuration is locked: ${path}; use authorized recovery after its owner exits`);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
return lease(path, directory, owner);
|
|
122
|
+
}
|
|
123
|
+
export async function reclaimWorkerWriteLock(project) {
|
|
124
|
+
const path = workerWriteLock(project);
|
|
125
|
+
let directory;
|
|
126
|
+
let previous;
|
|
127
|
+
try {
|
|
128
|
+
directory = resolve(dirname(path), await readlink(path));
|
|
129
|
+
previous = latest(directory);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (missing(error))
|
|
133
|
+
throw new Error(`No interrupted apply; nothing to recover at ${path}`);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (!provablyDead(previous))
|
|
137
|
+
throw new Error(`Worker configuration is locked by a live or unverifiable owner (${previous.host}:${previous.pid}); ${path}`);
|
|
138
|
+
const owner = identity();
|
|
139
|
+
try {
|
|
140
|
+
await symlink(JSON.stringify(owner), join(directory, `next-${previous.generation}`));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.code === 'EEXIST')
|
|
144
|
+
throw new Error(`Worker configuration is locked: another recovery claimed generation ${previous.generation}`);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const result = lease(path, directory, owner);
|
|
148
|
+
result.assertOwned();
|
|
149
|
+
await writeFile(join(directory, 'blocked'), 'Worker policy recovery in progress.\n', { mode: 0o600 });
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=worker-owner-lock.js.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Defines and validates direct worker parallelism configuration before use.
|
|
2
|
+
import Ajv from 'ajv';
|
|
3
|
+
import addFormats from 'ajv-formats';
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { PROVIDER_IDS } from '../providers/provider-definitions.js';
|
|
8
|
+
import { isCanonicalWorkerModelKey, loadProviderModelCapabilities, resolveWorkerInstallation } from '../models/provider-policy.js';
|
|
9
|
+
import { resolveDispatchHostThresholds } from './dispatch-thresholds-runtime.mjs';
|
|
10
|
+
import { expandWorkerGroupTemplates } from './worker-group-templates.js';
|
|
11
|
+
let parallelismValidator;
|
|
12
|
+
/** Offline shape and declaration validation. Qualification is checked at use time. */
|
|
13
|
+
export function validateParallelismConfig(value) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return [];
|
|
16
|
+
const raw = value;
|
|
17
|
+
const expansion = expandWorkerGroupTemplates(value);
|
|
18
|
+
if (expansion.errors.length)
|
|
19
|
+
return expansion.errors;
|
|
20
|
+
value = expansion.parallelism;
|
|
21
|
+
if (!parallelismValidator) {
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const candidates = [resolve(here, '../../vscode-extension/schemas/aiwg.config.v1.json'), resolve(here, '../../../vscode-extension/schemas/aiwg.config.v1.json')];
|
|
24
|
+
const file = candidates.find(existsSync);
|
|
25
|
+
if (!file)
|
|
26
|
+
throw new Error('Parallelism schema is unavailable');
|
|
27
|
+
const schema = JSON.parse(readFileSync(file, 'utf8'));
|
|
28
|
+
const ajv = addFormats(new Ajv({ strict: true, allErrors: true }));
|
|
29
|
+
parallelismValidator = ajv.compile({ ...schema.properties.parallelism, definitions: schema.definitions });
|
|
30
|
+
}
|
|
31
|
+
if (!parallelismValidator(raw))
|
|
32
|
+
return (parallelismValidator.errors ?? []).map(error => `OG_CONFIG_REFERENCE /parallelism${error.instancePath}: ${error.message}`);
|
|
33
|
+
if (!parallelismValidator(value))
|
|
34
|
+
return (parallelismValidator.errors ?? []).map(error => `OG_CONFIG_REFERENCE /parallelism${error.instancePath}: ${error.message}`);
|
|
35
|
+
const policy = value;
|
|
36
|
+
const errors = [];
|
|
37
|
+
const error = (pointer, message) => { errors.push(`OG_CONFIG_REFERENCE /parallelism/${pointer}: ${message}`); };
|
|
38
|
+
const builtinIds = new Set(PROVIDER_IDS.filter(id => id !== 'generic'));
|
|
39
|
+
for (const [id, installation] of Object.entries(policy.clis ?? {})) {
|
|
40
|
+
if (installation.adapter.startsWith('providers:') && !builtinIds.has(installation.adapter.slice(10)))
|
|
41
|
+
error(`clis/${id}/adapter`, 'Unknown provider binding');
|
|
42
|
+
}
|
|
43
|
+
for (const [id, group] of Object.entries(policy.groups ?? {})) {
|
|
44
|
+
if (group.execution?.launchMode === 'native-sandbox'
|
|
45
|
+
&& (!['read-only', 'workspace-write'].includes(group.execution.sandbox ?? '')
|
|
46
|
+
|| (group.execution.transport ?? 'shell') !== 'shell')) {
|
|
47
|
+
error(`groups/${id}/execution/launchMode`, 'native-sandbox requires an enforcing read-only or workspace-write shell sandbox');
|
|
48
|
+
}
|
|
49
|
+
if (group.execution?.launchMode === 'native-sandbox') {
|
|
50
|
+
if (group.execution.approval === 'on-request')
|
|
51
|
+
error(`groups/${id}/execution/approval`, 'native-sandbox requires approval="never"');
|
|
52
|
+
if (group.execution.network === 'allowlisted')
|
|
53
|
+
error(`groups/${id}/execution/network`, 'native-sandbox requires network="deny"');
|
|
54
|
+
}
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
for (const member of (group.members ?? [])) {
|
|
57
|
+
if (ids.has(member.id))
|
|
58
|
+
error(`groups/${id}/members`, 'Duplicate member ID');
|
|
59
|
+
ids.add(member.id);
|
|
60
|
+
if (!policy.clis?.[member.cli] && !builtinIds.has(member.cli))
|
|
61
|
+
error(`groups/${id}/members/${member.id}/cli`, 'Unknown installation');
|
|
62
|
+
if (group.execution?.launchMode === 'native-sandbox'
|
|
63
|
+
&& (policy.clis?.[member.cli]?.adapter ?? `providers:${member.cli}`) !== 'providers:codex') {
|
|
64
|
+
error(`groups/${id}/members/${member.id}/cli`, 'native-sandbox requires a codex adapter');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const member of group.fallback ?? [])
|
|
68
|
+
if (!ids.has(member))
|
|
69
|
+
error(`groups/${id}/fallback`, 'Unknown fallback member');
|
|
70
|
+
for (const role of group.requiredRoles ?? []) {
|
|
71
|
+
if (!(group.members ?? []).some(m => m.enabled !== false && policy.clis?.[m.cli]?.enabled !== false && m.roles.includes(role)))
|
|
72
|
+
error(`groups/${id}/requiredRoles`, `No enabled member covers ${role}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const admission = policy.admission;
|
|
76
|
+
for (const group of Object.keys(admission?.groupCaps ?? {}))
|
|
77
|
+
if (!policy.groups?.[group])
|
|
78
|
+
error('admission/groupCaps', 'Unknown group');
|
|
79
|
+
for (const cli of Object.keys(admission?.cliCaps ?? {}))
|
|
80
|
+
if (!policy.clis?.[cli] && !builtinIds.has(cli))
|
|
81
|
+
error('admission/cliCaps', 'Unknown installation');
|
|
82
|
+
for (const key of Object.keys(admission?.memberCaps ?? {})) {
|
|
83
|
+
const [group, member, extra] = key.split('/');
|
|
84
|
+
if (extra !== undefined || !policy.groups?.[group]?.members?.some(m => m.id === member))
|
|
85
|
+
error('admission/memberCaps', 'Unknown group/member');
|
|
86
|
+
}
|
|
87
|
+
for (const key of Object.keys(admission?.modelCaps ?? {}))
|
|
88
|
+
if (!isCanonicalWorkerModelKey(key))
|
|
89
|
+
error('admission/modelCaps', 'Noncanonical inference model key');
|
|
90
|
+
if (admission?.hostResources !== undefined) {
|
|
91
|
+
try {
|
|
92
|
+
resolveDispatchHostThresholds(undefined, admission.hostResources);
|
|
93
|
+
}
|
|
94
|
+
catch (validation) {
|
|
95
|
+
error('admission/hostResources', validation instanceof Error ? validation.message : String(validation));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
errors.push(...validateWorkerHealthConfig(policy.health));
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
/** Cross-field health preferences are validated offline; controls are qualified at dispatch. */
|
|
102
|
+
export function validateWorkerHealthConfig(health) {
|
|
103
|
+
if (health === undefined)
|
|
104
|
+
return [];
|
|
105
|
+
const errors = [];
|
|
106
|
+
const error = (field, message) => { errors.push(`OG_CONFIG_REFERENCE /parallelism/health/${field}: ${message}`); };
|
|
107
|
+
const checkSeconds = health.checkSeconds ?? 15;
|
|
108
|
+
if (checkSeconds > (health.startupSeconds ?? 60))
|
|
109
|
+
error('checkSeconds', 'Must not exceed startupSeconds');
|
|
110
|
+
if ((health.quietSeconds ?? 90) >= (health.stallSeconds ?? 300))
|
|
111
|
+
error('quietSeconds', 'Must be less than stallSeconds');
|
|
112
|
+
if ((health.cooldownSeconds ?? 120) > (health.maxCooldownSeconds ?? 900))
|
|
113
|
+
error('cooldownSeconds', 'Must not exceed maxCooldownSeconds');
|
|
114
|
+
for (const role of Object.keys(health.roleScale ?? {})) {
|
|
115
|
+
if (!['efficiency', 'coding', 'reasoning'].includes(role))
|
|
116
|
+
error('roleScale', `Unknown role ${role}`);
|
|
117
|
+
}
|
|
118
|
+
for (const field of ['modelScale', 'modelStartupSeconds']) {
|
|
119
|
+
for (const [key, value] of Object.entries(health[field] ?? {})) {
|
|
120
|
+
if (!isCanonicalWorkerModelKey(key))
|
|
121
|
+
error(field, 'Noncanonical inference model key');
|
|
122
|
+
if (field === 'modelStartupSeconds' && checkSeconds > value)
|
|
123
|
+
error(field, 'Model startup must cover checkSeconds');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return errors;
|
|
127
|
+
}
|
|
128
|
+
/** Resolve one enabled installation through trusted metadata, without probes.
|
|
129
|
+
* Implicit canonical IDs inherit current-account auth; no account/quota identity
|
|
130
|
+
* is invented. Profile qualification remains the invocation owner's obligation.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveWorkerGroupInstallation(parallelism, installationId, registry = loadProviderModelCapabilities()) {
|
|
133
|
+
const errors = validateParallelismConfig(parallelism);
|
|
134
|
+
if (errors.length)
|
|
135
|
+
throw new Error(errors.join('; '));
|
|
136
|
+
if (!parallelism.worker_dispatch)
|
|
137
|
+
throw new Error('OG_CONFIG_REFERENCE /parallelism/worker_dispatch: Worker dispatch is disabled');
|
|
138
|
+
const explicit = parallelism.clis?.[installationId];
|
|
139
|
+
const installation = explicit
|
|
140
|
+
? structuredClone(explicit)
|
|
141
|
+
: { adapter: `providers:${installationId}`, enabled: true };
|
|
142
|
+
if (!explicit && (!PROVIDER_IDS.includes(installationId) || installationId === 'generic')) {
|
|
143
|
+
throw new Error(`OG_CONFIG_REFERENCE /parallelism/clis/${installationId}: Unknown canonical installation`);
|
|
144
|
+
}
|
|
145
|
+
if (installation.enabled === false)
|
|
146
|
+
throw new Error(`OG_CONFIG_REFERENCE /parallelism/clis/${installationId}: Installation is disabled`);
|
|
147
|
+
try {
|
|
148
|
+
return { installation: { ...installation, enabled: true }, ...resolveWorkerInstallation(registry, installation) };
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw new Error(`OG_ADAPTER_UNQUALIFIED /parallelism/clis/${installationId}: ${error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=worker-parallelism.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Shares atomic replacement and serialization across worker configuration owners.
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { mkdir, writeFile, rename, unlink, stat } from 'node:fs/promises';
|
|
4
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { acquireWorkerWriteLock, workerWriteLock } from './worker-owner-lock.js';
|
|
8
|
+
export { acquireWorkerWriteLock, reclaimWorkerWriteLock, workerWriteLock } from './worker-owner-lock.js';
|
|
9
|
+
const maintenance = new AsyncLocalStorage();
|
|
10
|
+
export const runWorkerMaintenance = (body) => maintenance.run(true, body);
|
|
11
|
+
export const isWorkerMaintenance = () => maintenance.getStore() === true;
|
|
12
|
+
export function workerDispatchBlocked(project) {
|
|
13
|
+
try {
|
|
14
|
+
statSync(join(workerWriteLock(project), 'blocked'));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function atomicConfigWrite(file, content, assertOwned) {
|
|
24
|
+
assertOwned?.();
|
|
25
|
+
await mkdir(dirname(file), { recursive: true });
|
|
26
|
+
const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
27
|
+
let mode = 0o600;
|
|
28
|
+
try {
|
|
29
|
+
mode = (await stat(file)).mode;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code !== 'ENOENT')
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
assertOwned?.();
|
|
37
|
+
await writeFile(temporary, content, { encoding: 'utf8', mode });
|
|
38
|
+
assertOwned?.();
|
|
39
|
+
await rename(temporary, file);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
43
|
+
throw error; });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function workerRecoveryDiagnostic(project) {
|
|
47
|
+
const record = join(workerWriteLock(project), 'recovery.json');
|
|
48
|
+
let digest = 'sha256:<original-proposal-digest>';
|
|
49
|
+
try {
|
|
50
|
+
const retained = JSON.parse(readFileSync(record, 'utf8')).proposal?.digest;
|
|
51
|
+
if (typeof retained === 'string')
|
|
52
|
+
digest = retained;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
try {
|
|
56
|
+
const retained = JSON.parse(readFileSync(join(workerWriteLock(project), 'owner.json'), 'utf8')).proposalDigest;
|
|
57
|
+
if (typeof retained === 'string')
|
|
58
|
+
digest = retained;
|
|
59
|
+
}
|
|
60
|
+
catch { /* Legacy or unreadable records require the retained review's digest. */ }
|
|
61
|
+
}
|
|
62
|
+
const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
63
|
+
return `Worker dispatch is closed for project ${project}; recovery record: ${record}. Recover with: aiwg config set --project --target ${quote(project)} --recover --authorize ${quote(digest)}`;
|
|
64
|
+
}
|
|
65
|
+
export async function withWorkerWriteLock(project, body) {
|
|
66
|
+
const lock = await acquireWorkerWriteLock(project);
|
|
67
|
+
try {
|
|
68
|
+
lock.assertOwned();
|
|
69
|
+
return await body();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await lock.release();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=worker-write.js.map
|
|
@@ -63,7 +63,8 @@ export class BoundedDecisionTraceExporter {
|
|
|
63
63
|
await Promise.race([this.sink.export(trace, controller.signal), deadline]);
|
|
64
64
|
}
|
|
65
65
|
catch (error) {
|
|
66
|
-
this.record(controller.signal.aborted ? 'timeout' : 'failed',
|
|
66
|
+
this.record(controller.signal.aborted ? 'timeout' : 'failed', this.options.redactErrors
|
|
67
|
+
? 'telemetry export failed' : error instanceof Error ? error.message : 'export failed');
|
|
67
68
|
}
|
|
68
69
|
finally {
|
|
69
70
|
if (timeout)
|
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
import { sanitizeOpaqueValue } from './redaction.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { GEN_AI_SEMCONV_REVISION } from './types.js';
|
|
4
|
+
/** Correlation is opaque on export, including when hostile content is planted in an ID. */
|
|
5
|
+
function workerCorrelation(value) {
|
|
6
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 1024
|
|
7
|
+
? createHash('sha256').update(value).digest('hex') : null;
|
|
8
|
+
}
|
|
9
|
+
/** Only operator-listed model names, fixed enums and finite numbers leave this mapping. */
|
|
10
|
+
export function mapWorkerReceipt(receipt, correlation, allowedModels = []) {
|
|
11
|
+
const out = { attributes: {}, provenance: {} };
|
|
12
|
+
const model = (value) => typeof value === 'string' && value.length <= 128
|
|
13
|
+
&& /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(value)
|
|
14
|
+
&& !/(?:sk[-_]|gh[pousr]_|github_pat_|AKIA|bearer|secret|password)/i.test(value)
|
|
15
|
+
&& allowedModels.includes(value) ? value : null;
|
|
16
|
+
const confidence = ['unavailable', 'runtime-configured-only', 'backend-attested'].includes(String(receipt.identityConfidence))
|
|
17
|
+
? String(receipt.identityConfidence) : 'unavailable';
|
|
18
|
+
const observed = confidence === 'unavailable' ? null : model(receipt.observed?.model);
|
|
19
|
+
const observedProvenance = observed === null ? 'unknown' : confidence === 'backend-attested' ? 'provider-fact' : 'client-derived';
|
|
20
|
+
const quality = receipt.usage?.quality === 'reported' || receipt.usage?.quality === 'estimated' ? receipt.usage.quality : 'unknown';
|
|
21
|
+
const usageProvenance = quality === 'reported' ? 'provider-fact' : quality === 'estimated' ? 'estimate' : 'unknown';
|
|
22
|
+
put(out, 'aiwg.otel.gen_ai_semconv_revision', GEN_AI_SEMCONV_REVISION, 'client-derived');
|
|
23
|
+
for (const [key, value] of Object.entries({ 'aiwg.conductor.id': correlation.conductorId,
|
|
24
|
+
'aiwg.run.id': correlation.runId, 'aiwg.task.id': receipt.taskId, 'aiwg.invocation.id': receipt.invocationId })) {
|
|
25
|
+
put(out, key, workerCorrelation(value), 'client-derived');
|
|
26
|
+
}
|
|
27
|
+
put(out, 'aiwg.worker.transport', receipt.transport === 'shell' || receipt.transport === 'mcp' ? receipt.transport : null, 'client-derived');
|
|
28
|
+
put(out, 'aiwg.worker.termination', receipt.termination === 'confirmed' || receipt.termination === 'uncertain' ? receipt.termination : null, 'client-derived');
|
|
29
|
+
put(out, 'aiwg.worker.identity_confidence', confidence, 'client-derived');
|
|
30
|
+
put(out, 'gen_ai.request.model', model(receipt.requested?.model), 'client-derived');
|
|
31
|
+
put(out, 'gen_ai.response.model', observed, observedProvenance);
|
|
32
|
+
put(out, 'aiwg.worker.observed_model_provenance', observedProvenance, 'client-derived');
|
|
33
|
+
put(out, 'aiwg.usage.quality', quality, 'client-derived');
|
|
34
|
+
for (const [key, value] of [['input_tokens', receipt.usage?.inputTokens], ['output_tokens', receipt.usage?.outputTokens]]) {
|
|
35
|
+
const count = quality !== 'unknown' && typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
36
|
+
put(out, `gen_ai.usage.${key}`, count, usageProvenance);
|
|
37
|
+
put(out, `aiwg.usage.${key}_provenance`, count === null ? 'unknown' : usageProvenance, 'client-derived');
|
|
38
|
+
}
|
|
39
|
+
const cost = receipt.usage?.costUsd;
|
|
40
|
+
const knownCost = quality !== 'unknown' && typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? cost : null;
|
|
41
|
+
put(out, 'aiwg.usage.cost_usd', knownCost, usageProvenance);
|
|
42
|
+
put(out, 'aiwg.usage.cost_provenance', knownCost === null ? 'unknown' : usageProvenance, 'client-derived');
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
2
45
|
function put(target, key, value, provenance) {
|
|
3
46
|
target.attributes[key] = value;
|
|
4
47
|
target.provenance[key] = value === null ? 'unknown' : provenance;
|
|
@@ -28,7 +28,8 @@ export class DecisionOtlpHttpSink {
|
|
|
28
28
|
}
|
|
29
29
|
async export(trace, signal) {
|
|
30
30
|
const safe = sanitizedTelemetryExport(trace);
|
|
31
|
-
const payload = JSON.stringify({ resourceSpans: [{ scopeSpans: [{
|
|
31
|
+
const payload = JSON.stringify({ resourceSpans: [{ scopeSpans: [{ ...(safe.schemaUrl ? { schemaUrl: safe.schemaUrl } : {}),
|
|
32
|
+
scope: { name: safe.spans.every(span => span.name === 'worker.attempt') ? 'aiwg.worker' : 'aiwg.decision', version: '1' },
|
|
32
33
|
spans: safe.spans.map(toOtlpSpan) }] }] });
|
|
33
34
|
if (Buffer.byteLength(payload, 'utf8') > this.maxPayloadBytes)
|
|
34
35
|
throw new Error('OTLP payload exceeds configured bound');
|
|
@@ -172,7 +173,9 @@ function toOtlpSpan(span) {
|
|
|
172
173
|
name: span.name,
|
|
173
174
|
startTimeUnixNano: String(Math.trunc(span.startTimeUnixMs * 1_000_000)),
|
|
174
175
|
endTimeUnixNano: String(Math.trunc(span.endTimeUnixMs * 1_000_000)),
|
|
175
|
-
|
|
176
|
+
// GenAI unknowns have no measurement; never serialize a token count as zero or a string.
|
|
177
|
+
attributes: Object.entries(span.attributes).filter(([key, value]) => value !== null || !key.startsWith('gen_ai.'))
|
|
178
|
+
.map(([key, value]) => toOtlpAttribute(key, value)),
|
|
176
179
|
links: span.links.map(link => ({ traceId: link.traceId, spanId: link.spanId,
|
|
177
180
|
attributes: Object.entries(link.attributes ?? {}).map(([key, value]) => toOtlpAttribute(key, value)) })),
|
|
178
181
|
events: span.events.map(event => ({ name: event.name, timeUnixNano: String(Math.trunc(event.timeUnixMs * 1_000_000)),
|
|
@@ -5,6 +5,10 @@ const SAFE_EVENT_NAMES = new Set(['retry.scheduled', 'attempt.terminated', 'brea
|
|
|
5
5
|
// plausible custom key can still carry body text or PII in its value.
|
|
6
6
|
const SAFE_ATTRIBUTE_KEYS = new Set(`
|
|
7
7
|
aiwg.run.id aiwg.invocation.id aiwg.workflow.status aiwg.evaluation.count
|
|
8
|
+
aiwg.conductor.id aiwg.task.id aiwg.worker.transport aiwg.worker.identity_confidence
|
|
9
|
+
aiwg.worker.termination aiwg.otel.gen_ai_semconv_revision aiwg.usage.quality
|
|
10
|
+
aiwg.usage.input_tokens_provenance aiwg.usage.output_tokens_provenance
|
|
11
|
+
aiwg.worker.observed_model_provenance
|
|
8
12
|
aiwg.decision.id aiwg.decision.version aiwg.decision.alias aiwg.decision.status aiwg.decision.reason
|
|
9
13
|
aiwg.ruleset.id aiwg.ruleset.version aiwg.binding.id aiwg.binding.version
|
|
10
14
|
aiwg.adapter.id aiwg.adapter.version aiwg.attempt.ordinal aiwg.attempt.count
|
|
@@ -39,7 +43,8 @@ for (const prefix of ['aiwg.definition', 'aiwg.policy', 'aiwg.calibration']) {
|
|
|
39
43
|
// Declared keys whose names match PROTECTED_KEY but carry only fixed metadata.
|
|
40
44
|
// `aiwg.link.state` is the deletion tombstone marker; dropping it would turn an
|
|
41
45
|
// explicit tombstone into an unexplained link in exports.
|
|
42
|
-
const PROTECTED_KEY_EXEMPTIONS = new Set(['gen_ai.response.model', 'http.response.status_code', 'aiwg.link.state'
|
|
46
|
+
const PROTECTED_KEY_EXEMPTIONS = new Set(['gen_ai.response.model', 'http.response.status_code', 'aiwg.link.state',
|
|
47
|
+
'aiwg.usage.input_tokens_provenance', 'aiwg.usage.output_tokens_provenance']);
|
|
43
48
|
export const DEFAULT_ATTRIBUTE_VALUE_LIMIT = 256;
|
|
44
49
|
export function sanitizeOpaqueValue(value, maximum = DEFAULT_ATTRIBUTE_VALUE_LIMIT) {
|
|
45
50
|
return value.replace(CONTROL, '').slice(0, maximum);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
/** OpenTelemetry-compatible, dependency-free decision telemetry contract. */
|
|
2
2
|
export const DECISION_TELEMETRY_SCHEMA_VERSION = 'decision-telemetry/v1';
|
|
3
|
+
/** Adopted GenAI attributes from semantic-conventions v1.37.0 (development). */
|
|
4
|
+
export const GEN_AI_SEMCONV_REVISION = '1.37.0';
|
|
5
|
+
export const GEN_AI_SCHEMA_URL = `https://opentelemetry.io/schemas/${GEN_AI_SEMCONV_REVISION}`;
|
|
3
6
|
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createTelemetryContext, extractTraceContext } from './context.js';
|
|
2
|
+
import { BoundedDecisionTraceExporter } from './exporter.js';
|
|
3
|
+
import { mapWorkerReceipt } from './mapping.js';
|
|
4
|
+
import { DECISION_TELEMETRY_SCHEMA_VERSION, GEN_AI_SCHEMA_URL } from './types.js';
|
|
5
|
+
/** Optional observer for attempts sharing one task policy; retains no receipts or completion state. */
|
|
6
|
+
export class WorkerReceiptTelemetry {
|
|
7
|
+
exporter;
|
|
8
|
+
options;
|
|
9
|
+
root;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = structuredClone({ ...options, sink: undefined });
|
|
12
|
+
this.root = createTelemetryContext();
|
|
13
|
+
if (options.enabled === true && options.sink) {
|
|
14
|
+
this.exporter = new BoundedDecisionTraceExporter(options.sink, {
|
|
15
|
+
capacity: options.capacity ?? 32, timeoutMs: options.timeoutMs ?? 1000,
|
|
16
|
+
maximumTraceBytes: options.maximumTraceBytes, maximumDiagnostics: options.maximumDiagnostics,
|
|
17
|
+
canaries: options.canaries, redactErrors: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
get diagnostics() { return this.exporter?.diagnostics ?? []; }
|
|
22
|
+
/** Gate before mapping or enqueue. Unknown policy and local-only tasks suppress all export. */
|
|
23
|
+
offer(receipt, timing) {
|
|
24
|
+
try {
|
|
25
|
+
if (!this.exporter || !this.externalExportAllowed())
|
|
26
|
+
return false;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const start = timing?.startTimeUnixMs ?? now;
|
|
29
|
+
const end = timing?.endTimeUnixMs ?? now;
|
|
30
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start)
|
|
31
|
+
return false;
|
|
32
|
+
const parent = this.options.parent;
|
|
33
|
+
const safeParent = parent ? extractTraceContext({ traceparent: `00-${parent.traceId}-${parent.spanId}-${parent.traceFlags}` }) : null;
|
|
34
|
+
if (parent && !safeParent)
|
|
35
|
+
return false;
|
|
36
|
+
const context = createTelemetryContext(undefined, safeParent ?? this.root);
|
|
37
|
+
const mapped = mapWorkerReceipt(receipt, this.options, this.options.allowedModels);
|
|
38
|
+
const span = {
|
|
39
|
+
schemaVersion: DECISION_TELEMETRY_SCHEMA_VERSION, name: 'worker.attempt', context,
|
|
40
|
+
parentSpanId: safeParent?.spanId ?? null, startTimeUnixMs: start, endTimeUnixMs: end,
|
|
41
|
+
// A confirmed stop is not proof of acceptance or success.
|
|
42
|
+
status: 'unset', attributes: mapped.attributes, provenance: mapped.provenance, links: [],
|
|
43
|
+
events: [{ name: 'attempt.terminated', timeUnixMs: end, attributes: {
|
|
44
|
+
'aiwg.worker.termination': mapped.attributes['aiwg.worker.termination'] ?? null,
|
|
45
|
+
} }],
|
|
46
|
+
};
|
|
47
|
+
return this.exporter.offer({ schemaVersion: DECISION_TELEMETRY_SCHEMA_VERSION,
|
|
48
|
+
schemaUrl: GEN_AI_SCHEMA_URL, traceId: context.traceId, spans: [span] });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async shutdown() { await this.exporter?.shutdown(); }
|
|
55
|
+
externalExportAllowed() {
|
|
56
|
+
const selection = this.options.selection;
|
|
57
|
+
if (!selection?.dataPolicy?.allowedProcessing?.includes('remote'))
|
|
58
|
+
return false;
|
|
59
|
+
if (this.options.dataClass !== undefined) {
|
|
60
|
+
if (!selection.dataClasses || !Object.hasOwn(selection.dataClasses, this.options.dataClass))
|
|
61
|
+
return false;
|
|
62
|
+
const narrowed = selection.dataClasses?.[this.options.dataClass];
|
|
63
|
+
if (!narrowed || (narrowed.allowedProcessing !== undefined && !narrowed.allowedProcessing.includes('remote')))
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/** Activation checks within the existing lint runner, not a delivery selector. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { BUILTIN_GRAPH_CONFIGS } from '../artifacts/types.js';
|
|
5
|
+
import { resolveRuleBindingTarget } from '../artifacts/query-engine.js';
|
|
6
|
+
import { collectRuleBindingEntries } from '../artifacts/index-builder.js';
|
|
7
|
+
import { resolveFrameworkIndexRoot } from '../artifacts/framework-index-paths.js';
|
|
5
8
|
import { parseRuleFrontmatter, RULE_BINDING_TYPES, coreBodyPath, validateCoreBody } from '../rules/activation.mjs';
|
|
6
9
|
export const ruleActivationRuleset = {
|
|
7
10
|
id: 'rule-activation', name: 'Rule activation', framework: 'core', version: '1.0.0',
|
|
8
|
-
description: 'Validate rule and behavior activation contracts and exact
|
|
11
|
+
description: 'Validate rule and behavior activation contracts and exact source bindings',
|
|
9
12
|
rules: [{
|
|
10
13
|
id: 'rules/activation', name: 'Rule activation contract', severity: 'error',
|
|
11
14
|
description: 'Validate activation, summaries, paths, bindings, and migration fallback',
|
|
@@ -13,7 +16,14 @@ export const ruleActivationRuleset = {
|
|
|
13
16
|
checks: [{ type: 'rule-activation' }],
|
|
14
17
|
}],
|
|
15
18
|
};
|
|
16
|
-
|
|
19
|
+
/** Load the index builder's canonical binding metadata without reading or writing an index. */
|
|
20
|
+
export async function loadRuleActivationEntries(projectRoot) {
|
|
21
|
+
const root = resolveFrameworkIndexRoot(projectRoot);
|
|
22
|
+
const snapshots = await Promise.all(BUILTIN_GRAPH_CONFIGS.framework.scanDirs.map(scope => collectRuleBindingEntries(root, scope)));
|
|
23
|
+
return snapshots.flat();
|
|
24
|
+
}
|
|
25
|
+
/** Validate one rule against the source snapshot supplied by the lint runner. */
|
|
26
|
+
export function lintRuleActivation(content, file, projectRoot, entries = [], absolutePath) {
|
|
17
27
|
// Directory documentation and generated indexes are not authored rules.
|
|
18
28
|
if (/^(?:README|RULES-INDEX|RULES-ONDEMAND)\.md$/.test(file.split('/').pop()))
|
|
19
29
|
return [];
|
|
@@ -39,13 +49,11 @@ export function lintRuleActivation(content, file, projectRoot, entries, absolute
|
|
|
39
49
|
const bindings = result.rule.bound_to;
|
|
40
50
|
if (!Object.values(bindings).some(list => list.length > 0))
|
|
41
51
|
return diagnostics;
|
|
42
|
-
//
|
|
43
|
-
// corpus directory resolver, or extra binding registry.
|
|
44
|
-
const indexed = entries ?? loadLocalCapabilityEntries(projectRoot);
|
|
52
|
+
// Preserve exact identities and show precedence using current source metadata.
|
|
45
53
|
for (const key of Object.keys(RULE_BINDING_TYPES)) {
|
|
46
54
|
for (const id of bindings[key]) {
|
|
47
55
|
try {
|
|
48
|
-
resolveRuleBindingTarget(
|
|
56
|
+
resolveRuleBindingTarget(entries, RULE_BINDING_TYPES[key], id);
|
|
49
57
|
}
|
|
50
58
|
catch (error) {
|
|
51
59
|
diagnostics.push({ ruleId: 'rules/activation', ruleName: 'Rule activation contract',
|
package/dist/src/lint/runner.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @issue #810
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'fs';
|
|
10
|
-
import { lintRuleActivation } from './rule-activation.js';
|
|
10
|
+
import { lintRuleActivation, loadRuleActivationEntries } from './rule-activation.js';
|
|
11
11
|
import { guardRulesInlineBudget } from '../rules/inline-budget.mjs';
|
|
12
12
|
import { dropGitignored } from '../config/gitignore.js';
|
|
13
13
|
import fsp from 'fs/promises';
|
|
@@ -563,7 +563,7 @@ async function runRule(rule, targetDir, allFiles, referenceIndex, projectRoot, a
|
|
|
563
563
|
if (check.type === 'id-unique')
|
|
564
564
|
continue; // Already handled
|
|
565
565
|
if (check.type === 'rule-activation') {
|
|
566
|
-
diagnostics.push(...lintRuleActivation(content, file, projectRoot, artifactEntries, absPath));
|
|
566
|
+
diagnostics.push(...lintRuleActivation(content, file, projectRoot, artifactEntries ??= await loadRuleActivationEntries(projectRoot), absPath));
|
|
567
567
|
continue;
|
|
568
568
|
}
|
|
569
569
|
const checkDiagnostics = runCheck(check, content, frontmatter, file, targetDir, allFiles, referenceIndex, projectRoot);
|