@guanlin4924/devflow-pi 0.1.70 → 0.1.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.70";
2
+ export const PRODUCT_VERSION = "0.1.71";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { copyFile, cp, mkdir, readFile, realpath, stat, writeFile, rename, rm } from "node:fs/promises";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { Database } from "bun:sqlite";
5
6
 
@@ -263,12 +264,33 @@ export class InitializationStore {
263
264
  await writeFile(migrationBackup, db.serialize(), { flag: "wx" });
264
265
  }
265
266
  }
266
- db.transaction(() => {
267
+ const migrate = db.transaction(() => {
267
268
  db.exec(SCHEMA);
269
+ const ensureColumn = (table: string, column: string, definition: string) => {
270
+ const columns = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
271
+ if (!columns.some(item => item.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
272
+ };
273
+ ensureColumn("context_snapshots", "index_ref_json", "TEXT");
274
+ ensureColumn("document_jobs", "model_role", "TEXT");
275
+ ensureColumn("document_jobs", "output_hash", "TEXT");
276
+ ensureColumn("document_jobs", "conflict_path", "TEXT");
277
+ ensureColumn("document_jobs", "error", "TEXT");
268
278
  const meta = db.query("SELECT version FROM schema_meta LIMIT 1").get() as { version: number } | null;
269
- if (!meta) db.query("INSERT INTO schema_meta(version) VALUES (?)").run(INITIALIZATION_SCHEMA_VERSION);
270
- else if (meta.version < INITIALIZATION_SCHEMA_VERSION) db.query("UPDATE schema_meta SET version=?").run(INITIALIZATION_SCHEMA_VERSION);
271
- })();
279
+ if (!meta) {
280
+ db.query("INSERT INTO schema_meta(version) VALUES (?)").run(INITIALIZATION_SCHEMA_VERSION);
281
+ } else {
282
+ let version = meta.version;
283
+ if (version < 2) {
284
+ db.exec("CREATE INDEX IF NOT EXISTS idx_input_snapshots_project ON input_snapshots(project_id, workspace_id, config_id)");
285
+ db.exec("CREATE INDEX IF NOT EXISTS idx_document_jobs_status ON document_jobs(status, updated_at)");
286
+ version = 2;
287
+ }
288
+ db.query("UPDATE schema_meta SET version=?").run(version);
289
+ }
290
+ const verified = db.query("SELECT version FROM schema_meta LIMIT 1").get() as { version: number };
291
+ if (verified.version !== INITIALIZATION_SCHEMA_VERSION) throw new Error(`Initialization schema migration stopped at ${verified.version}`);
292
+ });
293
+ try { migrate(); } catch (error) { db.close(); throw new Error(`Initialization database migration failed: ${error instanceof Error ? error.message : String(error)}`); }
272
294
  db.query("INSERT OR IGNORE INTO projects(id,name,created_at) VALUES (?,?,?)").run(context.manifest.projectId, context.manifest.name, now());
273
295
  db.query("INSERT OR IGNORE INTO authority_state(project_id,authority,revision,switched_at) VALUES (?,?,?,?)").run(context.manifest.projectId, "legacy", 0, now());
274
296
  return new InitializationStore(context, databasePath, db);
@@ -311,13 +333,23 @@ export class InitializationStore {
311
333
  return { id, revision: row.revision, created: true };
312
334
  }
313
335
 
314
- createInputSnapshot(workspaceId: string, configId: string, files: Array<{ repository: string; path: string; contentHash: string; size?: number }>, stable = true): { id: string; fingerprint: string; created: boolean } {
336
+ createInputSnapshot(workspaceId: string, configId: string, files: Array<{ repository: string; path: string; contentHash: string; size?: number }>, stable = false): { id: string; fingerprint: string; created: boolean } {
337
+ const binding = this.db.query(`SELECT w.id AS workspaceId,c.id AS configId,w.project_id AS workspaceProject,c.project_id AS configProject,c.config_json AS config
338
+ FROM workspaces w JOIN scan_configs c ON c.project_id=w.project_id
339
+ WHERE w.id=? AND c.id=? AND w.project_id=?`).get(workspaceId, configId, this.context.manifest.projectId) as { workspaceId: string; configId: string; workspaceProject: string; configProject: string; config: string } | null;
340
+ if (!binding) throw new Error("Input snapshot workspace and scan config do not belong to the same project boundary");
341
+ const configured = new Map((parse<ScanConfigInput>(binding.config).repositories ?? []).map(repository => [repository.name, repository.relativePath]));
342
+ for (const file of files) {
343
+ if (!configured.has(file.repository)) throw new Error(`Input snapshot repository is not configured: ${file.repository}`);
344
+ const base = configured.get(file.repository)!;
345
+ if (base !== "." && !path.isAbsolute(base) && file.path !== base && !file.path.startsWith(`${base.replace(/\\/g, "/").replace(/\/$/, "")}/`)) throw new Error(`Input snapshot path is outside repository: ${file.repository}/${file.path}`);
346
+ }
315
347
  const normalized = files.map(file => ({ ...file, path: validateRelative(file.path) })).sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`));
316
348
  const manifestJson = canonicalJson(normalized);
317
349
  const fingerprint = hash(manifestJson);
318
350
  const existing = this.db.query("SELECT id,stable FROM input_snapshots WHERE workspace_id=? AND config_id=? AND fingerprint=?").get(workspaceId, configId, fingerprint) as { id: string; stable: number } | null;
319
351
  if (existing) {
320
- if (stable && !existing.stable) this.db.query("UPDATE input_snapshots SET stable=1 WHERE id=?").run(existing.id);
352
+ if (stable && !existing.stable) throw new Error("Existing input snapshot is unstable; confirm it explicitly before publishing");
321
353
  return { id: existing.id, fingerprint, created: false };
322
354
  }
323
355
  const id = randomUUID();
@@ -369,27 +401,39 @@ export class InitializationStore {
369
401
  recordCollection(runId: string, leaseToken: string, input: { repository?: string; kind: FactKind; status: CoverageStatus; adapter: string; capability?: Record<string, unknown>; diagnostics?: unknown[]; facts?: FactInput[] }): void {
370
402
  this.assertWritableRun(runId, leaseToken);
371
403
  const transaction = this.db.transaction(() => {
372
- this.db.query("INSERT INTO collection_results(id,run_id,repository,kind,status,adapter,capability_json,diagnostics_json,created_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,repository,kind) DO UPDATE SET status=excluded.status,adapter=excluded.adapter,capability_json=excluded.capability_json,diagnostics_json=excluded.diagnostics_json").run(randomUUID(), runId, input.repository ?? "", input.kind, input.status, input.adapter, canonicalJson(input.capability ?? {}), canonicalJson(input.diagnostics ?? []), now());
404
+ const repository = input.repository ?? "";
405
+ this.db.query("DELETE FROM evidence WHERE fact_id IN (SELECT id FROM facts WHERE run_id=? AND repository=? AND kind=?)").run(runId, repository, input.kind);
406
+ this.db.query("DELETE FROM facts WHERE run_id=? AND repository=? AND kind=?").run(runId, repository, input.kind);
407
+ this.db.query("INSERT INTO collection_results(id,run_id,repository,kind,status,adapter,capability_json,diagnostics_json,created_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,repository,kind) DO UPDATE SET status=excluded.status,adapter=excluded.adapter,capability_json=excluded.capability_json,diagnostics_json=excluded.diagnostics_json,created_at=excluded.created_at").run(randomUUID(), runId, repository, input.kind, input.status, input.adapter, canonicalJson(input.capability ?? {}), canonicalJson(input.diagnostics ?? []), now());
373
408
  for (const fact of input.facts ?? []) {
374
409
  if (fact.kind !== input.kind) throw new Error(`Fact kind ${fact.kind} does not match collection ${input.kind}`);
375
410
  if (!fact.objectKey || !fact.extractor) throw new Error("Facts require objectKey and extractor");
376
411
  if (fact.evidenceLevel === "observed" && !fact.sourcePath) throw new Error("Observed facts require source evidence");
377
412
  validateFactShape(fact);
378
413
  const factId = randomUUID();
379
- this.db.query("INSERT INTO facts(id,run_id,kind,object_key,repository,value_json,evidence_level,extractor,confidence,created_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,kind,repository,object_key) DO UPDATE SET value_json=excluded.value_json,evidence_level=excluded.evidence_level,extractor=excluded.extractor,confidence=excluded.confidence").run(factId, runId, fact.kind, fact.objectKey, fact.repository ?? input.repository ?? "", canonicalJson(fact.value), fact.evidenceLevel, fact.extractor, fact.confidence ?? "inferred", now());
380
- const stored = this.db.query("SELECT id FROM facts WHERE run_id=? AND kind=? AND repository=? AND object_key=?").get(runId, fact.kind, fact.repository ?? input.repository ?? "", fact.objectKey) as { id: string };
414
+ this.db.query("INSERT INTO facts(id,run_id,kind,object_key,repository,value_json,evidence_level,extractor,confidence,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(factId, runId, fact.kind, fact.objectKey, fact.repository ?? repository, canonicalJson(fact.value), fact.evidenceLevel, fact.extractor, fact.confidence ?? "inferred", now());
415
+ const stored = { id: factId };
381
416
  if (fact.sourcePath) this.db.query("INSERT OR IGNORE INTO evidence(id,fact_id,source_path,source_line,source_fingerprint,method,created_at) VALUES (?,?,?,?,?,?,?)").run(randomUUID(), stored.id, validateRelative(fact.sourcePath), fact.sourceLine ?? null, fact.sourceFingerprint ?? "unavailable", fact.extractor, now());
382
417
  }
383
418
  });
384
419
  transaction();
385
420
  }
386
421
 
387
- cancelRun(runId: string): boolean {
388
- return this.db.query("UPDATE initialization_runs SET status='cancelled',updated_at=? WHERE id=? AND status IN ('preparing','collecting','validating')").run(now(), runId).changes === 1;
422
+ cancelRun(runId: string, leaseToken: string, expectedRevision: number): boolean {
423
+ const row = this.db.query("SELECT status,lease_token AS leaseToken,lease_expires_at AS expires,workspace_id AS workspaceId,config_id AS configId FROM initialization_runs WHERE id=?").get(runId) as { status: RunStatus; leaseToken: string; expires: string; workspaceId: string; configId: string } | null;
424
+ if (!row) throw new Error(`Unknown initialization run: ${runId}`);
425
+ if (row.leaseToken !== leaseToken) throw new Error("Initialization lease token mismatch");
426
+ if (Date.parse(row.expires) < Date.now()) throw new Error("Initialization lease expired; recover the run before cancelling");
427
+ const current = this.db.query("SELECT revision FROM current_context WHERE workspace_id=? AND config_id=?").get(row.workspaceId, row.configId) as { revision: number } | null;
428
+ if (expectedRevision !== (current?.revision ?? 0)) throw new Error("Initialization run revision conflict");
429
+ const timestamp = now();
430
+ return this.db.query("UPDATE initialization_runs SET status='cancelled',updated_at=? WHERE id=? AND lease_token=? AND lease_expires_at>? AND status IN ('preparing','collecting','validating')").run(timestamp, runId, leaseToken, timestamp).changes === 1;
389
431
  }
390
432
 
391
433
  failRun(runId: string, leaseToken: string, error: string): boolean {
392
- return this.db.query("UPDATE initialization_runs SET status='failed',error=?,updated_at=? WHERE id=? AND lease_token=? AND status IN ('preparing','collecting','validating')").run(error.slice(0, 4000), now(), runId, leaseToken).changes === 1;
434
+ this.assertWritableRun(runId, leaseToken);
435
+ const timestamp = now();
436
+ return this.db.query("UPDATE initialization_runs SET status='failed',error=?,updated_at=? WHERE id=? AND lease_token=? AND lease_expires_at>? AND status IN ('preparing','collecting','validating')").run(error.slice(0, 4000), timestamp, runId, leaseToken, timestamp).changes === 1;
393
437
  }
394
438
 
395
439
  recoverRun(runId: string, leaseMs = 15 * 60_000): { leaseToken: string } {
@@ -399,11 +443,12 @@ export class InitializationStore {
399
443
  if (["failed", "finished"].includes(row.status)) throw new Error(`Run cannot be recovered from ${row.status}`);
400
444
  if (Date.parse(row.expires) >= Date.now()) throw new Error("Run lease is still active");
401
445
  const leaseToken = randomUUID();
402
- this.db.query("UPDATE initialization_runs SET lease_token=?,lease_expires_at=?,updated_at=? WHERE id=?").run(leaseToken, new Date(Date.now() + leaseMs).toISOString(), now(), runId);
446
+ const changed = this.db.query("UPDATE initialization_runs SET lease_token=?,lease_expires_at=?,updated_at=? WHERE id=? AND lease_expires_at<? AND status IN ('preparing','collecting','validating')").run(leaseToken, new Date(Date.now() + leaseMs).toISOString(), now(), runId, now());
447
+ if (changed.changes !== 1) throw new Error("Initialization run recovery conflict");
403
448
  return { leaseToken };
404
449
  }
405
450
 
406
- publishRun(runId: string, leaseToken: string, options: { expectedRevision?: number; completeness?: "complete" | "partial"; indexRef?: Record<string, unknown>; updateCurrent?: boolean } = {}): { snapshotId: string; revision: number } {
451
+ publishRun(runId: string, leaseToken: string, options: { expectedRevision: number; completeness?: "complete" | "partial"; indexRef?: Record<string, unknown>; updateCurrent?: boolean }): { snapshotId: string; revision: number } {
407
452
  const run = this.assertWritableRun(runId, leaseToken);
408
453
  const input = this.db.query("SELECT stable,fingerprint FROM input_snapshots WHERE id=?").get(run.inputSnapshotId) as { stable: number; fingerprint: string };
409
454
  if (!input.stable) throw new Error("Cannot publish an unstable input snapshot");
@@ -422,12 +467,31 @@ export class InitializationStore {
422
467
  if (completeness === "complete" && blockers.length) throw new Error(`Required collection coverage is incomplete: ${blockers.join(", ")}`);
423
468
  const current = this.db.query("SELECT revision FROM current_context WHERE workspace_id=? AND config_id=?").get(run.workspaceId, run.configId) as { revision: number } | null;
424
469
  const revision = (current?.revision ?? 0) + 1;
425
- if (options.expectedRevision !== undefined && options.expectedRevision !== (current?.revision ?? 0)) throw new Error(`Current context revision conflict: expected ${options.expectedRevision}, actual ${current?.revision ?? 0}`);
470
+ if (options.expectedRevision !== (current?.revision ?? 0)) throw new Error(`Current context revision conflict: expected ${options.expectedRevision}, actual ${current?.revision ?? 0}`);
471
+ if (options.indexRef) {
472
+ if (options.indexRef.inputFingerprint !== input.fingerprint || options.indexRef.workspaceId !== run.workspaceId || options.indexRef.configId !== run.configId) throw new Error("Index reference does not match initialization input snapshot");
473
+ if (options.indexRef.status === "complete" && (typeof options.indexRef.generation !== "string" || typeof options.indexRef.snapshotHash !== "string")) throw new Error("Complete index references require generation and snapshot hash");
474
+ if (options.indexRef.status === "complete") {
475
+ if (typeof options.indexRef.contentManifestHash !== "string" || typeof options.indexRef.repositoryRevision !== "string") throw new Error("Complete index references require content manifest and repository revision");
476
+ const indexRoot = path.resolve(this.context.root, String(options.indexRef.stagingPath ?? options.indexRef.path ?? ".planning/code-index"));
477
+ const immutable = path.resolve(indexRoot, String(options.indexRef.immutablePath ?? `snapshots/${options.indexRef.generation}`));
478
+ const snapshotFile = path.join(immutable, "SNAPSHOT.json");
479
+ if (!existsSync(snapshotFile)) throw new Error("Complete index reference does not point to an immutable generation");
480
+ const actual = hash(readFileSync(snapshotFile));
481
+ if (actual !== options.indexRef.snapshotHash) throw new Error("Code index snapshot hash does not match index reference");
482
+ const payload = JSON.parse(readFileSync(snapshotFile, "utf8")) as { generation?: string };
483
+ if (payload.generation !== options.indexRef.generation) throw new Error("Code index generation does not match SNAPSHOT.json");
484
+ const workspace = (payload as any).workspace ?? {};
485
+ if (workspace.contentManifestHash !== options.indexRef.contentManifestHash) throw new Error("Code index content manifest does not match index reference");
486
+ if (workspace.repositoryRevision !== options.indexRef.repositoryRevision) throw new Error("Code index repository revision does not match index reference");
487
+ }
488
+ }
426
489
  const facts = this.db.query("SELECT kind,repository,object_key,value_json,evidence_level,extractor,confidence FROM facts WHERE run_id=? ORDER BY kind,repository,object_key").all(runId);
427
490
  const snapshotId = randomUUID();
428
491
  const transaction = this.db.transaction(() => {
429
492
  this.db.query("UPDATE initialization_runs SET status='validating',updated_at=? WHERE id=? AND lease_token=?").run(now(), runId, leaseToken);
430
- this.db.query("INSERT INTO context_snapshots(id,project_id,workspace_id,config_id,input_snapshot_id,run_id,completeness,content_hash,index_ref_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(snapshotId, this.context.manifest.projectId, run.workspaceId, run.configId, run.inputSnapshotId, runId, completeness, hash(canonicalJson(facts)), options.indexRef ? canonicalJson(options.indexRef) : null, now());
493
+ const persistedIndexRef = options.indexRef ? Object.fromEntries(Object.entries(options.indexRef).filter(([key]) => key !== "stagingPath")) : null;
494
+ this.db.query("INSERT INTO context_snapshots(id,project_id,workspace_id,config_id,input_snapshot_id,run_id,completeness,content_hash,index_ref_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(snapshotId, this.context.manifest.projectId, run.workspaceId, run.configId, run.inputSnapshotId, runId, completeness, hash(canonicalJson(facts)), persistedIndexRef ? canonicalJson(persistedIndexRef) : null, now());
431
495
  if (options.updateCurrent === false) {
432
496
  // Shadow imports are immutable and queryable without becoming current.
433
497
  } else if (current) {
@@ -484,6 +548,11 @@ export class InitializationStore {
484
548
  return this.queryContext({ workspaceId, configId: current.configId, ...input });
485
549
  }
486
550
 
551
+ currentRevision(workspaceId: string, configId: string): number {
552
+ const row = this.db.query("SELECT revision FROM current_context WHERE workspace_id=? AND config_id=?").get(workspaceId, configId) as { revision: number } | null;
553
+ return row?.revision ?? 0;
554
+ }
555
+
487
556
  getInitializationConfiguration(workspaceId: string): Record<string, unknown> {
488
557
  const row = this.db.query(`SELECT c.id,c.revision,c.content_hash AS contentHash,c.config_json AS config
489
558
  FROM scan_configs c LEFT JOIN current_context p ON p.config_id=c.id AND p.workspace_id=?
@@ -498,11 +567,14 @@ export class InitializationStore {
498
567
 
499
568
  compareSnapshots(fromSnapshotId: string, toSnapshotId: string): Record<string, unknown> {
500
569
  if (fromSnapshotId === toSnapshotId) return { comparable: true, fromSnapshotId, toSnapshotId, added: [], changed: [], disappeared: [] };
501
- const snapshots = this.db.query("SELECT id,config_id AS configId FROM context_snapshots WHERE id IN (?,?)").all(fromSnapshotId, toSnapshotId) as Array<{ id: string; configId: string }>;
570
+ const snapshots = this.db.query("SELECT id,project_id AS projectId,workspace_id AS workspaceId,config_id AS configId,input_snapshot_id AS inputSnapshotId FROM context_snapshots WHERE id IN (?,?)").all(fromSnapshotId, toSnapshotId) as Array<{ id: string; projectId: string; workspaceId: string; configId: string; inputSnapshotId: string }>;
502
571
  if (snapshots.length !== 2) throw new Error("Both context snapshots must exist");
503
572
  const fromMeta = snapshots.find(item => item.id === fromSnapshotId)!;
504
573
  const toMeta = snapshots.find(item => item.id === toSnapshotId)!;
574
+ if (fromMeta.projectId !== toMeta.projectId || fromMeta.workspaceId !== toMeta.workspaceId) return { comparable: false, reason: "workspace_changed", added: [], changed: [], disappeared: [] };
505
575
  if (fromMeta.configId !== toMeta.configId) return { comparable: false, reason: "scan_config_changed", added: [], changed: [], disappeared: [] };
576
+ const manifests = this.db.query("SELECT id,manifest_json AS manifest FROM input_snapshots WHERE id IN (?,?)").all(fromMeta.inputSnapshotId, toMeta.inputSnapshotId) as Array<{ id: string; manifest: string }>;
577
+ if (manifests.length !== 2) return { comparable: false, reason: "input_snapshot_missing", added: [], changed: [], disappeared: [] };
506
578
  const rows = (snapshotId: string) => this.db.query(`SELECT f.kind,f.repository,f.object_key AS objectKey,f.value_json AS value
507
579
  FROM facts f JOIN context_snapshots s ON s.run_id=f.run_id WHERE s.id=?`).all(snapshotId) as Array<{ kind: string; repository: string; objectKey: string; value: string }>;
508
580
  const key = (row: { kind: string; repository: string; objectKey: string }) => `${row.kind}\0${row.repository}\0${row.objectKey}`;
@@ -539,7 +611,8 @@ export class InitializationStore {
539
611
  const job = this.db.query("SELECT target_path AS targetPath,ownership,status FROM document_jobs WHERE id=?").get(jobId) as { targetPath: string; ownership: string; status: string } | null;
540
612
  if (!job) throw new Error(`Unknown document job: ${jobId}`);
541
613
  if (job.ownership === "human") throw new Error("Human-owned documents cannot be generated");
542
- this.db.query("UPDATE document_jobs SET status='generating',error=NULL,updated_at=? WHERE id=? AND status IN ('queued','failed','pending_review')").run(now(), jobId);
614
+ const claimed = this.db.query("UPDATE document_jobs SET status='generating',error=NULL,updated_at=? WHERE id=? AND status IN ('queued','failed','pending_review')").run(now(), jobId);
615
+ if (claimed.changes !== 1) throw new Error(`Document job is no longer writable: ${jobId}`);
543
616
  const target = path.resolve(this.context.root, job.targetPath);
544
617
  if (target !== this.context.root && !target.startsWith(`${this.context.root}${path.sep}`)) throw new Error("Document path escapes project root");
545
618
  let existing: string | null = null;
@@ -559,7 +632,8 @@ export class InitializationStore {
559
632
  }
560
633
  await mkdir(path.dirname(target), { recursive: true });
561
634
  const temporary = `${target}.${randomUUID()}.tmp`; await writeFile(temporary, output, { encoding: "utf8", flag: "wx" }); await rename(temporary, target);
562
- this.db.query("UPDATE document_jobs SET status='complete',output_hash=?,updated_at=? WHERE id=?").run(hash(output), now(), jobId);
635
+ const completed = this.db.query("UPDATE document_jobs SET status='complete',output_hash=?,updated_at=? WHERE id=? AND status='generating'").run(hash(output), now(), jobId);
636
+ if (completed.changes !== 1) throw new Error(`Document job completion conflict: ${jobId}`);
563
637
  return { status: "complete", path: target };
564
638
  }
565
639
 
@@ -136,15 +136,17 @@ export async function loadProjectContext(root: string): Promise<ProjectContext>
136
136
  const manifestPath = path.join(canonicalRoot, ".devflow", "project.json");
137
137
  const manifest = validateManifest(JSON.parse(await readFile(manifestPath, "utf8")), manifestPath);
138
138
  const planningRoot = await assertPlanningBoundary(canonicalRoot);
139
+ if (!(await exists(projectControlDatabasePath(manifest.projectId)))) throw new Error("Project initialization database is not registered; run explicit project initialization first");
139
140
  return attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot });
140
141
  }
141
142
 
142
143
  async function attachInitializationStore(context: Omit<ProjectContext, "workspaceId" | "controlDatabasePath">): Promise<ProjectContext> {
143
144
  const store = await InitializationStore.open(context);
144
145
  try {
145
- const workspace = await store.registerWorkspace();
146
- store.putScanConfig({ mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }] });
147
- return { ...context, workspaceId: workspace.id, controlDatabasePath: projectControlDatabasePath(context.manifest.projectId) };
146
+ const location = await realpath(context.root);
147
+ const workspace = store.listWorkspaces().find(item => item.location === location);
148
+ if (!workspace) throw new Error("Project workspace is not registered; run explicit project initialization first");
149
+ return { ...context, workspaceId: String(workspace.id), controlDatabasePath: projectControlDatabasePath(context.manifest.projectId) };
148
150
  } finally {
149
151
  store.close();
150
152
  }
@@ -174,6 +176,8 @@ export async function initializeProject(root: string): Promise<{ context: Projec
174
176
  createdAt: new Date().toISOString(),
175
177
  };
176
178
  await atomicWriteJson(manifestPath, manifest);
179
+ const bootstrapStore = await InitializationStore.open({ root: canonicalRoot, manifest });
180
+ try { await bootstrapStore.registerWorkspace(); } finally { bootstrapStore.close(); }
177
181
  return {
178
182
  context: await attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot }),
179
183
  created: true,
@@ -77,6 +77,10 @@ function table(headers: string[], rows: unknown[][]): string {
77
77
  }
78
78
  function yamlString(value: string): string { return JSON.stringify(value); }
79
79
  function frontmatter(fields: Record<string, string | boolean | string[] | null | undefined>): string {
80
+ if (fields.source_snapshot && !fields.snapshot_id) fields.snapshot_id = fields.source_snapshot;
81
+ if (fields.source_snapshot) fields.generated = true;
82
+ if (fields.source_snapshot && !fields.generated_at) fields.generated_at = new Date().toISOString();
83
+ if (fields.source_snapshot && !fields.source_revision) fields.source_revision = fields.code_index_generation ?? "unknown";
80
84
  const lines = ["---"];
81
85
  for (const [key, value] of Object.entries(fields)) {
82
86
  if (value === undefined || value === null) continue;
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { readFile, readdir, stat } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, readdir, stat, rename, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { FACT_KINDS, InitializationStore, type FactInput, type FactKind, type ScanConfigInput } from "./initialization-store.ts";
@@ -177,7 +177,7 @@ async function runProcess(argv: string[], cwd: string): Promise<{ stdout: string
177
177
 
178
178
  async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput): Promise<Record<string, unknown>> {
179
179
  const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
180
- const output = ".planning/code-index";
180
+ const output = `.planning/.code-index-pending-${randomUUID()}`;
181
181
  const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
182
182
  await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
183
183
  await runProcess(["python3", script, "validate", "--index", output], context.root);
@@ -185,7 +185,29 @@ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput):
185
185
  const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
186
186
  const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
187
187
  if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
188
- return { path: output, generation: snapshot.generation, immutablePath: snapshot.immutablePath, snapshotHash: digest(bytes), status: "complete" };
188
+ const workspace = (snapshot.workspace ?? {}) as Record<string, unknown>;
189
+ return { path: ".planning/code-index", stagingPath: output, generation: snapshot.generation, immutablePath: snapshot.immutablePath, snapshotHash: digest(bytes), contentManifestHash: workspace.contentManifestHash, repositoryRevision: workspace.repositoryRevision, status: "complete" };
190
+ }
191
+
192
+ async function promoteCodeIndex(context: ProjectContext, indexRef: Record<string, unknown>): Promise<() => Promise<void>> {
193
+ const stagingPath = typeof indexRef.stagingPath === "string" ? path.resolve(context.root, indexRef.stagingPath) : null;
194
+ const target = path.resolve(context.root, ".planning/code-index");
195
+ if (!stagingPath) return async () => {};
196
+ const previous = `${target}.previous-${randomUUID()}`;
197
+ const existing = await stat(target).catch(() => null);
198
+ if (existing) await rename(target, previous);
199
+ try {
200
+ await rename(stagingPath, target);
201
+ delete indexRef.stagingPath;
202
+ return async () => {
203
+ await rm(target, { recursive: true, force: true });
204
+ if (existing) await rename(previous, target);
205
+ };
206
+ } catch (error) {
207
+ await rm(target, { recursive: true, force: true }).catch(() => {});
208
+ if (existing) await rename(previous, target).catch(() => {});
209
+ throw error;
210
+ }
189
211
  }
190
212
 
191
213
  export async function runProjectInitialization(context: ProjectContext, input: { config?: ScanConfigInput; requestId?: string; documentMode?: "low-tier-model" | "template" | "none"; organizer?: import("./project-documents.ts").LowTierDocumentOrganizer; switchAuthority?: boolean } = {}): Promise<InitializationProgress> {
@@ -236,13 +258,22 @@ export async function runProjectInitialization(context: ProjectContext, input: {
236
258
  facts: status === "complete" ? repositoryFacts : [],
237
259
  });
238
260
  }
239
- const indexRef = config.mode === "full" ? await buildCodeIndex(context, config) : { status: "skipped", reason: "explicit_fast_mode" };
261
+ const indexRef = config.mode === "full"
262
+ ? { ...(await buildCodeIndex(context, config)), inputFingerprint: inputSnapshot.fingerprint, workspaceId: workspace.id, configId: storedConfig.id }
263
+ : { status: "skipped", reason: "explicit_fast_mode", inputFingerprint: inputSnapshot.fingerprint, workspaceId: workspace.id, configId: storedConfig.id };
240
264
  const after = await scan(context, config);
241
265
  const afterManifest = frozenManifest(after);
242
266
  const verified = store.createInputSnapshot(workspace.id, storedConfig.id, afterManifest, false);
243
267
  if (verified.fingerprint !== inputSnapshot.fingerprint) throw new Error("Source changed during initialization; refusing to publish mixed input");
244
268
  store.confirmInputSnapshot(inputSnapshot.id, inputSnapshot.fingerprint);
245
- const published = store.publishRun(run.id, run.leaseToken, { expectedRevision: undefined, indexRef });
269
+ const rollbackIndex = await promoteCodeIndex(context, indexRef);
270
+ let published: { snapshotId: string; revision: number };
271
+ try {
272
+ published = store.publishRun(run.id, run.leaseToken, { expectedRevision: store.currentRevision(workspace.id, storedConfig.id), indexRef });
273
+ } catch (error) {
274
+ await rollbackIndex();
275
+ throw error;
276
+ }
246
277
  if (input.switchAuthority !== false) {
247
278
  const authority = store.getAuthority();
248
279
  if (authority.authority === "legacy") store.switchAuthority("database", authority.revision);
@@ -22,7 +22,8 @@ export async function importLegacyInitialization(context: ProjectContext, source
22
22
  try {
23
23
  const workspace = await store.registerWorkspace();
24
24
  const config = store.putScanConfig({ mode: "fast", repositories: [{ name: "legacy-materials", relativePath: ".", role: "other" }], requiredKinds: ["evidence"], adapterVersions: { "legacy-import": "1" } });
25
- const input = store.createInputSnapshot(workspace.id, config.id, documents.map(item => ({ repository: "legacy-materials", path: item.relative, contentHash: item.contentHash, size: item.size })), true);
25
+ const input = store.createInputSnapshot(workspace.id, config.id, documents.map(item => ({ repository: "legacy-materials", path: item.relative, contentHash: item.contentHash, size: item.size })), false);
26
+ store.confirmInputSnapshot(input.id, input.fingerprint);
26
27
  const run = store.startRun({ workspaceId: workspace.id, configId: config.id, inputSnapshotId: input.id, requestId });
27
28
  if (!run.reused) store.recordCollection(run.id, run.leaseToken, {
28
29
  kind: "evidence", status: "complete", adapter: "legacy-import@1",
@@ -30,7 +31,7 @@ export async function importLegacyInitialization(context: ProjectContext, source
30
31
  });
31
32
  const snapshot = run.reused && store.getRun(run.id)?.publishedSnapshotId
32
33
  ? { snapshotId: String(store.getRun(run.id)!.publishedSnapshotId) }
33
- : store.publishRun(run.id, run.leaseToken, { completeness: "partial", updateCurrent: false });
34
+ : store.publishRun(run.id, run.leaseToken, { expectedRevision: 0, completeness: "partial", updateCurrent: false });
34
35
  const imports = documents.map(item => store.registerHistoricalImport(workspace.id, item.relative, item.contentHash, snapshot.snapshotId));
35
36
  const authority = store.getAuthority();
36
37
  if (authority.authority === "legacy") store.switchAuthority("shadow", authority.revision);