failproofai 1.0.2-beta.4 → 1.0.2-beta.5

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 (147) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  34. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  35. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/app/index.html +1 -1
  38. package/.next/standalone/.next/server/app/index.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  40. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  41. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  42. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  48. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  63. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  67. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0clqfab._.js → [root-of-the-server]__1zkek3z._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{_1lbh81u._.js → _12moi5-._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -5
  77. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14muoc4._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{_1dkh76z._.js → _1kesqs-._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/{_0ovx4lz._.js → _1km9-gn._.js} +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
  106. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
  107. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  108. package/.next/standalone/.next/server/pages/404.html +1 -1
  109. package/.next/standalone/.next/server/pages/500.html +1 -1
  110. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  111. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  112. package/.next/standalone/.next/static/chunks/{0lshnlpy1m6bc.js → 0tyt-3a5rtzy8.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0ds64uculrze8.js → 21r7iw3rmixf5.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{07j2u70_3qsx8.js → 26lfjhb2kzzgb.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{2dbi_x0c8n8fi.js → 2_27jraat038x.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{0d_re32wscs0b.js → 2hwc8e923g5df.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{1q76jk_1_wgjq.js → 33np9nrh1mvj9.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{0k_yvjc1yz0pq.js → 36-8i_vbkh85w.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{1o0ahew9568ls.js → 391tpdqcas7rq.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{00_t-xvc59tkb.js → 3_b73tc78jwsq.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1oz0svxwt9574.js → 3i8rg2fv9nd6e.js} +2 -2
  122. package/.next/standalone/.next/static/chunks/{2817_8e94ah9z.js → 3jqm8ubpv3i-4.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{turbopack-17utl_jw0wj11.js → turbopack-2x-o099q64i8h.js} +1 -1
  124. package/.next/standalone/lib/opencode-db.ts +77 -0
  125. package/.next/standalone/lib/opencode-projects.ts +37 -41
  126. package/.next/standalone/lib/opencode-sessions.ts +59 -36
  127. package/.next/standalone/package.json +9 -9
  128. package/.next/standalone/server.js +1 -1
  129. package/bin/failproofai.mjs +24 -9
  130. package/dist/cli.mjs +734 -382
  131. package/dist/worker.mjs +179 -164
  132. package/lib/opencode-db.ts +77 -0
  133. package/lib/opencode-projects.ts +37 -41
  134. package/lib/opencode-sessions.ts +59 -36
  135. package/package.json +9 -9
  136. package/src/audit/cache.ts +167 -11
  137. package/src/audit/cli-adapters/claude.ts +111 -1
  138. package/src/audit/cli-adapters/index.ts +31 -1
  139. package/src/audit/index.ts +131 -11
  140. package/src/hooks/manager.ts +50 -3
  141. package/src/hooks/pack-cli.ts +237 -11
  142. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
  143. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
  144. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
  145. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
  146. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
  147. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_ssgManifest.js +0 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Read-only access to OpenCode's SQLite database.
3
+ *
4
+ * This used to shell out: `opencode db --format json "<sql>"`, on the reasoning
5
+ * that going through the CLI avoided coupling us to opencode's internal schema.
6
+ * It did not — the SQL was ours either way, naming opencode's own tables and
7
+ * columns, so the schema coupling was identical and the subprocess bought
8
+ * nothing but its own cost.
9
+ *
10
+ * And that cost was the single largest component of `failproofai audit` on any
11
+ * machine with opencode installed. Each spawn is a full CLI start: ~1.5s
12
+ * measured. `getOpenCodeSessionExport` issues three queries, so a 30-session
13
+ * history spent ~135 seconds launching 90 processes — against a total audit
14
+ * time of ~140s. The audit sat at ~0% CPU throughout, which is why it read as
15
+ * "slow scanning" rather than as what it was: waiting on `execFileSync`.
16
+ *
17
+ * Reading the file directly is the same thing every other SQLite-backed
18
+ * integration already does through `openSqliteReadonly` — goose, devin, hermes,
19
+ * openclaw and antigravity. Opening the database costs microseconds, and one
20
+ * open now serves every query a caller needs instead of one process per query.
21
+ *
22
+ * Fail-open contract is unchanged: a missing file, an unreadable database or a
23
+ * query error yields `null`, and callers degrade to an empty result exactly as
24
+ * they did when the binary was absent from PATH.
25
+ */
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { openSqliteReadonly, type SqliteReader } from "./sqlite-reader";
29
+
30
+ /**
31
+ * Where the database lives.
32
+ *
33
+ * `OPENCODE_DB_PATH` names the file outright and `OPENCODE_HOME` the directory
34
+ * holding it — the same override shape `GOOSE_DB_PATH`/`GOOSE_HOME` and
35
+ * `DEVIN_DB_PATH`/`DEVIN_HOME` use, so tests point at a fixture the same way
36
+ * for every one of them.
37
+ */
38
+ export function opencodeDbPath(): string {
39
+ if (process.env.OPENCODE_DB_PATH) return process.env.OPENCODE_DB_PATH;
40
+ const home = process.env.OPENCODE_HOME ?? join(homedir(), ".local", "share", "opencode");
41
+ return join(home, "opencode.db");
42
+ }
43
+
44
+ /**
45
+ * Open the database, hand it to `fn`, and always close it.
46
+ *
47
+ * Callers that need several queries take ONE of these and run them all inside
48
+ * it. That is the whole point: the shape being replaced ran a separate process
49
+ * per query, so three related reads cost three CLI starts.
50
+ */
51
+ export async function withOpenCodeDb<T>(
52
+ fn: (db: SqliteReader) => T,
53
+ ): Promise<T | null> {
54
+ const db = await openSqliteReadonly(opencodeDbPath());
55
+ if (!db) return null;
56
+ try {
57
+ return fn(db);
58
+ } catch {
59
+ // A query error is indistinguishable from a missing database to every
60
+ // caller, and both mean "no opencode data" rather than "audit failed".
61
+ return null;
62
+ } finally {
63
+ try {
64
+ db.close();
65
+ } catch {
66
+ /* closing a read-only handle cannot fail in a way that matters here */
67
+ }
68
+ }
69
+ }
70
+
71
+ /** One query, for callers that genuinely need only one. */
72
+ export async function queryOpenCodeDb<T = Record<string, unknown>>(
73
+ sql: string,
74
+ params: unknown[] = [],
75
+ ): Promise<T[] | null> {
76
+ return withOpenCodeDb((db) => db.query<T>(sql, params));
77
+ }
@@ -3,10 +3,15 @@
3
3
  *
4
4
  * Unlike the other agent CLIs that store sessions as filesystem JSONL trees,
5
5
  * OpenCode stores everything in a single SQLite database (managed by
6
- * Drizzle ORM) at `~/.local/share/opencode/opencode.db`. Rather than read
7
- * the DB directly with `bun:sqlite` (which would couple us to opencode's
8
- * internal schema), we shell out to `opencode db --format json "<sql>"` —
9
- * the same surface opencode itself documents as read-only safe.
6
+ * Drizzle ORM) at `~/.local/share/opencode/opencode.db`, read directly
7
+ * through `lib/opencode-db.ts`.
8
+ *
9
+ * This used to shell out to `opencode db --format json "<sql>"`, to avoid
10
+ * coupling to opencode's internal schema. That reasoning did not survive
11
+ * contact with the SQL: the queries name opencode's own tables and columns
12
+ * either way, so the coupling was identical and the subprocess bought only
13
+ * its own cost — see `lib/opencode-db.ts` for what that cost turned out to
14
+ * be.
10
15
  *
11
16
  * Verified live against opencode v1.14.31:
12
17
  * • `session` columns: id, project_id, parent_id, slug, directory, title,
@@ -21,7 +26,7 @@
21
26
  * Refs: https://opencode.ai/docs/ (CLI reference)
22
27
  * https://opencode.ai/docs/plugins/ (plugin model context)
23
28
  */
24
- import { execFileSync } from "node:child_process";
29
+ import { queryOpenCodeDb, withOpenCodeDb } from "./opencode-db";
25
30
  import { encodeFolderName } from "./paths";
26
31
  import type { ProjectFolder, SessionFile } from "./projects";
27
32
  import { runtimeCache } from "./runtime-cache";
@@ -51,41 +56,34 @@ interface OpenCodeProjectRow {
51
56
  }
52
57
 
53
58
  /**
54
- * Run `opencode db --format json "<sql>"` and parse the result. Returns
55
- * `null` (not an empty array) when the binary is missing or the query fails
56
- * callers can decide whether absent vs. empty is meaningful.
57
- */
58
- function runOpenCodeDb<T>(sql: string): T[] | null {
59
- try {
60
- const stdout = execFileSync("opencode", ["db", "--format", "json", sql], {
61
- encoding: "utf8",
62
- timeout: 5_000,
63
- stdio: ["ignore", "pipe", "pipe"],
64
- });
65
- if (!stdout.trim()) return [];
66
- const parsed = JSON.parse(stdout) as unknown;
67
- if (!Array.isArray(parsed)) return null;
68
- return parsed as T[];
69
- } catch {
70
- // Binary missing, db locked, malformed JSON, or SQL error — fail open.
71
- return null;
72
- }
73
- }
74
-
75
- /**
76
- * Pull session rows directly from opencode's SQLite. Selects the columns the
77
- * dashboard needs and orders newest-first.
59
+ * Session rows the dashboard needs, newest first.
60
+ *
61
+ * One caller needs sessions alone; the other two need sessions and projects
62
+ * together and take `readBothRows`, which serves both from a single open
63
+ * rather than two.
78
64
  */
79
- function readSessionRows(): OpenCodeSessionRow[] | null {
80
- return runOpenCodeDb<OpenCodeSessionRow>(
81
- `SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session ORDER BY time_updated DESC LIMIT ${SESSION_LIMIT}`,
65
+ async function readSessionRows(): Promise<OpenCodeSessionRow[] | null> {
66
+ return queryOpenCodeDb<OpenCodeSessionRow>(
67
+ "SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session ORDER BY time_updated DESC LIMIT ?",
68
+ [SESSION_LIMIT],
82
69
  );
83
70
  }
84
71
 
85
- function readProjectRows(): OpenCodeProjectRow[] | null {
86
- return runOpenCodeDb<OpenCodeProjectRow>(
87
- `SELECT id, worktree, vcs, name, time_created, time_updated FROM project`,
88
- );
72
+ /** Both tables from one open, for the callers that use them together. */
73
+ async function readBothRows(): Promise<{
74
+ sessions: OpenCodeSessionRow[] | null;
75
+ projects: OpenCodeProjectRow[] | null;
76
+ }> {
77
+ const rows = await withOpenCodeDb((db) => ({
78
+ sessions: db.query<OpenCodeSessionRow>(
79
+ "SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session ORDER BY time_updated DESC LIMIT ?",
80
+ [SESSION_LIMIT],
81
+ ),
82
+ projects: db.query<OpenCodeProjectRow>(
83
+ "SELECT id, worktree, vcs, name, time_created, time_updated FROM project",
84
+ ),
85
+ }));
86
+ return { sessions: rows?.sessions ?? null, projects: rows?.projects ?? null };
89
87
  }
90
88
 
91
89
  /**
@@ -97,8 +95,7 @@ function readProjectRows(): OpenCodeProjectRow[] | null {
97
95
  * if no sessions exist yet).
98
96
  */
99
97
  export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
100
- const sessions = readSessionRows();
101
- const projects = readProjectRows();
98
+ const { sessions, projects } = await readBothRows();
102
99
  if (sessions === null && projects === null) {
103
100
  // Binary missing or query failed — silent degrade (no log spam).
104
101
  return [];
@@ -163,7 +160,7 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
163
160
 
164
161
  /** Sessions under a given absolute cwd. Used by the project detail page. */
165
162
  export async function getOpenCodeSessionsForCwd(cwd: string): Promise<SessionFile[]> {
166
- const sessions = readSessionRows();
163
+ const sessions = await readSessionRows();
167
164
  if (!sessions) return [];
168
165
  const matches = sessions.filter((s) => s.directory === cwd);
169
166
  return matches.map((s) => {
@@ -193,8 +190,7 @@ export async function getOpenCodeSessionsByEncodedName(name: string): Promise<Op
193
190
  let projects: OpenCodeProjectRow[] | null;
194
191
  let sessions: OpenCodeSessionRow[] | null;
195
192
  try {
196
- projects = readProjectRows();
197
- sessions = readSessionRows();
193
+ ({ projects, sessions } = await readBothRows());
198
194
  } catch (error) {
199
195
  logWarn("Failed to read OpenCode DB:", error);
200
196
  return { cwd: null, sessions: [] };
@@ -2,9 +2,10 @@
2
2
  * OpenCode (sst/opencode) session transcript loader.
3
3
  *
4
4
  * Sessions live in opencode's SQLite DB (`~/.local/share/opencode/opencode.db`),
5
- * not on disk as JSONL like the other CLIs. We read them by shelling out to
6
- * `opencode db --format json "<sql>"` same surface as `lib/opencode-projects.ts`
7
- * and the same fail-open contract (binary missing return null).
5
+ * not on disk as JSONL like the other CLIs. Read directly through
6
+ * `lib/opencode-db.ts` see there for why this stopped shelling out to
7
+ * `opencode db` — with the same fail-open contract as every other provider
8
+ * (database unreadable → return null).
8
9
  *
9
10
  * Schema verified live on opencode v1.14.31:
10
11
  * • `session(id, project_id, parent_id, slug, directory, title, time_*, …)`
@@ -17,7 +18,7 @@
17
18
  *
18
19
  * Refs: https://opencode.ai/docs/ (CLI reference)
19
20
  */
20
- import { execFileSync } from "node:child_process";
21
+ import { withOpenCodeDb } from "./opencode-db";
21
22
  import { runtimeCache } from "./runtime-cache";
22
23
  import {
23
24
  baseEntry,
@@ -59,19 +60,19 @@ interface OpenCodePartRow {
59
60
  data: string; // JSON-encoded
60
61
  }
61
62
 
62
- /** Run a parameter-free SELECT against opencode's DB. Returns `null` on any
63
- * failure (binary missing, query error, malformed output). */
64
- function runOpenCodeDb<T>(sql: string): T[] | null {
63
+ /**
64
+ * One query, with its failure isolated to itself.
65
+ *
66
+ * The three reads below used to be three separate processes, so one of them
67
+ * failing left the others' results intact — and the callers depend on that: a
68
+ * session row that loads while its messages do not yields an EMPTY log rather
69
+ * than a missing one. Running all three inside a single open would otherwise
70
+ * turn any one failure into a null for the whole session, which reads to the
71
+ * dashboard as "no such session" rather than "no messages".
72
+ */
73
+ function safeQuery<T>(run: () => T[]): T[] | null {
65
74
  try {
66
- const stdout = execFileSync("opencode", ["db", "--format", "json", sql], {
67
- encoding: "utf8",
68
- timeout: 5_000,
69
- stdio: ["ignore", "pipe", "pipe"],
70
- });
71
- if (!stdout.trim()) return [];
72
- const parsed = JSON.parse(stdout) as unknown;
73
- if (!Array.isArray(parsed)) return null;
74
- return parsed as T[];
75
+ return run();
75
76
  } catch {
76
77
  return null;
77
78
  }
@@ -215,19 +216,33 @@ export interface OpenCodeSessionLogData {
215
216
  * or the binary is unavailable.
216
217
  */
217
218
  export async function getOpenCodeSessionLog(sessionId: string): Promise<OpenCodeSessionLogData | null> {
218
- if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null; // SQL-injection guard
219
- const sessions = runOpenCodeDb<OpenCodeSessionRow>(
220
- `SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session WHERE id = '${sessionId}'`,
221
- );
219
+ // Parameter binding already makes injection impossible, so this guard is no
220
+ // longer load-bearing for that — but an id that cannot match anything is
221
+ // still not worth a database open, and weakening an existing check while
222
+ // changing transport is how a regression gets in unnoticed.
223
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
224
+ // One open, three queries. Each of these was a separate `opencode db`
225
+ // process — and this function is called once per session by the audit, so
226
+ // the spawns were the audit.
227
+ const rows = await withOpenCodeDb((db) => ({
228
+ sessions: safeQuery(() => db.query<OpenCodeSessionRow>(
229
+ "SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session WHERE id = ?",
230
+ [sessionId],
231
+ )),
232
+ messages: safeQuery(() => db.query<OpenCodeMessageRow>(
233
+ "SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ? ORDER BY time_created ASC",
234
+ [sessionId],
235
+ )),
236
+ parts: safeQuery(() => db.query<OpenCodePartRow>(
237
+ "SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ? ORDER BY time_created ASC",
238
+ [sessionId],
239
+ )),
240
+ }));
241
+ const sessions = rows?.sessions ?? null;
222
242
  if (!sessions || sessions.length === 0) return null;
223
243
  const session = sessions[0];
224
-
225
- const messages = runOpenCodeDb<OpenCodeMessageRow>(
226
- `SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = '${sessionId}' ORDER BY time_created ASC`,
227
- );
228
- const parts = runOpenCodeDb<OpenCodePartRow>(
229
- `SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = '${sessionId}' ORDER BY time_created ASC`,
230
- );
244
+ const messages = rows?.messages ?? null;
245
+ const parts = rows?.parts ?? null;
231
246
  if (!messages) return { entries: [], rawLines: [], cwd: session.directory ?? undefined, filePath: `opencode://${sessionId}` };
232
247
 
233
248
  // Group parts by message_id for O(1) lookup.
@@ -289,20 +304,28 @@ export interface OpenCodeSessionExportData {
289
304
 
290
305
  export async function getOpenCodeSessionExport(sessionId: string): Promise<OpenCodeSessionExportData | null> {
291
306
  if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
292
- const sessions = runOpenCodeDb<OpenCodeSessionRow>(
293
- `SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session WHERE id = '${sessionId}'`,
294
- );
307
+ const rows = await withOpenCodeDb((db) => ({
308
+ sessions: safeQuery(() => db.query<OpenCodeSessionRow>(
309
+ "SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session WHERE id = ?",
310
+ [sessionId],
311
+ )),
312
+ messages: safeQuery(() => db.query<OpenCodeMessageRow>(
313
+ "SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ? ORDER BY time_created ASC",
314
+ [sessionId],
315
+ )),
316
+ parts: safeQuery(() => db.query<OpenCodePartRow>(
317
+ "SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ? ORDER BY time_created ASC",
318
+ [sessionId],
319
+ )),
320
+ }));
321
+ const sessions = rows?.sessions ?? null;
295
322
  if (!sessions || sessions.length === 0) return null;
296
323
  // Don't coalesce a `null` return (query failure) into `[]` — that would
297
324
  // silently serve an "empty session" export. A genuinely empty session has
298
325
  // an `[]` from the DB; a query failure has `null`. Treat the latter as
299
326
  // not-found so the route returns 404 rather than a misleading 200.
300
- const messages = runOpenCodeDb<OpenCodeMessageRow>(
301
- `SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = '${sessionId}' ORDER BY time_created ASC`,
302
- );
303
- const parts = runOpenCodeDb<OpenCodePartRow>(
304
- `SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = '${sessionId}' ORDER BY time_created ASC`,
305
- );
327
+ const messages = rows?.messages ?? null;
328
+ const parts = rows?.parts ?? null;
306
329
  if (messages === null || parts === null) return null;
307
330
  return {
308
331
  session: sessions[0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.2-beta.4",
3
+ "version": "1.0.2-beta.5",
4
4
  "description": "Observability and enforcement for AI agent harnesses. 39 built-in policies hooked into 12 of them — Claude Code, Codex, Cursor, Hermes, OpenClaw and more — blocking the tool call before it runs. Local dashboard included, no account needed.",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -120,15 +120,15 @@
120
120
  "sharp": "0.35.0"
121
121
  },
122
122
  "optionalDependencies": {
123
- "@failproofai/failproofaid-linux-x64": "1.0.2-beta.4",
124
- "@failproofai/failproofaid-linux-arm64": "1.0.2-beta.4",
125
- "@failproofai/failproofaid-darwin-x64": "1.0.2-beta.4",
126
- "@failproofai/failproofaid-darwin-arm64": "1.0.2-beta.4"
123
+ "@failproofai/failproofaid-linux-x64": "1.0.2-beta.5",
124
+ "@failproofai/failproofaid-linux-arm64": "1.0.2-beta.5",
125
+ "@failproofai/failproofaid-darwin-x64": "1.0.2-beta.5",
126
+ "@failproofai/failproofaid-darwin-arm64": "1.0.2-beta.5"
127
127
  },
128
128
  "failproofaidBinaries": {
129
- "linux-x64": "08bc00a7f8d7c80bf898471ce59c57f4e96c1420080009cc933b2ddeb4f23750",
130
- "linux-arm64": "1167d809628cff04c38efbc0eaa83ca26ab8de2878bed0ef59ca43054173eea7",
131
- "darwin-x64": "6ed32db21af6869c4475e753e524ad8e128436674209ad778421b240a4bd5c53",
132
- "darwin-arm64": "3843de034c9d6b11f77c1f423e6201ad6a77b7eff3b3d4460ba324c5aeb62021"
129
+ "linux-x64": "e19a1cf9a254df830f205fac024b8a229c162c93ae73851b985d2794e09da035",
130
+ "linux-arm64": "fafcff01e4cce00809b5cd9ff2822705d7373acbab79444e0384cb058b6da921",
131
+ "darwin-x64": "d7bd609387e13f5e75287674d948c69d739bac8d6963aa731b93111d1bdd2ea2",
132
+ "darwin-arm64": "9a57c8eb0f23ed25328e0f7b06638a6ab7f51e1deebf479bac2c779bf9912941"
133
133
  }
134
134
  }
@@ -10,11 +10,11 @@
10
10
  * sessions live in a SQLite DB rather than a file with a stable mtime).
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, openSync, readSync, closeSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { BUILTIN_POLICIES } from "../hooks/builtin-policies";
16
16
  import { AUDIT_DETECTORS } from "./detectors";
17
- import type { TranscriptAuditResult } from "./types";
17
+ import type { TranscriptAuditResult, DetectorSessionState } from "./types";
18
18
  import { auditCacheDir } from "../hooks/fp-home";
19
19
 
20
20
  let cachedEngineVersion: string | null = null;
@@ -110,7 +110,29 @@ function getCachePathFor(transcriptPath: string): string {
110
110
  * accidentally on some paths) so we reject them outright and force a
111
111
  * re-scan.
112
112
  */
113
- export const CACHE_SCHEMA_VERSION = 3;
113
+ /**
114
+ * v4: entries carry `bytesScanned`, `anchorHash` and `detectorState` so a
115
+ * transcript that GREW can be resumed from where the last scan stopped
116
+ * instead of being read from byte zero. v3 entries have none of the three;
117
+ * they are rejected rather than treated as "scanned 0 bytes", which would
118
+ * re-scan everything anyway but silently, and rather than as "scanned it
119
+ * all", which would drop every event appended since.
120
+ */
121
+ export const CACHE_SCHEMA_VERSION = 4;
122
+
123
+ /**
124
+ * How much of the head and of the resume boundary is hashed to decide the
125
+ * bytes already scanned are still the same bytes.
126
+ *
127
+ * A transcript is append-only in normal operation, so a grown file is
128
+ * ordinarily the same file plus more. "Ordinarily" is not a guarantee: a
129
+ * compaction can rewrite one in place. Re-reading the whole prefix to be sure
130
+ * would cost exactly what this change exists to avoid, so two cheap windows
131
+ * stand in for it — the first bytes of the file, and the last bytes before the
132
+ * offset being resumed from. A rewrite that leaves both intact and diverges
133
+ * only in between is not a shape any of these writers produce.
134
+ */
135
+ const ANCHOR_WINDOW = 4096;
114
136
 
115
137
  /** Hard expiry: a cached transcript result is rejected on read once it's
116
138
  * this old. Keeps the audit pipeline honest about long-lived results that
@@ -141,39 +163,163 @@ interface CacheEntry {
141
163
  engineVersion: string;
142
164
  detectorVersion: string;
143
165
  result: TranscriptAuditResult;
166
+ /**
167
+ * How far into the file the recorded result actually accounts for, at a LINE
168
+ * boundary. Not the same as `sizeBytes`: a transcript being appended to while
169
+ * the audit reads it ends in a partial line, which is not parsed and must not
170
+ * be counted as scanned or the events in it are lost forever.
171
+ */
172
+ bytesScanned?: number;
173
+ /** Head + boundary fingerprint of the bytes already scanned. */
174
+ anchorHash?: string;
175
+ /** Stateful detectors' session state at `bytesScanned`, Map-aware encoded. */
176
+ detectorState?: string;
144
177
  }
145
178
 
146
- export function readCachedTranscriptResult(
179
+ /** What a cache lookup can say. */
180
+ export type CacheLookup =
181
+ | { kind: "hit"; result: TranscriptAuditResult }
182
+ | {
183
+ kind: "resume";
184
+ result: TranscriptAuditResult;
185
+ fromByte: number;
186
+ detectorState: DetectorSessionState;
187
+ };
188
+
189
+ /**
190
+ * `Map` does not survive `JSON.stringify` — it encodes as `{}`, silently. The
191
+ * one stateful detector keeps its state in one, so a plain round-trip would
192
+ * hand back an empty countdown and quietly change what the detector finds
193
+ * across a resume boundary. Encoded generically rather than per-detector so a
194
+ * detector added later cannot forget to do it.
195
+ */
196
+ function encodeDetectorState(state: DetectorSessionState): string {
197
+ return JSON.stringify(state, (_k, v) =>
198
+ v instanceof Map ? { __map: [...v.entries()] } : v,
199
+ );
200
+ }
201
+
202
+ function decodeDetectorState(raw: string | undefined): DetectorSessionState {
203
+ if (!raw) return {};
204
+ try {
205
+ return JSON.parse(raw, (_k, v) =>
206
+ v && typeof v === "object" && Array.isArray((v as { __map?: unknown }).__map)
207
+ ? new Map((v as { __map: [unknown, unknown][] }).__map)
208
+ : v,
209
+ ) as DetectorSessionState;
210
+ } catch {
211
+ return {};
212
+ }
213
+ }
214
+
215
+ /**
216
+ * A fingerprint of the head of the file and of the bytes immediately before
217
+ * `upTo`, which together stand in for "the prefix has not been rewritten".
218
+ * Returns null when the file cannot be read that far — which is itself an
219
+ * answer: do not resume.
220
+ */
221
+ export function anchorHashFor(transcriptPath: string, upTo: number): string | null {
222
+ if (upTo <= 0) return null;
223
+ let fd: number | null = null;
224
+ try {
225
+ fd = openSync(transcriptPath, "r");
226
+ const head = Buffer.alloc(Math.min(ANCHOR_WINDOW, upTo));
227
+ readSync(fd, head, 0, head.length, 0);
228
+ const tailLen = Math.min(ANCHOR_WINDOW, upTo);
229
+ const tail = Buffer.alloc(tailLen);
230
+ readSync(fd, tail, 0, tailLen, upTo - tailLen);
231
+ return createHash("sha1")
232
+ .update(String(upTo))
233
+ .update(head)
234
+ .update(tail)
235
+ .digest("hex")
236
+ .slice(0, 32);
237
+ } catch {
238
+ return null;
239
+ } finally {
240
+ if (fd !== null) {
241
+ try { closeSync(fd); } catch { /* nothing useful to do */ }
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * What the cache can offer for this transcript: everything, the part before it
248
+ * grew, or nothing.
249
+ *
250
+ * The exact-match case is unchanged and still the common one. What is new is
251
+ * the middle answer. Keying validity on `(mtime, size)` alone made the cache
252
+ * all-or-nothing per FILE, so a session gaining a single line was re-parsed and
253
+ * re-replayed from byte zero — and the files that gain lines are the long-lived
254
+ * ones, which are also the largest. The cost of an audit tracked the size of
255
+ * whatever was still being written to, rather than the amount actually written
256
+ * since last time.
257
+ */
258
+ export function readCachedTranscript(
147
259
  transcriptPath: string,
148
260
  mtimeMs: number,
149
261
  sizeBytes: number,
150
- ): TranscriptAuditResult | null {
151
- if (sizeBytes === 0) return null; // OpenCode and other DB-backed sources
262
+ ): CacheLookup | null {
263
+ if (sizeBytes === 0) return null; // DB-backed sources have no file to key on
152
264
  const cachePath = getCachePathFor(transcriptPath);
153
265
  if (!existsSync(cachePath)) return null;
154
266
  try {
155
267
  const raw = readFileSync(cachePath, "utf-8");
156
268
  const entry = JSON.parse(raw) as Partial<CacheEntry>;
157
269
  if (entry.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
158
- if (entry.mtimeMs !== mtimeMs) return null;
159
- if (entry.sizeBytes !== sizeBytes) return null;
160
270
  if (entry.engineVersion !== getEngineVersion()) return null;
161
271
  if (entry.detectorVersion !== getDetectorVersion()) return null;
162
272
  // Number.isFinite (not typeof) so a malformed JSON `Infinity` /
163
273
  // `NaN` is rejected — those would otherwise bypass the TTL check
164
274
  // and pin a stale entry as valid forever.
165
275
  if (!Number.isFinite(entry.cachedAt) || Date.now() - (entry.cachedAt as number) > CACHE_TTL_MS) return null;
166
- return entry.result ?? null;
276
+ if (!entry.result) return null;
277
+
278
+ if (entry.mtimeMs === mtimeMs && entry.sizeBytes === sizeBytes) {
279
+ return { kind: "hit", result: entry.result };
280
+ }
281
+
282
+ // Grew. Everything below has to hold before any of the recorded result can
283
+ // be trusted, and each one failing means a full re-scan rather than a
284
+ // guess.
285
+ const from = entry.bytesScanned;
286
+ if (!Number.isFinite(from) || (from as number) <= 0) return null;
287
+ if ((from as number) > sizeBytes) return null; // truncated, or rewritten shorter
288
+ if (!entry.anchorHash) return null;
289
+ if (anchorHashFor(transcriptPath, from as number) !== entry.anchorHash) return null;
290
+
291
+ return {
292
+ kind: "resume",
293
+ result: entry.result,
294
+ fromByte: from as number,
295
+ detectorState: decodeDetectorState(entry.detectorState),
296
+ };
167
297
  } catch {
168
298
  return null;
169
299
  }
170
300
  }
171
301
 
302
+ /**
303
+ * The exact-match half on its own, for callers that cannot resume.
304
+ *
305
+ * Kept because "is this transcript already fully accounted for" is a question
306
+ * worth being able to ask without also being handed a partial answer to merge.
307
+ */
308
+ export function readCachedTranscriptResult(
309
+ transcriptPath: string,
310
+ mtimeMs: number,
311
+ sizeBytes: number,
312
+ ): TranscriptAuditResult | null {
313
+ const found = readCachedTranscript(transcriptPath, mtimeMs, sizeBytes);
314
+ return found?.kind === "hit" ? found.result : null;
315
+ }
316
+
172
317
  export function writeCachedTranscriptResult(
173
318
  transcriptPath: string,
174
319
  mtimeMs: number,
175
320
  sizeBytes: number,
176
321
  result: TranscriptAuditResult,
322
+ resume?: { bytesScanned: number; detectorState: DetectorSessionState },
177
323
  ): void {
178
324
  if (sizeBytes === 0) return;
179
325
  const cachePath = getCachePathFor(transcriptPath);
@@ -188,6 +334,18 @@ export function writeCachedTranscriptResult(
188
334
  detectorVersion: getDetectorVersion(),
189
335
  result,
190
336
  };
337
+ if (resume && resume.bytesScanned > 0) {
338
+ const anchor = anchorHashFor(transcriptPath, resume.bytesScanned);
339
+ // No anchor means no way to prove the prefix later, so the entry is
340
+ // written WITHOUT resume data: still a valid exact-match entry, just not
341
+ // a resumable one. Recording an offset we cannot verify would be worse
342
+ // than recording none.
343
+ if (anchor) {
344
+ entry.bytesScanned = resume.bytesScanned;
345
+ entry.anchorHash = anchor;
346
+ entry.detectorState = encodeDetectorState(resume.detectorState);
347
+ }
348
+ }
191
349
  // Set 0o600 at file-creation time so there's no window where the file
192
350
  // exists with the umask default (typically 0o644). The chmodSync below is
193
351
  // a belt-and-suspenders pass for the case where the file already existed.
@@ -198,8 +356,6 @@ export function writeCachedTranscriptResult(
198
356
  }
199
357
  }
200
358
 
201
- /** Test helper: reset memoized version hashes (so they recompute after a
202
- * detector or policy is monkey-patched in a test). */
203
359
  export function _resetVersionCache(): void {
204
360
  cachedEngineVersion = null;
205
361
  cachedDetectorVersion = null;