prism-mcp-server 20.21.21 → 20.21.23

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.
@@ -3,6 +3,7 @@ import { resolve, dirname } from "path";
3
3
  import { homedir } from "os";
4
4
  import { existsSync } from "fs";
5
5
  import { mkdirUsableSync } from "../utils/usableDirectory.js";
6
+ import { createHash, randomUUID } from "node:crypto";
6
7
  const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
7
8
  // We use a small, dedicated DB just for configuration settings.
8
9
  // This solves the chicken-and-egg problem: we need to know WHICH
@@ -97,6 +98,14 @@ export async function initConfigStorage() {
97
98
  PRIMARY KEY (conversation_hash, project_hash)
98
99
  )
99
100
  `);
101
+ await client.execute(`CREATE TABLE IF NOT EXISTS dark_factory_authority (
102
+ pipeline_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, project TEXT NOT NULL,
103
+ user_id TEXT NOT NULL, started_at TEXT NOT NULL, spec_json TEXT NOT NULL,
104
+ created_at INTEGER NOT NULL
105
+ )`);
106
+ await client.execute(`CREATE TABLE IF NOT EXISTS dark_factory_execution_leases (
107
+ pipeline_id TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at INTEGER NOT NULL, reclaimed INTEGER NOT NULL DEFAULT 0
108
+ )`);
100
109
  // Preload all rows into the cache so subsequent reads are zero-cost.
101
110
  const rs = await client.execute("SELECT key, value FROM system_settings");
102
111
  settingsCache = Object.create(null);
@@ -387,6 +396,41 @@ export async function getAllSettings() {
387
396
  }
388
397
  return settings;
389
398
  }
399
+ export const DEFAULT_FACTORY_LEASE_MS = 30_000;
400
+ export const FACTORY_LEASE_RENEW_MS = 10_000;
401
+ function authorityFingerprint(p) {
402
+ const parsed = Date.parse(p.started_at);
403
+ if (!Number.isFinite(parsed))
404
+ throw new Error("Invalid Factory pipeline start time");
405
+ const normalized = { id: p.id, project: p.project, user_id: p.user_id, started_at: new Date(parsed).toISOString(), spec: p.spec };
406
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
407
+ }
408
+ export async function registerFactoryAuthority(p) {
409
+ await initConfigStorage();
410
+ const fingerprint = authorityFingerprint(p);
411
+ await getClient().execute({ sql: `INSERT INTO dark_factory_authority (pipeline_id,fingerprint,project,user_id,started_at,spec_json,created_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(pipeline_id) DO NOTHING`, args: [p.id, fingerprint, p.project, p.user_id, p.started_at, JSON.stringify(p.spec), Date.now()] });
412
+ const existing = await getClient().execute({ sql: `SELECT fingerprint FROM dark_factory_authority WHERE pipeline_id=?`, args: [p.id] });
413
+ if (existing.rows[0]?.fingerprint !== fingerprint)
414
+ throw new Error("Factory authority fingerprint mismatch");
415
+ return fingerprint;
416
+ }
417
+ export async function isFactoryAuthorized(p) {
418
+ await initConfigStorage();
419
+ const rs = await getClient().execute({ sql: `SELECT fingerprint FROM dark_factory_authority WHERE pipeline_id=?`, args: [p.id] });
420
+ return rs.rows.length === 1 && rs.rows[0].fingerprint === authorityFingerprint(p);
421
+ }
422
+ export async function claimFactoryLease(p, leaseMs = DEFAULT_FACTORY_LEASE_MS) {
423
+ await initConfigStorage();
424
+ const token = randomUUID();
425
+ const now = Date.now();
426
+ const expires = now + leaseMs;
427
+ const client = getClient();
428
+ const result = await client.execute({ sql: `INSERT INTO dark_factory_execution_leases (pipeline_id,token,expires_at,reclaimed) SELECT ?,?,?,0 FROM dark_factory_authority WHERE pipeline_id=? AND fingerprint=? ON CONFLICT(pipeline_id) DO UPDATE SET token=excluded.token,expires_at=excluded.expires_at,reclaimed=CASE WHEN dark_factory_execution_leases.reclaimed=1 OR (dark_factory_execution_leases.token <> '' AND dark_factory_execution_leases.expires_at <= ?) THEN 1 ELSE 0 END WHERE dark_factory_execution_leases.expires_at <= ? RETURNING reclaimed`, args: [p.id, token, expires, p.id, authorityFingerprint(p), now, now] });
429
+ if (!result.rows.length)
430
+ return null;
431
+ const reclaimed = Number(result.rows[0].reclaimed) === 1;
432
+ return { token, reclaimed, isHeld: async () => { const heldNow = Date.now(); const held = await client.execute({ sql: `SELECT 1 FROM dark_factory_execution_leases WHERE pipeline_id=? AND token=? AND expires_at>? AND EXISTS (SELECT 1 FROM dark_factory_authority WHERE pipeline_id=? AND fingerprint=?)`, args: [p.id, token, heldNow, p.id, authorityFingerprint(p)] }); return held.rows.length === 1; }, renew: async () => { const renewNow = Date.now(); const renewed = await client.execute({ sql: `UPDATE dark_factory_execution_leases SET expires_at=? WHERE pipeline_id=? AND token=? AND expires_at>? AND EXISTS (SELECT 1 FROM dark_factory_authority WHERE pipeline_id=? AND fingerprint=?)`, args: [renewNow + leaseMs, p.id, token, renewNow, p.id, authorityFingerprint(p)] }); return renewed.rowsAffected === 1; }, acknowledgeRecovery: async () => { await client.execute({ sql: `UPDATE dark_factory_execution_leases SET reclaimed=0 WHERE pipeline_id=? AND token=?`, args: [p.id, token] }); }, release: async () => { await client.execute({ sql: `UPDATE dark_factory_execution_leases SET expires_at=0,token='' WHERE pipeline_id=? AND token=?`, args: [p.id, token] }); } };
433
+ }
390
434
  /**
391
435
  * Closes the config SQLite client to release the file handle on prism-config.db.
392
436
  * Called by the lifecycle module during graceful shutdown.
@@ -399,5 +443,10 @@ export function closeConfigStorage() {
399
443
  catch (e) {
400
444
  console.error(`[ConfigStorage] Error closing db:`, e);
401
445
  }
446
+ finally {
447
+ configClient = null;
448
+ initialized = false;
449
+ settingsCache = null;
450
+ }
402
451
  }
403
452
  }
@@ -123,8 +123,22 @@ async function ensureSupabaseCredentials() {
123
123
  return false;
124
124
  }
125
125
  let storageInstance = null;
126
+ let storageInitialization = null;
126
127
  export let activeStorageBackend = "local";
127
128
  export async function getStorage() {
129
+ if (storageInitialization)
130
+ return storageInitialization;
131
+ if (storageInstance)
132
+ return storageInstance;
133
+ storageInitialization = initializeStorage();
134
+ try {
135
+ return await storageInitialization;
136
+ }
137
+ finally {
138
+ storageInitialization = null;
139
+ }
140
+ }
141
+ async function initializeStorage() {
128
142
  if (storageInstance)
129
143
  return storageInstance;
130
144
  const envStorage = process.env.PRISM_STORAGE;
@@ -198,21 +212,33 @@ export async function getStorage() {
198
212
  // ─── Initialize ───────────────────────────────────────────────
199
213
  activeStorageBackend = requested;
200
214
  debugLog(`[Prism Storage] Initializing backend: ${activeStorageBackend}`);
215
+ let instance;
201
216
  if (activeStorageBackend === "synalux") {
202
217
  const { SynaluxStorage } = await import("./synalux.js");
203
- storageInstance = new SynaluxStorage();
218
+ instance = new SynaluxStorage();
204
219
  }
205
220
  else if (activeStorageBackend === "supabase") {
206
- storageInstance = new SupabaseStorage();
221
+ instance = new SupabaseStorage();
207
222
  }
208
223
  else if (activeStorageBackend === "local") {
209
224
  const { SqliteStorage } = await import("./sqlite.js");
210
- storageInstance = new SqliteStorage();
225
+ instance = new SqliteStorage();
211
226
  }
212
227
  else {
213
228
  throw new Error(`Unknown PRISM_STORAGE value: "${activeStorageBackend}".`);
214
229
  }
215
- await storageInstance.initialize(activeStorageBackend === "local");
230
+ try {
231
+ await instance.initialize(activeStorageBackend === "local");
232
+ }
233
+ catch (error) {
234
+ try {
235
+ await instance.close();
236
+ }
237
+ catch (cleanupError) {
238
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; storage cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: error });
239
+ }
240
+ throw error;
241
+ }
216
242
  // ─── Cross-backend reconciliation (local + Supabase available) ─
217
243
  // v9.2.4: when running on local SQLite but Supabase credentials exist, pull
218
244
  // newer handoffs from Supabase into SQLite. Fixes the split-brain where one
@@ -222,16 +248,19 @@ export async function getStorage() {
222
248
  try {
223
249
  const { reconcileHandoffs } = await import("./reconcile.js");
224
250
  const { SqliteStorage } = await import("./sqlite.js");
225
- const sqliteInstance = storageInstance;
226
- await reconcileHandoffs(storageInstance, () => sqliteInstance.getHandoffTimestamps());
251
+ const sqliteInstance = instance;
252
+ await reconcileHandoffs(instance, () => sqliteInstance.getHandoffTimestamps());
227
253
  }
228
254
  catch (err) {
229
255
  debugLog(`[Prism Storage] Reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`);
230
256
  }
231
257
  }
232
- return storageInstance;
258
+ storageInstance = instance;
259
+ return instance;
233
260
  }
234
261
  export async function closeStorage() {
262
+ if (storageInitialization)
263
+ await storageInitialization.catch(() => { });
235
264
  if (!storageInstance)
236
265
  return;
237
266
  const closing = storageInstance;
@@ -246,6 +275,7 @@ export async function closeStorage() {
246
275
  }
247
276
  finally {
248
277
  storageInstance = null;
278
+ storageInitialization = null;
249
279
  }
250
280
  }
251
281
  /**
@@ -264,4 +294,5 @@ export async function closeStorage() {
264
294
  */
265
295
  export function _setStorageForTesting(instance) {
266
296
  storageInstance = instance;
297
+ storageInitialization = null;
267
298
  }
@@ -17,6 +17,7 @@
17
17
  * 3. Add a schema-contract test in synalux-portal-contract.test.ts.
18
18
  */
19
19
  import { z } from "zod";
20
+ import { TestAssertionSchema } from '../verification/schema.js';
20
21
  import { ANALYTICS_DATE_PATTERN } from "../dashboard/readMessages.js";
21
22
  const AnalyticsCountSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
22
23
  const UTC_DAY_MS = 24 * 60 * 60 * 1000;
@@ -40,6 +41,60 @@ export const ProjectAnalyticsResponseSchema = z.object({
40
41
  project: z.string().min(1).max(100),
41
42
  analytics: ProjectAnalyticsSchema,
42
43
  });
44
+ // Factory reads fail closed: corrupt rows must not become an empty or partial list.
45
+ const PipelineTextSchema = z.string().refine(value => value.trim().length > 0);
46
+ const PipelineTimestampSchema = z.string().refine(value => Number.isFinite(Date.parse(value)));
47
+ const PipelineSpecSchema = z.string().refine(value => {
48
+ try {
49
+ const spec = JSON.parse(value);
50
+ return spec !== null && typeof spec === 'object' && !Array.isArray(spec);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ });
56
+ export const FactoryPipelineSchema = z.object({
57
+ id: PipelineTextSchema,
58
+ project: PipelineTextSchema,
59
+ user_id: PipelineTextSchema,
60
+ status: z.enum(['PENDING', 'RUNNING', 'PAUSED', 'ABORTED', 'COMPLETED', 'FAILED']),
61
+ current_step: PipelineTextSchema,
62
+ iteration: AnalyticsCountSchema,
63
+ eval_revisions: AnalyticsCountSchema.optional(),
64
+ started_at: PipelineTimestampSchema,
65
+ updated_at: PipelineTimestampSchema,
66
+ spec: PipelineSpecSchema,
67
+ error: z.string().nullable().optional(),
68
+ last_heartbeat: PipelineTimestampSchema.nullable().optional(),
69
+ contract_payload: z.object({
70
+ criteria: z.array(z.object({ id: z.string(), description: z.string() }).passthrough()),
71
+ }).passthrough().nullable().optional(),
72
+ notes: z.string().nullable().optional(),
73
+ }).passthrough();
74
+ export const FactoryPipelinesResponseSchema = z.object({
75
+ status: z.literal('success'),
76
+ pipelines: z.array(FactoryPipelineSchema).max(100)
77
+ .refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
78
+ });
79
+ export const FactoryPipelineResponseSchema = z.object({
80
+ status: z.literal('success'),
81
+ pipeline: FactoryPipelineSchema.nullable(),
82
+ });
83
+ const VerificationHarnessSchema = z.object({
84
+ project: z.string().min(1), conversation_id: z.string().min(1), created_at: PipelineTimestampSchema,
85
+ rubric_hash: z.string().regex(/^[a-f0-9]{64}$/), min_pass_rate: z.number().min(0).max(1),
86
+ tests: z.array(z.custom(value => TestAssertionSchema.safeParse(value).success)), metadata: z.record(z.string(), z.unknown()).optional(),
87
+ });
88
+ const VerificationRunSchema = z.object({
89
+ id: z.string().min(1), rubric_hash: z.string().regex(/^[a-f0-9]{64}$/), project: z.string().min(1),
90
+ conversation_id: z.string().min(1), run_at: PipelineTimestampSchema, passed: z.boolean(),
91
+ pass_rate: z.number().min(0).max(1), critical_failures: AnalyticsCountSchema,
92
+ coverage_score: z.number().min(0).max(1), result_json: PipelineSpecSchema,
93
+ gate_action: z.enum(['continue', 'block', 'abort']), gate_override: z.boolean().optional(), override_reason: z.string().optional(),
94
+ });
95
+ export const FactoryHarnessResponseSchema = z.object({ status: z.literal('success'), harness: VerificationHarnessSchema.nullable(), reused: z.boolean().optional() });
96
+ export const FactoryRunResponseSchema = z.object({ status: z.literal('success'), run: VerificationRunSchema.nullable() });
97
+ export const FactoryRunsResponseSchema = z.object({ status: z.literal('success'), runs: z.array(VerificationRunSchema).max(100).refine(runs => new Set(runs.map(run => run.id)).size === runs.length) });
43
98
  // ─── knowledge_search ────────────────────────────────────────────
44
99
  export const KnowledgeSearchRequestSchema = z.object({
45
100
  action: z.literal("knowledge_search"),
@@ -3105,10 +3105,11 @@ export class SqliteStorage {
3105
3105
  }
3106
3106
  // ─── Dark Factory (v7.3) ───────────────────────────────────
3107
3107
  async savePipeline(state) {
3108
- const now = new Date().toISOString();
3109
- const updatedState = { ...state, updated_at: now };
3110
3108
  // Status Guard: prevent overwriting a terminated pipeline
3111
3109
  const existing = await this.getPipeline(state.id, state.user_id);
3110
+ const clock = Date.now();
3111
+ const prior = existing ? Date.parse(existing.updated_at) : 0;
3112
+ const updatedState = { ...state, updated_at: new Date(Math.max(clock, prior + 1)).toISOString() };
3112
3113
  if (existing) {
3113
3114
  if (existing.status === 'ABORTED' || existing.status === 'COMPLETED') {
3114
3115
  throw new Error(`Cannot update pipeline ${state.id} because it is already ${existing.status}.`);
@@ -3120,7 +3121,7 @@ export class SqliteStorage {
3120
3121
  `${SafetyController.getLegalTransitions(existing.status).join(', ') || 'NONE (terminal)'}.`);
3121
3122
  }
3122
3123
  }
3123
- await this.db.execute({
3124
+ const result = await this.db.execute({
3124
3125
  sql: `
3125
3126
  INSERT INTO dark_factory_pipelines (id, project, user_id, status, current_step, iteration, eval_revisions, started_at, updated_at, spec, error, last_heartbeat, contract_payload, notes)
3126
3127
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -3135,6 +3136,9 @@ export class SqliteStorage {
3135
3136
  last_heartbeat = excluded.last_heartbeat,
3136
3137
  contract_payload = excluded.contract_payload,
3137
3138
  notes = excluded.notes
3139
+ WHERE dark_factory_pipelines.user_id = excluded.user_id
3140
+ AND dark_factory_pipelines.updated_at = ?
3141
+ AND dark_factory_pipelines.status NOT IN ('ABORTED', 'COMPLETED')
3138
3142
  `,
3139
3143
  args: [
3140
3144
  updatedState.id,
@@ -3150,9 +3154,14 @@ export class SqliteStorage {
3150
3154
  updatedState.error || null,
3151
3155
  updatedState.last_heartbeat || null,
3152
3156
  updatedState.contract_payload ? JSON.stringify(updatedState.contract_payload) : null,
3153
- updatedState.notes || null
3157
+ updatedState.notes || null,
3158
+ existing ? state.updated_at : null
3154
3159
  ]
3155
3160
  });
3161
+ if (result.rowsAffected !== 1) {
3162
+ throw new Error(`Pipeline ${state.id} changed concurrently or is no longer writable.`);
3163
+ }
3164
+ state.updated_at = updatedState.updated_at;
3156
3165
  }
3157
3166
  async getPipeline(id, userId) {
3158
3167
  const result = await this.db.execute({
@@ -1283,10 +1283,11 @@ export class SupabaseStorage {
1283
1283
  }
1284
1284
  // ─── Dark Factory (v7.3) ───────────────────────────────────
1285
1285
  async savePipeline(state) {
1286
- const now = new Date().toISOString();
1287
- const updatedState = { ...state, updated_at: now };
1288
1286
  // Status Guard: prevent overwriting a terminated pipeline
1289
1287
  const existing = await this.getPipeline(state.id, state.user_id);
1288
+ const clock = Date.now();
1289
+ const prior = existing ? Date.parse(existing.updated_at) : 0;
1290
+ const updatedState = { ...state, updated_at: new Date(Math.max(clock, prior + 1)).toISOString() };
1290
1291
  if (existing) {
1291
1292
  if (existing.status === 'ABORTED' || existing.status === 'COMPLETED') {
1292
1293
  throw new Error(`Cannot update pipeline ${state.id} because it is already ${existing.status}.`);
@@ -1299,7 +1300,26 @@ export class SupabaseStorage {
1299
1300
  }
1300
1301
  }
1301
1302
  try {
1302
- await supabasePost("dark_factory_pipelines", {
1303
+ if (existing) {
1304
+ const result = await supabasePatch("dark_factory_pipelines", {
1305
+ user_id: updatedState.user_id,
1306
+ status: updatedState.status,
1307
+ current_step: updatedState.current_step,
1308
+ iteration: updatedState.iteration,
1309
+ updated_at: updatedState.updated_at,
1310
+ spec: updatedState.spec,
1311
+ error: updatedState.error || null,
1312
+ last_heartbeat: updatedState.last_heartbeat || null,
1313
+ eval_revisions: updatedState.eval_revisions ?? 0,
1314
+ contract_payload: updatedState.contract_payload ? JSON.stringify(updatedState.contract_payload) : null,
1315
+ notes: updatedState.notes || null,
1316
+ }, { id: `eq.${state.id}`, user_id: `eq.${state.user_id}`, updated_at: `eq.${state.updated_at}`, status: `eq.${existing.status}` }, { Prefer: "return=representation" });
1317
+ if (!Array.isArray(result) || result.length !== 1)
1318
+ throw new Error(`Pipeline ${state.id} changed concurrently or is no longer writable.`);
1319
+ state.updated_at = updatedState.updated_at;
1320
+ return;
1321
+ }
1322
+ const inserted = await supabasePost("dark_factory_pipelines", {
1303
1323
  id: updatedState.id,
1304
1324
  project: updatedState.project,
1305
1325
  user_id: updatedState.user_id,
@@ -1317,13 +1337,15 @@ export class SupabaseStorage {
1317
1337
  ? JSON.stringify(updatedState.contract_payload)
1318
1338
  : null,
1319
1339
  notes: updatedState.notes || null,
1320
- }, { on_conflict: "id" }, { Prefer: "return=minimal,resolution=merge-duplicates" });
1340
+ }, { on_conflict: "id" }, { Prefer: "return=representation" });
1341
+ if (!Array.isArray(inserted) || inserted.length !== 1)
1342
+ throw new Error(`Pipeline ${state.id} was not created.`);
1321
1343
  }
1322
1344
  catch (e) {
1323
1345
  // PGRST202 fallback if the table doesn't exist yet
1324
1346
  if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation")) {
1325
1347
  debugLog("[SupabaseStorage] dark_factory_pipelines missing — please run migration 038");
1326
- return;
1348
+ throw new Error(`Pipeline ${state.id} was not saved because dark_factory_pipelines is unavailable.`);
1327
1349
  }
1328
1350
  throw e;
1329
1351
  }
@@ -31,6 +31,7 @@
31
31
  * - getEntriesMissingEmbeddings → POST /api/v1/prism/memory action=list_missing_embeddings
32
32
  * - listProjects → POST /api/v1/prism/memory action=list_projects
33
33
  * - getAnalytics → GET /api/v1/prism/analytics view=project
34
+ * - listPipelines → GET /api/v1/prism/factory/pipelines
34
35
  * - exportLedger → POST /api/v1/prism/memory action=export_memory (paginated)
35
36
  *
36
37
  * Methods still falling through to SupabaseStorage (Phase 3 Tier B+):
@@ -52,7 +53,7 @@ import { SupabaseStorage } from "./supabase.js";
52
53
  import { debugLog } from "../utils/logger.js";
53
54
  import { PRISM_SYNALUX_BASE_URL, PRISM_SYNALUX_API_KEY } from "../config.js";
54
55
  import { isSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
55
- import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema, ProjectAnalyticsResponseSchema } from "./portalContracts.js";
56
+ import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema, ProjectAnalyticsResponseSchema, FactoryPipelinesResponseSchema, FactoryPipelineResponseSchema, FactoryHarnessResponseSchema, FactoryRunResponseSchema, FactoryRunsResponseSchema } from "./portalContracts.js";
56
57
  function resolveKnowledgeScope(callerScope) {
57
58
  if (callerScope === "user" || callerScope === "workspace") {
58
59
  return callerScope;
@@ -77,6 +78,7 @@ export class SynaluxStorage extends SupabaseStorage {
77
78
  inflightContextLoads = new Map();
78
79
  closeController = new AbortController();
79
80
  closed = false;
81
+ factoryReady = false;
80
82
  constructor() {
81
83
  super();
82
84
  const url = process.env.PRISM_SYNALUX_BASE_URL || PRISM_SYNALUX_BASE_URL;
@@ -94,9 +96,18 @@ export class SynaluxStorage extends SupabaseStorage {
94
96
  }
95
97
  async initialize(_isLocal = false) {
96
98
  debugLog(`[SynaluxStorage] Initializing (portal=${this.baseUrl})`);
99
+ try {
100
+ const capabilities = await this.portalPost('/api/v1/prism/factory/verification', { action: 'capabilities' });
101
+ this.factoryReady = capabilities.status === 'success' && capabilities.execution_ready === true;
102
+ }
103
+ catch {
104
+ this.factoryReady = false;
105
+ }
97
106
  }
107
+ isFactoryReady() { return !this.closed && !isSynaluxSignedOut() && this.factoryReady; }
98
108
  async close() {
99
109
  this.closed = true;
110
+ this.factoryReady = false;
100
111
  this.cachedJwt = null;
101
112
  this.cachedJwtExpiresAt = 0;
102
113
  this.closeController.abort();
@@ -758,6 +769,71 @@ export class SynaluxStorage extends SupabaseStorage {
758
769
  created_at: v.created_at ?? new Date().toISOString(),
759
770
  }));
760
771
  }
772
+ // ─── Factory read path ────────────────────────────────────────
773
+ async getPipeline(id, _userId) {
774
+ const result = FactoryPipelineResponseSchema.safeParse(await this.portalGet(`/api/v1/prism/factory/pipeline?id=${encodeURIComponent(id)}`));
775
+ if (!result.success || (result.data.pipeline && result.data.pipeline.id !== id))
776
+ throw new Error('[SynaluxStorage] Invalid Factory pipeline response');
777
+ return result.data.pipeline;
778
+ }
779
+ async saveVerificationHarness(harness, _userId) {
780
+ const result = FactoryHarnessResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/verification', { action: 'save_harness', harness }));
781
+ if (!result.success || !result.data.harness || result.data.harness.rubric_hash !== harness.rubric_hash
782
+ || result.data.harness.min_pass_rate !== harness.min_pass_rate
783
+ || (!result.data.reused && (result.data.harness.project !== harness.project || result.data.harness.conversation_id !== harness.conversation_id)))
784
+ throw new Error('[SynaluxStorage] Invalid verification harness save response');
785
+ }
786
+ async getVerificationHarness(rubric_hash, _userId) {
787
+ const result = FactoryHarnessResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/verification', { action: 'get_harness', rubric_hash }));
788
+ if (!result.success || (result.data.harness && result.data.harness.rubric_hash !== rubric_hash))
789
+ throw new Error('[SynaluxStorage] Invalid verification harness response');
790
+ return result.data.harness;
791
+ }
792
+ async saveVerificationRun(run, _userId) {
793
+ const result = FactoryRunResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/verification', { action: 'save_run', run }));
794
+ if (!result.success || !result.data.run || result.data.run.id !== run.id || result.data.run.rubric_hash !== run.rubric_hash
795
+ || result.data.run.project !== run.project || result.data.run.conversation_id !== run.conversation_id || result.data.run.result_json !== run.result_json)
796
+ throw new Error('[SynaluxStorage] Invalid verification run save response');
797
+ }
798
+ async getVerificationRun(id, _userId) {
799
+ const result = FactoryRunResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/verification', { action: 'get_run', id }));
800
+ if (!result.success || (result.data.run && result.data.run.id !== id))
801
+ throw new Error('[SynaluxStorage] Invalid verification run response');
802
+ return result.data.run;
803
+ }
804
+ async listVerificationRuns(project, _userId) {
805
+ const result = FactoryRunsResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/verification', { action: 'list_runs', project }));
806
+ if (!result.success || result.data.runs.some(run => run.project !== project))
807
+ throw new Error('[SynaluxStorage] Invalid verification runs response');
808
+ return result.data.runs;
809
+ }
810
+ async savePipeline(state) {
811
+ const { user_id: _userId, ...pipeline } = state;
812
+ const create = state.status === 'PENDING' && state.current_step === 'INIT' && state.iteration === 0 && state.started_at === state.updated_at;
813
+ const result = FactoryPipelineResponseSchema.safeParse(await this.portalPost('/api/v1/prism/factory/pipeline', {
814
+ operation: create ? 'create' : 'update', pipeline,
815
+ ...(create ? {} : { expected_updated_at: state.updated_at }),
816
+ }));
817
+ if (!result.success || !result.data.pipeline || result.data.pipeline.id !== state.id || result.data.pipeline.project !== state.project
818
+ || result.data.pipeline.status !== state.status || result.data.pipeline.current_step !== state.current_step
819
+ || result.data.pipeline.iteration !== state.iteration || result.data.pipeline.spec !== state.spec
820
+ || Date.parse(result.data.pipeline.updated_at) <= Date.parse(state.updated_at))
821
+ throw new Error('[SynaluxStorage] Invalid Factory save response');
822
+ state.updated_at = result.data.pipeline.updated_at;
823
+ state.user_id = result.data.pipeline.user_id;
824
+ }
825
+ async listPipelines(project, status, _userId) {
826
+ const query = new URLSearchParams();
827
+ if (project !== undefined)
828
+ query.set('project', project);
829
+ if (status !== undefined)
830
+ query.set('status', status);
831
+ const result = FactoryPipelinesResponseSchema.safeParse(await this.portalGet('/api/v1/prism/factory/pipelines' + (query.size ? '?' + query : '')));
832
+ if (!result.success || result.data.pipelines.some(row => (project !== undefined && row.project !== project) || (status !== undefined && row.status !== status))) {
833
+ throw new Error('Factory pipelines unavailable');
834
+ }
835
+ return result.data.pipelines;
836
+ }
761
837
  // ─── Health Check ─────────────────────────────────────────────
762
838
  async getAnalytics(project, _userId) {
763
839
  const query = new URLSearchParams({ view: 'project', project });
@@ -37,6 +37,9 @@ import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdt
37
37
  import { resolveProject } from "../utils/projectResolver.js";
38
38
  import { getUpdateNotice } from "../updateNotice.js";
39
39
  import { findRunningDashboardAccessState } from "../dashboard/dashboardAccess.js";
40
+ import { isDarkFactoryEnabled } from "../darkfactory/enablement.js";
41
+ import { routeFactoryPrompt, formatFactoryPromptRoute } from "../darkfactory/promptRouting.js";
42
+ import { factoryBackendReadiness } from "../darkfactory/backendReadiness.js";
40
43
  // The running server's own version, for the update-available notice. A read
41
44
  // failure must never affect startup: an empty string fails the notice's
42
45
  // semver gate, so no notice renders rather than a bogus "running 0.0.0".
@@ -2317,10 +2320,20 @@ export async function sessionRoutePromptHandler(args) {
2317
2320
  ? input.loaded.filter((n) => typeof n === "string")
2318
2321
  : [];
2319
2322
  const result = await runPromptRouteFromCache(prompt, loaded);
2323
+ const project = typeof input.project === "string" ? input.project : undefined;
2324
+ const workingDirectory = typeof input.working_directory === "string" ? input.working_directory :
2325
+ (project ? await getSetting(`repo_path:${project}`, "") : "") || undefined;
2326
+ const route = routeFactoryPrompt(prompt, {
2327
+ enabled: isDarkFactoryEnabled(), project, working_directory: workingDirectory,
2328
+ files_involved: Array.isArray(input.files_involved) ? input.files_involved.filter((file) => typeof file === "string") : undefined,
2329
+ test_command: typeof input.test_command === "string" ? input.test_command : undefined,
2330
+ ...(await factoryBackendReadiness()),
2331
+ allowed_write_paths: Array.isArray(input.allowed_write_paths) ? input.allowed_write_paths.filter((file) => typeof file === "string") : undefined,
2332
+ });
2320
2333
  // Text only, never structuredContent. A result carrying both lets a host
2321
2334
  // surface just the structured half — which is exactly how Claude Code
2322
2335
  // silently dropped the bootstrap payload for three weeks.
2323
- return { content: [{ type: "text", text: result.text }] };
2336
+ return { content: [{ type: "text", text: `${result.text}\n\n${formatFactoryPromptRoute(route)}` }] };
2324
2337
  }
2325
2338
  export async function sessionBootstrapHandler(args = {}, options = {}) {
2326
2339
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
@@ -2357,7 +2370,16 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2357
2370
  : "developer";
2358
2371
  const role = sanitizeNativeIdentity(defaultRole) || "global";
2359
2372
  const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
2360
- const { text: systemReadyBlock, floorDigestChars } = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
2373
+ const { text: baseSystemReadyBlock, floorDigestChars } = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
2374
+ const startupProject = projects.length === 1 ? projects[0] : undefined;
2375
+ const startupFactoryRoute = routeFactoryPrompt(typeof input.prompt === "string" ? input.prompt : "", {
2376
+ enabled: isDarkFactoryEnabled(), project: startupProject,
2377
+ working_directory: startupProject ? await getSetting(`repo_path:${startupProject}`, "") : undefined,
2378
+ ...(await factoryBackendReadiness()),
2379
+ });
2380
+ const factoryStatusLine = `- 🏭 **Factory:** ${isDarkFactoryEnabled() ? "enabled" : "disabled"} for this MCP startup; enablement does not prove execution readiness.`;
2381
+ const systemReadyBlock = `${baseSystemReadyBlock}\n${factoryStatusLine}` +
2382
+ (startupFactoryRoute.candidate ? `\n${formatFactoryPromptRoute(startupFactoryRoute)}\nFor a suitable request, supply the current workspace and verification command through session_route_prompt, then automatically call its recommended pipeline tool.` : "");
2361
2383
  // First run = the dashboard has never been touched: no agent identity AND no
2362
2384
  // projects. Measured 2026-08-05: a brand-new free-tier install was greeted
2363
2385
  // with "Welcome back", three "Not loaded" rows, a warning, and three
@@ -3,7 +3,7 @@ export const SESSION_START_PIPELINE_TOOL = {
3
3
  name: "session_start_pipeline",
4
4
  description: "Start an autonomous Dark Factory pipeline. The pipeline runs in the background " +
5
5
  "and executes a PLAN → EXECUTE → VERIFY cycle up to `max_iterations` times.\n\n" +
6
- "**Requires:** `PRISM_DARK_FACTORY_ENABLED=true` in the environment.\n\n" +
6
+ "**Requires:** Dark Factory is enabled at startup (enabled by default; the dashboard setting applies when no explicit `PRISM_DARK_FACTORY_ENABLED=true|false` override is present).\n\n" +
7
7
  "**How it works:**\n" +
8
8
  "1. Call this tool with an objective (what to accomplish)\n" +
9
9
  "2. The pipeline is queued and executes autonomously in the background\n" +
@@ -45,6 +45,8 @@ export const SESSION_START_PIPELINE_TOOL = {
45
45
  description: "Optional model name to use instead of the default LLM. " +
46
46
  "Useful for routing to a local model (e.g., 'qwen3') via Claw.",
47
47
  },
48
+ test_command: { type: "string", description: "Caller-authorized verification command. Factory runs this without a shell and requires it to pass." },
49
+ allowed_write_paths: { type: "array", items: { type: "string" }, description: "Relative files Factory may change. Other context files remain read-only." },
48
50
  },
49
51
  required: ["project", "objective"],
50
52
  },
@@ -109,6 +111,10 @@ export function isStartPipelineArgs(args) {
109
111
  return false;
110
112
  if (a.model_override !== undefined && typeof a.model_override !== "string")
111
113
  return false;
114
+ if (a.test_command !== undefined && (typeof a.test_command !== "string" || !a.test_command.trim()))
115
+ return false;
116
+ if (a.allowed_write_paths !== undefined && (!Array.isArray(a.allowed_write_paths) || !a.allowed_write_paths.every((f) => typeof f === "string" && f.trim())))
117
+ return false;
112
118
  return true;
113
119
  }
114
120
  export function isCheckPipelineStatusArgs(args) {
@@ -10,11 +10,13 @@
10
10
  * all other tools in /tools/*.ts.
11
11
  */
12
12
  import { randomUUID } from 'crypto';
13
+ import { resolve } from 'node:path';
13
14
  import { getStorage } from '../storage/index.js';
14
15
  import { PRISM_USER_ID } from '../config.js';
15
16
  import { getSettingSync } from '../storage/configStorage.js';
16
17
  import { isStartPipelineArgs, isCheckPipelineStatusArgs, isAbortPipelineArgs, } from './pipelineDefinitions.js';
17
18
  import { debugLog } from '../utils/logger.js';
19
+ import { registerPipelineAuthority } from '../darkfactory/executionAuthority.js';
18
20
  // ─── Start Pipeline Handler ─────────────────────────────────
19
21
  export async function sessionStartPipelineHandler(args) {
20
22
  if (!isStartPipelineArgs(args)) {
@@ -23,7 +25,7 @@ export async function sessionStartPipelineHandler(args) {
23
25
  isError: true,
24
26
  };
25
27
  }
26
- const { project, objective, working_directory, max_iterations, context_files, model_override } = args;
28
+ const { project, objective, working_directory, max_iterations, context_files, model_override, test_command, allowed_write_paths } = args;
27
29
  // Resolve working directory: explicit arg > dashboard repo_path > reject
28
30
  let resolvedWorkDir = working_directory;
29
31
  if (!resolvedWorkDir) {
@@ -37,6 +39,7 @@ export async function sessionStartPipelineHandler(args) {
37
39
  };
38
40
  }
39
41
  }
42
+ resolvedWorkDir = resolve(resolvedWorkDir);
40
43
  const pipelineId = randomUUID();
41
44
  const now = new Date().toISOString();
42
45
  const spec = {
@@ -45,6 +48,8 @@ export async function sessionStartPipelineHandler(args) {
45
48
  workingDirectory: resolvedWorkDir,
46
49
  contextFiles: context_files,
47
50
  modelOverride: model_override,
51
+ testCommand: test_command,
52
+ allowedWritePaths: allowed_write_paths,
48
53
  };
49
54
  const pipelineState = {
50
55
  id: pipelineId,
@@ -62,6 +67,18 @@ export async function sessionStartPipelineHandler(args) {
62
67
  try {
63
68
  const storage = await getStorage();
64
69
  await storage.savePipeline(pipelineState);
70
+ try {
71
+ await registerPipelineAuthority(pipelineState);
72
+ }
73
+ catch (error) {
74
+ try {
75
+ await storage.savePipeline({ ...pipelineState, status: 'ABORTED', error: 'Local execution authorization could not be persisted.' });
76
+ }
77
+ catch (abortError) {
78
+ throw new Error(`Local execution authorization failed; cancelling the queued pipeline also failed: ${abortError instanceof Error ? abortError.message : String(abortError)}`, { cause: error });
79
+ }
80
+ throw error;
81
+ }
65
82
  debugLog(`[PipelineHandler] Pipeline ${pipelineId} created for project=${project} objective="${objective.slice(0, 80)}"`);
66
83
  return {
67
84
  content: [{
@@ -184,7 +184,9 @@ export const SESSION_ROUTE_PROMPT_TOOL = {
184
184
  "table and the same account-scoped frontmatter triggers; it never leaves the machine. Calling it is cheap: when " +
185
185
  "nothing new matches it returns a single line, and anything named in `loaded` is never returned again. When it " +
186
186
  "does return skills, read and follow them before doing the work \u2014 they are the rules for the task at hand, " +
187
- "not background. Skip it only for follow-ups within work you have already routed.",
187
+ "not background. It also checks bounded implementation requests for Factory. Supply the current project, working_directory, files_involved and test_command when known. " +
188
+ "When prism_factory_route recommends session_start_pipeline, call it automatically with recommended_args, then report the pipeline id and monitor it. " +
189
+ "Routing itself does not execute code. Skip it only for follow-ups within work you have already routed.",
188
190
  annotations: {
189
191
  readOnlyHint: true,
190
192
  destructiveHint: false,
@@ -208,6 +210,10 @@ export const SESSION_ROUTE_PROMPT_TOOL = {
208
210
  type: "string",
209
211
  description: "Optional project override. Omit to use the session's project.",
210
212
  },
213
+ working_directory: { type: "string", description: "Current repository's absolute path for a bounded Factory task." },
214
+ files_involved: { type: "array", items: { type: "string" }, description: "Relative code files the current implementation task concerns." },
215
+ test_command: { type: "string", description: "Repository verification command, supplied by the host from the request or repository instructions." },
216
+ allowed_write_paths: { type: "array", items: { type: "string" }, description: "Relative files the user authorized Factory to change." },
211
217
  },
212
218
  required: ["prompt"],
213
219
  },