prism-mcp-server 7.2.0 → 7.3.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hivemind Watchdog (v5.3) — Active Agent Health Monitoring
2
+ * Hivemind Watchdog (v7.2) — Active Agent Health Monitoring
3
3
  *
4
4
  * Server-side health monitor for multi-agent coordination.
5
5
  * Runs every WATCHDOG_INTERVAL_MS when PRISM_ENABLE_HIVEMIND=true.
@@ -22,7 +22,13 @@
22
22
  * - Sweep is non-blocking: errors are caught and logged, never crash
23
23
  */
24
24
  import { getStorage } from "./storage/index.js";
25
- import { PRISM_USER_ID } from "./config.js";
25
+ import { PRISM_USER_ID, PRISM_VERIFICATION_HARNESS_ENABLED, PRISM_VERIFICATION_LAYERS, PRISM_VERIFICATION_DEFAULT_SEVERITY } from "./config.js";
26
+ import * as fs from "fs";
27
+ import * as path from "path";
28
+ import { VerificationRunner } from "./verification/runner.js";
29
+ import { TestSuiteSchema } from "./verification/schema.js";
30
+ import { validateWithClaw } from "./verification/clawValidator.js";
31
+ import { sessionSaveExperienceHandler } from "./tools/ledgerHandlers.js";
26
32
  export const DEFAULT_WATCHDOG_CONFIG = {
27
33
  intervalMs: 60_000,
28
34
  staleThresholdMin: 5,
@@ -35,6 +41,11 @@ export const DEFAULT_WATCHDOG_CONFIG = {
35
41
  * Only one alert per agent per status is kept until drained.
36
42
  */
37
43
  const pendingAlerts = new Map();
44
+ /**
45
+ * Deduplicates concurrent verification jobs per agent.
46
+ * Key format: project:user_id:role
47
+ */
48
+ const inFlightVerifications = new Map();
38
49
  /**
39
50
  * Drain all pending alerts for a project.
40
51
  * Called by server.ts in the CallToolRequestSchema handler
@@ -118,7 +129,8 @@ export async function runWatchdogSweep(cfg = DEFAULT_WATCHDOG_CONFIG) {
118
129
  // ── State Transition: Heartbeat-based ──────────────────
119
130
  let newStatus = null;
120
131
  if (minutesSinceHeartbeat >= cfg.offlineThresholdMin) {
121
- // OFFLINE → prune the agent
132
+ // OFFLINE → prune the agent and clean up assertion files
133
+ cleanupAssertionFiles(agent);
122
134
  try {
123
135
  await storage.deregisterAgent(agent.project, agent.user_id, agent.role);
124
136
  queueAlert(agent, "OFFLINE", `No heartbeat for ${Math.floor(minutesSinceHeartbeat)}m — auto-pruned from registry.`);
@@ -161,6 +173,166 @@ export async function runWatchdogSweep(cfg = DEFAULT_WATCHDOG_CONFIG) {
161
173
  }
162
174
  }
163
175
  }
176
+ // ── State Transition: Verification Phase (v7.2.0 Enhanced) ──
177
+ // ARCHITECTURE: Verification is fire-and-forget. The sweep transitions the agent
178
+ // to 'verifying' synchronously and spawns a detached async closure. This prevents
179
+ // long-running Claw/Runner calls (10-70s) from blocking heartbeat checks for
180
+ // other agents.
181
+ if (!newStatus && (currentStatus === "active" || currentStatus === "failed_validation")) {
182
+ // v7.2.0 FIX: Scope assertion file per project+role to prevent multi-agent collision
183
+ const scopedFile = path.join(".prism-mcp", `test_assertions_${agent.project}_${agent.role}.json`);
184
+ // Also check the legacy global path for backward compat
185
+ const legacyFile = "test_assertions.json";
186
+ const activeFile = fs.existsSync(scopedFile) ? scopedFile
187
+ : fs.existsSync(legacyFile) ? legacyFile
188
+ : null;
189
+ if (activeFile) {
190
+ const flightKey = `${agent.project}:${agent.user_id}:${agent.role}`;
191
+ // Skip if a verification is already in-flight for this agent
192
+ if (!inFlightVerifications.has(flightKey)) {
193
+ // Set verifying state synchronously (non-blocking for the sweep)
194
+ console.error(`[Watchdog] 🔬 Verifying agent "${agent.role}" on "${agent.project}"`);
195
+ newStatus = "verifying";
196
+ // Capture values for the async closure
197
+ const capturedAgent = { ...agent };
198
+ const capturedFile = activeFile;
199
+ const capturedFailCount = agent.loop_count || 0;
200
+ // Spawn detached verification — does NOT block the sweep
201
+ const verificationJob = (async () => {
202
+ try {
203
+ const assertionsContent = fs.readFileSync(capturedFile, "utf8");
204
+ const innerStorage = await getStorage();
205
+ // v7.2.0: Build verification config from env vars
206
+ const vConfig = {
207
+ enabled: PRISM_VERIFICATION_HARNESS_ENABLED,
208
+ layers: PRISM_VERIFICATION_LAYERS,
209
+ default_severity: PRISM_VERIFICATION_DEFAULT_SEVERITY,
210
+ };
211
+ // v7.2.0: Claw-as-Validator adversarial pre-check (fail-open)
212
+ if (PRISM_VERIFICATION_HARNESS_ENABLED) {
213
+ try {
214
+ const suite = TestSuiteSchema.parse(JSON.parse(assertionsContent));
215
+ const clawResult = await validateWithClaw({
216
+ suite,
217
+ project: capturedAgent.project,
218
+ files_changed: [],
219
+ change_summary: `Automated verification for ${capturedAgent.role}`,
220
+ }, async (prompt, cwd) => {
221
+ // @ts-ignore: Optional runtime dependency; handled by .catch()
222
+ const mod = await import("./tools/clawHandlers.js").catch(() => null);
223
+ if (!mod?.clawRunTaskHandler)
224
+ throw new Error("claw-agent not available");
225
+ return mod.clawRunTaskHandler({ prompt, cwd });
226
+ });
227
+ if (!clawResult.accepted) {
228
+ console.error(`[Watchdog] ⚠️ Claw validator flagged ${clawResult.issues.length} issues`);
229
+ }
230
+ }
231
+ catch (clawErr) {
232
+ console.error(`[Watchdog] Claw validator skipped: ${clawErr instanceof Error ? clawErr.message : String(clawErr)}`);
233
+ }
234
+ }
235
+ // v7.2.0: Use enhanced runner with layer filtering
236
+ const result = await VerificationRunner.runSuite(assertionsContent, PRISM_VERIFICATION_HARNESS_ENABLED
237
+ ? { layers: PRISM_VERIFICATION_LAYERS, config: vConfig }
238
+ : undefined);
239
+ let resolvedStatus;
240
+ let resolvedLoopCount = capturedFailCount;
241
+ if (!result.passed) {
242
+ // Emit structured experience event
243
+ try {
244
+ await sessionSaveExperienceHandler({
245
+ project: capturedAgent.project,
246
+ event_type: "validation_result",
247
+ context: `Verification run for ${capturedAgent.role}`,
248
+ action: "automated_verification",
249
+ outcome: `${result.failed_count}/${result.total} failed — gate: ${result.severity_gate.action}`,
250
+ role: capturedAgent.role,
251
+ confidence_score: Math.round((result.passed_count / Math.max(result.total, 1)) * 100)
252
+ });
253
+ }
254
+ catch (err) {
255
+ console.error(`[Watchdog] Error saving failure experience: ${err}`);
256
+ }
257
+ // Severity gate enforcement
258
+ if (PRISM_VERIFICATION_HARNESS_ENABLED && result.severity_gate.action === "abort") {
259
+ resolvedStatus = "failed_validation";
260
+ resolvedLoopCount = capturedFailCount + 1;
261
+ queueAlert(capturedAgent, "FAILED_VALIDATION", `[ABORT] ${result.severity_gate.summary}`);
262
+ console.error(`[Watchdog] 🛑 ABORT gate triggered for "${capturedAgent.role}" — ${result.severity_gate.summary}`);
263
+ }
264
+ else if (PRISM_VERIFICATION_HARNESS_ENABLED && result.severity_gate.action === "block") {
265
+ resolvedStatus = "failed_validation";
266
+ resolvedLoopCount = capturedFailCount + 1;
267
+ queueAlert(capturedAgent, "FAILED_VALIDATION", `[BLOCKED] ${result.severity_gate.summary}`);
268
+ console.error(`[Watchdog] 🚫 Gate BLOCKED for "${capturedAgent.role}" — ${result.severity_gate.summary}`);
269
+ }
270
+ else if (capturedFailCount >= 3) {
271
+ resolvedStatus = "looping";
272
+ // FIX: Clean up orphaned assertion file on LOOPING
273
+ cleanupAssertionFiles(capturedAgent);
274
+ queueAlert(capturedAgent, "LOOPING", `Validation failed ${capturedFailCount} times. Bailing out.`);
275
+ }
276
+ else {
277
+ resolvedStatus = "failed_validation";
278
+ resolvedLoopCount = capturedFailCount + 1;
279
+ const failSummary = result.assertion_results
280
+ .filter(a => !a.passed && !a.skipped)
281
+ .map(a => `[${a.layer}] ${a.description}: ${a.error}`)
282
+ .join(" | ");
283
+ queueAlert(capturedAgent, "FAILED_VALIDATION", `[Verification Failed] ${failSummary}`);
284
+ }
285
+ }
286
+ else {
287
+ // Passed! Clean up assertion file
288
+ cleanupAssertionFiles(capturedAgent);
289
+ resolvedStatus = "active";
290
+ resolvedLoopCount = 0;
291
+ queueAlert(capturedAgent, "SUCCESS", "All test assertions passed successfully.");
292
+ console.error(`[Watchdog] ✅ Verification PASSED for "${capturedAgent.role}" on "${capturedAgent.project}"`);
293
+ try {
294
+ await sessionSaveExperienceHandler({
295
+ project: capturedAgent.project,
296
+ event_type: "validation_result",
297
+ context: `Verification run for ${capturedAgent.role}`,
298
+ action: "automated_verification",
299
+ outcome: `Passed all ${result.total} assertions (${result.duration_ms}ms)`,
300
+ role: capturedAgent.role,
301
+ confidence_score: 100
302
+ });
303
+ }
304
+ catch (err) {
305
+ console.error(`[Watchdog] Error saving success experience: ${err}`);
306
+ }
307
+ }
308
+ // Persist final status
309
+ try {
310
+ await innerStorage.updateAgentStatus(capturedAgent.project, capturedAgent.user_id, capturedAgent.role, resolvedStatus, { loop_count: resolvedLoopCount });
311
+ }
312
+ catch (err) {
313
+ console.error(`[Watchdog] Failed to update status after verification: ${err}`);
314
+ }
315
+ }
316
+ catch (e) {
317
+ // Verification script error — mark as failed_validation
318
+ try {
319
+ const innerStorage = await getStorage();
320
+ await innerStorage.updateAgentStatus(capturedAgent.project, capturedAgent.user_id, capturedAgent.role, "failed_validation", { loop_count: capturedFailCount + 1 });
321
+ }
322
+ catch { /* best-effort */ }
323
+ queueAlert(capturedAgent, "FAILED_VALIDATION", `[Verification Script Error] ${e.message}`);
324
+ }
325
+ finally {
326
+ inFlightVerifications.delete(flightKey);
327
+ }
328
+ })();
329
+ inFlightVerifications.set(flightKey, verificationJob);
330
+ }
331
+ }
332
+ }
333
+ else if (!newStatus && currentStatus === "verifying") {
334
+ // Agent is already verifying — don't re-trigger, just skip
335
+ }
164
336
  // ── State Transition: LOOPING confirmation ─────────────
165
337
  // Loop detection is primarily done in heartbeatAgent().
166
338
  // The watchdog just confirms and queues alerts for it.
@@ -169,6 +341,8 @@ export async function runWatchdogSweep(cfg = DEFAULT_WATCHDOG_CONFIG) {
169
341
  agent.loop_count >= cfg.loopThreshold &&
170
342
  currentStatus !== "looping") {
171
343
  newStatus = "looping";
344
+ // FIX: Clean up orphaned assertion files on LOOPING
345
+ cleanupAssertionFiles(agent);
172
346
  queueAlert(agent, "LOOPING", `Same task repeated ${agent.loop_count} times — possible infinite loop.`);
173
347
  console.error(`[Watchdog] 🔄 Agent "${agent.role}" on "${agent.project}" detected LOOPING ` +
174
348
  `(task repeated ${agent.loop_count}x)`);
@@ -176,7 +350,7 @@ export async function runWatchdogSweep(cfg = DEFAULT_WATCHDOG_CONFIG) {
176
350
  // ── Apply status update ────────────────────────────────
177
351
  if (newStatus && newStatus !== currentStatus) {
178
352
  try {
179
- await storage.updateAgentStatus(agent.project, agent.user_id, agent.role, newStatus);
353
+ await storage.updateAgentStatus(agent.project, agent.user_id, agent.role, newStatus, { loop_count: agent.loop_count });
180
354
  }
181
355
  catch (err) {
182
356
  console.error(`[Watchdog] Status update failed for ${agent.project}/${agent.role}: ${err instanceof Error ? err.message : String(err)}`);
@@ -204,3 +378,22 @@ function truncate(str, maxLen) {
204
378
  return str;
205
379
  return str.slice(0, maxLen - 3) + "...";
206
380
  }
381
+ /**
382
+ * Clean up assertion files for an agent (scoped + legacy).
383
+ * Prevents orphaned files from triggering phantom verifications on restart.
384
+ */
385
+ function cleanupAssertionFiles(agent) {
386
+ const scopedFile = path.join(".prism-mcp", `test_assertions_${agent.project}_${agent.role}.json`);
387
+ const legacyFile = "test_assertions.json";
388
+ for (const filePath of [scopedFile, legacyFile]) {
389
+ try {
390
+ if (fs.existsSync(filePath)) {
391
+ fs.unlinkSync(filePath);
392
+ console.error(`[Watchdog] 🧹 Cleaned up assertion file: ${filePath}`);
393
+ }
394
+ }
395
+ catch (err) {
396
+ console.error(`[Watchdog] Failed to clean up ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
397
+ }
398
+ }
399
+ }
package/dist/lifecycle.js CHANGED
@@ -157,7 +157,15 @@ export function registerShutdownHandlers() {
157
157
  shuttingDown = true;
158
158
  log(`Shutting down gracefully (${reason})...`);
159
159
  try {
160
- // 0. Await pending background tasks FIRST (max 5s timeout)
160
+ // 0. Stop the Dark Factory background runner first (prevents new DB writes)
161
+ try {
162
+ const { stopDarkFactoryRunner } = await import("./darkfactory/runner.js");
163
+ stopDarkFactoryRunner();
164
+ }
165
+ catch {
166
+ // Runner may not be initialized — safe to ignore
167
+ }
168
+ // 0.5 Await pending background tasks (max 5s timeout)
161
169
  await BackgroundTaskRegistry.awaitAll(5000);
162
170
  // 0.5. Flush OTel span buffer FIRST — before any DBs are closed.
163
171
  // BatchSpanProcessor holds spans in memory (up to 5s). If we close
package/dist/server.js CHANGED
@@ -58,9 +58,10 @@ ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequ
58
58
  // Claude Desktop that the attached resource has changed.
59
59
  // Without this, the paperclipped context becomes stale.
60
60
  SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
61
- import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_SCHOLAR_ENABLED, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, } from "./config.js";
61
+ import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_SCHOLAR_ENABLED, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, PRISM_DARK_FACTORY_ENABLED, } from "./config.js";
62
62
  import { startWatchdog, drainAlerts } from "./hivemindWatchdog.js";
63
63
  import { startScheduler, startScholarScheduler } from "./backgroundScheduler.js";
64
+ import { startDarkFactoryRunner } from "./darkfactory/runner.js";
64
65
  import { getSyncBus } from "./sync/factory.js";
65
66
  import { startDashboardServer } from "./dashboard/server.js";
66
67
  import { acquireLock, registerShutdownHandlers } from "./lifecycle.js";
@@ -123,7 +124,9 @@ MAINTENANCE_VACUUM_TOOL, maintenanceVacuumHandler,
123
124
  // ─── v3.0: Agent Hivemind tools ───
124
125
  AGENT_REGISTRY_TOOLS, agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler,
125
126
  // v7.1: Task Router
126
- sessionTaskRouteHandler, } from "./tools/index.js";
127
+ sessionTaskRouteHandler,
128
+ // v7.3: Dark Factory Pipeline tools
129
+ SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL, sessionStartPipelineHandler, sessionCheckPipelineStatusHandler, sessionAbortPipelineHandler, } from "./tools/index.js";
127
130
  // ─── Dynamic Tool Registration ───────────────────────────────────
128
131
  // Base tools: always available regardless of configuration
129
132
  const BASE_TOOLS = [
@@ -282,6 +285,8 @@ export function createServer() {
282
285
  ...(PRISM_ENABLE_HIVEMIND ? AGENT_REGISTRY_TOOLS : []),
283
286
  // v7.1: Task Router tool — only when PRISM_TASK_ROUTER_ENABLED=true
284
287
  ...(getSettingSync("task_router_enabled", String(PRISM_TASK_ROUTER_ENABLED_ENV)) === "true" ? [SESSION_TASK_ROUTE_TOOL] : []),
288
+ // v7.3: Dark Factory pipeline tools — only when PRISM_DARK_FACTORY_ENABLED=true
289
+ ...(PRISM_DARK_FACTORY_ENABLED ? [SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL] : []),
285
290
  ];
286
291
  const server = new Server({
287
292
  name: SERVER_CONFIG.name,
@@ -811,6 +816,28 @@ export function createServer() {
811
816
  throw new Error("Task router not enabled. Enable it in the dashboard or set PRISM_TASK_ROUTER_ENABLED=true.");
812
817
  result = await sessionTaskRouteHandler(args);
813
818
  break;
819
+ // ─── v7.3: Dark Factory Pipeline Tools ───
820
+ case "session_start_pipeline":
821
+ if (!SESSION_MEMORY_ENABLED)
822
+ throw new Error("Session memory not configured.");
823
+ if (!PRISM_DARK_FACTORY_ENABLED)
824
+ throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
825
+ result = await sessionStartPipelineHandler(args);
826
+ break;
827
+ case "session_check_pipeline_status":
828
+ if (!SESSION_MEMORY_ENABLED)
829
+ throw new Error("Session memory not configured.");
830
+ if (!PRISM_DARK_FACTORY_ENABLED)
831
+ throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
832
+ result = await sessionCheckPipelineStatusHandler(args);
833
+ break;
834
+ case "session_abort_pipeline":
835
+ if (!SESSION_MEMORY_ENABLED)
836
+ throw new Error("Session memory not configured.");
837
+ if (!PRISM_DARK_FACTORY_ENABLED)
838
+ throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
839
+ result = await sessionAbortPipelineHandler(args);
840
+ break;
814
841
  default:
815
842
  result = {
816
843
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
@@ -892,7 +919,7 @@ export function createSandboxServer() {
892
919
  });
893
920
  // Register all tool listings unconditionally
894
921
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
895
- tools: [...BASE_TOOLS, ...buildSessionMemoryTools([]), ...AGENT_REGISTRY_TOOLS, SESSION_TASK_ROUTE_TOOL],
922
+ tools: [...BASE_TOOLS, ...buildSessionMemoryTools([]), ...AGENT_REGISTRY_TOOLS, SESSION_TASK_ROUTE_TOOL, SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL],
896
923
  }));
897
924
  // Register prompts listing so scanners see resume_session
898
925
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({
@@ -1146,6 +1173,17 @@ export async function startServer() {
1146
1173
  console.error(`[WebScholar] Startup failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1147
1174
  });
1148
1175
  }
1176
+ // ─── v7.3: Dark Factory Background Runner ────────────────
1177
+ // Autonomous pipeline orchestration engine. Picks up RUNNING
1178
+ // pipelines and advances them through PLAN → EXECUTE → VERIFY
1179
+ // cycles. Non-blocking — uses setInterval to yield between ticks.
1180
+ if (PRISM_DARK_FACTORY_ENABLED && SESSION_MEMORY_ENABLED) {
1181
+ storageReady?.then(() => {
1182
+ startDarkFactoryRunner();
1183
+ }).catch(err => {
1184
+ console.error(`[DarkFactory] Startup failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1185
+ });
1186
+ }
1149
1187
  // Keep the process alive — without this, Node.js would exit
1150
1188
  // because there are no active event loop handles after the
1151
1189
  // synchronous setup completes.
@@ -24,6 +24,7 @@ import { AccessLogBuffer } from "../utils/accessLogBuffer.js";
24
24
  import { PRISM_ACTR_BUFFER_FLUSH_MS } from "../config.js";
25
25
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
26
26
  import { debugLog } from "../utils/logger.js";
27
+ import { SafetyController } from "../darkfactory/safetyController.js";
27
28
  export class SqliteStorage {
28
29
  db;
29
30
  dbPath;
@@ -556,6 +557,23 @@ export class SqliteStorage {
556
557
  ON memory_access_log(entry_id, accessed_at DESC)`);
557
558
  await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_access_log_time
558
559
  ON memory_access_log(accessed_at)`);
560
+ // ─── v7.3 Migration: Dark Factory Pipelines ───────────────
561
+ await this.db.execute(`
562
+ CREATE TABLE IF NOT EXISTS dark_factory_pipelines (
563
+ id TEXT PRIMARY KEY,
564
+ project TEXT NOT NULL,
565
+ user_id TEXT NOT NULL DEFAULT 'default',
566
+ status TEXT NOT NULL,
567
+ current_step TEXT NOT NULL,
568
+ iteration INTEGER NOT NULL,
569
+ started_at TEXT NOT NULL,
570
+ updated_at TEXT NOT NULL,
571
+ spec TEXT NOT NULL,
572
+ error TEXT,
573
+ last_heartbeat TEXT
574
+ )
575
+ `);
576
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_pipelines_status ON dark_factory_pipelines(user_id, project, status)`);
559
577
  // ─── v6.1 Migration: Integrity Check ──────────────────────
560
578
  //
561
579
  // REVIEWER NOTE: PRAGMA integrity_check scans the B-tree structure of
@@ -2786,4 +2804,74 @@ export class SqliteStorage {
2786
2804
  debugLog(`[SqliteStorage] pruneAccessLog: removed ${pruned} entries older than ${olderThanDays} days`);
2787
2805
  return pruned;
2788
2806
  }
2807
+ // ─── Dark Factory (v7.3) ───────────────────────────────────
2808
+ async savePipeline(state) {
2809
+ const now = new Date().toISOString();
2810
+ const updatedState = { ...state, updated_at: now };
2811
+ // Status Guard: prevent overwriting a terminated pipeline
2812
+ const existing = await this.getPipeline(state.id, state.user_id);
2813
+ if (existing) {
2814
+ if (existing.status === 'ABORTED' || existing.status === 'COMPLETED') {
2815
+ throw new Error(`Cannot update pipeline ${state.id} because it is already ${existing.status}.`);
2816
+ }
2817
+ // Validate state machine transition
2818
+ if (!SafetyController.validateTransition(existing.status, updatedState.status)) {
2819
+ throw new Error(`Illegal pipeline transition: ${existing.status} → ${updatedState.status} ` +
2820
+ `for pipeline ${state.id}. Legal transitions from ${existing.status}: ` +
2821
+ `${SafetyController.getLegalTransitions(existing.status).join(', ') || 'NONE (terminal)'}.`);
2822
+ }
2823
+ }
2824
+ await this.db.execute({
2825
+ sql: `
2826
+ INSERT INTO dark_factory_pipelines (id, project, user_id, status, current_step, iteration, started_at, updated_at, spec, error, last_heartbeat)
2827
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2828
+ ON CONFLICT(id) DO UPDATE SET
2829
+ status = excluded.status,
2830
+ current_step = excluded.current_step,
2831
+ iteration = excluded.iteration,
2832
+ updated_at = excluded.updated_at,
2833
+ spec = excluded.spec,
2834
+ error = excluded.error,
2835
+ last_heartbeat = excluded.last_heartbeat
2836
+ `,
2837
+ args: [
2838
+ updatedState.id,
2839
+ updatedState.project,
2840
+ updatedState.user_id,
2841
+ updatedState.status,
2842
+ updatedState.current_step,
2843
+ updatedState.iteration,
2844
+ updatedState.started_at,
2845
+ updatedState.updated_at,
2846
+ updatedState.spec,
2847
+ updatedState.error || null,
2848
+ updatedState.last_heartbeat || null
2849
+ ]
2850
+ });
2851
+ }
2852
+ async getPipeline(id, userId) {
2853
+ const result = await this.db.execute({
2854
+ sql: `SELECT * FROM dark_factory_pipelines WHERE id = ? AND user_id = ?`,
2855
+ args: [id, userId]
2856
+ });
2857
+ if (result.rows.length === 0)
2858
+ return null;
2859
+ return result.rows[0];
2860
+ }
2861
+ async listPipelines(project, status, userId) {
2862
+ const conditions = ['user_id = ?'];
2863
+ const args = [userId];
2864
+ if (project) {
2865
+ conditions.push('project = ?');
2866
+ args.push(project);
2867
+ }
2868
+ if (status) {
2869
+ conditions.push('status = ?');
2870
+ args.push(status);
2871
+ }
2872
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
2873
+ const sql = `SELECT * FROM dark_factory_pipelines ${where} ORDER BY updated_at DESC`;
2874
+ const result = await this.db.execute({ sql, args });
2875
+ return result.rows;
2876
+ }
2789
2877
  }
@@ -18,6 +18,7 @@ import { debugLog } from "../utils/logger.js";
18
18
  import { PRISM_USER_ID } from "../config.js";
19
19
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
20
20
  import { runAutoMigrations } from "./supabaseMigrations.js";
21
+ import { SafetyController } from "../darkfactory/safetyController.js";
21
22
  export class SupabaseStorage {
22
23
  // ─── Lifecycle ─────────────────────────────────────────────
23
24
  async initialize() {
@@ -1182,9 +1183,6 @@ export class SupabaseStorage {
1182
1183
  return Number(first.prism_prune_access_log) || 0;
1183
1184
  }
1184
1185
  }
1185
- if (rpcResult && typeof rpcResult.deleted_count !== "undefined") {
1186
- return Number(rpcResult.deleted_count) || 0;
1187
- }
1188
1186
  if (rpcResult && typeof rpcResult.prism_prune_access_log !== "undefined") {
1189
1187
  return Number(rpcResult.prism_prune_access_log) || 0;
1190
1188
  }
@@ -1195,4 +1193,82 @@ export class SupabaseStorage {
1195
1193
  return 0;
1196
1194
  }
1197
1195
  }
1196
+ // ─── Dark Factory (v7.3) ───────────────────────────────────
1197
+ async savePipeline(state) {
1198
+ const now = new Date().toISOString();
1199
+ const updatedState = { ...state, updated_at: now };
1200
+ // Status Guard: prevent overwriting a terminated pipeline
1201
+ const existing = await this.getPipeline(state.id, state.user_id);
1202
+ if (existing) {
1203
+ if (existing.status === 'ABORTED' || existing.status === 'COMPLETED') {
1204
+ throw new Error(`Cannot update pipeline ${state.id} because it is already ${existing.status}.`);
1205
+ }
1206
+ // Validate state machine transition
1207
+ if (!SafetyController.validateTransition(existing.status, updatedState.status)) {
1208
+ throw new Error(`Illegal pipeline transition: ${existing.status} → ${updatedState.status} ` +
1209
+ `for pipeline ${state.id}. Legal transitions from ${existing.status}: ` +
1210
+ `${SafetyController.getLegalTransitions(existing.status).join(', ') || 'NONE (terminal)'}.`);
1211
+ }
1212
+ }
1213
+ try {
1214
+ await supabasePost("dark_factory_pipelines", {
1215
+ id: updatedState.id,
1216
+ project: updatedState.project,
1217
+ user_id: updatedState.user_id,
1218
+ status: updatedState.status,
1219
+ current_step: updatedState.current_step,
1220
+ iteration: updatedState.iteration,
1221
+ started_at: updatedState.started_at,
1222
+ updated_at: updatedState.updated_at,
1223
+ spec: updatedState.spec,
1224
+ error: updatedState.error || null,
1225
+ last_heartbeat: updatedState.last_heartbeat || null
1226
+ }, { on_conflict: "id" }, { Prefer: "return=minimal,resolution=merge-duplicates" });
1227
+ }
1228
+ catch (e) {
1229
+ // PGRST202 fallback if the table doesn't exist yet
1230
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation")) {
1231
+ debugLog("[SupabaseStorage] dark_factory_pipelines missing — please run migration 038");
1232
+ return;
1233
+ }
1234
+ throw e;
1235
+ }
1236
+ }
1237
+ async getPipeline(id, userId) {
1238
+ try {
1239
+ const result = await supabaseGet("dark_factory_pipelines", {
1240
+ id: `eq.${id}`,
1241
+ user_id: `eq.${userId}`,
1242
+ limit: "1"
1243
+ });
1244
+ const rows = Array.isArray(result) ? result : [];
1245
+ if (rows.length === 0)
1246
+ return null;
1247
+ return rows[0];
1248
+ }
1249
+ catch (e) {
1250
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1251
+ return null;
1252
+ throw e;
1253
+ }
1254
+ }
1255
+ async listPipelines(project, status, userId) {
1256
+ try {
1257
+ const query = {
1258
+ user_id: `eq.${userId}`,
1259
+ order: "updated_at.desc"
1260
+ };
1261
+ if (project)
1262
+ query.project = `eq.${project}`;
1263
+ if (status)
1264
+ query.status = `eq.${status}`;
1265
+ const result = await supabaseGet("dark_factory_pipelines", query);
1266
+ return (Array.isArray(result) ? result : []);
1267
+ }
1268
+ catch (e) {
1269
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1270
+ return [];
1271
+ throw e;
1272
+ }
1273
+ }
1198
1274
  }
@@ -721,6 +721,58 @@ export const MIGRATIONS = [
721
721
  GRANT EXECUTE ON FUNCTION public.prism_seed_access_log_on_ledger_insert() TO service_role, authenticated;
722
722
  `
723
723
  },
724
+ {
725
+ // ─── v7.3: Dark Factory Pipelines ─────────────────────────────
726
+ //
727
+ // Creates the dark_factory_pipelines table for autonomous Plan-Execute-Verify
728
+ // pipeline orchestration. Includes status CHECK constraint for the canonical set.
729
+ //
730
+ // EXISTING DEPLOYMENT GUARD: If the table already exists (e.g., from running
731
+ // 038_dark_factory_pipelines.sql directly), CREATE TABLE IF NOT EXISTS is a no-op.
732
+ // We then ALTER TABLE to add the CHECK constraint for existing deployments.
733
+ version: 38,
734
+ name: "dark_factory_pipelines",
735
+ sql: `
736
+ -- Create the table if fresh install
737
+ CREATE TABLE IF NOT EXISTS public.dark_factory_pipelines (
738
+ id TEXT PRIMARY KEY,
739
+ project TEXT NOT NULL,
740
+ user_id TEXT NOT NULL DEFAULT 'default',
741
+ status TEXT NOT NULL,
742
+ current_step TEXT NOT NULL,
743
+ iteration INTEGER NOT NULL,
744
+ started_at TIMESTAMPTZ NOT NULL,
745
+ updated_at TIMESTAMPTZ NOT NULL,
746
+ spec TEXT NOT NULL,
747
+ error TEXT,
748
+ last_heartbeat TIMESTAMPTZ
749
+ );
750
+
751
+ CREATE INDEX IF NOT EXISTS idx_pipelines_status
752
+ ON public.dark_factory_pipelines(user_id, project, status);
753
+
754
+ ALTER TABLE public.dark_factory_pipelines ENABLE ROW LEVEL SECURITY;
755
+
756
+ -- Idempotent policy creation
757
+ DO $$
758
+ BEGIN
759
+ IF NOT EXISTS (
760
+ SELECT 1 FROM pg_policies WHERE tablename = 'dark_factory_pipelines' AND policyname = 'allow_all_dark_factory'
761
+ ) THEN
762
+ CREATE POLICY allow_all_dark_factory
763
+ ON public.dark_factory_pipelines AS PERMISSIVE FOR ALL USING (true);
764
+ END IF;
765
+ END $$;
766
+
767
+ -- Retrofit CHECK constraint for existing deployments.
768
+ -- DROP first (idempotent) then ADD — covers both fresh and upgraded tables.
769
+ ALTER TABLE public.dark_factory_pipelines
770
+ DROP CONSTRAINT IF EXISTS chk_pipeline_status;
771
+ ALTER TABLE public.dark_factory_pipelines
772
+ ADD CONSTRAINT chk_pipeline_status
773
+ CHECK (status IN ('PENDING', 'RUNNING', 'PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'));
774
+ `
775
+ },
724
776
  ];
725
777
  /**
726
778
  * Current schema version — derived from the MIGRATIONS array.
@@ -47,3 +47,8 @@ export { agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler } fro
47
47
  // Registered when PRISM_TASK_ROUTER_ENABLED=true.
48
48
  // server.ts handles the conditional registration.
49
49
  export { sessionTaskRouteHandler } from "./taskRouterHandler.js";
50
+ // ── Dark Factory Pipeline Tools (v7.3 — Autonomous Execution, Optional) ──
51
+ // Registered when PRISM_DARK_FACTORY_ENABLED=true.
52
+ // server.ts handles the conditional registration.
53
+ export { SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL, isStartPipelineArgs, isCheckPipelineStatusArgs, isAbortPipelineArgs, } from "./pipelineDefinitions.js";
54
+ export { sessionStartPipelineHandler, sessionCheckPipelineStatusHandler, sessionAbortPipelineHandler, } from "./pipelineHandlers.js";