@geraldmaron/construct 1.0.20 → 1.0.21
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/bin/construct +3 -3
- package/lib/cli-commands.mjs +1 -1
- package/lib/config/schema.mjs +18 -0
- package/lib/decisions/enforced-baseline.json +1 -0
- package/lib/document-ingest.mjs +13 -4
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/ingest/strategy.mjs +35 -3
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/runtime.mjs +126 -49
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +167 -0
- package/lib/parity.mjs +35 -3
- package/package.json +1 -1
package/bin/construct
CHANGED
|
@@ -3380,7 +3380,7 @@ async function cmdOrchestrate(args) {
|
|
|
3380
3380
|
fileCount: Number(flag('--file-count') || 0),
|
|
3381
3381
|
moduleCount: Number(flag('--module-count') || 0),
|
|
3382
3382
|
};
|
|
3383
|
-
const run = rest.includes('--no-execute') ? planRun(request, {}) : runOrchestration(request, {});
|
|
3383
|
+
const run = rest.includes('--no-execute') ? await planRun(request, {}) : await runOrchestration(request, {});
|
|
3384
3384
|
if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
|
|
3385
3385
|
println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode} · backend=${run.workerBackend} · hostRole=${run.hostRole}`);
|
|
3386
3386
|
if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
|
|
@@ -3393,14 +3393,14 @@ async function cmdOrchestrate(args) {
|
|
|
3393
3393
|
const { getRun, getRuns, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
|
|
3394
3394
|
const runId = rest.find((a) => !a.startsWith('--'));
|
|
3395
3395
|
if (runId) {
|
|
3396
|
-
const run = getRun(process.cwd(), runId);
|
|
3396
|
+
const run = await getRun(process.cwd(), runId);
|
|
3397
3397
|
if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
|
|
3398
3398
|
if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
|
|
3399
3399
|
println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
|
|
3400
3400
|
for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
|
|
3401
3401
|
return;
|
|
3402
3402
|
}
|
|
3403
|
-
const runs = getRuns(process.cwd());
|
|
3403
|
+
const runs = await getRuns(process.cwd());
|
|
3404
3404
|
if (wantsJson) { println(JSON.stringify(runs, null, 2)); return; }
|
|
3405
3405
|
if (!runs.length) { println('No orchestration runs recorded.'); return; }
|
|
3406
3406
|
for (const r of runs) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -250,7 +250,7 @@ export const CLI_COMMANDS = [
|
|
|
250
250
|
category: 'Work',
|
|
251
251
|
core: false,
|
|
252
252
|
description: 'Convert documents to indexed markdown',
|
|
253
|
-
usage: 'construct ingest <file> [--strategy=adapter|provider] [--strict] [--legacy-extractor]',
|
|
253
|
+
usage: 'construct ingest <file> [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--strict] [--legacy-extractor]',
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
name: 'infer',
|
package/lib/config/schema.mjs
CHANGED
|
@@ -26,6 +26,10 @@ export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cu
|
|
|
26
26
|
|
|
27
27
|
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
28
28
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
29
|
+
export const INGEST_ORCHESTRATIONS = ['prompt-only', 'orchestrated'];
|
|
30
|
+
|
|
31
|
+
export const ORCHESTRATION_WORKER_BACKENDS = ['inline', 'provider'];
|
|
32
|
+
export const ORCHESTRATION_STORES = ['filesystem', 'sqlite', 'postgres'];
|
|
29
33
|
|
|
30
34
|
// SessionStart context routing. `auto` keeps the rich payload on stdout for
|
|
31
35
|
// interactive sessions and suppresses it (to a debug log) for non-interactive /
|
|
@@ -47,6 +51,11 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
|
|
|
47
51
|
ingest: Object.freeze({
|
|
48
52
|
strategy: 'adapter',
|
|
49
53
|
fallback: 'none',
|
|
54
|
+
orchestration: 'prompt-only',
|
|
55
|
+
}),
|
|
56
|
+
orchestration: Object.freeze({
|
|
57
|
+
workerBackend: 'inline',
|
|
58
|
+
store: 'filesystem',
|
|
50
59
|
}),
|
|
51
60
|
telemetry: Object.freeze({
|
|
52
61
|
enabled: true,
|
|
@@ -105,6 +114,15 @@ export const FIELD_RULES = {
|
|
|
105
114
|
fields: {
|
|
106
115
|
strategy: { type: 'string', enum: INGEST_STRATEGIES },
|
|
107
116
|
fallback: { type: 'string', enum: INGEST_FALLBACKS },
|
|
117
|
+
orchestration: { type: 'string', enum: INGEST_ORCHESTRATIONS },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
orchestration: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
required: false,
|
|
123
|
+
fields: {
|
|
124
|
+
workerBackend: { type: 'string', enum: ORCHESTRATION_WORKER_BACKENDS },
|
|
125
|
+
store: { type: 'string', enum: ORCHESTRATION_STORES },
|
|
108
126
|
},
|
|
109
127
|
},
|
|
110
128
|
telemetry: {
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { syncFileStateToSql } from './storage/sync.mjs';
|
|
|
14
14
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
15
15
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
16
16
|
import { loadProjectConfig } from './config/project-config.mjs';
|
|
17
|
-
import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
|
|
17
|
+
import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
|
|
18
18
|
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
|
|
@@ -167,6 +167,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
167
167
|
strict = false,
|
|
168
168
|
highFidelity = true,
|
|
169
169
|
strategy = null,
|
|
170
|
+
orchestration = null,
|
|
170
171
|
env = process.env,
|
|
171
172
|
} = {}) {
|
|
172
173
|
if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
|
|
@@ -182,7 +183,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
const { config } = loadProjectConfig(cwd, env);
|
|
185
|
-
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
|
|
186
|
+
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
|
|
186
187
|
|
|
187
188
|
const results = [];
|
|
188
189
|
let totalDrops = 0;
|
|
@@ -266,9 +267,11 @@ export async function ingestDocuments(inputPaths, {
|
|
|
266
267
|
ingestion: {
|
|
267
268
|
strategy: resolvedStrategy.strategy,
|
|
268
269
|
fallback: resolvedStrategy.fallback,
|
|
270
|
+
orchestration: resolvedStrategy.orchestration,
|
|
269
271
|
model: resolvedStrategy.model,
|
|
270
272
|
provider: resolvedStrategy.provider,
|
|
271
273
|
fallbackApplied,
|
|
274
|
+
execution: resolvedStrategy.execution,
|
|
272
275
|
},
|
|
273
276
|
};
|
|
274
277
|
}
|
|
@@ -282,6 +285,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
282
285
|
let strict = false;
|
|
283
286
|
let highFidelity = true;
|
|
284
287
|
let strategy = null;
|
|
288
|
+
let orchestration = null;
|
|
285
289
|
|
|
286
290
|
for (const arg of argv) {
|
|
287
291
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
@@ -289,6 +293,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
289
293
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
290
294
|
else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
|
|
291
295
|
else if (arg === '--strategy') strategy = '';
|
|
296
|
+
else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
|
|
292
297
|
else if (arg === '--sync') sync = true;
|
|
293
298
|
else if (arg === '--strict') strict = true;
|
|
294
299
|
else if (arg === '--legacy-extractor') highFidelity = false;
|
|
@@ -299,8 +304,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
299
304
|
if (inputs.length === 0) {
|
|
300
305
|
throw new Error(
|
|
301
306
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
302
|
-
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
307
|
+
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
303
308
|
` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
|
|
309
|
+
` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
|
|
304
310
|
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
305
311
|
` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
|
|
306
312
|
` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
|
|
@@ -309,6 +315,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
309
315
|
if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
|
|
310
316
|
throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
|
|
311
317
|
}
|
|
318
|
+
if (orchestration !== null && !INGEST_ORCHESTRATION_STRATEGIES.includes(orchestration)) {
|
|
319
|
+
throw new Error(`Unsupported orchestration: ${orchestration}. Valid values: ${INGEST_ORCHESTRATION_STRATEGIES.join(', ')}`);
|
|
320
|
+
}
|
|
312
321
|
const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
|
|
313
322
|
if (!['sibling', ...knowledgeTargets].includes(target)) {
|
|
314
323
|
throw new Error(
|
|
@@ -316,5 +325,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
316
325
|
);
|
|
317
326
|
}
|
|
318
327
|
|
|
319
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
|
|
328
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
|
|
320
329
|
}
|
|
@@ -16,6 +16,7 @@ import { classifyRdIntake } from '../intake/classify.mjs';
|
|
|
16
16
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
17
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
18
18
|
import { workflowTypeForIntake } from './workflow-defs.mjs';
|
|
19
|
+
import { resolveExecution } from './execution.mjs';
|
|
19
20
|
|
|
20
21
|
const CONFIDENCE_FLOOR = 0.6;
|
|
21
22
|
|
|
@@ -46,7 +47,7 @@ function buildRiskFactors(triage) {
|
|
|
46
47
|
* @returns {object}
|
|
47
48
|
*/
|
|
48
49
|
export function recommendPlan(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
49
|
-
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null } = request;
|
|
50
|
+
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null, host, hostModel, hostProvider, constructStrategy = 'auto' } = request;
|
|
50
51
|
const warnings = [];
|
|
51
52
|
|
|
52
53
|
// Surface extraction provenance and flag low-yield/truncated inputs so a
|
|
@@ -109,6 +110,26 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
109
110
|
if (canExecute) nextStepOptions.push({ action: 'invoke-workflow', description: 'Invoke the recommended role chain as an embedded workflow.' });
|
|
110
111
|
if (!canExecute) nextStepOptions.push({ action: 'clarify', description: 'Request more detail; classification confidence is low or unknown.' });
|
|
111
112
|
|
|
113
|
+
const suggestedWorkflowType = workflowTypeForIntake(triage.intakeType);
|
|
114
|
+
|
|
115
|
+
// Execution preview: forecast the executionMode an invocation of the suggested
|
|
116
|
+
// workflow would resolve to, but only when the host supplies context — absent
|
|
117
|
+
// it, forcing a model resolution on every triage call would be wasteful and
|
|
118
|
+
// the preview would be uninformative. null keeps the field parity-stable.
|
|
119
|
+
let execution = null;
|
|
120
|
+
if (suggestedWorkflowType && (hostModel || hostProvider || (constructStrategy && constructStrategy !== 'auto'))) {
|
|
121
|
+
const e = resolveExecution({ workflowType: suggestedWorkflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider }, { env, cwd });
|
|
122
|
+
execution = {
|
|
123
|
+
executionMode: e.executionMode,
|
|
124
|
+
effectiveStrategy: e.effectiveStrategy,
|
|
125
|
+
requestedStrategy: e.requestedStrategy,
|
|
126
|
+
constructCapabilitiesActive: e.constructCapabilitiesActive,
|
|
127
|
+
degraded: e.degraded,
|
|
128
|
+
degradationReason: e.degradationReason,
|
|
129
|
+
semantics: e.semantics,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
return {
|
|
113
134
|
classification: { intakeType: triage.intakeType, rdStage: triage.rdStage },
|
|
114
135
|
ingestion,
|
|
@@ -117,7 +138,8 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
117
138
|
primaryOwner,
|
|
118
139
|
recommendedAction: triage.recommendedAction,
|
|
119
140
|
recommendedChain: chain,
|
|
120
|
-
suggestedWorkflowType
|
|
141
|
+
suggestedWorkflowType,
|
|
142
|
+
execution,
|
|
121
143
|
roleRationale: rationale,
|
|
122
144
|
suggestedSkills,
|
|
123
145
|
evidenceRequirements,
|
|
@@ -26,6 +26,7 @@ import { recordApprovalRequest } from '../roles/approval-surface.mjs';
|
|
|
26
26
|
import { getWorkflowDef, WORKFLOW_TYPES } from './workflow-defs.mjs';
|
|
27
27
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
28
28
|
import { resolveEmbeddedModel } from './model-resolve.mjs';
|
|
29
|
+
import { resolveExecution } from './execution.mjs';
|
|
29
30
|
import { resolveWriteGate, newTraceId, DEFAULT_APPROVAL_MODE } from './audit.mjs';
|
|
30
31
|
|
|
31
32
|
const VALID_STRATEGIES = ['auto', 'explicit', 'constrained'];
|
|
@@ -91,6 +92,7 @@ function errorResult({ workflowId, traceId, approvalMode, code, message, warning
|
|
|
91
92
|
* @param {string} [request.host]
|
|
92
93
|
* @param {string} [request.hostModel]
|
|
93
94
|
* @param {string} [request.hostProvider]
|
|
95
|
+
* @param {string} [request.constructStrategy] orchestrated | prompt-only | auto (execution mode)
|
|
94
96
|
* @param {object} [opts] { env, cwd }
|
|
95
97
|
* @returns {Promise<object>}
|
|
96
98
|
*/
|
|
@@ -98,6 +100,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
98
100
|
const {
|
|
99
101
|
workflowType, context = {}, roleStrategy = 'auto', requestedRoles,
|
|
100
102
|
approvalMode, trace = true, host, hostModel, hostProvider, ingestion = null,
|
|
103
|
+
constructStrategy = 'auto',
|
|
101
104
|
} = request;
|
|
102
105
|
const warnings = [];
|
|
103
106
|
if (ingestion) {
|
|
@@ -129,6 +132,23 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
129
132
|
);
|
|
130
133
|
for (const w of modelWarnings) warnings.push(`model-resolution: ${w}`);
|
|
131
134
|
|
|
135
|
+
// The execution-capability contract reports the PLANNED executionMode for this
|
|
136
|
+
// run (descriptive, not enforced — ADR-0019). Construct returns a plan; the
|
|
137
|
+
// host runtime executes it, so this never claims observed specialist execution.
|
|
138
|
+
const executionData = resolveExecution(
|
|
139
|
+
{ workflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider, requestedTier: def.tier },
|
|
140
|
+
{ env, cwd },
|
|
141
|
+
);
|
|
142
|
+
const execution = {
|
|
143
|
+
executionMode: executionData.executionMode,
|
|
144
|
+
effectiveStrategy: executionData.effectiveStrategy,
|
|
145
|
+
requestedStrategy: executionData.requestedStrategy,
|
|
146
|
+
constructCapabilitiesActive: executionData.constructCapabilitiesActive,
|
|
147
|
+
degraded: executionData.degraded,
|
|
148
|
+
degradationReason: executionData.degradationReason,
|
|
149
|
+
semantics: executionData.semantics,
|
|
150
|
+
};
|
|
151
|
+
|
|
132
152
|
const primaryOwner = selectedRoles[0];
|
|
133
153
|
const facts = contractFacts(primaryOwner);
|
|
134
154
|
const skillsApplied = skillsForChain(selectedRoles, map);
|
|
@@ -204,6 +224,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
204
224
|
roleRationale: rationale,
|
|
205
225
|
skillsApplied,
|
|
206
226
|
modelResolution,
|
|
227
|
+
execution,
|
|
207
228
|
outputs,
|
|
208
229
|
recommendations,
|
|
209
230
|
evidence: { requirements: facts.evidenceRequirements, satisfied, missing: missingEvidence, traceId },
|
package/lib/ingest/strategy.mjs
CHANGED
|
@@ -15,12 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { resolveSetting } from '../config/project-config.mjs';
|
|
17
17
|
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
18
|
+
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
18
19
|
|
|
19
20
|
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
20
21
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
21
22
|
|
|
23
|
+
// Orchestration is a second, independent axis from extraction (adapter|provider):
|
|
24
|
+
// `prompt-only` runs ingest as a deterministic extraction pass; `orchestrated`
|
|
25
|
+
// engages the Construct specialist chain for the evidence-ingest workflow. Both
|
|
26
|
+
// axes coexist — extraction is HOW bytes become text, orchestration is WHETHER
|
|
27
|
+
// specialists process the result.
|
|
28
|
+
export const INGEST_ORCHESTRATION_STRATEGIES = ['prompt-only', 'orchestrated'];
|
|
29
|
+
|
|
22
30
|
export const DEFAULT_INGEST_STRATEGY = 'adapter';
|
|
23
31
|
export const DEFAULT_INGEST_FALLBACK = 'none';
|
|
32
|
+
export const DEFAULT_INGEST_ORCHESTRATION = 'prompt-only';
|
|
24
33
|
|
|
25
34
|
function coerceEnum(value, allowed) {
|
|
26
35
|
if (typeof value !== 'string') return null;
|
|
@@ -55,11 +64,13 @@ function resolveProviderModel({ env, registryPath }) {
|
|
|
55
64
|
* @param {object} opts
|
|
56
65
|
* @param {object} [opts.config] loaded project config object
|
|
57
66
|
* @param {Record<string,string>} [opts.env]
|
|
58
|
-
* @param {string} [opts.override] explicit strategy override (CLI flag)
|
|
67
|
+
* @param {string} [opts.override] explicit extraction strategy override (CLI flag)
|
|
68
|
+
* @param {string} [opts.orchestrationOverride] explicit orchestration override (CLI flag)
|
|
69
|
+
* @param {string} [opts.cwd]
|
|
59
70
|
* @param {string} [opts.registryPath]
|
|
60
|
-
* @returns {{strategy:string, fallback:string, model:(string|null), provider:(string|null), modelResolution:(object|null)}}
|
|
71
|
+
* @returns {{strategy:string, fallback:string, orchestration:string, model:(string|null), provider:(string|null), modelResolution:(object|null), execution:object}}
|
|
61
72
|
*/
|
|
62
|
-
export function resolveIngestStrategy({ config = null, env = process.env, override = null, registryPath = null } = {}) {
|
|
73
|
+
export function resolveIngestStrategy({ config = null, env = process.env, override = null, orchestrationOverride = null, cwd = process.cwd(), registryPath = null } = {}) {
|
|
63
74
|
const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
|
|
64
75
|
|
|
65
76
|
const fromSettings = resolveSetting({
|
|
@@ -82,14 +93,35 @@ export function resolveIngestStrategy({ config = null, env = process.env, overri
|
|
|
82
93
|
});
|
|
83
94
|
const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
|
|
84
95
|
|
|
96
|
+
const orchestrationSetting = resolveSetting({
|
|
97
|
+
config,
|
|
98
|
+
jsonPath: 'ingest.orchestration',
|
|
99
|
+
env,
|
|
100
|
+
envKey: 'CONSTRUCT_INGEST_ORCHESTRATION',
|
|
101
|
+
defaultValue: DEFAULT_INGEST_ORCHESTRATION,
|
|
102
|
+
});
|
|
103
|
+
const orchestration = coerceEnum(orchestrationOverride, INGEST_ORCHESTRATION_STRATEGIES)
|
|
104
|
+
|| coerceEnum(orchestrationSetting.value, INGEST_ORCHESTRATION_STRATEGIES)
|
|
105
|
+
|| DEFAULT_INGEST_ORCHESTRATION;
|
|
106
|
+
|
|
85
107
|
const needsProviderModel = strategy === 'provider' || fallback === 'provider';
|
|
86
108
|
const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
|
|
87
109
|
|
|
110
|
+
// The execution-capability contract is the single source of truth for whether
|
|
111
|
+
// the ingest workflow will actually orchestrate; reuse it rather than deriving
|
|
112
|
+
// a second answer here.
|
|
113
|
+
const { warnings, modelResolution: _drop, ...execution } = resolveExecution(
|
|
114
|
+
{ workflowType: INGEST_WORKFLOW_TYPE, requestedStrategy: orchestration },
|
|
115
|
+
{ env, cwd, registryPath },
|
|
116
|
+
);
|
|
117
|
+
|
|
88
118
|
return {
|
|
89
119
|
strategy,
|
|
90
120
|
fallback,
|
|
121
|
+
orchestration,
|
|
91
122
|
model: modelResolution?.model || null,
|
|
92
123
|
provider: modelResolution?.provider || null,
|
|
93
124
|
modelResolution,
|
|
125
|
+
execution,
|
|
94
126
|
};
|
|
95
127
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/run-store-postgres.mjs — Postgres-backed run store (Mode-C).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors lib/intake/postgres-queue.mjs: a class taking the tagged-template `sql`
|
|
5
|
+
* client from createSqlClient(env) (porsager/postgres style) and a project key.
|
|
6
|
+
* Orchestration runs persist to `construct_orchestration_runs`; triage-like
|
|
7
|
+
* columns (status, execution_mode) are flattened for filtering while the full run
|
|
8
|
+
* round-trips in `payload jsonb`. Multi-project isolation comes from the
|
|
9
|
+
* (run_id, project) composite primary key.
|
|
10
|
+
*
|
|
11
|
+
* Scope: the STORE is the deliverable. Container-worker spawning (Mode-C's
|
|
12
|
+
* optional worker topology) is out of scope (ADR-0021); a Postgres run store lets
|
|
13
|
+
* a team/enterprise deployment share durable runs across processes today.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class PostgresRunStore {
|
|
17
|
+
constructor({ sql, project } = {}) {
|
|
18
|
+
if (!sql) throw new Error('PostgresRunStore: sql client is required');
|
|
19
|
+
if (!project) throw new Error('PostgresRunStore: project is required');
|
|
20
|
+
this.sql = sql;
|
|
21
|
+
this.project = project;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async ensureSchema() {
|
|
25
|
+
await this.sql`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS construct_orchestration_runs (
|
|
27
|
+
run_id TEXT,
|
|
28
|
+
project TEXT,
|
|
29
|
+
created_at TEXT,
|
|
30
|
+
status TEXT,
|
|
31
|
+
execution_mode TEXT,
|
|
32
|
+
payload JSONB,
|
|
33
|
+
PRIMARY KEY (run_id, project)
|
|
34
|
+
)
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async saveRun(run) {
|
|
39
|
+
if (!run?.runId) throw new Error('saveRun: run.runId is required');
|
|
40
|
+
await this.sql`
|
|
41
|
+
INSERT INTO construct_orchestration_runs (
|
|
42
|
+
run_id, project, created_at, status, execution_mode, payload
|
|
43
|
+
) VALUES (
|
|
44
|
+
${run.runId}, ${this.project}, ${run.createdAt || null},
|
|
45
|
+
${run.status || null}, ${run.execution?.executionMode || null},
|
|
46
|
+
${this.sql.json(run)}
|
|
47
|
+
)
|
|
48
|
+
ON CONFLICT (run_id, project) DO UPDATE SET
|
|
49
|
+
created_at = EXCLUDED.created_at,
|
|
50
|
+
status = EXCLUDED.status,
|
|
51
|
+
execution_mode = EXCLUDED.execution_mode,
|
|
52
|
+
payload = EXCLUDED.payload
|
|
53
|
+
`;
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async loadRun(runId) {
|
|
58
|
+
if (!runId) return null;
|
|
59
|
+
const rows = await this.sql`
|
|
60
|
+
SELECT payload FROM construct_orchestration_runs
|
|
61
|
+
WHERE run_id = ${runId} AND project = ${this.project}
|
|
62
|
+
LIMIT 1
|
|
63
|
+
`;
|
|
64
|
+
return rows[0]?.payload || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async listRuns({ limit = 20 } = {}) {
|
|
68
|
+
const rows = await this.sql`
|
|
69
|
+
SELECT payload FROM construct_orchestration_runs
|
|
70
|
+
WHERE project = ${this.project}
|
|
71
|
+
ORDER BY created_at DESC
|
|
72
|
+
LIMIT ${limit}
|
|
73
|
+
`;
|
|
74
|
+
return rows.map((row) => {
|
|
75
|
+
const run = row.payload || {};
|
|
76
|
+
return {
|
|
77
|
+
runId: run.runId,
|
|
78
|
+
status: run.status,
|
|
79
|
+
executionMode: run.execution?.executionMode || null,
|
|
80
|
+
createdAt: run.createdAt,
|
|
81
|
+
request: run.request?.summary || null,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/run-store-sqlite.mjs — SQLite-backed run store (Mode-B).
|
|
3
|
+
*
|
|
4
|
+
* Same saveRun/loadRun/listRuns surface as the filesystem store (run-store.mjs),
|
|
5
|
+
* backed by a single `node:sqlite` database under
|
|
6
|
+
* `.cx/runtime/orchestration/runs.db`. A daemon-grade local store: durable,
|
|
7
|
+
* single-file, queryable, with no Postgres/Docker dependency.
|
|
8
|
+
*
|
|
9
|
+
* Node compatibility boundary: `node:sqlite` exists only on Node >=22.5, but the
|
|
10
|
+
* CI matrix also runs Node 20. So the import is LAZY (inside a function, never at
|
|
11
|
+
* module top level) and `sqliteAvailable()` reports false when it throws. The
|
|
12
|
+
* store factory throws a structured SQLITE_UNAVAILABLE error (with remediation)
|
|
13
|
+
* rather than crashing, so the resolver can fall back to filesystem. Nothing on
|
|
14
|
+
* the default test path statically imports node:sqlite.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync } from 'node:fs';
|
|
18
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
|
|
23
|
+
|
|
24
|
+
let cachedDatabaseSync;
|
|
25
|
+
|
|
26
|
+
// The node:sqlite probe is cached and synchronous. createRequire above is a
|
|
27
|
+
// plain static import of node:module (always present); only node:sqlite itself
|
|
28
|
+
// is loaded lazily here, so Node 20 never touches the unavailable builtin.
|
|
29
|
+
|
|
30
|
+
function loadDatabaseSync() {
|
|
31
|
+
if (cachedDatabaseSync !== undefined) return cachedDatabaseSync;
|
|
32
|
+
try {
|
|
33
|
+
cachedDatabaseSync = require('node:sqlite').DatabaseSync;
|
|
34
|
+
} catch {
|
|
35
|
+
cachedDatabaseSync = null;
|
|
36
|
+
}
|
|
37
|
+
return cachedDatabaseSync;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sqliteAvailable() {
|
|
41
|
+
return loadDatabaseSync() != null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectRoot(cwd) {
|
|
45
|
+
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function dbPath(cwd) {
|
|
49
|
+
const dir = join(projectRoot(cwd), RUNTIME_REL);
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
return join(dir, 'runs.db');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function openDb(cwd) {
|
|
55
|
+
const DatabaseSync = loadDatabaseSync();
|
|
56
|
+
if (!DatabaseSync) {
|
|
57
|
+
const err = new Error('SQLite run store requires Node >=22.5 (node:sqlite is unavailable on this runtime).');
|
|
58
|
+
err.code = 'SQLITE_UNAVAILABLE';
|
|
59
|
+
err.remediation = 'Upgrade to Node >=22.5, or set orchestration.store to "filesystem" / "postgres".';
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
const db = new DatabaseSync(dbPath(cwd));
|
|
63
|
+
db.exec(`CREATE TABLE IF NOT EXISTS runs (
|
|
64
|
+
run_id TEXT PRIMARY KEY,
|
|
65
|
+
created_at TEXT,
|
|
66
|
+
status TEXT,
|
|
67
|
+
execution_mode TEXT,
|
|
68
|
+
json TEXT
|
|
69
|
+
)`);
|
|
70
|
+
return db;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a SQLite-backed run store bound to a project directory.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} opts
|
|
77
|
+
* @param {string} opts.cwd
|
|
78
|
+
* @returns {{ saveRun(run):object, loadRun(runId):object|null, listRuns(opts):Array }}
|
|
79
|
+
*/
|
|
80
|
+
export function createSqliteRunStore({ cwd = process.cwd() } = {}) {
|
|
81
|
+
const db = openDb(cwd);
|
|
82
|
+
return {
|
|
83
|
+
saveRun(run) {
|
|
84
|
+
const stmt = db.prepare(`INSERT INTO runs (run_id, created_at, status, execution_mode, json)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?)
|
|
86
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
87
|
+
created_at = excluded.created_at,
|
|
88
|
+
status = excluded.status,
|
|
89
|
+
execution_mode = excluded.execution_mode,
|
|
90
|
+
json = excluded.json`);
|
|
91
|
+
stmt.run(run.runId, run.createdAt || null, run.status || null, run.execution?.executionMode || null, JSON.stringify(run));
|
|
92
|
+
return run;
|
|
93
|
+
},
|
|
94
|
+
loadRun(runId) {
|
|
95
|
+
if (!runId) return null;
|
|
96
|
+
const row = db.prepare('SELECT json FROM runs WHERE run_id = ?').get(runId);
|
|
97
|
+
if (!row || !row.json) return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(row.json);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
listRuns({ limit = 20 } = {}) {
|
|
105
|
+
const rows = db.prepare('SELECT json FROM runs ORDER BY created_at DESC LIMIT ?').all(limit);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
try {
|
|
109
|
+
const run = JSON.parse(row.json);
|
|
110
|
+
out.push({
|
|
111
|
+
runId: run.runId,
|
|
112
|
+
status: run.status,
|
|
113
|
+
executionMode: run.execution?.executionMode || null,
|
|
114
|
+
createdAt: run.createdAt,
|
|
115
|
+
request: run.request?.summary || null,
|
|
116
|
+
});
|
|
117
|
+
} catch { /* a corrupt row is skipped, not fatal to listing */ }
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime
|
|
2
|
+
* lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime.
|
|
3
3
|
*
|
|
4
4
|
* The product goal: hosts are front doors, Construct is the system. A
|
|
5
5
|
* host that lacks native multi-agent execution should still reach equivalent
|
|
6
6
|
* Construct outcomes through a Construct-owned runtime rather than depending on
|
|
7
|
-
* whichever editor exposes the strongest teammate mechanics. This module
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* whichever editor exposes the strongest teammate mechanics. This module intakes
|
|
8
|
+
* a request, plans/decomposes it into a sequenced specialist chain (reusing the
|
|
9
|
+
* orchestration-policy planner), resolves the execution-capability contract
|
|
10
|
+
* (reusing resolveExecution), persists a durable run + task lifecycle through a
|
|
11
|
+
* pluggable run store (filesystem default, sqlite Mode-B, postgres Mode-C), and
|
|
12
|
+
* emits lifecycle traces.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* `inline`
|
|
16
|
-
* observability, and PREPARES each specialist task
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
14
|
+
* Worker backends (ADR-0020, ADR-0021):
|
|
15
|
+
* - `inline` (default): OWNS planning, sequencing, handoff state, persistence,
|
|
16
|
+
* and observability, and PREPARES each specialist task for a downstream
|
|
17
|
+
* executor. It does NOT itself perform specialist LLM reasoning. Tasks reach
|
|
18
|
+
* status `prepared`; the prepare-only disclaimer applies.
|
|
19
|
+
* - `provider`: EXECUTES each specialist task by calling the configured
|
|
20
|
+
* provider/model with the specialist persona prompt. Tasks reach status
|
|
21
|
+
* `done` carrying real model output; the prepare-only disclaimer does NOT
|
|
22
|
+
* apply to provider-executed tasks. A failing task is recorded (status
|
|
23
|
+
* `failed`) and the run completes `completed-with-failures` rather than
|
|
24
|
+
* crashing.
|
|
25
|
+
*
|
|
26
|
+
* The run reports `workerBackend` and per-task `executor` so a host can never
|
|
27
|
+
* mistake a prepared task for executed specialist output. When the execution
|
|
28
|
+
* contract resolves to prompt-only or host-direct, Construct owns no specialist
|
|
29
|
+
* task sequence and the run records that explicitly.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
import { randomBytes } from 'node:crypto';
|
|
@@ -27,13 +34,15 @@ import { randomBytes } from 'node:crypto';
|
|
|
27
34
|
import { routeRequest } from '../orchestration-policy.mjs';
|
|
28
35
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
29
36
|
import { detectHostCapabilities } from '../host-capabilities.mjs';
|
|
37
|
+
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
30
38
|
import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
|
|
31
|
-
import {
|
|
39
|
+
import { resolveRunStore } from './store.mjs';
|
|
40
|
+
import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
|
|
32
41
|
|
|
33
|
-
export const WORKER_BACKENDS =
|
|
34
|
-
export const DEFAULT_WORKER_BACKEND =
|
|
42
|
+
export const WORKER_BACKENDS = WORKER_BACKEND_SET;
|
|
43
|
+
export const DEFAULT_WORKER_BACKEND = INLINE;
|
|
35
44
|
|
|
36
|
-
const RUNTIME_SEMANTICS = '
|
|
45
|
+
const RUNTIME_SEMANTICS = 'The inline worker backend owns planning, sequencing, handoff state, persistence, and observability, and prepares each specialist task for a downstream executor. It does not perform specialist LLM reasoning itself; tasks reaching status "prepared" are ready for a worker backend or host to execute. The provider worker backend executes specialist reasoning via the configured model and records real output as task.output.';
|
|
37
46
|
|
|
38
47
|
function newRunId() {
|
|
39
48
|
return `run-${randomBytes(6).toString('hex')}`;
|
|
@@ -87,25 +96,47 @@ function buildTasks(route) {
|
|
|
87
96
|
handoffContract: handoffByProducer.get(role.replace(/^cx-/, '')) || null,
|
|
88
97
|
status: 'queued',
|
|
89
98
|
executor: null,
|
|
99
|
+
output: null,
|
|
100
|
+
error: null,
|
|
90
101
|
startedAt: null,
|
|
91
102
|
finishedAt: null,
|
|
92
103
|
}));
|
|
93
104
|
}
|
|
94
105
|
|
|
106
|
+
// The worker backend is a deployment concern: an explicit option wins, then the
|
|
107
|
+
// run's recorded backend, then config (orchestration.workerBackend), then the
|
|
108
|
+
// inline default. An unknown value falls back to inline rather than guessing.
|
|
109
|
+
|
|
110
|
+
function resolveWorkerBackend({ explicit, run, config }) {
|
|
111
|
+
const candidate = explicit || run?.workerBackend || config?.orchestration?.workerBackend || DEFAULT_WORKER_BACKEND;
|
|
112
|
+
return WORKER_BACKENDS.includes(candidate) ? candidate : DEFAULT_WORKER_BACKEND;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadConfig(cwd, env) {
|
|
116
|
+
try {
|
|
117
|
+
return loadProjectConfig(cwd, env).config || {};
|
|
118
|
+
} catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
/**
|
|
96
124
|
* Plan a run: resolve the execution contract, decompose into a specialist chain,
|
|
97
125
|
* and persist a durable run record (status `planned`). Pure of model calls.
|
|
98
126
|
*
|
|
99
127
|
* @param {object} request
|
|
100
128
|
* @param {object} [opts] { env, cwd }
|
|
101
|
-
* @returns {object} the persisted run
|
|
129
|
+
* @returns {Promise<object>} the persisted run
|
|
102
130
|
*/
|
|
103
|
-
export function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
131
|
+
export async function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
104
132
|
const {
|
|
105
133
|
request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
|
|
106
134
|
host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
|
|
107
135
|
} = request;
|
|
108
136
|
|
|
137
|
+
const config = loadConfig(cwd, env);
|
|
138
|
+
const { store, warnings: storeWarnings } = resolveRunStore({ config, env, cwd });
|
|
139
|
+
|
|
109
140
|
const execData = resolveExecution(
|
|
110
141
|
{ workflowType, requestedStrategy, useConstruct, host, hostModel, hostProvider },
|
|
111
142
|
{ env, cwd },
|
|
@@ -115,6 +146,7 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
|
|
|
115
146
|
const traceId = newTraceId();
|
|
116
147
|
const runId = newRunId();
|
|
117
148
|
const now = new Date().toISOString();
|
|
149
|
+
const workerBackend = resolveWorkerBackend({ config });
|
|
118
150
|
|
|
119
151
|
// Construct owns a specialist task sequence only when the contract resolves to
|
|
120
152
|
// orchestrated execution; prompt-only and host-direct own none, and the run
|
|
@@ -129,7 +161,7 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
|
|
|
129
161
|
updatedAt: now,
|
|
130
162
|
request: { summary: truncate(text, 200), workflowType, requestedStrategy },
|
|
131
163
|
hostRole: resolveHostRole(host),
|
|
132
|
-
workerBackend
|
|
164
|
+
workerBackend,
|
|
133
165
|
execution: pickExecution(execData),
|
|
134
166
|
plan: {
|
|
135
167
|
intent: route.intent,
|
|
@@ -140,12 +172,12 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
|
|
|
140
172
|
},
|
|
141
173
|
tasks,
|
|
142
174
|
status: 'planned',
|
|
143
|
-
warnings: execData.warnings || [],
|
|
175
|
+
warnings: [...(execData.warnings || []), ...storeWarnings],
|
|
144
176
|
semantics: RUNTIME_SEMANTICS,
|
|
145
177
|
executionSemantics: execData.semantics,
|
|
146
178
|
};
|
|
147
179
|
|
|
148
|
-
saveRun(
|
|
180
|
+
await store.saveRun(run);
|
|
149
181
|
emitTraceEvent({
|
|
150
182
|
rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
|
|
151
183
|
metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
|
|
@@ -153,46 +185,87 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
|
|
|
153
185
|
return run;
|
|
154
186
|
}
|
|
155
187
|
|
|
188
|
+
// The inline backend prepares one task: queued → running → prepared, no model
|
|
189
|
+
// reasoning. The boundary ADR-0020 makes explicit.
|
|
190
|
+
|
|
191
|
+
function prepareTaskInline(task) {
|
|
192
|
+
task.executor = 'inline:prepared';
|
|
193
|
+
task.status = 'prepared';
|
|
194
|
+
task.finishedAt = new Date().toISOString();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The provider backend executes one task against the configured model. A failed
|
|
198
|
+
// task is recorded (status `failed`, task.error) and does not abort the run, so
|
|
199
|
+
// one specialist failure cannot lose the work of the others.
|
|
200
|
+
|
|
201
|
+
async function executeTaskViaProvider(task, run, env, fetchImpl) {
|
|
202
|
+
try {
|
|
203
|
+
const result = await runTaskViaProvider({
|
|
204
|
+
task, run,
|
|
205
|
+
model: run.execution?.selectedModel,
|
|
206
|
+
provider: run.execution?.selectedProvider,
|
|
207
|
+
env, fetchImpl,
|
|
208
|
+
});
|
|
209
|
+
task.output = result.output;
|
|
210
|
+
task.executor = `provider:${result.provider}:${result.model}`;
|
|
211
|
+
task.status = 'done';
|
|
212
|
+
task.finishedAt = new Date().toISOString();
|
|
213
|
+
return true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
task.executor = `provider:error`;
|
|
216
|
+
task.error = { code: err.code || 'PROVIDER_EXECUTION_FAILED', message: err.message };
|
|
217
|
+
task.status = 'failed';
|
|
218
|
+
task.finishedAt = new Date().toISOString();
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
156
223
|
/**
|
|
157
|
-
* Execute a planned run through the
|
|
158
|
-
* task
|
|
159
|
-
* is resumable and observable. The inline backend prepares tasks; it does not
|
|
160
|
-
* perform specialist LLM reasoning.
|
|
224
|
+
* Execute a planned run through the chosen worker backend, persisting after each
|
|
225
|
+
* task transition so the run is resumable and observable.
|
|
161
226
|
*
|
|
162
227
|
* @param {string} cwd
|
|
163
228
|
* @param {string} runId
|
|
164
|
-
* @param {object} [opts] { env }
|
|
165
|
-
* @returns {object} the completed run
|
|
229
|
+
* @param {object} [opts] { env, workerBackend, fetchImpl }
|
|
230
|
+
* @returns {Promise<object>} the completed run
|
|
166
231
|
*/
|
|
167
|
-
export function executeRun(cwd, runId, { env = process.env } = {}) {
|
|
168
|
-
const
|
|
232
|
+
export async function executeRun(cwd, runId, { env = process.env, workerBackend = null, fetchImpl } = {}) {
|
|
233
|
+
const config = loadConfig(cwd, env);
|
|
234
|
+
const { store } = resolveRunStore({ config, env, cwd });
|
|
235
|
+
const run = await store.loadRun(runId);
|
|
169
236
|
if (!run) {
|
|
170
237
|
const err = new Error(`Orchestration run not found: ${runId}`);
|
|
171
238
|
err.code = 'RUN_NOT_FOUND';
|
|
172
239
|
throw err;
|
|
173
240
|
}
|
|
174
241
|
|
|
242
|
+
const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
|
|
243
|
+
run.workerBackend = backend;
|
|
175
244
|
run.status = 'running';
|
|
176
245
|
run.updatedAt = new Date().toISOString();
|
|
177
|
-
saveRun(
|
|
246
|
+
await store.saveRun(run);
|
|
178
247
|
|
|
248
|
+
let anyFailed = false;
|
|
179
249
|
for (const task of run.tasks) {
|
|
180
250
|
task.status = 'running';
|
|
181
251
|
task.startedAt = new Date().toISOString();
|
|
182
|
-
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId } });
|
|
252
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
|
|
183
253
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
254
|
+
if (backend === PROVIDER) {
|
|
255
|
+
const ok = await executeTaskViaProvider(task, run, env, fetchImpl);
|
|
256
|
+
if (!ok) anyFailed = true;
|
|
257
|
+
} else {
|
|
258
|
+
prepareTaskInline(task);
|
|
259
|
+
}
|
|
188
260
|
|
|
261
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: task.status } });
|
|
189
262
|
run.updatedAt = new Date().toISOString();
|
|
190
|
-
saveRun(
|
|
263
|
+
await store.saveRun(run);
|
|
191
264
|
}
|
|
192
265
|
|
|
193
|
-
run.status = 'completed';
|
|
266
|
+
run.status = anyFailed ? 'completed-with-failures' : 'completed';
|
|
194
267
|
run.updatedAt = new Date().toISOString();
|
|
195
|
-
saveRun(
|
|
268
|
+
await store.saveRun(run);
|
|
196
269
|
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
|
|
197
270
|
return run;
|
|
198
271
|
}
|
|
@@ -200,9 +273,9 @@ export function executeRun(cwd, runId, { env = process.env } = {}) {
|
|
|
200
273
|
/**
|
|
201
274
|
* Plan and execute in one call (the common `orchestrate run` path).
|
|
202
275
|
*/
|
|
203
|
-
export function runOrchestration(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
204
|
-
const planned = planRun(request, { env, cwd });
|
|
205
|
-
return executeRun(cwd, planned.runId, { env });
|
|
276
|
+
export async function runOrchestration(request = {}, { env = process.env, cwd = process.cwd(), workerBackend = null, fetchImpl } = {}) {
|
|
277
|
+
const planned = await planRun(request, { env, cwd });
|
|
278
|
+
return executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl });
|
|
206
279
|
}
|
|
207
280
|
|
|
208
281
|
/**
|
|
@@ -231,10 +304,14 @@ export function hostAdapterMetadata(run) {
|
|
|
231
304
|
};
|
|
232
305
|
}
|
|
233
306
|
|
|
234
|
-
export function getRun(cwd, runId) {
|
|
235
|
-
|
|
307
|
+
export async function getRun(cwd, runId, { env = process.env } = {}) {
|
|
308
|
+
const config = loadConfig(cwd, env);
|
|
309
|
+
const { store } = resolveRunStore({ config, env, cwd });
|
|
310
|
+
return store.loadRun(runId);
|
|
236
311
|
}
|
|
237
312
|
|
|
238
|
-
export function getRuns(cwd, opts) {
|
|
239
|
-
|
|
313
|
+
export async function getRuns(cwd, { env = process.env, ...opts } = {}) {
|
|
314
|
+
const config = loadConfig(cwd, env);
|
|
315
|
+
const { store } = resolveRunStore({ config, env, cwd });
|
|
316
|
+
return store.listRuns(opts);
|
|
240
317
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/store.mjs — run-store resolver for the orchestration runtime.
|
|
3
|
+
*
|
|
4
|
+
* One interface — saveRun(run), loadRun(runId), listRuns(opts) — over three
|
|
5
|
+
* backends: filesystem (Mode-A default, zero dependency), sqlite (Mode-B,
|
|
6
|
+
* node:sqlite), and postgres (Mode-C, shared team/enterprise store). The runtime
|
|
7
|
+
* resolves a store through here instead of importing run-store functions
|
|
8
|
+
* directly, so the backend is a deployment concern rather than a code edit.
|
|
9
|
+
*
|
|
10
|
+
* Selection precedence: explicit config (`orchestration.store`) or the
|
|
11
|
+
* CONSTRUCT_ORCHESTRATION_STORE env override win; otherwise the deployment mode
|
|
12
|
+
* decides (solo→filesystem, team/enterprise→postgres). When the chosen backend
|
|
13
|
+
* is unavailable (sqlite on Node <22.5, or postgres with no DATABASE_URL / null
|
|
14
|
+
* sql client) the resolver falls back to filesystem and records a warning rather
|
|
15
|
+
* than failing — durability of the default tier is never sacrificed to an
|
|
16
|
+
* optional one. The returned store is always async-uniform so callers await it
|
|
17
|
+
* regardless of backend; the filesystem and sqlite backends resolve synchronously
|
|
18
|
+
* under the hood, so the default Mode-A run record is byte-for-byte unchanged.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
22
|
+
import { createSqlClient } from '../storage/backend.mjs';
|
|
23
|
+
import { saveRun as fsSaveRun, loadRun as fsLoadRun, listRuns as fsListRuns } from './run-store.mjs';
|
|
24
|
+
import { sqliteAvailable, createSqliteRunStore } from './run-store-sqlite.mjs';
|
|
25
|
+
import { PostgresRunStore } from './run-store-postgres.mjs';
|
|
26
|
+
|
|
27
|
+
export const ORCHESTRATION_STORES = ['filesystem', 'sqlite', 'postgres'];
|
|
28
|
+
export const STORE_ENV_KEY = 'CONSTRUCT_ORCHESTRATION_STORE';
|
|
29
|
+
|
|
30
|
+
function filesystemStore(cwd) {
|
|
31
|
+
return {
|
|
32
|
+
kind: 'filesystem',
|
|
33
|
+
saveRun: (run) => fsSaveRun(cwd, run),
|
|
34
|
+
loadRun: (runId) => fsLoadRun(cwd, runId),
|
|
35
|
+
listRuns: (opts) => fsListRuns(cwd, opts),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sqliteStore(cwd) {
|
|
40
|
+
const store = createSqliteRunStore({ cwd });
|
|
41
|
+
return {
|
|
42
|
+
kind: 'sqlite',
|
|
43
|
+
saveRun: (run) => store.saveRun(run),
|
|
44
|
+
loadRun: (runId) => store.loadRun(runId),
|
|
45
|
+
listRuns: (opts) => store.listRuns(opts),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function postgresStore({ sql, project }) {
|
|
50
|
+
const store = new PostgresRunStore({ sql, project });
|
|
51
|
+
let ensured = null;
|
|
52
|
+
const ready = () => {
|
|
53
|
+
if (!ensured) ensured = store.ensureSchema();
|
|
54
|
+
return ensured;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
kind: 'postgres',
|
|
58
|
+
saveRun: async (run) => { await ready(); return store.saveRun(run); },
|
|
59
|
+
loadRun: async (runId) => { await ready(); return store.loadRun(runId); },
|
|
60
|
+
listRuns: async (opts) => { await ready(); return store.listRuns(opts); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectKey(config, cwd) {
|
|
65
|
+
return config?.deployment?.projectName || cwd || 'default';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function chooseBackend({ config, env, cwd }) {
|
|
69
|
+
const explicit = String(env?.[STORE_ENV_KEY] || config?.orchestration?.store || '').trim().toLowerCase();
|
|
70
|
+
if (ORCHESTRATION_STORES.includes(explicit)) return explicit;
|
|
71
|
+
const mode = getDeploymentMode(env, { cwd });
|
|
72
|
+
return mode === 'solo' ? 'filesystem' : 'postgres';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a run store for the runtime.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} opts
|
|
79
|
+
* @param {object} [opts.config] loaded project config (reads orchestration.store)
|
|
80
|
+
* @param {Record<string,string>} [opts.env]
|
|
81
|
+
* @param {string} [opts.cwd]
|
|
82
|
+
* @returns {{ store: {kind:string, saveRun:Function, loadRun:Function, listRuns:Function}, backend: string, warnings: string[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function resolveRunStore({ config = {}, env = process.env, cwd = process.cwd() } = {}) {
|
|
85
|
+
const warnings = [];
|
|
86
|
+
const requested = chooseBackend({ config, env, cwd });
|
|
87
|
+
|
|
88
|
+
if (requested === 'sqlite') {
|
|
89
|
+
if (sqliteAvailable()) return { store: sqliteStore(cwd), backend: 'sqlite', warnings };
|
|
90
|
+
warnings.push('orchestration.store "sqlite" requires Node >=22.5 (node:sqlite unavailable); falling back to filesystem.');
|
|
91
|
+
return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (requested === 'postgres') {
|
|
95
|
+
const sql = createSqlClient(env);
|
|
96
|
+
if (sql) return { store: postgresStore({ sql, project: projectKey(config, cwd) }), backend: 'postgres', warnings };
|
|
97
|
+
warnings.push('orchestration.store "postgres" requires a reachable DATABASE_URL; falling back to filesystem.');
|
|
98
|
+
return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
|
|
102
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/worker.mjs — provider-backed worker backend for the local
|
|
3
|
+
* orchestration runtime.
|
|
4
|
+
*
|
|
5
|
+
* The inline backend (runtime.mjs) PREPARES a specialist task and stops short of
|
|
6
|
+
* model reasoning (ADR-0020). The provider backend closes that gap: it EXECUTES
|
|
7
|
+
* one specialist task by calling the configured provider/model with the
|
|
8
|
+
* specialist's persona prompt as system context and the run's request as the
|
|
9
|
+
* user turn, returning the model's real output (ADR-0021). Because it genuinely
|
|
10
|
+
* runs the model, the prepare-only disclaimer does not apply to provider-executed
|
|
11
|
+
* tasks — runtime.mjs records `task.output` and `executor='provider:<provider>:<model>'`
|
|
12
|
+
* so a host can distinguish prepared from executed work.
|
|
13
|
+
*
|
|
14
|
+
* Provider selection mirrors lib/ingest/provider-extract.mjs: Anthropic
|
|
15
|
+
* `/v1/messages` for Claude-family models, OpenRouter `/v1/chat/completions`
|
|
16
|
+
* otherwise; key resolution reads env (ANTHROPIC_API_KEY / OPENROUTER_API_KEY)
|
|
17
|
+
* and augments with ambient dotenv only when the caller did not inject an
|
|
18
|
+
* explicit env (hermetic-when-explicit). `fetchImpl` is injectable so the path is
|
|
19
|
+
* exercised end to end against a mock provider without a live key. Failures
|
|
20
|
+
* surface as structured codes (PROVIDER_KEY_MISSING, PROVIDER_MODEL_UNRESOLVED,
|
|
21
|
+
* PROVIDER_EXECUTION_FAILED) rather than opaque HTTP errors.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
export const INLINE = 'inline';
|
|
30
|
+
export const PROVIDER = 'provider';
|
|
31
|
+
export const WORKER_BACKEND_SET = [INLINE, PROVIDER];
|
|
32
|
+
|
|
33
|
+
const MAX_OUTPUT_TOKENS = 2048;
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const PROMPTS_DIR = join(HERE, '..', '..', 'specialists', 'prompts');
|
|
37
|
+
|
|
38
|
+
function providerError(code, reason, remediation) {
|
|
39
|
+
const err = new Error(reason);
|
|
40
|
+
err.code = code;
|
|
41
|
+
err.remediation = remediation;
|
|
42
|
+
return err;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isAnthropic(provider, model) {
|
|
46
|
+
return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Key resolution mirrors provider-extract: env first, then the two cheap dotenv
|
|
50
|
+
// files, and only when the caller did not inject an explicit env. A caller that
|
|
51
|
+
// passes its own env object (embedded callers, tests) is authoritative and
|
|
52
|
+
// hermetic — ambient discovery is suppressed so a developer's real key never
|
|
53
|
+
// bleeds into a test run.
|
|
54
|
+
|
|
55
|
+
function resolveKey(varName, env, allowAmbient) {
|
|
56
|
+
if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
|
|
57
|
+
if (!allowAmbient) return null;
|
|
58
|
+
for (const file of [join(homedir(), '.construct', 'config.env'), join(homedir(), '.env')]) {
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(file)) continue;
|
|
61
|
+
const m = readFileSync(file, 'utf8').match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
62
|
+
if (m && m[1]) return m[1].trim();
|
|
63
|
+
} catch { /* unreadable dotenv is not authoritative */ }
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The persona prompt is the specialist's system context. It lives in
|
|
69
|
+
// specialists/prompts/cx-<role>.md; absent that file, a minimal role-named system
|
|
70
|
+
// prompt keeps the backend functional rather than failing on a missing persona.
|
|
71
|
+
|
|
72
|
+
function loadPersona(role) {
|
|
73
|
+
const slug = String(role || '').replace(/^cx-/, '');
|
|
74
|
+
const file = join(PROMPTS_DIR, `cx-${slug}.md`);
|
|
75
|
+
try {
|
|
76
|
+
if (existsSync(file)) return readFileSync(file, 'utf8');
|
|
77
|
+
} catch { /* unreadable persona falls back to the minimal prompt */ }
|
|
78
|
+
return `You are the ${slug} specialist. Execute your part of the request within your role and return your result directly.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildUserPrompt({ task, run }) {
|
|
82
|
+
const summary = run?.request?.summary || '';
|
|
83
|
+
const reason = task?.reason ? `\n\nWhy you were dispatched: ${task.reason}` : '';
|
|
84
|
+
const handoff = task?.handoffContract ? `\nHandoff contract: ${task.handoffContract}` : '';
|
|
85
|
+
return `Request: ${summary}${reason}${handoff}\n\nDo your part of this request as the ${String(task?.role || '').replace(/^cx-/, '')} specialist. Return your result directly.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function bodyExcerpt(res) {
|
|
89
|
+
try {
|
|
90
|
+
return (await res.text()).slice(0, 200);
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function callAnthropic({ model, apiKey, system, user, fetchImpl }) {
|
|
97
|
+
const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
model: model.replace(/^anthropic\//, ''),
|
|
102
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
103
|
+
system,
|
|
104
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw providerError('PROVIDER_EXECUTION_FAILED', `Anthropic specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set orchestration.workerBackend to "inline".');
|
|
109
|
+
}
|
|
110
|
+
const data = await res.json();
|
|
111
|
+
return (data.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function callOpenRouter({ model, apiKey, system, user, fetchImpl }) {
|
|
115
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/construct' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
model: model.replace(/^openrouter\//, ''),
|
|
120
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
121
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw providerError('PROVIDER_EXECUTION_FAILED', `OpenRouter specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set orchestration.workerBackend to "inline".');
|
|
126
|
+
}
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
return data.choices?.[0]?.message?.content || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Execute one specialist task via the configured provider/model.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} opts
|
|
135
|
+
* @param {object} opts.task the run task (carries role, reason, handoffContract)
|
|
136
|
+
* @param {object} opts.run the run (carries request summary)
|
|
137
|
+
* @param {string} opts.model resolved provider model id
|
|
138
|
+
* @param {string} [opts.provider] resolved provider id (selects the API)
|
|
139
|
+
* @param {Record<string,string>} [opts.env]
|
|
140
|
+
* @param {Function} [opts.fetchImpl] injectable fetch (for tests)
|
|
141
|
+
* @returns {Promise<{output:string, model:string, provider:string, characters:number}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
144
|
+
if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider worker backend selected but no model resolved.', 'Configure the model tier registry so a model resolves, or set orchestration.workerBackend to "inline".');
|
|
145
|
+
if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider execution.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
|
|
146
|
+
|
|
147
|
+
const anthropic = isAnthropic(provider, model);
|
|
148
|
+
const keyVar = anthropic ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
|
|
149
|
+
const apiKey = resolveKey(keyVar, env, env === process.env);
|
|
150
|
+
if (!apiKey) {
|
|
151
|
+
throw providerError('PROVIDER_KEY_MISSING', `No API key for ${anthropic ? 'Anthropic' : 'OpenRouter'} specialist execution.`, `Set ${keyVar}, or set orchestration.workerBackend to "inline".`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const system = loadPersona(task?.role);
|
|
155
|
+
const user = buildUserPrompt({ task, run });
|
|
156
|
+
const output = anthropic
|
|
157
|
+
? await callAnthropic({ model, apiKey, system, user, fetchImpl })
|
|
158
|
+
: await callOpenRouter({ model, apiKey, system, user, fetchImpl });
|
|
159
|
+
|
|
160
|
+
const text = output || '';
|
|
161
|
+
return {
|
|
162
|
+
output: text,
|
|
163
|
+
model,
|
|
164
|
+
provider: anthropic ? 'anthropic' : 'openrouter',
|
|
165
|
+
characters: text.length,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/lib/parity.mjs
CHANGED
|
@@ -30,6 +30,38 @@ import { fileURLToPath } from 'node:url';
|
|
|
30
30
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
31
31
|
const ROOT_DIR = path.resolve(MODULE_DIR, '..');
|
|
32
32
|
|
|
33
|
+
// VS Code and Cursor user settings are JSONC: line/block comments and trailing
|
|
34
|
+
// commas are valid and common. Strict JSON.parse rejects them, producing a
|
|
35
|
+
// false "unreadable" parity failure on a perfectly valid editor config. This
|
|
36
|
+
// strips comments string-aware (so `//` inside a "https://…" value survives)
|
|
37
|
+
// and drops trailing commas before parsing.
|
|
38
|
+
|
|
39
|
+
function parseJsonc(text) {
|
|
40
|
+
let out = '';
|
|
41
|
+
let inString = false;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
44
|
+
const ch = text[i];
|
|
45
|
+
const next = text[i + 1];
|
|
46
|
+
if (inString) {
|
|
47
|
+
out += ch;
|
|
48
|
+
if (escaped) escaped = false;
|
|
49
|
+
else if (ch === '\\') escaped = true;
|
|
50
|
+
else if (ch === '"') inString = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '"') { inString = true; out += ch; continue; }
|
|
54
|
+
if (ch === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i += 1; out += '\n'; continue; }
|
|
55
|
+
if (ch === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1; i += 1; continue; }
|
|
56
|
+
out += ch;
|
|
57
|
+
}
|
|
58
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readJsoncFile(file) {
|
|
62
|
+
return parseJsonc(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
function loadRegistry(rootDir = ROOT_DIR) {
|
|
34
66
|
const file = path.join(rootDir, 'specialists', 'registry.json');
|
|
35
67
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -130,7 +162,7 @@ function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
|
|
|
130
162
|
if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
|
|
131
163
|
let config;
|
|
132
164
|
try {
|
|
133
|
-
config =
|
|
165
|
+
config = readJsoncFile(file);
|
|
134
166
|
} catch (err) {
|
|
135
167
|
return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
|
|
136
168
|
}
|
|
@@ -183,7 +215,7 @@ function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
|
|
|
183
215
|
const actualSet = new Set();
|
|
184
216
|
for (const settingsPath of paths) {
|
|
185
217
|
try {
|
|
186
|
-
const settings =
|
|
218
|
+
const settings = readJsoncFile(settingsPath);
|
|
187
219
|
for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
|
|
188
220
|
} catch (err) {
|
|
189
221
|
return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
|
|
@@ -208,7 +240,7 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
|
|
|
208
240
|
if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
|
|
209
241
|
let config;
|
|
210
242
|
try {
|
|
211
|
-
config =
|
|
243
|
+
config = readJsoncFile(file);
|
|
212
244
|
} catch (err) {
|
|
213
245
|
return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
|
|
214
246
|
}
|
package/package.json
CHANGED