@njeybe/nativ 2.4.1 → 2.5.1

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +26 -0
  3. package/dist/commands/db/db-formatter.d.ts +5 -0
  4. package/dist/commands/db/db-formatter.js +116 -0
  5. package/dist/commands/db/db-introspect.d.ts +11 -0
  6. package/dist/commands/db/db-introspect.js +84 -0
  7. package/dist/commands/db.js +4 -206
  8. package/dist/commands/profile.d.ts +3 -0
  9. package/dist/commands/profile.js +39 -0
  10. package/dist/commands/task/task-add.d.ts +11 -0
  11. package/dist/commands/task/task-add.js +169 -0
  12. package/dist/commands/task/task-common.d.ts +57 -0
  13. package/dist/commands/task/task-common.js +45 -0
  14. package/dist/commands/task/task-escalate.d.ts +7 -0
  15. package/dist/commands/task/task-escalate.js +119 -0
  16. package/dist/commands/task/task-lifecycle.d.ts +5 -0
  17. package/dist/commands/task/task-lifecycle.js +252 -0
  18. package/dist/commands/task/task-query.d.ts +10 -0
  19. package/dist/commands/task/task-query.js +269 -0
  20. package/dist/commands/task.d.ts +9 -87
  21. package/dist/commands/task.js +8 -850
  22. package/dist/commands/triage/triage-store.d.ts +56 -0
  23. package/dist/commands/triage/triage-store.js +108 -0
  24. package/dist/commands/triage/triage-ui.d.ts +14 -0
  25. package/dist/commands/triage/triage-ui.js +104 -0
  26. package/dist/commands/triage.d.ts +3 -52
  27. package/dist/commands/triage.js +8 -216
  28. package/dist/core/enforcement.d.ts +1 -0
  29. package/dist/core/enforcement.js +19 -0
  30. package/dist/core/human-gate.d.ts +2 -1
  31. package/dist/core/human-gate.js +9 -2
  32. package/dist/core/profiles.d.ts +14 -0
  33. package/dist/core/profiles.js +82 -0
  34. package/dist/core/setup/config-merger.d.ts +59 -0
  35. package/dist/core/setup/config-merger.js +107 -0
  36. package/dist/core/setup/session-context.d.ts +4 -0
  37. package/dist/core/setup/session-context.js +65 -0
  38. package/dist/core/setup/setup-planner.d.ts +14 -0
  39. package/dist/core/setup/setup-planner.js +180 -0
  40. package/dist/core/setup/template-planner.d.ts +8 -0
  41. package/dist/core/setup/template-planner.js +32 -0
  42. package/dist/core/setup/types.d.ts +65 -0
  43. package/dist/core/setup/types.js +55 -0
  44. package/dist/core/setup-assets.d.ts +8 -119
  45. package/dist/core/setup-assets.js +7 -437
  46. package/dist/core/tier1-liaison.d.ts +2 -0
  47. package/dist/core/tier1-liaison.js +64 -3
  48. package/dist/core/verifier/executor.d.ts +5 -0
  49. package/dist/core/verifier/executor.js +62 -0
  50. package/dist/core/verifier/phases.d.ts +5 -0
  51. package/dist/core/verifier/phases.js +71 -0
  52. package/dist/core/verifier/task-verifier.d.ts +37 -0
  53. package/dist/core/verifier/task-verifier.js +233 -0
  54. package/dist/core/verifier/types.d.ts +51 -0
  55. package/dist/core/verifier/types.js +1 -0
  56. package/dist/core/verifier.d.ts +5 -93
  57. package/dist/core/verifier.js +5 -359
  58. package/dist/db/introspector.js +59 -59
  59. package/dist/governor/evaluator.d.ts +4 -64
  60. package/dist/governor/evaluator.js +21 -686
  61. package/dist/governor/patch-applier.d.ts +8 -0
  62. package/dist/governor/patch-applier.js +121 -0
  63. package/dist/governor/self-healing.d.ts +24 -0
  64. package/dist/governor/self-healing.js +351 -0
  65. package/dist/governor/test-integrity.d.ts +41 -0
  66. package/dist/governor/test-integrity.js +237 -0
  67. package/dist/index.js +8 -0
  68. package/dist/mcp/capture.d.ts +4 -0
  69. package/dist/mcp/capture.js +33 -0
  70. package/dist/mcp/resources.d.ts +39 -0
  71. package/dist/mcp/resources.js +44 -0
  72. package/dist/mcp/server.d.ts +11 -0
  73. package/dist/mcp/server.js +14 -89
  74. package/dist/mcp/tools.d.ts +1 -0
  75. package/dist/mcp/tools.js +1 -0
  76. package/dist/runner/agent-supervisor.d.ts +8 -302
  77. package/dist/runner/agent-supervisor.js +41 -1030
  78. package/dist/runner/cli-config.d.ts +84 -0
  79. package/dist/runner/cli-config.js +215 -0
  80. package/dist/runner/native-driver.d.ts +18 -0
  81. package/dist/runner/native-driver.js +208 -0
  82. package/dist/runner/native-engine.d.ts +27 -0
  83. package/dist/runner/native-engine.js +184 -0
  84. package/dist/runner/native-tools.d.ts +31 -0
  85. package/dist/runner/native-tools.js +241 -0
  86. package/dist/runner/process-utils.d.ts +12 -0
  87. package/dist/runner/process-utils.js +96 -0
  88. package/dist/runner/run-lifecycle.d.ts +6 -0
  89. package/dist/runner/run-lifecycle.js +42 -0
  90. package/dist/runner/types.d.ts +215 -0
  91. package/dist/runner/types.js +41 -0
  92. package/dist/scanner/detector.js +42 -663
  93. package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
  94. package/dist/scanner/detectors/ecosystem-detector.js +354 -0
  95. package/dist/scanner/detectors/orm-detector.d.ts +5 -0
  96. package/dist/scanner/detectors/orm-detector.js +59 -0
  97. package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
  98. package/dist/scanner/detectors/workspace-detector.js +266 -0
  99. package/dist/server/cli-runner.d.ts +15 -0
  100. package/dist/server/cli-runner.js +43 -0
  101. package/dist/server/event-hub.d.ts +42 -0
  102. package/dist/server/event-hub.js +169 -0
  103. package/dist/server/http-utils.d.ts +15 -0
  104. package/dist/server/http-utils.js +56 -0
  105. package/dist/server/plan-utils.d.ts +9 -0
  106. package/dist/server/plan-utils.js +31 -0
  107. package/dist/server/routes/db-routes.d.ts +57 -0
  108. package/dist/server/routes/db-routes.js +484 -0
  109. package/dist/server/routes/task-routes.d.ts +266 -0
  110. package/dist/server/routes/task-routes.js +285 -0
  111. package/dist/server/routes/telemetry-routes.d.ts +128 -0
  112. package/dist/server/routes/telemetry-routes.js +179 -0
  113. package/dist/server/routes/triage-routes.d.ts +81 -0
  114. package/dist/server/routes/triage-routes.js +244 -0
  115. package/dist/server/routes/worktree-routes.d.ts +31 -0
  116. package/dist/server/routes/worktree-routes.js +144 -0
  117. package/dist/server/studio-server.d.ts +4 -11
  118. package/dist/server/studio-server.js +34 -1578
  119. package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
  120. package/dist/server/studio-ui/shell/triage.js +10 -0
  121. package/package.json +2 -2
  122. package/plugin/.claude-plugin/plugin.json +1 -1
  123. package/plugin/agents/architect.md +4 -2
  124. package/plugin/agents/verifier.md +1 -1
  125. package/plugin/agents/worker.md +7 -4
  126. package/plugin/skills/nativ/SKILL.md +1 -0
  127. package/templates/.claudeignore +62 -45
  128. package/templates/AGENTS.md +1 -0
  129. package/templates/CLAUDE.md +13 -0
  130. package/templates/claude-agents/architect.md +4 -2
  131. package/templates/claude-agents/verifier.md +1 -1
  132. package/templates/claude-agents/worker.md +7 -4
  133. package/templates/dot-ai/api_contracts.json +66 -66
  134. package/templates/dot-ai/context.md +38 -38
  135. package/templates/dot-ai/db_schema.json +39 -39
  136. package/templates/dot-ai/subagents/architect.md +54 -0
  137. package/dist/commands/session.d.ts +0 -22
  138. package/dist/commands/session.js +0 -106
  139. package/dist/core/session-memory.d.ts +0 -42
  140. package/dist/core/session-memory.js +0 -121
@@ -1,158 +1,22 @@
1
- import { execFile } from 'node:child_process';
2
- import fs from 'node:fs';
3
1
  import http from 'node:http';
4
2
  import path from 'node:path';
5
- import { connectionUrlFromComponents, detectEngine, detectExampleDbKeys, firestoreUrlFromConfig, maskConnectionString, resolveConnections, } from '../db/env-parser.js';
6
- import { disconnectedStatus, introspectDatabase } from '../db/introspector.js';
7
- import { CONTRACT_SCHEMA_PATH, diffSchemas, loadContractEngine, loadContractTables, toContractDiff, } from '../db/diff.js';
8
- import { fetchData, insertRecord, updateRecord, deleteRecord } from '../db/data-engine.js';
9
- import { generateContractTests, TestGenError } from '../core/test-generator.js';
10
- import { isTestFramework, TEST_FRAMEWORKS } from '../core/test-generator-types.js';
11
- import { loadPlan, withFileLock, withPlanLock } from '../core/lock-manager.js';
12
- import { cacheHitRate, emptyActualUsage, loadTelemetry, mergeActualUsage, resolveModelPricing, } from '../core/telemetry.js';
3
+ import { detectExampleDbKeys, resolveConnections } from '../db/env-parser.js';
13
4
  import { resolveProjectRoot } from '../core/root-resolver.js';
14
- import { CircuitBreaker, applySelfHealingProposal, } from '../governor/index.js';
15
- import { runTaskBlock, runTaskComplete, runTaskStart } from '../commands/task.js';
16
- import { runWorktreeMerge, runWorktreeRemove } from '../commands/worktree.js';
17
- import { runBench } from '../commands/bench.js';
18
- import { finalizeTriage, triageBlocker } from '../commands/triage.js';
19
5
  import { Tier1Liaison } from '../core/tier1-liaison.js';
20
- import { AgentSupervisor, SupervisorError, } from '../runner/agent-supervisor.js';
21
- /**
22
- * Embedded Studio HTTP server (native node:http).
23
- * Serves the REST endpoints from .ai/api_contracts.json plus the dashboard page.
24
- *
25
- * Air-gap rules: binds to loopback only, keeps raw connection URLs in this process's
26
- * memory, and only ever returns masked URLs in responses.
27
- */
6
+ import { AgentSupervisor, } from '../runner/agent-supervisor.js';
7
+ import { HttpError, LOOPBACK_HOSTS, readJsonBody, sendError, sendJson, sendPipelineError, } from './http-utils.js';
8
+ import { AI_DIR } from './plan-utils.js';
9
+ import { DEFAULT_HEARTBEAT_MS, PipelineEventHub } from './event-hub.js';
10
+ import { StudioSession, handleConnect, handleDataDelete, handleDataGet, handleDataPost, handleDataPut, handleDiff, handleExportContract, handleSchema, handleStatus, handleTestGenerate, } from './routes/db-routes.js';
11
+ import { handlePipelineStatus, handlePipelineTasks, handleTaskAbort, handleTaskAction, handleTaskDispatch, handleTaskLogs, handleTaskRuns, runView, } from './routes/task-routes.js';
12
+ import { describeWorktrees, handleWorktreeAction, handleWorktreeDiff, } from './routes/worktree-routes.js';
13
+ import { createBenchmarkRunner, handleBenchmarks, handleTelemetryDetailed, } from './routes/telemetry-routes.js';
14
+ import { handleEscalationResolve, handleEscalations, handleTriageConfig, handleTriageEvaluate, runAutoTriageQueue, } from './routes/triage-routes.js';
28
15
  export const DEFAULT_STUDIO_PORT = 4983;
29
- const MAX_BODY_BYTES = 64 * 1024;
30
- const CACHE_TTL_MS = 5000;
31
- const ENVS = ['dev', 'prod'];
32
- class HttpError extends Error {
33
- status;
34
- code;
35
- constructor(status, code, message) {
36
- super(message);
37
- this.status = status;
38
- this.code = code;
39
- }
40
- }
41
- /** In-memory studio session: raw URLs never leave this object except to database drivers. */
42
- class StudioSession {
43
- urls = { dev: null, prod: null };
44
- meta = new Map();
45
- cache = new Map();
46
- cwd;
47
- constructor(initial, cwd = process.cwd(), initialMeta) {
48
- this.cwd = cwd;
49
- for (const env of ENVS) {
50
- this.urls[env] = initial[env] ?? null;
51
- if (initialMeta?.[env])
52
- this.meta.set(env, initialMeta[env]);
53
- }
54
- }
55
- set(env, url, result, meta) {
56
- this.urls[env] = url;
57
- if (meta)
58
- this.meta.set(env, meta);
59
- else
60
- this.meta.delete(env);
61
- this.cache.set(env, { at: Date.now(), result });
62
- }
63
- getUrl(env) {
64
- return this.urls[env];
65
- }
66
- invalidate() {
67
- this.cache.clear();
68
- }
69
- async introspect(env, fresh = false) {
70
- const url = this.urls[env];
71
- if (!url) {
72
- const conns = resolveConnections(this.cwd);
73
- const missingKey = conns.templateInfo?.missingKeys.find((k) => k.targetEnv === env);
74
- if (missingKey) {
75
- const engineLabel = missingKey.engine ? ` (${missingKey.engine})` : '';
76
- const templateName = conns.templateInfo?.templateFile ?? '.env.example';
77
- const suggestion = `Found "${missingKey.key}"${engineLabel} in ${templateName}, but it is not set in your .env`;
78
- return {
79
- status: disconnectedStatus(`No ${env === 'dev' ? 'Dev' : 'Prod'} database configured. ${suggestion}`, missingKey.engine ?? 'postgresql', '', {
80
- sourceKey: missingKey.key,
81
- detectedFromExample: true,
82
- exampleFile: templateName,
83
- suggestion,
84
- }),
85
- tables: [],
86
- };
87
- }
88
- return {
89
- status: disconnectedStatus(`No ${env === 'dev' ? 'Dev' : 'Prod'} database configured. Use Connection Settings or set ${env.toUpperCase()}_DATABASE_URL.`),
90
- tables: [],
91
- };
92
- }
93
- const cached = this.cache.get(env);
94
- if (!fresh && cached && Date.now() - cached.at < CACHE_TTL_MS)
95
- return cached.result;
96
- const result = await introspectDatabase(url);
97
- const m = this.meta.get(env);
98
- if (m) {
99
- result.status.sourceKey = m.sourceKey;
100
- result.status.detectedFromExample = m.detectedFromExample;
101
- result.status.exampleFile = m.exampleFile;
102
- if (m.synthesized)
103
- result.status.synthesized = true;
104
- }
105
- this.cache.set(env, { at: Date.now(), result });
106
- return result;
107
- }
108
- }
109
- // ─── Request helpers ───────────────────────────────────────────────────────────
110
- function sendJson(res, status, body) {
111
- const payload = JSON.stringify(body);
112
- res.writeHead(status, {
113
- 'Content-Type': 'application/json; charset=utf-8',
114
- 'Content-Length': Buffer.byteLength(payload),
115
- 'Cache-Control': 'no-store',
116
- 'X-Content-Type-Options': 'nosniff',
117
- });
118
- res.end(payload);
119
- }
120
- function sendError(res, status, code, message) {
121
- sendJson(res, status, { error: { code, message } });
122
- }
123
- /** /api/pipeline/* errors use the contract's `{ ok: false, error }` envelope. */
124
- function sendPipelineError(res, status, code, message) {
125
- sendJson(res, status, { ok: false, error: message, code });
126
- }
127
- async function readJsonBody(req) {
128
- const contentType = String(req.headers['content-type'] ?? '');
129
- if (!contentType.toLowerCase().startsWith('application/json')) {
130
- throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json');
131
- }
132
- const chunks = [];
133
- let size = 0;
134
- for await (const chunk of req) {
135
- size += chunk.length;
136
- if (size > MAX_BODY_BYTES)
137
- throw new HttpError(413, 'PAYLOAD_TOO_LARGE', 'Request body exceeds 64KB');
138
- chunks.push(chunk);
139
- }
140
- try {
141
- const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
142
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
143
- throw new Error('not an object');
144
- return parsed;
145
- }
146
- catch {
147
- throw new HttpError(400, 'INVALID_JSON', 'Request body must be a JSON object');
148
- }
149
- }
150
- function parseEnv(value, field) {
151
- if (value === 'dev' || value === 'prod')
152
- return value;
153
- throw new HttpError(400, 'VALIDATION_ERROR', `"${field}" must be 'dev' or 'prod'`);
154
- }
155
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
16
+ // Re-exports for backwards compatibility
17
+ export { DEFAULT_HEARTBEAT_MS, PipelineEventHub } from './event-hub.js';
18
+ export { writeContractSchema } from './routes/db-routes.js';
19
+ export { HttpError } from './http-utils.js';
156
20
  function hostnameOf(value) {
157
21
  try {
158
22
  return new URL(value.includes('://') ? value : `http://${value}`).hostname;
@@ -171,1416 +35,6 @@ function assertLocalRequest(req) {
171
35
  throw new HttpError(403, 'FORBIDDEN_ORIGIN', 'Cross-origin requests are not allowed');
172
36
  }
173
37
  }
174
- // ─── Route handlers ────────────────────────────────────────────────────────────
175
- async function handleStatus(session, fresh) {
176
- const [dev, prod] = await Promise.all(ENVS.map((env) => session.introspect(env, fresh)));
177
- return { dev: dev.status, prod: prod.status };
178
- }
179
- async function handleSchema(session, envParam, fresh) {
180
- const env = envParam ? parseEnv(envParam, 'env') : null;
181
- const [devTables, prodTables] = await Promise.all(ENVS.map(async (e) => (!env || env === e ? (await session.introspect(e, fresh)).tables : [])));
182
- return { devTables, prodTables };
183
- }
184
- /**
185
- * GET /api/diff: contract keys (added/dropped/altered/unchanged, summary.*Count + engine,
186
- * migrationScript) plus legacy *Tables aliases. The script targets the engine being migrated:
187
- * Prod's engine, or the contract's declared engine (falling back to Dev's).
188
- */
189
- async function handleDiff(session, cwd, targetParam, fresh) {
190
- const target = targetParam ?? 'prod';
191
- if (target !== 'prod' && target !== 'contract') {
192
- throw new HttpError(400, 'VALIDATION_ERROR', `"target" must be 'prod' or 'contract'`);
193
- }
194
- const dev = await session.introspect('dev', fresh);
195
- if (!dev.status.connected)
196
- throw new HttpError(409, 'DEV_NOT_CONNECTED', dev.status.error ?? 'Dev database is not connected');
197
- if (target === 'contract') {
198
- const engine = loadContractEngine(cwd) ?? dev.status.engine;
199
- return toContractDiff(diffSchemas(dev.tables, loadContractTables(cwd)), engine, '.ai/db_schema.json');
200
- }
201
- const prod = await session.introspect('prod', fresh);
202
- if (!prod.status.connected)
203
- throw new HttpError(409, 'PROD_NOT_CONNECTED', prod.status.error ?? 'Prod database is not connected');
204
- return toContractDiff(diffSchemas(dev.tables, prod.tables), prod.status.engine, 'Prod');
205
- }
206
- const ENGINES = ['postgresql', 'mysql', 'sqlite', 'mongodb', 'firestore'];
207
- function parseFirestoreConfig(value) {
208
- if (value === undefined || value === null)
209
- return null;
210
- if (typeof value !== 'object' || Array.isArray(value))
211
- throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig" must be an object');
212
- const raw = value;
213
- const projectId = typeof raw.projectId === 'string' ? raw.projectId.trim() : '';
214
- if (!/^[a-z0-9][a-z0-9-]{2,62}$/.test(projectId)) {
215
- throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.projectId" must be a valid Firestore project id');
216
- }
217
- const config = { projectId };
218
- if (raw.emulatorHost !== undefined && raw.emulatorHost !== '') {
219
- const host = typeof raw.emulatorHost === 'string' ? raw.emulatorHost.trim() : '';
220
- if (!/^[A-Za-z0-9.\-[\]:]+:\d{1,5}$/.test(host))
221
- throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.emulatorHost" must look like host:port');
222
- config.emulatorHost = host;
223
- }
224
- if (raw.databaseId !== undefined && raw.databaseId !== '') {
225
- const db = typeof raw.databaseId === 'string' ? raw.databaseId.trim() : '';
226
- if (!/^[a-z0-9()][a-z0-9-()]{0,62}$/.test(db))
227
- throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.databaseId" is invalid');
228
- config.databaseId = db;
229
- }
230
- return config;
231
- }
232
- const COMPONENT_ENGINES = ['mysql', 'postgresql'];
233
- function optionalString(raw, field) {
234
- const value = raw[field];
235
- if (value === undefined || value === null)
236
- return undefined;
237
- if (typeof value !== 'string')
238
- throw new HttpError(400, 'VALIDATION_ERROR', `"components.${field}" must be a string`);
239
- return value;
240
- }
241
- /** Validates the `components` payload ({ engine, host, port, user, password, database }). */
242
- function parseComponents(value, topLevelEngine) {
243
- if (value === undefined || value === null)
244
- return null;
245
- if (typeof value !== 'object' || Array.isArray(value))
246
- throw new HttpError(400, 'VALIDATION_ERROR', '"components" must be an object');
247
- const raw = value;
248
- const engine = raw.engine ?? topLevelEngine;
249
- if (typeof engine !== 'string' || !COMPONENT_ENGINES.includes(engine)) {
250
- throw new HttpError(400, 'VALIDATION_ERROR', `"components.engine" must be one of ${COMPONENT_ENGINES.join(', ')}`);
251
- }
252
- if (topLevelEngine && topLevelEngine !== engine) {
253
- throw new HttpError(400, 'VALIDATION_ERROR', `"engine" is "${topLevelEngine}" but "components.engine" is "${engine}"`);
254
- }
255
- const host = (optionalString(raw, 'host') ?? '').trim();
256
- if (!/^[A-Za-z0-9._:[\]-]{1,253}$/.test(host))
257
- throw new HttpError(400, 'VALIDATION_ERROR', '"components.host" must be a hostname or IP address');
258
- let port;
259
- if (raw.port !== undefined && raw.port !== null && raw.port !== '') {
260
- port = typeof raw.port === 'number' ? raw.port : typeof raw.port === 'string' && /^\d+$/.test(raw.port.trim()) ? Number(raw.port) : NaN;
261
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
262
- throw new HttpError(400, 'VALIDATION_ERROR', '"components.port" must be an integer between 1 and 65535');
263
- }
264
- }
265
- const user = optionalString(raw, 'user')?.trim();
266
- const password = optionalString(raw, 'password');
267
- const database = optionalString(raw, 'database')?.trim();
268
- return {
269
- engine: engine,
270
- host,
271
- ...(port ? { port } : {}),
272
- ...(user ? { user } : {}),
273
- ...(password ? { password } : {}),
274
- ...(database ? { database } : {}),
275
- };
276
- }
277
- /**
278
- * POST /api/connect. Accepts `connectionUrl`, `components` (parameter / XAMPP form, assembled
279
- * in-memory), or (for Firestore) `firestoreConfig` alone. When `engine` is given it must agree
280
- * with the URL's scheme.
281
- */
282
- async function handleConnect(session, body) {
283
- const env = parseEnv(body.env, 'env');
284
- let engine = null;
285
- if (body.engine !== undefined) {
286
- if (typeof body.engine !== 'string' || !ENGINES.includes(body.engine)) {
287
- throw new HttpError(400, 'VALIDATION_ERROR', `"engine" must be one of ${ENGINES.join(', ')}`);
288
- }
289
- engine = body.engine;
290
- }
291
- const firestoreConfig = parseFirestoreConfig(body.firestoreConfig);
292
- let connectionUrl = typeof body.connectionUrl === 'string' ? body.connectionUrl.trim() : '';
293
- const components = connectionUrl ? null : parseComponents(body.components, engine);
294
- if (!connectionUrl && components) {
295
- try {
296
- connectionUrl = connectionUrlFromComponents(components);
297
- }
298
- catch (err) {
299
- throw new HttpError(400, 'VALIDATION_ERROR', err.message);
300
- }
301
- }
302
- if (!connectionUrl && firestoreConfig) {
303
- if (engine && engine !== 'firestore')
304
- throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig" requires engine "firestore"');
305
- connectionUrl = firestoreUrlFromConfig(firestoreConfig);
306
- }
307
- if (!connectionUrl) {
308
- throw new HttpError(400, 'VALIDATION_ERROR', '"connectionUrl" or "components" is required (or "firestoreConfig" for Firestore)');
309
- }
310
- const detected = detectEngine(connectionUrl);
311
- if (!detected) {
312
- throw new HttpError(400, 'VALIDATION_ERROR', 'Unsupported connection URL (expected postgres://, mysql://, sqlite:, file:, mongodb://, mongodb+srv:// or firestore://)');
313
- }
314
- if (engine && engine !== detected) {
315
- throw new HttpError(400, 'VALIDATION_ERROR', `"engine" is "${engine}" but the connection URL is for ${detected}`);
316
- }
317
- const result = await introspectDatabase(connectionUrl);
318
- // Only persist to the in-memory session once the connection is proven to work.
319
- if (result.status.connected)
320
- session.set(env, connectionUrl, result);
321
- const count = result.status.entityCount ?? result.status.tableCount;
322
- return {
323
- success: result.status.connected,
324
- pingMs: result.status.pingMs,
325
- engine: result.status.engine,
326
- entityCount: count,
327
- tableCount: count,
328
- maskedUrl: maskConnectionString(connectionUrl),
329
- error: result.status.error,
330
- };
331
- }
332
- /** Writes structure-only table metadata into .ai/db_schema.json, preserving other contract fields. */
333
- export function writeContractSchema(cwd, status, tables) {
334
- const filePath = path.join(cwd, CONTRACT_SCHEMA_PATH);
335
- let existing = {};
336
- try {
337
- existing = JSON.parse(fs.readFileSync(filePath, 'utf8'));
338
- }
339
- catch {
340
- // Missing or unreadable contract — start fresh.
341
- }
342
- const previousDescriptions = new Map();
343
- for (const t of existing.tables ?? []) {
344
- if (t?.name && t.description)
345
- previousDescriptions.set(t.name.toLowerCase(), t.description);
346
- }
347
- // Rebuild each table field-by-field so nothing beyond structural metadata can leak in.
348
- // Sampled values (media URLs, Base64 prefixes, document counts) are deliberately left out.
349
- const sanitizedTables = tables.map((t) => {
350
- const description = t.description ?? previousDescriptions.get(t.name.toLowerCase());
351
- return {
352
- name: t.name,
353
- ...(t.entityType ? { entityType: t.entityType } : {}),
354
- ...(description ? { description } : {}),
355
- columns: t.columns.map((c) => ({
356
- name: c.name,
357
- type: c.type,
358
- primaryKey: c.primaryKey,
359
- nullable: c.nullable,
360
- default: c.default,
361
- ...(c.isMedia ? { isMedia: true } : {}),
362
- ...(c.isSubcollection ? { isSubcollection: true } : {}),
363
- })),
364
- indexes: t.indexes.map((i) => ({ name: i.name, columns: [...i.columns], unique: i.unique })),
365
- foreignKeys: t.foreignKeys.map((f) => ({
366
- name: f.name,
367
- column: f.column,
368
- referencedTable: f.referencedTable,
369
- referencedColumn: f.referencedColumn,
370
- ...(f.onDelete ? { onDelete: f.onDelete } : {}),
371
- })),
372
- };
373
- });
374
- const contract = {
375
- $schema: existing.$schema ?? 'http://json-schema.org/draft-07/schema#',
376
- version: existing.version ?? '1.0.0',
377
- projectName: existing.projectName ?? path.basename(cwd),
378
- updatedAt: new Date().toISOString(),
379
- engine: status.engine,
380
- tables: sanitizedTables,
381
- migrations: existing.migrations ?? [],
382
- };
383
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
384
- fs.writeFileSync(filePath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
385
- return filePath;
386
- }
387
- async function handleExportContract(session, cwd, body) {
388
- const sourceEnv = parseEnv(body.sourceEnv, 'sourceEnv');
389
- const result = await session.introspect(sourceEnv, true);
390
- if (!result.status.connected) {
391
- throw new HttpError(409, `${sourceEnv.toUpperCase()}_NOT_CONNECTED`, result.status.error ?? 'Source database is not connected');
392
- }
393
- writeContractSchema(cwd, result.status, result.tables);
394
- return {
395
- success: true,
396
- filePath: CONTRACT_SCHEMA_PATH.split(path.sep).join('/'),
397
- exportedEntitiesCount: result.tables.length,
398
- exportedTablesCount: result.tables.length,
399
- };
400
- }
401
- const TEST_GEN_ERROR_STATUS = {
402
- INVALID_FRAMEWORK: 400,
403
- INVALID_BASE_URL: 400,
404
- INVALID_OUTPUT_PATH: 400,
405
- NO_CONTRACTS: 404,
406
- OUTPUT_CONFLICT: 409,
407
- INVALID_CONTRACT: 422,
408
- };
409
- /** POST /api/tests/generate. Output is confined to the project directory. */
410
- function handleTestGenerate(cwd, body) {
411
- const { outputDir, framework, baseUrl, dryRun } = body;
412
- if (outputDir !== undefined && typeof outputDir !== 'string') {
413
- throw new HttpError(400, 'VALIDATION_ERROR', '"outputDir" must be a string');
414
- }
415
- if (framework !== undefined && (typeof framework !== 'string' || !isTestFramework(framework))) {
416
- throw new HttpError(400, 'VALIDATION_ERROR', `"framework" must be one of: ${TEST_FRAMEWORKS.join(', ')}`);
417
- }
418
- if (baseUrl !== undefined && typeof baseUrl !== 'string') {
419
- throw new HttpError(400, 'VALIDATION_ERROR', '"baseUrl" must be a string');
420
- }
421
- if (dryRun !== undefined && typeof dryRun !== 'boolean') {
422
- throw new HttpError(400, 'VALIDATION_ERROR', '"dryRun" must be a boolean');
423
- }
424
- if (outputDir) {
425
- const rel = path.relative(cwd, path.resolve(cwd, outputDir));
426
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
427
- throw new HttpError(400, 'VALIDATION_ERROR', '"outputDir" must be a subdirectory of the project');
428
- }
429
- }
430
- try {
431
- return generateContractTests({ targetDir: cwd, outputDir, framework, baseUrl, dryRun });
432
- }
433
- catch (err) {
434
- if (err instanceof TestGenError)
435
- throw new HttpError(TEST_GEN_ERROR_STATUS[err.code] ?? 400, err.code, err.message);
436
- throw err;
437
- }
438
- }
439
- function logProdAudit(cwd, action, entity, primaryKey, details, req) {
440
- try {
441
- const entry = {
442
- timestamp: new Date().toISOString(),
443
- env: 'prod',
444
- action,
445
- entity,
446
- primaryKey,
447
- clientIp: req.socket.remoteAddress ?? '127.0.0.1',
448
- details,
449
- };
450
- const dir = path.join(cwd, '.nativ');
451
- fs.mkdirSync(dir, { recursive: true });
452
- const logFile = path.join(dir, 'prod_audit.log');
453
- fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`, 'utf8');
454
- const legacyDir = path.join(cwd, '.agentj');
455
- if (fs.existsSync(legacyDir)) {
456
- try {
457
- fs.appendFileSync(path.join(legacyDir, 'prod_audit.log'), `${JSON.stringify(entry)}\n`, 'utf8');
458
- }
459
- catch { }
460
- }
461
- }
462
- catch (err) {
463
- console.error('Failed to append to prod_audit.log:', err);
464
- }
465
- }
466
- async function handleDataGet(session, searchParams) {
467
- const env = parseEnv(searchParams.get('env'), 'env');
468
- const entity = searchParams.get('entity')?.trim();
469
- if (!entity)
470
- throw new HttpError(400, 'VALIDATION_ERROR', '"entity" query parameter is required');
471
- const page = searchParams.has('page') ? parseInt(searchParams.get('page'), 10) : undefined;
472
- const limit = searchParams.has('limit') ? parseInt(searchParams.get('limit'), 10) : undefined;
473
- const sort = searchParams.get('sort')?.trim() || undefined;
474
- const rawOrder = searchParams.get('order')?.toLowerCase();
475
- const order = rawOrder === 'desc' ? 'desc' : rawOrder === 'asc' ? 'asc' : undefined;
476
- const search = searchParams.get('search')?.trim() || undefined;
477
- const url = session.getUrl(env);
478
- if (!url) {
479
- throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
480
- }
481
- try {
482
- return await fetchData(url, entity, { page, limit, sort, order, search });
483
- }
484
- catch (err) {
485
- throw new HttpError(500, 'DATA_QUERY_ERROR', err.message);
486
- }
487
- }
488
- async function handleDataPost(session, cwd, body, req) {
489
- const env = parseEnv(body.env, 'env');
490
- const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
491
- if (!entity)
492
- throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
493
- if (typeof body.record !== 'object' || body.record === null || Array.isArray(body.record)) {
494
- throw new HttpError(400, 'VALIDATION_ERROR', '"record" must be an object');
495
- }
496
- const record = body.record;
497
- if (env === 'prod') {
498
- if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
499
- throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production insert requires confirmProd=true and challengePhrase matching "${entity}"`);
500
- }
501
- }
502
- const url = session.getUrl(env);
503
- if (!url) {
504
- throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
505
- }
506
- try {
507
- const result = await insertRecord(url, entity, record);
508
- if (env === 'prod') {
509
- logProdAudit(cwd, 'INSERT', entity, result.insertedId, { record }, req);
510
- }
511
- return result;
512
- }
513
- catch (err) {
514
- throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
515
- }
516
- }
517
- async function handleDataPut(session, cwd, body, req) {
518
- const env = parseEnv(body.env, 'env');
519
- const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
520
- if (!entity)
521
- throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
522
- const primaryKey = body.primaryKey;
523
- if (primaryKey === undefined || primaryKey === null || primaryKey === '') {
524
- throw new HttpError(400, 'VALIDATION_ERROR', '"primaryKey" is required for updates');
525
- }
526
- if (typeof body.updates !== 'object' || body.updates === null || Array.isArray(body.updates)) {
527
- throw new HttpError(400, 'VALIDATION_ERROR', '"updates" must be an object');
528
- }
529
- const updates = body.updates;
530
- if (env === 'prod') {
531
- if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
532
- throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production update requires confirmProd=true and challengePhrase matching "${entity}"`);
533
- }
534
- }
535
- const url = session.getUrl(env);
536
- if (!url) {
537
- throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
538
- }
539
- try {
540
- const result = await updateRecord(url, entity, primaryKey, updates);
541
- if (env === 'prod') {
542
- logProdAudit(cwd, 'UPDATE', entity, primaryKey, { updates }, req);
543
- }
544
- return result;
545
- }
546
- catch (err) {
547
- throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
548
- }
549
- }
550
- async function handleDataDelete(session, cwd, body, req) {
551
- const env = parseEnv(body.env, 'env');
552
- const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
553
- if (!entity)
554
- throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
555
- const primaryKey = body.primaryKey;
556
- if (primaryKey === undefined || primaryKey === null || primaryKey === '') {
557
- throw new HttpError(400, 'VALIDATION_ERROR', '"primaryKey" is required for deletion');
558
- }
559
- if (env === 'prod') {
560
- if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
561
- throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production deletion requires confirmProd=true and challengePhrase matching "${entity}"`);
562
- }
563
- }
564
- const url = session.getUrl(env);
565
- if (!url) {
566
- throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
567
- }
568
- try {
569
- const result = await deleteRecord(url, entity, primaryKey);
570
- if (env === 'prod') {
571
- logProdAudit(cwd, 'DELETE', entity, primaryKey, {}, req);
572
- }
573
- return result;
574
- }
575
- catch (err) {
576
- throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
577
- }
578
- }
579
- // ─── Pipeline (Mission Control) ────────────────────────────────────────────────
580
- const AI_DIR = '.ai';
581
- const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
582
- const MAX_REASON_LENGTH = 1000;
583
- const MAX_ERROR_OUTPUT_CHARS = 4000;
584
- const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
585
- export const DEFAULT_HEARTBEAT_MS = 15_000;
586
- const EVENT_DEBOUNCE_MS = 100;
587
- const WATCH_POLL_INTERVAL_MS = 1000;
588
- /** Files in .ai/ that drive /api/events, mapped to the event a change emits. Anything else (tmp/lock files) is ignored. */
589
- const WATCHED_FILES = new Map([
590
- ['master_plan.json', 'plan_change'],
591
- ['escalation.json', 'plan_change'],
592
- ['.governor_ledger.json', 'plan_change'],
593
- ['context.md', 'plan_change'],
594
- ['db_schema.json', 'plan_change'],
595
- ['api_contracts.json', 'plan_change'],
596
- ['ui_specs.md', 'plan_change'],
597
- ['telemetry.json', 'telemetry_change'],
598
- ['benchmark_report.json', 'telemetry_change'],
599
- ]);
600
- const CONTRACT_FILES = {
601
- masterPlanExists: 'master_plan.json',
602
- contextExists: 'context.md',
603
- dbSchemaExists: 'db_schema.json',
604
- apiContractsExists: 'api_contracts.json',
605
- uiSpecsExists: 'ui_specs.md',
606
- };
607
- const TASK_ACTION_STATUS = { start: 'in_progress', complete: 'completed', block: 'blocked' };
608
- let cachedVersion = null;
609
- function packageVersion() {
610
- if (cachedVersion === null) {
611
- try {
612
- const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
613
- cachedVersion = pkg.version ?? 'unknown';
614
- }
615
- catch {
616
- cachedVersion = 'unknown';
617
- }
618
- }
619
- return cachedVersion;
620
- }
621
- function finiteOr(value, fallback) {
622
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
623
- }
624
- function percent(part, total) {
625
- return total > 0 ? Math.round((part / total) * 100) : 0;
626
- }
627
- function roundUsd(value) {
628
- return Math.round(value * 1_000_000) / 1_000_000;
629
- }
630
- /** Telemetry is hand-editable JSON: coerce every counter so one bad record cannot NaN the totals. */
631
- function sanitizeUsage(value) {
632
- if (!value || typeof value !== 'object')
633
- return null;
634
- const u = value;
635
- const n = (v) => Math.max(0, finiteOr(v, 0));
636
- return {
637
- model: typeof u.model === 'string' ? u.model : 'unknown',
638
- turns: n(u.turns),
639
- inputTokens: n(u.inputTokens),
640
- outputTokens: n(u.outputTokens),
641
- cacheCreationTokens: n(u.cacheCreationTokens),
642
- cacheReadTokens: n(u.cacheReadTokens),
643
- thinkingTokens: n(u.thinkingTokens),
644
- costUsd: n(u.costUsd),
645
- };
646
- }
647
- function aggregateActualUsage(records) {
648
- let total = emptyActualUsage('');
649
- for (const record of records) {
650
- const usage = sanitizeUsage(record?.actualUsage);
651
- if (usage)
652
- total = mergeActualUsage(total, usage);
653
- }
654
- return total;
655
- }
656
- /** Usage as served by the API: the stored counters plus the derived cache hit rate. */
657
- function usageView(usage) {
658
- return usage ? { ...usage, costUsd: roundUsd(usage.costUsd), cacheHitRate: cacheHitRate(usage) } : null;
659
- }
660
- function truncateOutput(text) {
661
- return text.length > MAX_ERROR_OUTPUT_CHARS ? `${text.slice(0, MAX_ERROR_OUTPUT_CHARS)}…` : text;
662
- }
663
- function readPlan(root) {
664
- return loadPlan(path.join(root, AI_DIR, 'master_plan.json'), { silent: true, retries: 3 });
665
- }
666
- /** Milestones with a usable task list; tolerates hand-edited or half-written plans. */
667
- function planMilestones(plan) {
668
- const milestones = plan?.milestones;
669
- if (!Array.isArray(milestones))
670
- return [];
671
- return milestones.filter((m) => m && Array.isArray(m.tasks));
672
- }
673
- function findTask(plan, taskId) {
674
- for (const m of planMilestones(plan)) {
675
- const task = m.tasks.find((t) => t.id === taskId);
676
- if (task)
677
- return task;
678
- }
679
- return null;
680
- }
681
- /** Task ids reach git branch names and shell commands in the worktree handlers, so the charset is strict. */
682
- function parseTaskId(value) {
683
- const taskId = typeof value === 'string' ? value.trim() : '';
684
- if (!TASK_ID_PATTERN.test(taskId)) {
685
- throw new HttpError(400, 'VALIDATION_ERROR', '"taskId" must be a task id (letters, digits, ".", "_" or "-")');
686
- }
687
- return taskId;
688
- }
689
- // The CLI command handlers report through console.* and process.exitCode, which are process-global,
690
- // so captured runs are serialized (same approach as the MCP server).
691
- let captureQueue = Promise.resolve();
692
- function runCaptured(fn) {
693
- const run = async () => {
694
- const lines = [];
695
- const original = { log: console.log, error: console.error, warn: console.warn, info: console.info };
696
- const previousExitCode = process.exitCode;
697
- process.exitCode = undefined;
698
- console.log = console.error = console.warn = console.info = (...args) => {
699
- lines.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
700
- };
701
- try {
702
- const result = await fn();
703
- const failed = process.exitCode !== undefined && process.exitCode !== 0;
704
- return { result, failed, output: lines.join('\n').replace(ANSI_PATTERN, '').trim() };
705
- }
706
- finally {
707
- Object.assign(console, original);
708
- process.exitCode = previousExitCode;
709
- }
710
- };
711
- const result = captureQueue.then(run, run);
712
- captureQueue = result.catch(() => undefined);
713
- return result;
714
- }
715
- /** `--json` CLI handlers print `{ success: false, error }` on failure. */
716
- function jsonErrorMessage(output) {
717
- try {
718
- const parsed = JSON.parse(output);
719
- return typeof parsed?.error === 'string' ? parsed.error : null;
720
- }
721
- catch {
722
- return null;
723
- }
724
- }
725
- /** GET /api/pipeline/status: contract matrix, milestone/task burndown and telemetry KPIs. */
726
- function handlePipelineStatus(root) {
727
- const aiDir = path.join(root, AI_DIR);
728
- const contracts = Object.fromEntries(Object.entries(CONTRACT_FILES).map(([key, file]) => [key, fs.existsSync(path.join(aiDir, file))]));
729
- const milestones = planMilestones(readPlan(root));
730
- const tasks = milestones.flatMap((m) => m.tasks);
731
- const count = (items, status) => items.filter((i) => i.status === status).length;
732
- const completedTasks = count(tasks, 'completed');
733
- const { summary, tasks: trackedTasks } = loadTelemetry(path.join(aiDir, 'telemetry.json'));
734
- const actual = aggregateActualUsage(trackedTasks);
735
- return {
736
- ok: true,
737
- pipeline: {
738
- version: packageVersion(),
739
- contracts,
740
- milestones: {
741
- total: milestones.length,
742
- completed: count(milestones, 'completed'),
743
- inProgress: count(milestones, 'in_progress'),
744
- pending: count(milestones, 'pending'),
745
- },
746
- tasks: {
747
- total: tasks.length,
748
- completed: completedTasks,
749
- inProgress: count(tasks, 'in_progress'),
750
- pending: count(tasks, 'pending'),
751
- blocked: count(tasks, 'blocked'),
752
- progressPercentage: percent(completedTasks, tasks.length),
753
- },
754
- telemetry: {
755
- totalTokens: finiteOr(summary?.estimatedTotalTokens, 0),
756
- estimatedCostUsd: finiteOr(summary?.estimatedTotalCostUsd, 0),
757
- // Grounded in API-reported usage (native runs), summed from the task records so a
758
- // stale summary written by an older nativ can never under-report spend.
759
- actualSpendUsd: roundUsd(actual.costUsd),
760
- cacheReadTokens: actual.cacheReadTokens,
761
- cacheCreationTokens: actual.cacheCreationTokens,
762
- thinkingTokens: actual.thinkingTokens,
763
- cacheHitRate: cacheHitRate(actual),
764
- totalTasksTracked: trackedTasks.length,
765
- // Ratio in [0, 1], as stored in telemetry.json.
766
- passRate: finiteOr(summary?.verificationPassRate, 1),
767
- circuitBreakerTrips: finiteOr(summary?.circuitBreakerTrips, 0),
768
- },
769
- },
770
- };
771
- }
772
- /** GET /api/pipeline/tasks: milestones with per-task dependency readiness and circuit-breaker attempts. */
773
- function handlePipelineTasks(root) {
774
- const plan = readPlan(root);
775
- const milestones = planMilestones(plan);
776
- const completedIds = new Set(milestones.flatMap((m) => m.tasks).filter((t) => t.status === 'completed').map((t) => t.id));
777
- return {
778
- ok: true,
779
- projectName: plan?.projectName ?? null,
780
- overallStatus: plan?.overallStatus ?? null,
781
- activeMilestoneId: plan?.activeMilestoneId ?? null,
782
- milestones: milestones.map((m) => ({
783
- ...m,
784
- progressPercentage: percent(m.tasks.filter((t) => t.status === 'completed').length, m.tasks.length),
785
- tasks: m.tasks.map((t) => ({
786
- ...t,
787
- // Same readiness rule as `nativ task list --available`.
788
- isAvailable: (t.status === 'pending' || t.status === 'in_progress') &&
789
- (t.dependencies ?? []).every((d) => completedIds.has(d)),
790
- circuitBreaker: CircuitBreaker.getStatus(root, t.id),
791
- })),
792
- })),
793
- };
794
- }
795
- /** POST /api/pipeline/tasks/action: the same lock-guarded transitions as `nativ task start|complete|block`. */
796
- async function handleTaskAction(root, body) {
797
- const { action } = body;
798
- if (typeof action !== 'string' || !Object.hasOwn(TASK_ACTION_STATUS, action)) {
799
- throw new HttpError(400, 'VALIDATION_ERROR', `"action" must be one of: ${Object.keys(TASK_ACTION_STATUS).join(', ')}`);
800
- }
801
- const taskAction = action;
802
- const taskId = parseTaskId(body.taskId);
803
- if (body.reason !== undefined && body.reason !== null && typeof body.reason !== 'string') {
804
- throw new HttpError(400, 'VALIDATION_ERROR', '"reason" must be a string');
805
- }
806
- const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
807
- if (reason.length > MAX_REASON_LENGTH) {
808
- throw new HttpError(400, 'VALIDATION_ERROR', `"reason" must be at most ${MAX_REASON_LENGTH} characters`);
809
- }
810
- if (taskAction === 'block' && !reason)
811
- throw new HttpError(400, 'VALIDATION_ERROR', '"reason" is required when blocking a task');
812
- const task = findTask(readPlan(root), taskId);
813
- if (!task)
814
- throw new HttpError(400, 'TASK_NOT_FOUND', `Task "${taskId}" not found in .ai/master_plan.json`);
815
- if (task.status === TASK_ACTION_STATUS[taskAction]) {
816
- throw new HttpError(400, 'INVALID_TRANSITION', `Task "${taskId}" is already ${task.status}`);
817
- }
818
- // `complete` runs the task's verificationCommand as a gatekeeper, exactly like the CLI.
819
- const { failed, output } = await runCaptured(async () => {
820
- if (taskAction === 'start')
821
- await runTaskStart(taskId, root);
822
- else if (taskAction === 'complete')
823
- await runTaskComplete(taskId, root);
824
- else
825
- await runTaskBlock(taskId, reason, root);
826
- });
827
- if (failed)
828
- throw new HttpError(400, 'TASK_ACTION_FAILED', truncateOutput(output || `Task ${taskAction} failed`));
829
- return { ok: true, task: findTask(readPlan(root), taskId) ?? task };
830
- }
831
- function git(cwd, args) {
832
- return new Promise((resolve, reject) => {
833
- execFile('git', args, { cwd, timeout: 10_000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout) => err ? reject(err) : resolve(stdout));
834
- });
835
- }
836
- /** Async twin of `nativ worktree list --json` (which blocks on execSync and prints to the console). */
837
- async function listGitWorktrees(root) {
838
- let raw;
839
- try {
840
- raw = await git(root, ['worktree', 'list', '--porcelain']);
841
- }
842
- catch {
843
- return []; // Not a git repository, or git is unavailable.
844
- }
845
- const worktrees = [];
846
- for (const block of raw.trim().split(/\r?\n\r?\n/)) {
847
- let wtPath = '';
848
- let head = '';
849
- let branch = '';
850
- for (const line of block.split(/\r?\n/)) {
851
- if (line.startsWith('worktree '))
852
- wtPath = line.slice(9).trim();
853
- else if (line.startsWith('HEAD '))
854
- head = line.slice(5).trim();
855
- else if (line.startsWith('branch '))
856
- branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
857
- }
858
- if (!wtPath)
859
- continue;
860
- worktrees.push({
861
- path: wtPath,
862
- head,
863
- branch: branch || 'detached',
864
- isAgentWorktree: wtPath.includes('.worktrees') || branch.startsWith('agent/task-'),
865
- taskId: /^agent\/task-(.+)$/.exec(branch)?.[1],
866
- });
867
- }
868
- return worktrees;
869
- }
870
- /**
871
- * GET /api/pipeline/worktrees. Merge eligibility mirrors the `nativ worktree merge` gatekeeper (task completed,
872
- * circuit breaker not tripped), and additionally requires the task to be declared in the plan.
873
- */
874
- async function describeWorktrees(root) {
875
- const statusById = new Map(planMilestones(readPlan(root)).flatMap((m) => m.tasks.map((t) => [t.id, t.status])));
876
- return (await listGitWorktrees(root)).map((wt) => {
877
- const taskStatus = (wt.taskId && statusById.get(wt.taskId)) || null;
878
- let mergeBlockedReason = null;
879
- if (!wt.isAgentWorktree || !wt.taskId)
880
- mergeBlockedReason = 'Not an agent task worktree';
881
- else if (!taskStatus)
882
- mergeBlockedReason = `Task "${wt.taskId}" is not declared in .ai/master_plan.json`;
883
- else if (taskStatus !== 'completed')
884
- mergeBlockedReason = `Task status is '${taskStatus}'; only completed tasks may be merged`;
885
- else if (CircuitBreaker.getStatus(root, wt.taskId).tripped)
886
- mergeBlockedReason = 'Circuit breaker is tripped; resolve the escalation before merging';
887
- return { ...wt, taskStatus, mergeEligible: mergeBlockedReason === null, mergeBlockedReason };
888
- });
889
- }
890
- /** POST /api/pipeline/worktrees/action: `nativ worktree merge|remove`, never forced past the merge gatekeeper. */
891
- async function handleWorktreeAction(root, body) {
892
- const { action } = body;
893
- if (action !== 'merge' && action !== 'remove')
894
- throw new HttpError(400, 'VALIDATION_ERROR', '"action" must be one of: merge, remove');
895
- const taskId = parseTaskId(body.taskId);
896
- const worktree = (await describeWorktrees(root)).find((wt) => wt.isAgentWorktree && wt.taskId === taskId);
897
- if (!worktree)
898
- throw new HttpError(400, 'WORKTREE_NOT_FOUND', `No agent worktree found for task "${taskId}"`);
899
- if (action === 'merge' && !worktree.mergeEligible) {
900
- throw new HttpError(400, 'MERGE_REJECTED', worktree.mergeBlockedReason ?? 'Merge rejected by the gatekeeper');
901
- }
902
- const { result, failed, output } = await runCaptured(() => action === 'merge' ? runWorktreeMerge(taskId, root, { json: true }) : runWorktreeRemove(taskId, root, { json: true }));
903
- if (failed || !result) {
904
- throw new HttpError(400, 'WORKTREE_ACTION_FAILED', jsonErrorMessage(output) ?? truncateOutput(output || `Worktree ${action} failed`));
905
- }
906
- return { ok: true, message: result.message };
907
- }
908
- /** GET /api/pipeline/worktrees/diff: uncommitted changes and unified diff for an agent worktree. */
909
- async function handleWorktreeDiff(root, searchParams) {
910
- const rawTaskId = searchParams.get('taskId');
911
- if (!rawTaskId)
912
- throw new HttpError(400, 'VALIDATION_ERROR', '"taskId" query parameter is required');
913
- const taskId = parseTaskId(rawTaskId);
914
- const worktree = (await describeWorktrees(root)).find((wt) => wt.isAgentWorktree && wt.taskId === taskId);
915
- if (!worktree || !fs.existsSync(worktree.path)) {
916
- throw new HttpError(400, 'WORKTREE_NOT_FOUND', `No agent worktree found for task "${taskId}"`);
917
- }
918
- let statusOut;
919
- try {
920
- // -z keeps paths unquoted and unambiguous; -uall lists the files inside untracked directories.
921
- statusOut = await git(worktree.path, ['status', '--porcelain', '-z', '--untracked-files=all']);
922
- }
923
- catch {
924
- throw new HttpError(400, 'WORKTREE_INVALID', `Failed to read git status in the worktree for task "${taskId}"`);
925
- }
926
- const filesChanged = [];
927
- const untracked = [];
928
- const entries = statusOut.split('\0');
929
- for (let i = 0; i < entries.length; i++) {
930
- const entry = entries[i];
931
- if (entry.length < 4)
932
- continue;
933
- const xy = entry.slice(0, 2);
934
- const file = entry.slice(3);
935
- filesChanged.push(file);
936
- if (xy === '??')
937
- untracked.push(file);
938
- // Renames and copies carry the original path in the next field.
939
- if (xy[0] === 'R' || xy[0] === 'C')
940
- i++;
941
- }
942
- let diff = '';
943
- try {
944
- diff = await git(worktree.path, ['diff', 'HEAD']);
945
- }
946
- catch {
947
- diff = await git(worktree.path, ['diff']).catch(() => '');
948
- }
949
- // `git diff HEAD` skips untracked files; render them as additions without touching the index.
950
- for (const file of untracked) {
951
- diff += (await gitNoIndexDiff(worktree.path, file)) || `+++ b/${file} (untracked)\n`;
952
- }
953
- return {
954
- ok: true,
955
- taskId,
956
- branch: worktree.branch,
957
- hasChanges: filesChanged.length > 0,
958
- filesChanged,
959
- diff: diff.trim(),
960
- };
961
- }
962
- /** `git diff --no-index` exits 1 when the inputs differ, so stdout is also read from that "error". */
963
- function gitNoIndexDiff(cwd, file) {
964
- return new Promise((resolve) => {
965
- execFile('git', ['diff', '--no-index', '--', '/dev/null', file], { cwd, timeout: 10_000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout) => resolve(!err || err.code === 1 ? String(stdout) : ''));
966
- });
967
- }
968
- // ─── Autonomous agent dispatch (/api/pipeline/tasks/dispatch|abort|runs|logs) ──
969
- const MAX_RUNNER_COMMAND_LENGTH = 2000;
970
- const MAX_TIMEOUT_SECONDS = 24 * 60 * 60;
971
- const DEFAULT_TAIL_LINES = 200;
972
- const MAX_TAIL_LINES = 5000;
973
- /** Supervisor guard rails (unmet dependencies, already running, unknown task) map to the contract's 400 envelope. */
974
- function toHttpError(err) {
975
- if (err instanceof SupervisorError)
976
- throw new HttpError(err.status, err.code, err.message);
977
- throw err;
978
- }
979
- function parseRunnerCommand(value) {
980
- if (value === undefined || value === null || value === '')
981
- return undefined;
982
- if (typeof value !== 'string')
983
- throw new HttpError(400, 'VALIDATION_ERROR', '"runnerCommand" must be a string');
984
- const command = value.trim();
985
- if (!command)
986
- throw new HttpError(400, 'VALIDATION_ERROR', '"runnerCommand" must not be empty');
987
- if (command.length > MAX_RUNNER_COMMAND_LENGTH) {
988
- throw new HttpError(400, 'VALIDATION_ERROR', `"runnerCommand" must be at most ${MAX_RUNNER_COMMAND_LENGTH} characters`);
989
- }
990
- return command;
991
- }
992
- function parseTimeoutSeconds(value) {
993
- if (value === undefined || value === null)
994
- return undefined;
995
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_SECONDS) {
996
- throw new HttpError(400, 'VALIDATION_ERROR', `"timeoutSeconds" must be a number between 1 and ${MAX_TIMEOUT_SECONDS}`);
997
- }
998
- return Math.floor(value);
999
- }
1000
- function parseOptionalBoolean(value, field) {
1001
- if (value === undefined || value === null)
1002
- return undefined;
1003
- if (typeof value !== 'boolean')
1004
- throw new HttpError(400, 'VALIDATION_ERROR', `"${field}" must be a boolean`);
1005
- return value;
1006
- }
1007
- /** Query strings carry booleans as text: `?activeOnly=1|true|yes`. */
1008
- function parseBooleanParam(value) {
1009
- return value !== null && ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
1010
- }
1011
- function parseTailLines(value) {
1012
- if (value === null || value === '')
1013
- return DEFAULT_TAIL_LINES;
1014
- const tail = Number(value);
1015
- if (!Number.isFinite(tail) || tail <= 0 || tail > MAX_TAIL_LINES) {
1016
- throw new HttpError(400, 'VALIDATION_ERROR', `"tailLines" must be a number between 1 and ${MAX_TAIL_LINES}`);
1017
- }
1018
- return Math.floor(tail);
1019
- }
1020
- const MODEL_ID_PATTERN = /^[A-Za-z0-9][\w.:@-]{0,127}$/;
1021
- const MAX_THINKING_BUDGET = 1_000_000;
1022
- function parseRunnerEngine(value) {
1023
- if (value === undefined || value === null || value === '')
1024
- return undefined;
1025
- if (value !== 'native' && value !== 'cli')
1026
- throw new HttpError(400, 'VALIDATION_ERROR', `"runnerEngine" must be 'native' or 'cli'`);
1027
- return value;
1028
- }
1029
- function parseThinkingBudget(value) {
1030
- if (value === undefined || value === null)
1031
- return undefined;
1032
- // 0 is valid: "as little thinking as the model allows" (the Studio's None chip).
1033
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > MAX_THINKING_BUDGET) {
1034
- throw new HttpError(400, 'VALIDATION_ERROR', `"thinkingBudget" must be a number between 0 and ${MAX_THINKING_BUDGET}`);
1035
- }
1036
- return Math.floor(value);
1037
- }
1038
- function parseModel(value) {
1039
- if (value === undefined || value === null || value === '')
1040
- return undefined;
1041
- if (typeof value !== 'string' || !MODEL_ID_PATTERN.test(value.trim())) {
1042
- throw new HttpError(400, 'VALIDATION_ERROR', '"model" must be a model ID such as claude-opus-5-5');
1043
- }
1044
- return value.trim();
1045
- }
1046
- /**
1047
- * POST /api/pipeline/tasks/dispatch: hands a task to a background runner, either the native
1048
- * Messages API engine or a CLI command (Claude Code by default), in an isolated worktree.
1049
- */
1050
- async function handleTaskDispatch(supervisor, body) {
1051
- const taskId = parseTaskId(body.taskId);
1052
- const runnerEngine = parseRunnerEngine(body.runnerEngine);
1053
- const runnerCommand = parseRunnerCommand(body.runnerCommand);
1054
- const thinkingBudget = parseThinkingBudget(body.thinkingBudget);
1055
- const model = parseModel(body.model);
1056
- const timeoutSeconds = parseTimeoutSeconds(body.timeoutSeconds);
1057
- const useWorktree = parseOptionalBoolean(body.useWorktree, 'useWorktree');
1058
- const verify = parseOptionalBoolean(body.verify, 'verify');
1059
- const autoMerge = parseOptionalBoolean(body.autoMerge, 'autoMerge');
1060
- let run;
1061
- try {
1062
- run = await supervisor.dispatch({
1063
- taskId,
1064
- runnerEngine,
1065
- runnerCommand,
1066
- thinkingBudget,
1067
- model,
1068
- timeoutSeconds,
1069
- useWorktree,
1070
- verify,
1071
- autoMerge,
1072
- });
1073
- }
1074
- catch (err) {
1075
- toHttpError(err);
1076
- }
1077
- return { ok: true, run: runView(run) };
1078
- }
1079
- /** Runner records as served by the API: usage gains its derived cache hit rate. */
1080
- function runView(run) {
1081
- return { ...run, usage: usageView(sanitizeUsage(run.usage)) };
1082
- }
1083
- /** POST /api/pipeline/tasks/abort: terminates the runner's process tree and settles the run as aborted. */
1084
- async function handleTaskAbort(supervisor, body) {
1085
- const taskId = parseTaskId(body.taskId);
1086
- if (body.reason !== undefined && body.reason !== null && typeof body.reason !== 'string') {
1087
- throw new HttpError(400, 'VALIDATION_ERROR', '"reason" must be a string');
1088
- }
1089
- const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
1090
- if (reason.length > MAX_REASON_LENGTH) {
1091
- throw new HttpError(400, 'VALIDATION_ERROR', `"reason" must be at most ${MAX_REASON_LENGTH} characters`);
1092
- }
1093
- try {
1094
- const { status, message } = await supervisor.abort(taskId, reason || undefined);
1095
- return { ok: true, taskId, status, message };
1096
- }
1097
- catch (err) {
1098
- toHttpError(err);
1099
- }
1100
- }
1101
- /** GET /api/pipeline/tasks/runs: active and recent runs, newest first. */
1102
- function handleTaskRuns(supervisor, searchParams) {
1103
- const rawTaskId = searchParams.get('taskId');
1104
- const taskId = rawTaskId === null || rawTaskId === '' ? undefined : parseTaskId(rawTaskId);
1105
- const runs = supervisor.listRuns({ taskId, activeOnly: parseBooleanParam(searchParams.get('activeOnly')) });
1106
- return { ok: true, runs: runs.map(runView) };
1107
- }
1108
- /** GET /api/pipeline/tasks/logs: tail of a runner's streaming log buffer. */
1109
- function handleTaskLogs(supervisor, searchParams) {
1110
- const taskId = parseTaskId(searchParams.get('taskId'));
1111
- const { status, totalBytes, log, runId, truncated } = supervisor.getLogs(taskId, parseTailLines(searchParams.get('tailLines')));
1112
- if (status === null)
1113
- throw new HttpError(400, 'RUN_NOT_FOUND', `No runner history for task "${taskId}"`);
1114
- return { ok: true, taskId, runId, status, totalBytes, log, truncated };
1115
- }
1116
- /** GET /api/pipeline/benchmarks: the cached report written by `nativ bench` or benchmarks/run. */
1117
- function handleBenchmarks(root) {
1118
- let report = null;
1119
- try {
1120
- const parsed = JSON.parse(fs.readFileSync(path.join(root, AI_DIR, 'benchmark_report.json'), 'utf8'));
1121
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
1122
- report = parsed;
1123
- }
1124
- catch {
1125
- // No benchmark has been run yet, or the report is unreadable.
1126
- }
1127
- return { ok: true, report };
1128
- }
1129
- // ─── Grounded telemetry & self-healing escalations ─────────────────────────────
1130
- /**
1131
- * GET /api/pipeline/telemetry/detailed: per-task financial audit. Each breakdown pairs the heuristic
1132
- * estimate with API-reported usage and lists the task's runs (engine, model, per-run usage).
1133
- */
1134
- function handleTelemetryDetailed(root, supervisor) {
1135
- const telemetry = loadTelemetry(path.join(root, AI_DIR, 'telemetry.json'));
1136
- const records = telemetry.tasks.filter((t) => Boolean(t) && typeof t.taskId === 'string');
1137
- const planTasks = new Map(planMilestones(readPlan(root)).flatMap((m) => m.tasks).map((t) => [t.id, t]));
1138
- const runsByTask = new Map();
1139
- for (const run of supervisor.listRuns()) {
1140
- const list = runsByTask.get(run.taskId) ?? [];
1141
- list.push(run);
1142
- runsByTask.set(run.taskId, list);
1143
- }
1144
- const taskIds = [...new Set([...records.map((r) => r.taskId), ...runsByTask.keys()])];
1145
- const taskBreakdowns = taskIds
1146
- .map((taskId) => {
1147
- const record = records.find((r) => r.taskId === taskId);
1148
- const planTask = planTasks.get(taskId);
1149
- const actual = sanitizeUsage(record?.actualUsage);
1150
- const estimate = record?.tokens;
1151
- const estimated = estimate
1152
- ? {
1153
- inputTokens: finiteOr(estimate.inputEstimated, 0),
1154
- outputTokens: finiteOr(estimate.outputEstimated, 0),
1155
- totalTokens: finiteOr(estimate.totalEstimated, 0),
1156
- costUsd: finiteOr(estimate.costUsdEstimated, 0),
1157
- }
1158
- : null;
1159
- return {
1160
- taskId,
1161
- title: record?.title ?? planTask?.title ?? taskId,
1162
- assignedSubagent: record?.assignedSubagent ?? planTask?.assignedSubagent ?? null,
1163
- status: planTask?.status ?? record?.status ?? null,
1164
- startedAt: record?.startedAt ?? null,
1165
- completedAt: record?.completedAt ?? null,
1166
- durationMs: finiteOr(record?.durationMs, 0),
1167
- verification: record?.verification ?? null,
1168
- estimated,
1169
- actual: usageView(actual),
1170
- // Positive: the task cost more than estimated.
1171
- varianceUsd: actual && estimated ? roundUsd(actual.costUsd - estimated.costUsd) : null,
1172
- runs: (runsByTask.get(taskId) ?? []).map((run) => ({
1173
- runId: run.runId,
1174
- engine: run.engine,
1175
- model: run.model,
1176
- status: run.status,
1177
- startedAt: run.startedAt,
1178
- endedAt: run.endedAt,
1179
- durationMs: run.durationMs,
1180
- thinking: run.thinking,
1181
- usage: usageView(sanitizeUsage(run.usage)),
1182
- })),
1183
- };
1184
- })
1185
- // Most expensive first; tasks without grounded usage keep their telemetry order at the end.
1186
- .sort((a, b) => (b.actual?.costUsd ?? -1) - (a.actual?.costUsd ?? -1));
1187
- const byModel = new Map();
1188
- for (const record of records) {
1189
- const usage = sanitizeUsage(record.actualUsage);
1190
- if (!usage)
1191
- continue;
1192
- byModel.set(usage.model, mergeActualUsage(byModel.get(usage.model) ?? emptyActualUsage(usage.model), usage));
1193
- }
1194
- const { summary } = telemetry;
1195
- const actual = aggregateActualUsage(records);
1196
- return {
1197
- ok: true,
1198
- summary: {
1199
- projectName: telemetry.projectName,
1200
- lastUpdated: telemetry.lastUpdated,
1201
- modelTierDefault: telemetry.modelTierDefault,
1202
- tasksTracked: records.length,
1203
- totalTasksCompleted: finiteOr(summary?.totalTasksCompleted, 0),
1204
- totalDurationMs: finiteOr(summary?.totalDurationMs, 0),
1205
- estimated: {
1206
- totalTokens: finiteOr(summary?.estimatedTotalTokens, 0),
1207
- costUsd: finiteOr(summary?.estimatedTotalCostUsd, 0),
1208
- },
1209
- actual: {
1210
- turns: actual.turns,
1211
- inputTokens: actual.inputTokens,
1212
- outputTokens: actual.outputTokens,
1213
- cacheReadTokens: actual.cacheReadTokens,
1214
- cacheCreationTokens: actual.cacheCreationTokens,
1215
- thinkingTokens: actual.thinkingTokens,
1216
- spendUsd: roundUsd(actual.costUsd),
1217
- cacheHitRate: cacheHitRate(actual),
1218
- // What the cache reads would have cost at each model's full input rate, minus what they did cost.
1219
- cacheSavingsUsd: roundUsd([...byModel.values()].reduce((sum, u) => {
1220
- const rates = resolveModelPricing(u.model);
1221
- return sum + (u.cacheReadTokens * (rates.inputPerM - rates.cacheReadPerM)) / 1_000_000;
1222
- }, 0)),
1223
- },
1224
- byModel: [...byModel.values()].map(usageView).sort((a, b) => b.costUsd - a.costUsd),
1225
- verification: {
1226
- passRate: finiteOr(summary?.verificationPassRate, 1),
1227
- runs: finiteOr(summary?.totalVerificationsRun, 0),
1228
- passed: finiteOr(summary?.totalVerificationsPassed, 0),
1229
- },
1230
- circuitBreakerTrips: finiteOr(summary?.circuitBreakerTrips, 0),
1231
- },
1232
- taskBreakdowns,
1233
- };
1234
- }
1235
- const ESCALATION_FILTERS = ['pending_review', 'resolved', 'all'];
1236
- function escalationFilePath(root) {
1237
- return path.join(root, AI_DIR, 'escalation.json');
1238
- }
1239
- /** Null when no escalation has ever been filed. */
1240
- function readEscalationFile(root) {
1241
- let raw;
1242
- try {
1243
- raw = fs.readFileSync(escalationFilePath(root), 'utf8');
1244
- }
1245
- catch {
1246
- return null;
1247
- }
1248
- // withFileLock creates the file empty when it takes the first lock.
1249
- if (!raw.trim())
1250
- return null;
1251
- try {
1252
- const parsed = JSON.parse(raw);
1253
- if (!Array.isArray(parsed?.escalations))
1254
- parsed.escalations = [];
1255
- return parsed;
1256
- }
1257
- catch {
1258
- throw new HttpError(500, 'ESCALATION_FILE_CORRUPT', '.ai/escalation.json is not valid JSON');
1259
- }
1260
- }
1261
- /** tmp-file + rename so the SSE watcher and concurrent readers never observe a half-written file. */
1262
- function writeJsonAtomic(file, data) {
1263
- const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
1264
- fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
1265
- for (let attempt = 0;; attempt++) {
1266
- try {
1267
- fs.renameSync(tmp, file);
1268
- return;
1269
- }
1270
- catch (err) {
1271
- const code = err.code;
1272
- if (attempt >= 10 || (code !== 'EPERM' && code !== 'EBUSY')) {
1273
- fs.rmSync(tmp, { force: true });
1274
- throw err;
1275
- }
1276
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
1277
- }
1278
- }
1279
- }
1280
- /**
1281
- * GET /api/pipeline/escalations?status=pending_review|resolved|all: escalations newest first, each
1282
- * with its optional self-healing `proposedPatch`. `resolved` covers every closed record (approved or rejected).
1283
- */
1284
- function handleEscalations(root, searchParams) {
1285
- const rawFilter = searchParams.get('status') || 'all';
1286
- if (!ESCALATION_FILTERS.includes(rawFilter)) {
1287
- throw new HttpError(400, 'VALIDATION_ERROR', `"status" must be one of: ${ESCALATION_FILTERS.join(', ')}`);
1288
- }
1289
- const filter = rawFilter;
1290
- const escalations = (readEscalationFile(root)?.escalations ?? [])
1291
- .filter((e) => e && typeof e.id === 'string')
1292
- .filter((e) => filter === 'all' || (filter === 'pending_review' ? e.status === 'pending_review' : e.status !== 'pending_review'))
1293
- .sort((a, b) => (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0));
1294
- return { ok: true, escalations };
1295
- }
1296
- /** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
1297
- async function unblockTask(root, taskId, note) {
1298
- const planPath = path.join(root, AI_DIR, 'master_plan.json');
1299
- if (!fs.existsSync(planPath))
1300
- return false;
1301
- const result = await withPlanLock(planPath, (plan, ctx) => {
1302
- const task = findTask(plan, taskId);
1303
- if (!task || task.status !== 'blocked') {
1304
- ctx.abort();
1305
- return false;
1306
- }
1307
- task.status = 'pending';
1308
- task.notes = note;
1309
- return true;
1310
- });
1311
- return result === true;
1312
- }
1313
- /** Test restorations run where the task's files live: its agent worktree if one exists, else the project. */
1314
- function taskWorkspace(root, taskId) {
1315
- const worktree = path.join(root, '.worktrees', `task-${taskId}`);
1316
- return fs.existsSync(path.join(worktree, '.git')) ? worktree : root;
1317
- }
1318
- /**
1319
- * POST /api/pipeline/escalations/resolve: `approve` applies the self-healing proposal (re-checked
1320
- * against the live contract), resets the circuit breaker and unblocks the task; `reject` dismisses
1321
- * the escalation and leaves the task blocked. A proposal that no longer applies leaves the
1322
- * escalation pending and returns 400.
1323
- */
1324
- async function handleEscalationResolve(root, body) {
1325
- const escalationId = typeof body.escalationId === 'string' ? body.escalationId.trim() : '';
1326
- if (!TASK_ID_PATTERN.test(escalationId)) {
1327
- throw new HttpError(400, 'VALIDATION_ERROR', '"escalationId" must be an escalation id such as esc-01');
1328
- }
1329
- const { decision } = body;
1330
- if (decision !== 'approve' && decision !== 'reject') {
1331
- throw new HttpError(400, 'VALIDATION_ERROR', `"decision" must be 'approve' or 'reject'`);
1332
- }
1333
- if (body.notes !== undefined && body.notes !== null && typeof body.notes !== 'string') {
1334
- throw new HttpError(400, 'VALIDATION_ERROR', '"notes" must be a string');
1335
- }
1336
- const notes = typeof body.notes === 'string' ? body.notes.trim() : '';
1337
- if (notes.length > MAX_REASON_LENGTH) {
1338
- throw new HttpError(400, 'VALIDATION_ERROR', `"notes" must be at most ${MAX_REASON_LENGTH} characters`);
1339
- }
1340
- const file = escalationFilePath(root);
1341
- if (!readEscalationFile(root))
1342
- throw new HttpError(400, 'ESCALATION_NOT_FOUND', `Escalation "${escalationId}" not found`);
1343
- // One resolution at a time per project: a double-click must not apply a proposal twice.
1344
- return withFileLock(file, async () => {
1345
- const data = readEscalationFile(root);
1346
- const record = data?.escalations.find((e) => e?.id === escalationId);
1347
- if (!data || !record)
1348
- throw new HttpError(400, 'ESCALATION_NOT_FOUND', `Escalation "${escalationId}" not found`);
1349
- if (record.status !== 'pending_review') {
1350
- throw new HttpError(400, 'ESCALATION_ALREADY_RESOLVED', `Escalation "${escalationId}" is already ${record.status}`);
1351
- }
1352
- const proposal = record.proposedPatch;
1353
- const now = new Date().toISOString();
1354
- let applied = null;
1355
- let unblockedTaskId = null;
1356
- let message;
1357
- if (decision === 'approve') {
1358
- if (proposal) {
1359
- const workspace = proposal.kind === 'restore_tests' ? taskWorkspace(root, record.taskId) : root;
1360
- applied = applySelfHealingProposal(workspace, record.taskId, proposal);
1361
- if (!applied.applied) {
1362
- throw new HttpError(400, applied.ruleId, `Proposal ${proposal.proposalId} was not applied: ${applied.message}`);
1363
- }
1364
- }
1365
- else {
1366
- CircuitBreaker.recordSuccess(root, record.taskId);
1367
- }
1368
- const unblocked = await unblockTask(root, record.taskId, `Unblocked by escalation ${escalationId}${notes ? `: ${notes}` : ''}`);
1369
- unblockedTaskId = unblocked ? record.taskId : null;
1370
- record.status = 'resolved';
1371
- record.resolutionNotes = notes || applied?.message || 'Approved by operator';
1372
- message = [
1373
- applied ? `${applied.message}.` : `Escalation ${escalationId} approved.`,
1374
- unblocked ? `Task ${record.taskId} is unblocked and pending.` : `Task ${record.taskId} was not blocked.`,
1375
- ].join(' ');
1376
- }
1377
- else {
1378
- record.status = 'dismissed';
1379
- record.resolutionNotes = notes || (proposal ? `Proposal ${proposal.proposalId} rejected by operator` : 'Rejected by operator');
1380
- message = `Escalation ${escalationId} rejected; task ${record.taskId} stays blocked.`;
1381
- }
1382
- record.resolvedAt = now;
1383
- record.resolution = {
1384
- decision,
1385
- proposalApplied: Boolean(applied?.applied),
1386
- ruleId: applied?.ruleId ?? null,
1387
- unblockedTaskId,
1388
- };
1389
- data.lastUpdated = now;
1390
- writeJsonAtomic(file, data);
1391
- return { ok: true, message, unblockedTaskId };
1392
- });
1393
- }
1394
- // ─── Tier 1 triage ─────────────────────────────────────────────────────────────
1395
- /** The contract's evaluate body; the full verdict (patch, proof, guardrails) is stored on the escalation. */
1396
- function triageView(result) {
1397
- return {
1398
- ok: true,
1399
- escalationId: result.escalationId,
1400
- provider: result.provider,
1401
- model: result.model,
1402
- source: result.source,
1403
- latencyMs: result.latencyMs,
1404
- classification: result.classification,
1405
- reasoning: result.reasoning,
1406
- autoPatchApplied: result.autoPatchApplied,
1407
- ...(result.humanCard ? { humanCard: result.humanCard } : {}),
1408
- };
1409
- }
1410
- /**
1411
- * POST /api/pipeline/triage/evaluate: runs the Tier 1 strategist on one escalation. A proven additive
1412
- * patch is written only while auto-triage is enabled, and then unblocks the task. Concurrent requests
1413
- * for the same escalation share one evaluation, so a double-click cannot apply or count twice.
1414
- */
1415
- function handleTriageEvaluate(root, liaison, inFlight, body) {
1416
- const escalationId = typeof body.escalationId === 'string' ? body.escalationId.trim() : '';
1417
- if (!TASK_ID_PATTERN.test(escalationId)) {
1418
- throw new HttpError(400, 'VALIDATION_ERROR', '"escalationId" must be an escalation id such as esc-01');
1419
- }
1420
- const running = inFlight.get(escalationId);
1421
- if (running)
1422
- return running;
1423
- const evaluation = (async () => {
1424
- const blocker = triageBlocker(root, escalationId);
1425
- if (blocker)
1426
- throw new HttpError(400, 'TRIAGE_REJECTED', blocker);
1427
- const result = await liaison.evaluate(escalationId);
1428
- if (!result.ok)
1429
- throw new HttpError(400, 'TRIAGE_REJECTED', result.error);
1430
- await finalizeTriage(root, result);
1431
- return triageView(result);
1432
- })().finally(() => inFlight.delete(escalationId));
1433
- inFlight.set(escalationId, evaluation);
1434
- return evaluation;
1435
- }
1436
- const RISK_THRESHOLDS = ['safe_contracts_only', 'all_non_destructive'];
1437
- /** POST /api/pipeline/triage/config: session-only auto-triage settings, validated before they reach the liaison. */
1438
- function handleTriageConfig(liaison, body) {
1439
- if (typeof body.autoTriageEnabled !== 'boolean') {
1440
- throw new HttpError(400, 'VALIDATION_ERROR', '"autoTriageEnabled" must be a boolean');
1441
- }
1442
- if (body.riskThreshold !== undefined && !RISK_THRESHOLDS.includes(body.riskThreshold)) {
1443
- throw new HttpError(400, 'VALIDATION_ERROR', `"riskThreshold" must be one of: ${RISK_THRESHOLDS.join(', ')}`);
1444
- }
1445
- const result = liaison.updateConfig({ autoTriageEnabled: body.autoTriageEnabled, riskThreshold: body.riskThreshold });
1446
- if (!result.ok)
1447
- throw new HttpError(400, 'VALIDATION_ERROR', result.error);
1448
- return result;
1449
- }
1450
- /**
1451
- * Fan-out for GET /api/events. Watches .ai/ only while a client is connected, coalesces write bursts
1452
- * (tmp file + atomic rename + lock files) into one event per type, and sends heartbeats so idle
1453
- * streams survive proxies and sleep/wake.
1454
- *
1455
- * Frames: `event: plan_change` / `event: telemetry_change` with `data: {"files":[...],"at":"<ISO>"}`,
1456
- * and `event: heartbeat` with `data: {"at":"<ISO>"}`.
1457
- */
1458
- class PipelineEventHub {
1459
- aiDir;
1460
- heartbeatMs;
1461
- clients = new Set();
1462
- pending = new Map();
1463
- watcher = null;
1464
- pollers = [];
1465
- heartbeat = null;
1466
- flushTimer = null;
1467
- closed = false;
1468
- constructor(aiDir, heartbeatMs) {
1469
- this.aiDir = aiDir;
1470
- this.heartbeatMs = heartbeatMs;
1471
- }
1472
- subscribe(res) {
1473
- if (this.closed) {
1474
- sendPipelineError(res, 503, 'SHUTTING_DOWN', 'Studio server is shutting down');
1475
- return;
1476
- }
1477
- res.writeHead(200, {
1478
- 'Content-Type': 'text/event-stream; charset=utf-8',
1479
- 'Cache-Control': 'no-store',
1480
- Connection: 'keep-alive',
1481
- 'X-Accel-Buffering': 'no',
1482
- 'X-Content-Type-Options': 'nosniff',
1483
- });
1484
- res.socket?.setNoDelay(true);
1485
- // Reconnect delay for EventSource; the comment line flushes the headers right away.
1486
- res.write('retry: 3000\n: connected\n\n');
1487
- this.clients.add(res);
1488
- res.on('close', () => {
1489
- this.clients.delete(res);
1490
- if (this.clients.size === 0)
1491
- this.stop();
1492
- });
1493
- this.start();
1494
- }
1495
- /** Pushes an agent-supervisor event (`runner_status`, `runner_log`, `runner_token_usage`) to every connected client. */
1496
- publish(event, data) {
1497
- if (this.closed || this.clients.size === 0)
1498
- return;
1499
- this.broadcast(event, data);
1500
- }
1501
- /** SSE responses never finish on their own; drop them so http.Server#close() can complete. */
1502
- close() {
1503
- this.closed = true;
1504
- for (const res of this.clients)
1505
- res.destroy();
1506
- this.clients.clear();
1507
- this.stop();
1508
- }
1509
- start() {
1510
- if (!this.heartbeat) {
1511
- this.heartbeat = setInterval(() => this.broadcast('heartbeat', { at: new Date().toISOString() }), this.heartbeatMs);
1512
- this.heartbeat.unref();
1513
- }
1514
- if (this.watcher || this.pollers.length)
1515
- return;
1516
- try {
1517
- this.watcher = fs.watch(this.aiDir, { persistent: false }, (_event, filename) => {
1518
- if (filename)
1519
- this.queue(String(filename));
1520
- });
1521
- this.watcher.on('error', () => {
1522
- this.closeWatcher();
1523
- if (this.clients.size)
1524
- this.startPolling();
1525
- });
1526
- }
1527
- catch {
1528
- // .ai/ is missing (studio started before `nativ init`) or not watchable: poll the files instead.
1529
- this.startPolling();
1530
- }
1531
- }
1532
- startPolling() {
1533
- if (this.pollers.length)
1534
- return;
1535
- for (const file of WATCHED_FILES.keys()) {
1536
- // Missing files report zeroed stats, so an unchanged mtime means nothing happened.
1537
- const listener = (curr, prev) => {
1538
- if (curr.mtimeMs !== prev.mtimeMs)
1539
- this.queue(file);
1540
- };
1541
- fs.watchFile(path.join(this.aiDir, file), { interval: WATCH_POLL_INTERVAL_MS, persistent: false }, listener);
1542
- this.pollers.push({ file, listener });
1543
- }
1544
- }
1545
- stop() {
1546
- if (this.heartbeat)
1547
- clearInterval(this.heartbeat);
1548
- if (this.flushTimer)
1549
- clearTimeout(this.flushTimer);
1550
- this.heartbeat = this.flushTimer = null;
1551
- this.pending.clear();
1552
- this.closeWatcher();
1553
- for (const { file, listener } of this.pollers)
1554
- fs.unwatchFile(path.join(this.aiDir, file), listener);
1555
- this.pollers = [];
1556
- }
1557
- closeWatcher() {
1558
- this.watcher?.close();
1559
- this.watcher = null;
1560
- }
1561
- queue(file) {
1562
- const event = WATCHED_FILES.get(file);
1563
- if (!event)
1564
- return;
1565
- const files = this.pending.get(event) ?? new Set();
1566
- files.add(file);
1567
- this.pending.set(event, files);
1568
- this.flushTimer ??= setTimeout(() => this.flush(), EVENT_DEBOUNCE_MS);
1569
- }
1570
- flush() {
1571
- this.flushTimer = null;
1572
- const at = new Date().toISOString();
1573
- for (const [event, files] of this.pending)
1574
- this.broadcast(event, { files: [...files], at });
1575
- this.pending.clear();
1576
- }
1577
- broadcast(event, data) {
1578
- const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1579
- for (const res of this.clients)
1580
- res.write(frame);
1581
- }
1582
- }
1583
- // ─── Server ────────────────────────────────────────────────────────────────────
1584
38
  export function createStudioServer(options = {}) {
1585
39
  const cwd = options.cwd ?? process.cwd();
1586
40
  const resolved = options.connections ? null : resolveConnections(cwd);
@@ -1612,31 +66,33 @@ export function createStudioServer(options = {}) {
1612
66
  };
1613
67
  // Pipeline endpoints read the project that owns .ai/ (climbing out of .worktrees/task-* when needed).
1614
68
  const root = resolveProjectRoot(cwd);
69
+ // Tier 1 strategist: settings and counters live for this server session only.
70
+ const liaison = new Tier1Liaison(root, options.triage);
71
+ const triageInFlight = new Map();
72
+ let autoTriageTimer = null;
73
+ const scheduleAutoTriage = () => {
74
+ if (autoTriageTimer)
75
+ clearTimeout(autoTriageTimer);
76
+ autoTriageTimer = setTimeout(() => {
77
+ autoTriageTimer = null;
78
+ runAutoTriageQueue(root, liaison, triageInFlight).catch(() => { });
79
+ }, 150);
80
+ autoTriageTimer.unref();
81
+ };
1615
82
  const heartbeatMs = options.heartbeatMs && options.heartbeatMs > 0 ? options.heartbeatMs : DEFAULT_HEARTBEAT_MS;
1616
- const events = new PipelineEventHub(path.join(root, AI_DIR), heartbeatMs);
83
+ const events = new PipelineEventHub(path.join(root, AI_DIR), heartbeatMs, (file) => {
84
+ if (file === 'escalation.json') {
85
+ scheduleAutoTriage();
86
+ }
87
+ });
1617
88
  // Background agent runs: lifecycle phases, stdout/stderr chunks and per-turn token usage
1618
89
  // (native engine) fan out over /api/events.
1619
90
  const supervisor = new AgentSupervisor({ ...options.supervisor, cwd: root });
1620
91
  supervisor.on('runner_status', (record) => events.publish('runner_status', runView(record)));
1621
92
  supervisor.on('runner_log', (entry) => events.publish('runner_log', entry));
1622
93
  supervisor.on('runner_token_usage', (usage) => events.publish('runner_token_usage', usage));
1623
- // Tier 1 strategist: settings and counters live for this server session only.
1624
- const liaison = new Tier1Liaison(root, options.triage);
1625
- const triageInFlight = new Map();
1626
94
  // Concurrent "Run Benchmark" requests share one in-flight run.
1627
- let benchmarkRun = null;
1628
- const runBenchmarkSuite = () => {
1629
- benchmarkRun ??= runCaptured(() => runBench(root, { json: true }))
1630
- .then(({ result, output }) => {
1631
- if (!result)
1632
- throw new HttpError(500, 'BENCHMARK_FAILED', truncateOutput(output || 'Benchmark suite failed to run'));
1633
- return result;
1634
- })
1635
- .finally(() => {
1636
- benchmarkRun = null;
1637
- });
1638
- return benchmarkRun;
1639
- };
95
+ const runBenchmarkSuite = createBenchmarkRunner(root);
1640
96
  const server = http.createServer(async (req, res) => {
1641
97
  let pathname = '';
1642
98
  try {
@@ -1724,7 +180,7 @@ export function createStudioServer(options = {}) {
1724
180
  case 'GET /api/pipeline/triage/status':
1725
181
  return sendJson(res, 200, liaison.getStatus());
1726
182
  case 'POST /api/pipeline/triage/config':
1727
- return sendJson(res, 200, handleTriageConfig(liaison, await readJsonBody(req)));
183
+ return sendJson(res, 200, handleTriageConfig(liaison, await readJsonBody(req), scheduleAutoTriage));
1728
184
  case 'GET /favicon.ico':
1729
185
  res.writeHead(204).end();
1730
186
  return;