@geraldmaron/construct 1.0.19 → 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/README.md +2 -0
- package/bin/construct +79 -0
- package/lib/cli-commands.mjs +24 -1
- package/lib/config/schema.mjs +49 -1
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/document-ingest.mjs +80 -6
- package/lib/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +127 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +317 -0
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +167 -0
- package/lib/parity.mjs +35 -3
- package/package.json +2 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/strategy.mjs — resolve the ingest extraction strategy and provider model.
|
|
3
|
+
*
|
|
4
|
+
* Construct's ingest path historically extracted documents through local binary
|
|
5
|
+
* adapters (docling/whisper/pdftotext) without surfacing that choice. This module
|
|
6
|
+
* makes the choice explicit and configurable:
|
|
7
|
+
* - `adapter` (default): the local-extractor pipeline, byte-for-byte unchanged.
|
|
8
|
+
* - `provider`: route document understanding through the configured provider/model.
|
|
9
|
+
* A `fallback` policy (`none`/`provider`/`adapter`) governs what happens on
|
|
10
|
+
* primary-path failure; the default `none` never silently masks a strategy
|
|
11
|
+
* mismatch. Resolution precedence matches the project-config convention:
|
|
12
|
+
* env > config > default. Provider/model identification reuses the embedded model
|
|
13
|
+
* resolution contract and the tier registry so no second selection surface exists.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolveSetting } from '../config/project-config.mjs';
|
|
17
|
+
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
18
|
+
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
19
|
+
|
|
20
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
21
|
+
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
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
|
+
|
|
30
|
+
export const DEFAULT_INGEST_STRATEGY = 'adapter';
|
|
31
|
+
export const DEFAULT_INGEST_FALLBACK = 'none';
|
|
32
|
+
export const DEFAULT_INGEST_ORCHESTRATION = 'prompt-only';
|
|
33
|
+
|
|
34
|
+
function coerceEnum(value, allowed) {
|
|
35
|
+
if (typeof value !== 'string') return null;
|
|
36
|
+
const lowered = value.trim().toLowerCase();
|
|
37
|
+
return allowed.includes(lowered) ? lowered : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The strategy step a document-understanding model serves under is `fast`; ingest
|
|
41
|
+
// is an extraction/understanding pass, not a reasoning workflow. The embedded
|
|
42
|
+
// resolution contract maps this tier to a concrete provider/model.
|
|
43
|
+
|
|
44
|
+
const INGEST_WORKFLOW_TYPE = 'evidence-ingest';
|
|
45
|
+
|
|
46
|
+
function resolveProviderModel({ env, registryPath }) {
|
|
47
|
+
const resolved = resolveEmbeddedModel({ workflowType: INGEST_WORKFLOW_TYPE }, { env, registryPath });
|
|
48
|
+
return {
|
|
49
|
+
model: resolved.selectedModel || null,
|
|
50
|
+
provider: resolved.selectedProvider || null,
|
|
51
|
+
resolutionSource: resolved.resolutionSource || null,
|
|
52
|
+
error: resolved.error || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the effective ingest strategy, fallback policy, and provider model.
|
|
58
|
+
*
|
|
59
|
+
* Precedence for both strategy and fallback is env > config > explicit override
|
|
60
|
+
* > default, where an explicit `override` (a CLI flag) wins over env/config when
|
|
61
|
+
* provided. The returned `model`/`provider` are non-null only when the resolved
|
|
62
|
+
* strategy is `provider` or when `fallback` could route to a provider.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {object} [opts.config] loaded project config object
|
|
66
|
+
* @param {Record<string,string>} [opts.env]
|
|
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]
|
|
70
|
+
* @param {string} [opts.registryPath]
|
|
71
|
+
* @returns {{strategy:string, fallback:string, orchestration:string, model:(string|null), provider:(string|null), modelResolution:(object|null), execution:object}}
|
|
72
|
+
*/
|
|
73
|
+
export function resolveIngestStrategy({ config = null, env = process.env, override = null, orchestrationOverride = null, cwd = process.cwd(), registryPath = null } = {}) {
|
|
74
|
+
const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
|
|
75
|
+
|
|
76
|
+
const fromSettings = resolveSetting({
|
|
77
|
+
config,
|
|
78
|
+
jsonPath: 'ingest.strategy',
|
|
79
|
+
env,
|
|
80
|
+
envKey: 'CONSTRUCT_INGEST_STRATEGY',
|
|
81
|
+
defaultValue: DEFAULT_INGEST_STRATEGY,
|
|
82
|
+
});
|
|
83
|
+
const strategy = overrideStrategy
|
|
84
|
+
|| coerceEnum(fromSettings.value, INGEST_STRATEGIES)
|
|
85
|
+
|| DEFAULT_INGEST_STRATEGY;
|
|
86
|
+
|
|
87
|
+
const fallbackSetting = resolveSetting({
|
|
88
|
+
config,
|
|
89
|
+
jsonPath: 'ingest.fallback',
|
|
90
|
+
env,
|
|
91
|
+
envKey: 'CONSTRUCT_INGEST_FALLBACK',
|
|
92
|
+
defaultValue: DEFAULT_INGEST_FALLBACK,
|
|
93
|
+
});
|
|
94
|
+
const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
|
|
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
|
+
|
|
107
|
+
const needsProviderModel = strategy === 'provider' || fallback === 'provider';
|
|
108
|
+
const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
|
|
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
|
+
|
|
118
|
+
return {
|
|
119
|
+
strategy,
|
|
120
|
+
fallback,
|
|
121
|
+
orchestration,
|
|
122
|
+
model: modelResolution?.model || null,
|
|
123
|
+
provider: modelResolution?.provider || null,
|
|
124
|
+
modelResolution,
|
|
125
|
+
execution,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
12
|
import { resolve, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
15
|
import { loadToolkitEnv } from '../toolkit-env.mjs';
|
|
16
16
|
import { loadConstructEnv } from '../env-config.mjs';
|
|
17
|
+
import { getInstalledVersion } from '../version.mjs';
|
|
18
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
19
|
import { withGenAiSpan, GenAiAttrs, extractTraceContext, injectTraceContext } from '../telemetry/otel-tracer.mjs';
|
|
18
20
|
|
|
19
21
|
// Apply config.env values to process.env, letting config.env win over shell env
|
|
@@ -56,7 +58,7 @@ import {
|
|
|
56
58
|
profileCreate, profileArchive, sandboxList, learningStatus,
|
|
57
59
|
knowledgeGraphAsk,
|
|
58
60
|
} from './tools/profile.mjs';
|
|
59
|
-
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe } from './tools/embedded-contract.mjs';
|
|
61
|
+
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, executionResolve } from './tools/embedded-contract.mjs';
|
|
60
62
|
|
|
61
63
|
const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
62
64
|
const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
|
|
@@ -64,11 +66,58 @@ loadToolkitEnv(ROOT_DIR);
|
|
|
64
66
|
|
|
65
67
|
const opts = { ROOT_DIR };
|
|
66
68
|
|
|
69
|
+
// Identity an MCP host can render meaningfully: the real installed version (not a
|
|
70
|
+
// hardcoded stub), a one-line description of what the server offers (shown in the
|
|
71
|
+
// host's server-detail view), and a `construct://status` resource for live state.
|
|
72
|
+
// Without these the host shows only "<name> · OK" with an empty detail panel.
|
|
73
|
+
|
|
74
|
+
const VERSION = getInstalledVersion()?.version || 'unknown';
|
|
75
|
+
const DEPLOYMENT_MODE = getDeploymentMode(process.env, { cwd: ROOT_DIR });
|
|
76
|
+
const INSTRUCTIONS = [
|
|
77
|
+
`Construct MCP — agent orchestration for this workspace (v${VERSION}, ${DEPLOYMENT_MODE} mode).`,
|
|
78
|
+
'Tools cover: project context and diff review, skills/templates/teams, document ingestion and vector storage,',
|
|
79
|
+
'workflow orchestration and contracts, telemetry and scoring, and cross-session memory.',
|
|
80
|
+
'Read the `construct://status` resource for live state, or run `construct status` for full project health.',
|
|
81
|
+
'Start the dashboard with `construct dev`.',
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
67
84
|
const server = new Server(
|
|
68
|
-
{ name: 'construct-mcp', version:
|
|
69
|
-
{ capabilities: { tools: {} } }
|
|
85
|
+
{ name: 'construct-mcp', version: VERSION },
|
|
86
|
+
{ capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS },
|
|
70
87
|
);
|
|
71
88
|
|
|
89
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
90
|
+
resources: [
|
|
91
|
+
{
|
|
92
|
+
uri: 'construct://status',
|
|
93
|
+
name: 'Construct status',
|
|
94
|
+
description: 'Live toolkit status: version, deployment mode, MCP broker, and how to get full project health.',
|
|
95
|
+
mimeType: 'application/json',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
101
|
+
if (req.params?.uri !== 'construct://status') {
|
|
102
|
+
throw new Error(`Unknown resource: ${req.params?.uri}`);
|
|
103
|
+
}
|
|
104
|
+
const payload = {
|
|
105
|
+
name: 'Construct',
|
|
106
|
+
version: VERSION,
|
|
107
|
+
deploymentMode: DEPLOYMENT_MODE,
|
|
108
|
+
mcpBroker: process.env.CONSTRUCT_MCP_BROKER === 'on' ? 'on' : 'off',
|
|
109
|
+
capabilities: [
|
|
110
|
+
'project-context', 'skills', 'templates', 'teams', 'document-ingestion',
|
|
111
|
+
'vector-storage', 'workflow', 'telemetry', 'memory',
|
|
112
|
+
],
|
|
113
|
+
fullHealth: 'run `construct status`',
|
|
114
|
+
dashboard: 'run `construct dev`, then open the printed URL',
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
contents: [{ uri: req.params.uri, mimeType: 'application/json', text: JSON.stringify(payload, null, 2) }],
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
72
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
73
122
|
tools: [
|
|
74
123
|
{
|
|
@@ -1109,6 +1158,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1109
1158
|
},
|
|
1110
1159
|
},
|
|
1111
1160
|
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'construct_execution_resolve',
|
|
1163
|
+
description: 'Resolve the execution-capability contract for an embedded workflow BEFORE/at workflow start: returns executionMode (construct-orchestrated | construct-prompt-only | host-direct | same-family-fallback), constructCapabilitiesActive (subset of personas/skills/workflow-routing/prompt-envelope), degraded + machine-readable degradationReason, requestedStrategy vs effectiveStrategy, and the resolved provider/model. Descriptive, not enforced: reports what Construct planned and can resolve a model for, never an observation that the host ran personas (see the semantics field). Read-only and secret-free.',
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: 'object',
|
|
1166
|
+
properties: {
|
|
1167
|
+
workflow_type: { type: 'string', description: 'Workflow whose orchestration plan is weighed (e.g. evidence-ingest, architecture-review). Absent ⇒ generic orchestration availability.' },
|
|
1168
|
+
requested_strategy: { type: 'string', enum: ['orchestrated', 'prompt-only', 'auto'], description: 'Desired execution strategy (default auto).' },
|
|
1169
|
+
use_construct: { type: 'boolean', description: 'false ⇒ host-direct (host runs without Construct capabilities). Default true.' },
|
|
1170
|
+
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
1171
|
+
host_model: { type: 'string', description: 'Model the host is currently using, for model resolution.' },
|
|
1172
|
+
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
1173
|
+
requested_tier: { type: 'string', enum: ['reasoning', 'standard', 'fast'], description: 'Desired model tier; overrides the workflow-type hint.' },
|
|
1174
|
+
capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
|
|
1175
|
+
allow_cross_provider_fallback: { type: 'boolean', description: 'Permit model fallback outside the host provider family (default false).' },
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1112
1179
|
],
|
|
1113
1180
|
}));
|
|
1114
1181
|
|
|
@@ -1190,6 +1257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1190
1257
|
else if (name === 'triage_recommend') result = await triageRecommend(args);
|
|
1191
1258
|
else if (name === 'workflow_invoke') result = await workflowInvoke(args);
|
|
1192
1259
|
else if (name === 'capability_describe') result = capabilityDescribe(args);
|
|
1260
|
+
else if (name === 'construct_execution_resolve') result = executionResolve(args);
|
|
1193
1261
|
else result = { error: `Unknown tool: ${name}` };
|
|
1194
1262
|
} catch (err) {
|
|
1195
1263
|
result = { error: err.message ?? String(err) };
|
|
@@ -12,11 +12,12 @@ import { resolveEmbeddedModel as resolveModelCore } from '../../embedded-contrac
|
|
|
12
12
|
import { recommendPlan as recommendPlanCore } from '../../embedded-contract/triage.mjs';
|
|
13
13
|
import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/workflow-invoke.mjs';
|
|
14
14
|
import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
|
|
15
|
+
import { resolveExecution as resolveExecutionCore } from '../../embedded-contract/execution.mjs';
|
|
15
16
|
import { resolveInput } from '../../embedded-contract/ingest.mjs';
|
|
16
17
|
|
|
17
18
|
async function ingestArgs(args) {
|
|
18
19
|
if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
|
|
19
|
-
const resolved = await resolveInput({ filePath: args.file_path });
|
|
20
|
+
const resolved = await resolveInput({ filePath: args.file_path, strategy: args.ingest_strategy });
|
|
20
21
|
return { input: resolved.text, ingestion: resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion };
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -75,3 +76,18 @@ export async function workflowInvoke(args = {}) {
|
|
|
75
76
|
export function capabilityDescribe(args = {}) {
|
|
76
77
|
return wrapContractResult(buildCapabilityContract({ rootDir: args.root_dir }), { surface: 'mcp' });
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
export function executionResolve(args = {}) {
|
|
81
|
+
const request = {
|
|
82
|
+
workflowType: args.workflow_type,
|
|
83
|
+
requestedStrategy: args.requested_strategy,
|
|
84
|
+
useConstruct: args.use_construct !== false,
|
|
85
|
+
host: args.host,
|
|
86
|
+
hostModel: args.host_model,
|
|
87
|
+
hostProvider: args.host_provider,
|
|
88
|
+
requestedTier: args.requested_tier,
|
|
89
|
+
capabilities: csv(args.capabilities),
|
|
90
|
+
allowCrossProviderFallback: Boolean(args.allow_cross_provider_fallback),
|
|
91
|
+
};
|
|
92
|
+
return wrapContractResult(resolveExecutionCore(request), { surface: 'mcp' });
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/run-store.mjs — filesystem-backed persistence for the local
|
|
3
|
+
* orchestration runtime (Mode-A, zero-dependency).
|
|
4
|
+
*
|
|
5
|
+
* Orchestration runs and their task lifecycle live as JSON under the project's
|
|
6
|
+
* `.cx/runtime/orchestration/runs/`, so a run survives editor/host restarts and
|
|
7
|
+
* is inspectable without a daemon, a database, or Docker. Writes are atomic
|
|
8
|
+
* (temp file then rename) so a crash mid-write cannot leave a half-written run;
|
|
9
|
+
* reads tolerate a corrupt file by skipping it rather than throwing. This is the
|
|
10
|
+
* Mode-A backend; Mode-B/C (SQLite/Postgres) would implement the same load/save
|
|
11
|
+
* surface without changing callers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
|
|
18
|
+
|
|
19
|
+
let writeCounter = 0;
|
|
20
|
+
|
|
21
|
+
function projectRoot(cwd) {
|
|
22
|
+
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runtimeDir(cwd = process.cwd()) {
|
|
26
|
+
return join(projectRoot(cwd), RUNTIME_REL);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runsDir(cwd) {
|
|
30
|
+
return join(runtimeDir(cwd), 'runs');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Atomic JSON write: a same-directory temp file then rename, which is atomic on
|
|
34
|
+
// POSIX filesystems. A per-process counter keeps concurrent writers from
|
|
35
|
+
// colliding on the temp name before the rename lands.
|
|
36
|
+
|
|
37
|
+
function atomicWriteJson(filePath, value) {
|
|
38
|
+
writeCounter = (writeCounter + 1) % 100000;
|
|
39
|
+
const tmp = `${filePath}.${process.pid}.${writeCounter}.tmp`;
|
|
40
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
41
|
+
renameSync(tmp, filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveRun(cwd, run) {
|
|
45
|
+
const dir = runsDir(cwd);
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
atomicWriteJson(join(dir, `${run.runId}.json`), run);
|
|
48
|
+
return run;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadRun(cwd, runId) {
|
|
52
|
+
if (!runId) return null;
|
|
53
|
+
const file = join(runsDir(cwd), `${runId}.json`);
|
|
54
|
+
if (!existsSync(file)) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listRuns(cwd, { limit = 20 } = {}) {
|
|
63
|
+
const dir = runsDir(cwd);
|
|
64
|
+
if (!existsSync(dir)) return [];
|
|
65
|
+
const runs = [];
|
|
66
|
+
for (const name of readdirSync(dir)) {
|
|
67
|
+
if (!name.endsWith('.json')) continue;
|
|
68
|
+
try {
|
|
69
|
+
runs.push(JSON.parse(readFileSync(join(dir, name), 'utf8')));
|
|
70
|
+
} catch {
|
|
71
|
+
/* a corrupt run file is skipped, not fatal to listing */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
runs.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
75
|
+
return runs.slice(0, limit).map((run) => ({
|
|
76
|
+
runId: run.runId,
|
|
77
|
+
status: run.status,
|
|
78
|
+
executionMode: run.execution?.executionMode || null,
|
|
79
|
+
createdAt: run.createdAt,
|
|
80
|
+
request: run.request?.summary || null,
|
|
81
|
+
}));
|
|
82
|
+
}
|