@kodrunhq/opencode-autopilot 1.15.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +3 -3
  9. package/src/health/checks.ts +126 -4
  10. package/src/health/types.ts +1 -1
  11. package/src/index.ts +128 -13
  12. package/src/inspect/formatters.ts +225 -0
  13. package/src/inspect/repository.ts +882 -0
  14. package/src/kernel/database.ts +45 -0
  15. package/src/kernel/migrations.ts +62 -0
  16. package/src/kernel/repository.ts +571 -0
  17. package/src/kernel/schema.ts +122 -0
  18. package/src/kernel/transaction.ts +48 -0
  19. package/src/kernel/types.ts +65 -0
  20. package/src/logging/domains.ts +39 -0
  21. package/src/logging/forensic-writer.ts +177 -0
  22. package/src/logging/index.ts +4 -0
  23. package/src/logging/logger.ts +44 -0
  24. package/src/logging/performance.ts +59 -0
  25. package/src/logging/rotation.ts +261 -0
  26. package/src/logging/types.ts +33 -0
  27. package/src/memory/capture-utils.ts +149 -0
  28. package/src/memory/capture.ts +82 -67
  29. package/src/memory/database.ts +74 -12
  30. package/src/memory/decay.ts +11 -2
  31. package/src/memory/index.ts +17 -1
  32. package/src/memory/injector.ts +4 -1
  33. package/src/memory/lessons.ts +85 -0
  34. package/src/memory/observations.ts +177 -0
  35. package/src/memory/preferences.ts +718 -0
  36. package/src/memory/project-key.ts +6 -0
  37. package/src/memory/projects.ts +83 -0
  38. package/src/memory/repository.ts +52 -216
  39. package/src/memory/retrieval.ts +88 -170
  40. package/src/memory/schemas.ts +39 -7
  41. package/src/memory/types.ts +4 -0
  42. package/src/observability/context-display.ts +8 -0
  43. package/src/observability/event-handlers.ts +69 -20
  44. package/src/observability/event-store.ts +29 -1
  45. package/src/observability/forensic-log.ts +167 -0
  46. package/src/observability/forensic-schemas.ts +77 -0
  47. package/src/observability/forensic-types.ts +10 -0
  48. package/src/observability/index.ts +21 -27
  49. package/src/observability/log-reader.ts +161 -111
  50. package/src/observability/log-writer.ts +41 -83
  51. package/src/observability/retention.ts +2 -2
  52. package/src/observability/session-logger.ts +36 -57
  53. package/src/observability/summary-generator.ts +31 -19
  54. package/src/observability/types.ts +12 -24
  55. package/src/orchestrator/contracts/invariants.ts +14 -0
  56. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  57. package/src/orchestrator/error-context.ts +24 -0
  58. package/src/orchestrator/fallback/event-handler.ts +47 -3
  59. package/src/orchestrator/handlers/architect.ts +2 -1
  60. package/src/orchestrator/handlers/build-utils.ts +118 -0
  61. package/src/orchestrator/handlers/build.ts +42 -219
  62. package/src/orchestrator/handlers/retrospective.ts +2 -2
  63. package/src/orchestrator/handlers/types.ts +0 -1
  64. package/src/orchestrator/lesson-memory.ts +36 -11
  65. package/src/orchestrator/orchestration-logger.ts +53 -24
  66. package/src/orchestrator/phase.ts +8 -4
  67. package/src/orchestrator/progress.ts +63 -0
  68. package/src/orchestrator/state.ts +79 -17
  69. package/src/projects/database.ts +47 -0
  70. package/src/projects/repository.ts +264 -0
  71. package/src/projects/resolve.ts +301 -0
  72. package/src/projects/schemas.ts +30 -0
  73. package/src/projects/types.ts +12 -0
  74. package/src/review/memory.ts +39 -11
  75. package/src/review/parse-findings.ts +116 -0
  76. package/src/review/pipeline.ts +3 -107
  77. package/src/review/selection.ts +38 -4
  78. package/src/scoring/time-provider.ts +23 -0
  79. package/src/tools/doctor.ts +28 -4
  80. package/src/tools/forensics.ts +7 -12
  81. package/src/tools/logs.ts +38 -11
  82. package/src/tools/memory-preferences.ts +157 -0
  83. package/src/tools/memory-status.ts +17 -96
  84. package/src/tools/orchestrate.ts +108 -90
  85. package/src/tools/pipeline-report.ts +3 -2
  86. package/src/tools/quick.ts +2 -2
  87. package/src/tools/replay.ts +42 -0
  88. package/src/tools/review.ts +46 -7
  89. package/src/tools/session-stats.ts +3 -2
  90. package/src/tools/summary.ts +43 -0
  91. package/src/utils/paths.ts +20 -1
  92. package/src/utils/random.ts +33 -0
  93. package/src/ux/session-summary.ts +56 -0
@@ -1,5 +1,6 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { getLogger } from "../logging/domains";
2
+ import { createForensicSinkForArtifactDir } from "../logging/forensic-writer";
3
+ import type { LogLevel } from "../logging/types";
3
4
 
4
5
  export interface OrchestrationEvent {
5
6
  readonly timestamp: string;
@@ -9,32 +10,60 @@ export interface OrchestrationEvent {
9
10
  readonly promptLength?: number;
10
11
  readonly attempt?: number;
11
12
  readonly message?: string;
13
+ readonly runId?: string;
14
+ readonly dispatchId?: string;
15
+ readonly taskId?: number | null;
16
+ readonly code?: string;
17
+ readonly sessionId?: string;
18
+ readonly payload?: Record<string, string | number | boolean | null>;
12
19
  }
13
20
 
14
- const LOG_FILE = "orchestration.jsonl";
15
-
16
- /** Rate-limit: warn about log failures at most once per process. */
17
- let logWriteWarned = false;
21
+ function resolveOperation(event: OrchestrationEvent): string {
22
+ if (event.action === "dispatch") return "dispatch";
23
+ if (event.action === "dispatch_multi") return "dispatch_multi";
24
+ if (event.action === "complete") return "complete";
25
+ if (event.action === "loop_detected") return "loop_detected";
26
+ if (event.action === "error" && event.code?.startsWith("E_")) return "warning";
27
+ return "error";
28
+ }
18
29
 
19
- /**
20
- * Append an orchestration event to the project-local JSONL log.
21
- * Uses synchronous append to survive crashes. Best-effort — errors are swallowed.
22
- */
23
30
  export function logOrchestrationEvent(artifactDir: string, event: OrchestrationEvent): void {
24
31
  try {
25
- mkdirSync(artifactDir, { recursive: true });
26
- const logPath = join(artifactDir, LOG_FILE);
27
- // Redact filesystem paths from message to avoid leaking sensitive directory info
28
- const safe = event.message
29
- ? { ...event, message: event.message.replace(/[/\\][^\s"']+/g, "[PATH]") }
30
- : event;
31
- const line = `${JSON.stringify(safe)}\n`;
32
- appendFileSync(logPath, line, "utf-8");
33
- } catch (err) {
34
- // Best-effort — never block the pipeline. Warn once so operators know logging is broken.
35
- if (!logWriteWarned) {
36
- logWriteWarned = true;
37
- console.warn("[opencode-autopilot] orchestration log write failed:", err);
32
+ const domain =
33
+ event.action === "error" && event.code?.startsWith("E_") ? "contract" : "orchestrator";
34
+ const operation = resolveOperation(event);
35
+ const level: LogLevel = event.action === "error" ? "ERROR" : "INFO";
36
+
37
+ const metadata = {
38
+ domain,
39
+ operation,
40
+ runId: event.runId ?? null,
41
+ sessionId: event.sessionId ?? null,
42
+ phase: event.phase,
43
+ dispatchId: event.dispatchId ?? null,
44
+ taskId: event.taskId ?? null,
45
+ agent: event.agent ?? null,
46
+ code: event.code ?? null,
47
+ action: event.action,
48
+ ...(event.promptLength !== undefined ? { promptLength: event.promptLength } : {}),
49
+ ...(event.attempt !== undefined ? { attempt: event.attempt } : {}),
50
+ ...(event.payload ?? {}),
51
+ };
52
+
53
+ createForensicSinkForArtifactDir(artifactDir).write(
54
+ Object.freeze({
55
+ timestamp: event.timestamp,
56
+ level,
57
+ message: event.message ?? event.action,
58
+ metadata,
59
+ }),
60
+ );
61
+
62
+ const globalLogger = getLogger(domain);
63
+ if (event.action === "error") {
64
+ globalLogger.error(event.message ?? event.action, { operation, phase: event.phase });
65
+ } else {
66
+ globalLogger.info(event.message ?? event.action, { operation, phase: event.phase });
38
67
  }
39
- }
68
+ } catch {}
40
69
  }
@@ -1,3 +1,4 @@
1
+ import { patchState } from "./state";
1
2
  import type { Phase, PhaseStatus, PipelineState } from "./types";
2
3
 
3
4
  /** Maps each phase to its 1-based position for user-facing progress display. */
@@ -71,12 +72,15 @@ export function completePhase(state: Readonly<PipelineState>): PipelineState {
71
72
  return phase;
72
73
  });
73
74
 
74
- return {
75
- ...state,
75
+ return patchState(state, {
76
+ status: nextPhase === null ? "COMPLETED" : state.status,
76
77
  currentPhase: nextPhase,
77
78
  phases: updatedPhases,
78
- lastUpdatedAt: new Date().toISOString(),
79
- };
79
+ pendingDispatches:
80
+ nextPhase === null
81
+ ? []
82
+ : state.pendingDispatches.filter((entry) => entry.phase === nextPhase),
83
+ });
80
84
  }
81
85
 
82
86
  /**
@@ -0,0 +1,63 @@
1
+ import { PHASES } from "./schemas";
2
+ import type { PipelineState } from "./types";
3
+
4
+ const PHASE_INDEX = Object.freeze(
5
+ Object.fromEntries(PHASES.map((phase, index) => [phase, index + 1])) as Record<
6
+ (typeof PHASES)[number],
7
+ number
8
+ >,
9
+ );
10
+
11
+ /**
12
+ * Generate a concise progress string for the user indicating current phase and progress.
13
+ * e.g., "[1/8] Analyzing requirements..." or "[6/8] Building wave 2 of 5..."
14
+ */
15
+ export function getPhaseProgressString(state: PipelineState): string {
16
+ const currentPhase = state.currentPhase;
17
+ if (!currentPhase) {
18
+ if (state.status === "COMPLETED") return "[Done] Pipeline finished successfully.";
19
+ if (state.status === "FAILED") return "[Failed] Pipeline encountered an error.";
20
+ return `[0/${PHASES.length}] Not started`;
21
+ }
22
+
23
+ const phaseIndex = PHASE_INDEX[currentPhase];
24
+ const totalPhases = PHASES.length;
25
+ const baseProgress = `[${phaseIndex}/${totalPhases}]`;
26
+
27
+ switch (currentPhase) {
28
+ case "RECON":
29
+ return `${baseProgress} Researching feasibility and codebase context...`;
30
+ case "CHALLENGE":
31
+ return `${baseProgress} Evaluating architecture enhancements...`;
32
+ case "ARCHITECT":
33
+ return `${baseProgress} Designing technical architecture...`;
34
+ case "EXPLORE":
35
+ return `${baseProgress} Exploring implementation paths...`;
36
+ case "PLAN":
37
+ return `${baseProgress} Planning implementation waves...`;
38
+ case "BUILD": {
39
+ const progress = state.buildProgress;
40
+ if (!progress || progress.currentWave === null) {
41
+ return `${baseProgress} Starting build phase...`;
42
+ }
43
+
44
+ // Find max wave to show total waves
45
+ const allWaves = state.tasks.map((t) => t.wave);
46
+ const totalWaves = allWaves.length > 0 ? Math.max(...allWaves) : 0;
47
+ const totalTasksInWave = state.tasks.filter((t) => t.wave === progress.currentWave).length;
48
+
49
+ if (progress.reviewPending) {
50
+ return `${baseProgress} Reviewing wave ${progress.currentWave}/${totalWaves}...`;
51
+ }
52
+
53
+ // Just a sensible string for current build status
54
+ return `${baseProgress} Building wave ${progress.currentWave}/${totalWaves} (${totalTasksInWave} tasks)...`;
55
+ }
56
+ case "SHIP":
57
+ return `${baseProgress} Generating changelog and documentation...`;
58
+ case "RETROSPECTIVE":
59
+ return `${baseProgress} Extracting lessons learned...`;
60
+ default:
61
+ return `${baseProgress} Executing ${currentPhase}...`;
62
+ }
63
+ }
@@ -1,12 +1,15 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { loadLatestPipelineStateFromKernel, savePipelineStateToKernel } from "../kernel/repository";
5
+ import { KERNEL_STATE_CONFLICT_CODE } from "../kernel/types";
4
6
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
5
7
  import { assertStateInvariants } from "./contracts/invariants";
6
8
  import { PHASES, pipelineStateSchema } from "./schemas";
7
9
  import type { PipelineState } from "./types";
8
10
 
9
11
  const STATE_FILE = "state.json";
12
+ let legacyStateMirrorWarned = false;
10
13
 
11
14
  function generateRunId(): string {
12
15
  return `run_${randomBytes(8).toString("hex")}`;
@@ -37,12 +40,14 @@ export function createInitialState(idea: string): PipelineState {
37
40
  });
38
41
  }
39
42
 
40
- export async function loadState(artifactDir: string): Promise<PipelineState | null> {
43
+ async function loadLegacyState(artifactDir: string): Promise<PipelineState | null> {
41
44
  const statePath = join(artifactDir, STATE_FILE);
42
45
  try {
43
46
  const raw = await readFile(statePath, "utf-8");
44
47
  const parsed = JSON.parse(raw);
45
- return pipelineStateSchema.parse(parsed);
48
+ const validated = pipelineStateSchema.parse(parsed);
49
+ assertStateInvariants(validated);
50
+ return validated;
46
51
  } catch (error: unknown) {
47
52
  if (isEnoentError(error)) {
48
53
  return null;
@@ -51,28 +56,85 @@ export async function loadState(artifactDir: string): Promise<PipelineState | nu
51
56
  }
52
57
  }
53
58
 
59
+ async function writeLegacyStateMirror(state: PipelineState, artifactDir: string): Promise<void> {
60
+ await ensureDir(artifactDir);
61
+ const statePath = join(artifactDir, STATE_FILE);
62
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
63
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
64
+ await rename(tmpPath, statePath);
65
+ }
66
+
67
+ async function syncLegacyStateMirror(state: PipelineState, artifactDir: string): Promise<void> {
68
+ try {
69
+ await writeLegacyStateMirror(state, artifactDir);
70
+ } catch (error: unknown) {
71
+ if (!legacyStateMirrorWarned) {
72
+ legacyStateMirrorWarned = true;
73
+ console.warn("[opencode-autopilot] state.json mirror write failed:", error);
74
+ }
75
+ }
76
+ }
77
+
78
+ export async function loadState(artifactDir: string): Promise<PipelineState | null> {
79
+ const kernelState = loadLatestPipelineStateFromKernel(artifactDir);
80
+ if (kernelState !== null) {
81
+ return kernelState;
82
+ }
83
+
84
+ const legacyState = await loadLegacyState(artifactDir);
85
+ if (legacyState === null) {
86
+ return null;
87
+ }
88
+
89
+ savePipelineStateToKernel(artifactDir, legacyState);
90
+ return legacyState;
91
+ }
92
+
54
93
  export async function saveState(
55
94
  state: PipelineState,
56
95
  artifactDir: string,
57
96
  expectedRevision?: number,
58
97
  ): Promise<void> {
59
- if (typeof expectedRevision === "number") {
60
- const current = await loadState(artifactDir);
61
- const currentRevision = current?.stateRevision ?? -1;
62
- if (currentRevision !== expectedRevision) {
63
- throw new Error(
64
- `E_STATE_CONFLICT: expected stateRevision ${expectedRevision}, found ${currentRevision}`,
65
- );
66
- }
67
- }
68
-
69
98
  const validated = pipelineStateSchema.parse(state);
70
99
  assertStateInvariants(validated);
71
- await ensureDir(artifactDir);
72
- const statePath = join(artifactDir, STATE_FILE);
73
- const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
74
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
75
- await rename(tmpPath, statePath);
100
+ savePipelineStateToKernel(artifactDir, validated, expectedRevision);
101
+ await syncLegacyStateMirror(validated, artifactDir);
102
+ }
103
+
104
+ export function isStateConflictError(error: unknown): boolean {
105
+ return error instanceof Error && error.message.startsWith(`${KERNEL_STATE_CONFLICT_CODE}:`);
106
+ }
107
+
108
+ export async function updatePersistedState(
109
+ artifactDir: string,
110
+ state: Readonly<PipelineState>,
111
+ transform: (current: Readonly<PipelineState>) => PipelineState,
112
+ options?: { readonly maxConflicts?: number },
113
+ ): Promise<PipelineState> {
114
+ const maxConflicts = options?.maxConflicts ?? 2;
115
+ let currentState = state;
116
+
117
+ for (let attempt = 0; ; attempt += 1) {
118
+ const nextState = transform(currentState);
119
+ if (nextState === currentState) {
120
+ return currentState;
121
+ }
122
+
123
+ try {
124
+ await saveState(nextState, artifactDir, currentState.stateRevision);
125
+ return nextState;
126
+ } catch (error: unknown) {
127
+ if (!isStateConflictError(error) || attempt >= maxConflicts) {
128
+ throw error;
129
+ }
130
+
131
+ const latestState = await loadState(artifactDir);
132
+ if (latestState === null) {
133
+ throw new Error(`${KERNEL_STATE_CONFLICT_CODE}: state disappeared during update`);
134
+ }
135
+ currentState = latestState;
136
+ }
137
+ }
76
138
  }
77
139
 
78
140
  export function patchState(
@@ -0,0 +1,47 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export const PROJECT_REGISTRY_STATEMENTS: readonly string[] = Object.freeze([
4
+ `CREATE TABLE IF NOT EXISTS projects (
5
+ id TEXT PRIMARY KEY,
6
+ path TEXT NOT NULL UNIQUE,
7
+ name TEXT NOT NULL,
8
+ first_seen_at TEXT NOT NULL,
9
+ last_updated TEXT NOT NULL
10
+ )`,
11
+ `CREATE TABLE IF NOT EXISTS project_paths (
12
+ project_id TEXT NOT NULL,
13
+ path TEXT NOT NULL,
14
+ first_seen_at TEXT NOT NULL,
15
+ last_updated TEXT NOT NULL,
16
+ is_current INTEGER NOT NULL CHECK(is_current IN (0, 1)),
17
+ PRIMARY KEY (project_id, path),
18
+ UNIQUE(path),
19
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
20
+ )`,
21
+ `CREATE INDEX IF NOT EXISTS idx_project_paths_project ON project_paths(project_id, is_current DESC, path)`,
22
+ `CREATE TABLE IF NOT EXISTS project_git_fingerprints (
23
+ project_id TEXT NOT NULL,
24
+ normalized_remote_url TEXT NOT NULL,
25
+ default_branch TEXT,
26
+ first_seen_at TEXT NOT NULL,
27
+ last_updated TEXT NOT NULL,
28
+ PRIMARY KEY (project_id, normalized_remote_url),
29
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
30
+ )`,
31
+ `CREATE INDEX IF NOT EXISTS idx_project_git_remote ON project_git_fingerprints(normalized_remote_url, last_updated DESC)`,
32
+ ]);
33
+
34
+ export function runProjectRegistryMigrations(database: Database): void {
35
+ for (const statement of PROJECT_REGISTRY_STATEMENTS) {
36
+ database.run(statement);
37
+ }
38
+
39
+ const projectsInfo = database.query("PRAGMA table_info(projects)").all() as Array<{
40
+ name?: string;
41
+ }>;
42
+ const hasFirstSeenAt = projectsInfo.some((column) => column.name === "first_seen_at");
43
+ if (!hasFirstSeenAt) {
44
+ database.run("ALTER TABLE projects ADD COLUMN first_seen_at TEXT");
45
+ database.run("UPDATE projects SET first_seen_at = COALESCE(first_seen_at, last_updated)");
46
+ }
47
+ }
@@ -0,0 +1,264 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { getMemoryDb } from "../memory/database";
3
+ import {
4
+ gitFingerprintInputSchema,
5
+ projectGitFingerprintSchema,
6
+ projectPathRecordSchema,
7
+ projectRecordSchema,
8
+ } from "./schemas";
9
+ import type {
10
+ GitFingerprintInput,
11
+ ProjectGitFingerprint,
12
+ ProjectPathRecord,
13
+ ProjectRecord,
14
+ } from "./types";
15
+
16
+ interface ProjectRow {
17
+ readonly id: string;
18
+ readonly path: string;
19
+ readonly name: string;
20
+ readonly first_seen_at: string | null;
21
+ readonly last_updated: string;
22
+ }
23
+
24
+ interface ProjectPathRow {
25
+ readonly project_id: string;
26
+ readonly path: string;
27
+ readonly first_seen_at: string;
28
+ readonly last_updated: string;
29
+ readonly is_current: number;
30
+ }
31
+
32
+ interface ProjectFingerprintRow {
33
+ readonly project_id: string;
34
+ readonly normalized_remote_url: string;
35
+ readonly default_branch: string | null;
36
+ readonly first_seen_at: string;
37
+ readonly last_updated: string;
38
+ }
39
+
40
+ function resolveDb(db?: Database): Database {
41
+ return db ?? getMemoryDb();
42
+ }
43
+
44
+ function withWriteTransaction<T>(db: Database, callback: () => T): T {
45
+ const row = db.query("PRAGMA transaction_state").get() as { transaction_state?: string } | null;
46
+ const isAlreadyInTransaction = row?.transaction_state === "TRANSACTION";
47
+ if (isAlreadyInTransaction) {
48
+ return callback();
49
+ }
50
+
51
+ db.run("BEGIN IMMEDIATE");
52
+ try {
53
+ const result = callback();
54
+ db.run("COMMIT");
55
+ return result;
56
+ } catch (error: unknown) {
57
+ try {
58
+ db.run("ROLLBACK");
59
+ } catch {
60
+ // Ignore rollback failures so the original error wins.
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ function rowToProject(row: ProjectRow): ProjectRecord {
67
+ return projectRecordSchema.parse({
68
+ id: row.id,
69
+ path: row.path,
70
+ name: row.name,
71
+ firstSeenAt: row.first_seen_at ?? row.last_updated,
72
+ lastUpdated: row.last_updated,
73
+ });
74
+ }
75
+
76
+ function rowToProjectPath(row: ProjectPathRow): ProjectPathRecord {
77
+ return projectPathRecordSchema.parse({
78
+ projectId: row.project_id,
79
+ path: row.path,
80
+ firstSeenAt: row.first_seen_at,
81
+ lastUpdated: row.last_updated,
82
+ isCurrent: row.is_current === 1,
83
+ });
84
+ }
85
+
86
+ function rowToProjectFingerprint(row: ProjectFingerprintRow): ProjectGitFingerprint {
87
+ return projectGitFingerprintSchema.parse({
88
+ projectId: row.project_id,
89
+ normalizedRemoteUrl: row.normalized_remote_url,
90
+ defaultBranch: row.default_branch,
91
+ firstSeenAt: row.first_seen_at,
92
+ lastUpdated: row.last_updated,
93
+ });
94
+ }
95
+
96
+ export function upsertProjectRecord(project: ProjectRecord, db?: Database): ProjectRecord {
97
+ const validated = projectRecordSchema.parse(project);
98
+ const d = resolveDb(db);
99
+ const firstSeenAt = validated.firstSeenAt ?? validated.lastUpdated;
100
+
101
+ withWriteTransaction(d, () => {
102
+ d.run(
103
+ `INSERT INTO projects (id, path, name, first_seen_at, last_updated)
104
+ VALUES (?, ?, ?, ?, ?)
105
+ ON CONFLICT(id) DO UPDATE SET
106
+ path = excluded.path,
107
+ name = excluded.name,
108
+ first_seen_at = COALESCE(projects.first_seen_at, excluded.first_seen_at),
109
+ last_updated = excluded.last_updated`,
110
+ [validated.id, validated.path, validated.name, firstSeenAt, validated.lastUpdated],
111
+ );
112
+
113
+ d.run("UPDATE project_paths SET is_current = 0, last_updated = ? WHERE project_id = ?", [
114
+ validated.lastUpdated,
115
+ validated.id,
116
+ ]);
117
+ d.run(
118
+ `INSERT INTO project_paths (project_id, path, first_seen_at, last_updated, is_current)
119
+ VALUES (?, ?, ?, ?, 1)
120
+ ON CONFLICT(project_id, path) DO UPDATE SET
121
+ last_updated = excluded.last_updated,
122
+ is_current = 1`,
123
+ [validated.id, validated.path, firstSeenAt, validated.lastUpdated],
124
+ );
125
+ });
126
+
127
+ return projectRecordSchema.parse({
128
+ ...validated,
129
+ firstSeenAt,
130
+ });
131
+ }
132
+
133
+ export function getProjectById(projectId: string, db?: Database): ProjectRecord | null {
134
+ const d = resolveDb(db);
135
+ const row = d.query("SELECT * FROM projects WHERE id = ?").get(projectId) as ProjectRow | null;
136
+ return row ? rowToProject(row) : null;
137
+ }
138
+
139
+ export function getProjectByCurrentPath(path: string, db?: Database): ProjectRecord | null {
140
+ const d = resolveDb(db);
141
+ const row = d.query("SELECT * FROM projects WHERE path = ?").get(path) as ProjectRow | null;
142
+ return row ? rowToProject(row) : null;
143
+ }
144
+
145
+ export function getProjectByAnyPath(path: string, db?: Database): ProjectRecord | null {
146
+ const current = getProjectByCurrentPath(path, db);
147
+ if (current !== null) {
148
+ return current;
149
+ }
150
+
151
+ const d = resolveDb(db);
152
+ const row = d
153
+ .query(
154
+ `SELECT p.*
155
+ FROM project_paths pp
156
+ JOIN projects p ON p.id = pp.project_id
157
+ WHERE pp.path = ?
158
+ ORDER BY pp.is_current DESC, pp.last_updated DESC
159
+ LIMIT 1`,
160
+ )
161
+ .get(path) as ProjectRow | null;
162
+ return row ? rowToProject(row) : null;
163
+ }
164
+
165
+ export function listProjectPaths(projectId: string, db?: Database): readonly ProjectPathRecord[] {
166
+ const d = resolveDb(db);
167
+ const rows = d
168
+ .query(
169
+ `SELECT *
170
+ FROM project_paths
171
+ WHERE project_id = ?
172
+ ORDER BY is_current DESC, last_updated DESC, path ASC`,
173
+ )
174
+ .all(projectId) as ProjectPathRow[];
175
+ return Object.freeze(rows.map(rowToProjectPath));
176
+ }
177
+
178
+ export function setProjectCurrentPath(
179
+ projectId: string,
180
+ path: string,
181
+ name: string,
182
+ seenAt: string,
183
+ db?: Database,
184
+ ): ProjectRecord {
185
+ const d = resolveDb(db);
186
+ const existing = getProjectById(projectId, d);
187
+ if (existing === null) {
188
+ throw new Error(`Unknown project id: ${projectId}`);
189
+ }
190
+
191
+ const next = projectRecordSchema.parse({
192
+ id: existing.id,
193
+ path,
194
+ name,
195
+ firstSeenAt: existing.firstSeenAt,
196
+ lastUpdated: seenAt,
197
+ });
198
+ return upsertProjectRecord(next, d);
199
+ }
200
+
201
+ export function upsertProjectGitFingerprint(
202
+ projectId: string,
203
+ fingerprint: GitFingerprintInput,
204
+ seenAt: string,
205
+ db?: Database,
206
+ ): ProjectGitFingerprint {
207
+ const validated = gitFingerprintInputSchema.parse(fingerprint);
208
+ const d = resolveDb(db);
209
+
210
+ d.run(
211
+ `INSERT INTO project_git_fingerprints (
212
+ project_id,
213
+ normalized_remote_url,
214
+ default_branch,
215
+ first_seen_at,
216
+ last_updated
217
+ ) VALUES (?, ?, ?, ?, ?)
218
+ ON CONFLICT(project_id, normalized_remote_url) DO UPDATE SET
219
+ default_branch = excluded.default_branch,
220
+ last_updated = excluded.last_updated`,
221
+ [projectId, validated.normalizedRemoteUrl, validated.defaultBranch, seenAt, seenAt],
222
+ );
223
+
224
+ return projectGitFingerprintSchema.parse({
225
+ projectId,
226
+ normalizedRemoteUrl: validated.normalizedRemoteUrl,
227
+ defaultBranch: validated.defaultBranch,
228
+ firstSeenAt: seenAt,
229
+ lastUpdated: seenAt,
230
+ });
231
+ }
232
+
233
+ export function getProjectsByGitFingerprint(
234
+ normalizedRemoteUrl: string,
235
+ db?: Database,
236
+ ): readonly ProjectRecord[] {
237
+ const d = resolveDb(db);
238
+ const rows = d
239
+ .query(
240
+ `SELECT DISTINCT p.*
241
+ FROM project_git_fingerprints pgf
242
+ JOIN projects p ON p.id = pgf.project_id
243
+ WHERE pgf.normalized_remote_url = ?
244
+ ORDER BY p.last_updated DESC, p.id DESC`,
245
+ )
246
+ .all(normalizedRemoteUrl) as ProjectRow[];
247
+ return Object.freeze(rows.map(rowToProject));
248
+ }
249
+
250
+ export function listProjectGitFingerprints(
251
+ projectId: string,
252
+ db?: Database,
253
+ ): readonly ProjectGitFingerprint[] {
254
+ const d = resolveDb(db);
255
+ const rows = d
256
+ .query(
257
+ `SELECT *
258
+ FROM project_git_fingerprints
259
+ WHERE project_id = ?
260
+ ORDER BY last_updated DESC, normalized_remote_url ASC`,
261
+ )
262
+ .all(projectId) as ProjectFingerprintRow[];
263
+ return Object.freeze(rows.map(rowToProjectFingerprint));
264
+ }