@ryanstark24/sfgraph-cli 1.2.1 → 1.6.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 (78) hide show
  1. package/dist/commands/audit.d.ts.map +1 -1
  2. package/dist/commands/audit.js +8 -3
  3. package/dist/commands/audit.js.map +1 -1
  4. package/dist/commands/diagnose.d.ts +139 -0
  5. package/dist/commands/diagnose.d.ts.map +1 -0
  6. package/dist/commands/diagnose.js +508 -0
  7. package/dist/commands/diagnose.js.map +1 -0
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +1 -1
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/ingest.d.ts +73 -0
  12. package/dist/commands/ingest.d.ts.map +1 -1
  13. package/dist/commands/ingest.js +362 -19
  14. package/dist/commands/ingest.js.map +1 -1
  15. package/dist/commands/rebuild-bindings.d.ts.map +1 -1
  16. package/dist/commands/rebuild-bindings.js +2 -2
  17. package/dist/commands/rebuild-bindings.js.map +1 -1
  18. package/dist/commands/reset-elemid-map.d.ts.map +1 -1
  19. package/dist/commands/reset-elemid-map.js.map +1 -1
  20. package/dist/commands/serve.d.ts +7 -0
  21. package/dist/commands/serve.d.ts.map +1 -1
  22. package/dist/commands/serve.js +59 -1
  23. package/dist/commands/serve.js.map +1 -1
  24. package/dist/commands/skip-labels.d.ts +11 -0
  25. package/dist/commands/skip-labels.d.ts.map +1 -0
  26. package/dist/commands/skip-labels.js +15 -0
  27. package/dist/commands/skip-labels.js.map +1 -0
  28. package/dist/commands/telemetry.js +1 -1
  29. package/dist/commands/telemetry.js.map +1 -1
  30. package/dist/heap.d.ts +24 -0
  31. package/dist/heap.d.ts.map +1 -0
  32. package/dist/heap.js +45 -0
  33. package/dist/heap.js.map +1 -0
  34. package/dist/index.d.ts +13 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +43 -2
  37. package/dist/index.js.map +1 -1
  38. package/package.json +8 -7
  39. package/dist/__tests__/doctor.test.d.ts +0 -2
  40. package/dist/__tests__/doctor.test.d.ts.map +0 -1
  41. package/dist/__tests__/doctor.test.js +0 -64
  42. package/dist/__tests__/doctor.test.js.map +0 -1
  43. package/dist/__tests__/ingest-db-guard.test.d.ts +0 -2
  44. package/dist/__tests__/ingest-db-guard.test.d.ts.map +0 -1
  45. package/dist/__tests__/ingest-db-guard.test.js +0 -110
  46. package/dist/__tests__/ingest-db-guard.test.js.map +0 -1
  47. package/dist/__tests__/ingest-multi.test.d.ts +0 -2
  48. package/dist/__tests__/ingest-multi.test.d.ts.map +0 -1
  49. package/dist/__tests__/ingest-multi.test.js +0 -51
  50. package/dist/__tests__/ingest-multi.test.js.map +0 -1
  51. package/dist/__tests__/install-dry-run.test.d.ts +0 -2
  52. package/dist/__tests__/install-dry-run.test.d.ts.map +0 -1
  53. package/dist/__tests__/install-dry-run.test.js +0 -29
  54. package/dist/__tests__/install-dry-run.test.js.map +0 -1
  55. package/dist/__tests__/install-e2e.test.d.ts +0 -2
  56. package/dist/__tests__/install-e2e.test.d.ts.map +0 -1
  57. package/dist/__tests__/install-e2e.test.js +0 -33
  58. package/dist/__tests__/install-e2e.test.js.map +0 -1
  59. package/dist/__tests__/mcp-config.test.d.ts +0 -2
  60. package/dist/__tests__/mcp-config.test.d.ts.map +0 -1
  61. package/dist/__tests__/mcp-config.test.js +0 -104
  62. package/dist/__tests__/mcp-config.test.js.map +0 -1
  63. package/dist/__tests__/rebuild.test.d.ts +0 -2
  64. package/dist/__tests__/rebuild.test.d.ts.map +0 -1
  65. package/dist/__tests__/rebuild.test.js +0 -180
  66. package/dist/__tests__/rebuild.test.js.map +0 -1
  67. package/dist/__tests__/serve-guard.test.d.ts +0 -2
  68. package/dist/__tests__/serve-guard.test.d.ts.map +0 -1
  69. package/dist/__tests__/serve-guard.test.js +0 -82
  70. package/dist/__tests__/serve-guard.test.js.map +0 -1
  71. package/dist/__tests__/snapshot.test.d.ts +0 -2
  72. package/dist/__tests__/snapshot.test.d.ts.map +0 -1
  73. package/dist/__tests__/snapshot.test.js +0 -139
  74. package/dist/__tests__/snapshot.test.js.map +0 -1
  75. package/dist/__tests__/telemetry.test.d.ts +0 -2
  76. package/dist/__tests__/telemetry.test.d.ts.map +0 -1
  77. package/dist/__tests__/telemetry.test.js +0 -53
  78. package/dist/__tests__/telemetry.test.js.map +0 -1
@@ -1,4 +1,10 @@
1
+ import type { LiveIngestOpts } from "@ryanstark24/sfgraph-core";
2
+ import { ConsoleLogger } from "@ryanstark24/sfgraph-shared";
1
3
  export interface IngestOpts {
4
+ /** Offline ingest of an SFDX checkout; --org then only sets the alias. */
5
+ project?: string | undefined;
6
+ /** With --project: re-ingest only the members that changed (Task 11c). */
7
+ delta?: boolean | undefined;
2
8
  org?: string | undefined;
3
9
  orgs?: string | undefined;
4
10
  all?: boolean | undefined;
@@ -10,6 +16,9 @@ export interface IngestOpts {
10
16
  embedModelDim?: number | undefined;
11
17
  rebuild?: boolean | undefined;
12
18
  noBackup?: boolean | undefined;
19
+ /** Return at "graph ready" instead of waiting for embeddings
20
+ * (`--no-wait-embeddings`); the next ingest fills the pending vectors. */
21
+ noWaitEmbeddings?: boolean | undefined;
13
22
  detectDeletions?: boolean | undefined;
14
23
  /** Comma-separated source labels to fetch (e.g. 'apex,generic:Profile').
15
24
  * Forces mode=full and merges into the existing graph without rebuild. */
@@ -44,5 +53,69 @@ export interface IngestOpts {
44
53
  * Env: SFGRAPH_SKIP_THRESHOLD. Default 5. */
45
54
  skipThreshold?: number | undefined;
46
55
  }
56
+ /**
57
+ * The persistent embedding cache for the active model:
58
+ * `<dataDir>/embedding-cache/<modelId>@<sha16>[@<cap>].sqlite`, where sha16 is
59
+ * the first 16 hex chars of the model weights file's sha256, so new weights
60
+ * under the same model id never reuse old vectors. A non-default token cap
61
+ * changes the vectors too, so it gets its own file. Undefined (no cache) when
62
+ * the model file cannot be found: there is nothing trustworthy to key it by.
63
+ * The file lives outside the per-org graph DB, so `--rebuild` reuses it; it
64
+ * only grows.
65
+ */
66
+ export declare function embeddingCacheFileFor(dataDir: string): Promise<string | undefined>;
67
+ /**
68
+ * Ruling (a): an embedding failure after graph ready is not an ingest failure.
69
+ * The graph is complete and the unembedded texts stay flagged pending, so the
70
+ * next ingest fills them (find_similar uses text search meanwhile). Warn and
71
+ * leave the exit code alone; live-ingest already printed the cause.
72
+ */
73
+ export declare function reportEmbeddingOutcome(result: {
74
+ embeddings?: {
75
+ error?: string;
76
+ failed?: number;
77
+ pending?: number;
78
+ };
79
+ }): void;
80
+ /** Give every built ingest the same embedder (multi-org runs: one worker and
81
+ * one model in memory instead of one per org). The caller closes it. */
82
+ export declare function shareEmbedder(build: (alias: string) => Promise<LiveIngestOpts>, embedder: NonNullable<LiveIngestOpts["embedder"]>): (alias: string) => Promise<LiveIngestOpts>;
83
+ /** Offline ingest of an SFDX checkout (plus vlocity/datapacks when present).
84
+ * Reuses buildSingleIngestOpts with a resolver that never contacts an org. */
85
+ export declare function buildProjectIngestOpts(opts: IngestOpts, logger: ConsoleLogger, deps?: {
86
+ onResolved?: (orgId: string, alias: string) => void;
87
+ }): Promise<LiveIngestOpts>;
88
+ /**
89
+ * Run a move/delete of the graph file. On Windows a file another process has
90
+ * open (`sfgraph serve`, or an MCP client's server) cannot be renamed or
91
+ * deleted: report that plainly instead of a raw EBUSY/EPERM. Nothing has been
92
+ * moved when this throws, so the existing graph is untouched.
93
+ */
94
+ export declare function onGraphFile<T>(dbPath: string, op: () => T): T;
95
+ /** Rebuild backups kept per org (each is a full copy of the graph file). */
96
+ export declare const REBUILD_BACKUPS_KEPT = 2;
97
+ /**
98
+ * Delete all but the newest {@link REBUILD_BACKUPS_KEPT} rebuild backups of
99
+ * one org (with their sidecars). The names carry an ISO stamp, so name order
100
+ * is time order. The newest backup that still holds snapshot history is always
101
+ * kept too: a rebuild whose snapshot copy failed leaves a backup without
102
+ * history, and two of those in a row must not push the real history out.
103
+ * Best-effort.
104
+ */
105
+ export declare function pruneRebuildBackups(backupDir: string, orgId: string): Promise<string[]>;
106
+ /**
107
+ * Task 11c: one ingest per org at a time (`<dataDir>/<orgId>.ingest.lock`).
108
+ * Taken as soon as an org is resolved, before --rebuild moves its graph;
109
+ * released when the command ends. Re-taking a held org is a no-op.
110
+ * While a lock is held, SIGINT / SIGTERM release it and exit with 128 + signo
111
+ * (a stale lock is only detectable on the same host).
112
+ */
113
+ export declare function ingestLocks(o?: {
114
+ dir?: string;
115
+ onSignal?: (sig: NodeJS.Signals) => void;
116
+ }): {
117
+ take: (orgId: string, alias: string) => void;
118
+ releaseAll: () => void;
119
+ };
47
120
  export declare function ingestCmd(opts: IngestOpts): Promise<void>;
48
121
  //# sourceMappingURL=ingest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,GAAG,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC;IACnD,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC;+EAC2E;IAC3E,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;oBAEgB;IAChB,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACnC,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;uDAEmD;IACnD,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B;;;;iEAI6D;IAC7D,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAClC,oEAAoE;IACpE,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,sDAAsD;IACtD,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACrC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B;kDAC8C;IAC9C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAuMD,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAgM/D"}
1
+ {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,9 @@
1
+ import { existsSync } from "node:fs";
2
+ import { constants as osConstants } from "node:os";
1
3
  import path from "node:path";
2
- import { SqliteGraphStore, SqliteSnapshotStore, listAllAuthenticatedOrgs, liveIngest, multiOrgIngest, } from "@ryanstark24/sfgraph-core";
4
+ import { EmbeddingPool, SqliteGraphStore, SqliteSnapshotStore, SqliteVectorStore, acquireIngestLock, defaultEmbeddingWorkerUrl, embedMaxTokens, embedModelFingerprint, embeddingCachePath, listAllAuthenticatedOrgs, liveIngest, multiOrgIngest, resolveEmbedModelId, snapshotCountInFile, } from "@ryanstark24/sfgraph-core";
3
5
  import { ConsoleLogger, SfgraphError, getSfgraphPaths, safeOrgDbPath, } from "@ryanstark24/sfgraph-shared";
6
+ import { retrySkippedLabels } from "./skip-labels.js";
4
7
  /** Skip categories that are worth retrying. `insufficient_access` and
5
8
  * `not_found` are stable conditions that won't fix themselves on a
6
9
  * second pass; retrying them just wastes time. */
@@ -44,21 +47,68 @@ function validateDbPath(rawDbPath, dataDir) {
44
47
  }
45
48
  async function buildSingleIngestOpts(alias, opts, logger, deps) {
46
49
  const resolved = await deps.resolveOrg(alias);
50
+ deps.onResolved?.(String(resolved.orgId), alias);
47
51
  const dataDir = getSfgraphPaths().data;
48
52
  const dbPath = opts.db
49
53
  ? validateDbPath(opts.db, dataDir)
50
54
  : safeOrgDbPath(dataDir, String(resolved.orgId));
51
- if (opts.rebuild) {
52
- await applyRebuild(dbPath, String(resolved.orgId), Boolean(opts.noBackup), logger);
53
- }
55
+ const backupPath = opts.rebuild
56
+ ? await applyRebuild(dbPath, String(resolved.orgId), Boolean(opts.noBackup), logger)
57
+ : undefined;
54
58
  const graphStore = new SqliteGraphStore({ dbPath });
55
59
  await graphStore.init();
60
+ // Closed if anything below fails: the caller only closes what it gets back.
61
+ try {
62
+ return await buildOnStore(graphStore, {
63
+ alias,
64
+ opts,
65
+ logger,
66
+ dbPath,
67
+ dataDir,
68
+ resolved,
69
+ backupPath,
70
+ });
71
+ }
72
+ catch (e) {
73
+ await graphStore.close().catch(() => { });
74
+ throw e;
75
+ }
76
+ }
77
+ async function buildOnStore(graphStore, c) {
78
+ const { alias, opts, logger, dbPath, dataDir, resolved, backupPath } = c;
56
79
  const snapshotStore = new SqliteSnapshotStore({
57
80
  dbPath,
58
81
  db: graphStore.db,
59
82
  skipMigrations: true,
60
83
  });
61
84
  await snapshotStore.init();
85
+ // A rebuild keeps the snapshot history (point_in_time_diff): copy it from
86
+ // the backup before the first snapshot of the new graph is taken.
87
+ const carried = backupPath
88
+ ? carrySnapshots(snapshotStore, backupPath, String(resolved.orgId))
89
+ : 0;
90
+ // Wire a vector store so ingest GENERATES MiniLM embeddings — without this
91
+ // `opts.vectorStore` is undefined, live-ingest never builds its embedding
92
+ // queue, and the vec0 tables stay empty (semantic find_similar returns
93
+ // "vector index unavailable"). Reuses the graph store's SQLite handle and
94
+ // loads sqlite-vec on it. If the native vec extension or the embedding model
95
+ // is unavailable, degrade gracefully (null → structural ingest still works,
96
+ // just no vectors) instead of failing the whole ingest.
97
+ let vectorStore;
98
+ try {
99
+ const vs = new SqliteVectorStore({
100
+ dbPath,
101
+ db: graphStore.db,
102
+ skipMigrations: true,
103
+ });
104
+ await vs.init();
105
+ vectorStore = vs;
106
+ }
107
+ catch (e) {
108
+ logger.warn("ingest: vector store unavailable — embeddings disabled", {
109
+ err: e.message,
110
+ });
111
+ }
62
112
  // --rebuild and --only and --retry-skipped all force a full sync because
63
113
  // incremental wouldn't pick up newly-permitted/unblocked types.
64
114
  const forceFull = Boolean(opts.rebuild || opts.only || opts.retrySkipped);
@@ -80,9 +130,9 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
80
130
  }
81
131
  try {
82
132
  const raw = JSON.parse(readFileSync(skipReportPath, "utf8"));
83
- const labels = (raw.skips ?? []).map((s) => s.label).filter(Boolean);
133
+ const labels = retrySkippedLabels(raw.skips ?? []);
84
134
  if (labels.length === 0) {
85
- console.log("--retry-skipped: previous run had zero skips. Nothing to retry.");
135
+ console.log("--retry-skipped: previous run had no skips. Nothing to retry.");
86
136
  // Return a no-op opts; the live-ingest will exit cleanly with no work.
87
137
  onlyLabels = new Set();
88
138
  }
@@ -95,22 +145,100 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
95
145
  throw new Error(`--retry-skipped: failed to read skip report at ${skipReportPath}: ${e.message}`);
96
146
  }
97
147
  }
148
+ const embeddingCacheFile = vectorStore ? await embeddingCacheFileFor(dataDir) : undefined;
98
149
  return {
99
150
  alias,
100
151
  mode,
101
152
  graphStore,
102
153
  snapshotStore,
154
+ ...(vectorStore ? { vectorStore, waitEmbeddings: !opts.noWaitEmbeddings } : {}),
155
+ ...(embeddingCacheFile ? { embeddingCacheFile } : {}),
103
156
  logger,
104
157
  preResolved: resolved,
105
158
  detectDeletions: Boolean(opts.detectDeletions),
106
- ...(onlyLabels ? { onlyLabels } : {}),
159
+ // User-chosen labels (--only / --retry-skipped) are read in full: no
160
+ // early stop on identical refusals (review N1c).
161
+ ...(onlyLabels ? { onlyLabels, explicitOnlyLabels: true } : {}),
107
162
  skipReportPath,
108
163
  disableCrossFlavor: Boolean(opts.noCrossFlavor),
109
164
  disableArityResolve: Boolean(opts.noArityResolve),
110
165
  disableFlowInvocableResolve: Boolean(opts.noFlowResolve),
111
166
  disableAudit: Boolean(opts.noAudit),
167
+ // A snapshot of the still-empty rebuilt graph adds nothing to the carried
168
+ // history; the next ingest's snapshot records the rebuilt state.
169
+ ...(carried > 0 ? { skipSnapshot: true } : {}),
170
+ // Populate the _sfgraph_findings / dead-code / governor / test-coverage
171
+ // tables after merge. Without this the analysis MCP tools (dead_code_audit,
172
+ // security_audit, governor_risk_check, export_sarif) fall back to weak
173
+ // live scans or return empty results — the tables existed but nothing
174
+ // ever wrote to them.
175
+ analysisDb: graphStore.db,
112
176
  };
113
177
  }
178
+ /**
179
+ * The persistent embedding cache for the active model:
180
+ * `<dataDir>/embedding-cache/<modelId>@<sha16>[@<cap>].sqlite`, where sha16 is
181
+ * the first 16 hex chars of the model weights file's sha256, so new weights
182
+ * under the same model id never reuse old vectors. A non-default token cap
183
+ * changes the vectors too, so it gets its own file. Undefined (no cache) when
184
+ * the model file cannot be found: there is nothing trustworthy to key it by.
185
+ * The file lives outside the per-org graph DB, so `--rebuild` reuses it; it
186
+ * only grows.
187
+ */
188
+ export async function embeddingCacheFileFor(dataDir) {
189
+ const sha = await embedModelFingerprint();
190
+ if (!sha)
191
+ return undefined;
192
+ const cap = embedMaxTokens();
193
+ const key = `${resolveEmbedModelId()}@${sha.slice(0, 16)}`;
194
+ return embeddingCachePath(dataDir, cap === 256 ? key : `${key}@${cap}`);
195
+ }
196
+ /**
197
+ * Ruling (a): an embedding failure after graph ready is not an ingest failure.
198
+ * The graph is complete and the unembedded texts stay flagged pending, so the
199
+ * next ingest fills them (find_similar uses text search meanwhile). Warn and
200
+ * leave the exit code alone; live-ingest already printed the cause.
201
+ */
202
+ export function reportEmbeddingOutcome(result) {
203
+ const e = result.embeddings;
204
+ if (!e?.error)
205
+ return;
206
+ const left = Math.max(e.pending ?? 0, e.failed ?? 0);
207
+ console.warn(`warning: the graph is complete, but ${left} texts were left unembedded (${e.error}); their vectors stay pending and the next ingest fills them`);
208
+ }
209
+ /** Give every built ingest the same embedder (multi-org runs: one worker and
210
+ * one model in memory instead of one per org). The caller closes it. */
211
+ export function shareEmbedder(build, embedder) {
212
+ return async (alias) => {
213
+ const o = await build(alias);
214
+ return o.vectorStore ? { ...o, embedder } : o;
215
+ };
216
+ }
217
+ /** Offline ingest of an SFDX checkout (plus vlocity/datapacks when present).
218
+ * Reuses buildSingleIngestOpts with a resolver that never contacts an org. */
219
+ export async function buildProjectIngestOpts(opts, logger, deps = {}) {
220
+ const root = path.resolve(String(opts.project));
221
+ if (!existsSync(path.join(root, "sfdx-project.json"))) {
222
+ throw new Error(`--project: ${root} has no sfdx-project.json`);
223
+ }
224
+ const { offlineResolvedOrg, FilesystemMetadataSource } = await import("@ryanstark24/sfgraph-core");
225
+ const alias = opts.org ?? path.basename(root);
226
+ const built = await buildSingleIngestOpts(alias, opts, logger, {
227
+ resolveOrg: (async () => offlineResolvedOrg(root, alias)),
228
+ ...(deps.onResolved ? { onResolved: deps.onResolved } : {}),
229
+ });
230
+ try {
231
+ return {
232
+ ...built,
233
+ mode: "full",
234
+ metadataSource: await FilesystemMetadataSource.fromProjectRoot(root),
235
+ };
236
+ }
237
+ catch (e) {
238
+ await built.graphStore.close().catch(() => { });
239
+ throw e;
240
+ }
241
+ }
114
242
  /**
115
243
  * SQLite uses WAL by default in this codebase, which means alongside
116
244
  * `<file>.sqlite` you also get `<file>.sqlite-wal` and `<file>.sqlite-shm`.
@@ -119,14 +247,91 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
119
247
  * SQLite later reads back into the new DB, corrupting state.
120
248
  */
121
249
  const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"];
250
+ /**
251
+ * Run a move/delete of the graph file. On Windows a file another process has
252
+ * open (`sfgraph serve`, or an MCP client's server) cannot be renamed or
253
+ * deleted: report that plainly instead of a raw EBUSY/EPERM. Nothing has been
254
+ * moved when this throws, so the existing graph is untouched.
255
+ */
256
+ export function onGraphFile(dbPath, op) {
257
+ try {
258
+ return op();
259
+ }
260
+ catch (e) {
261
+ const code = e.code;
262
+ if (code === "EBUSY" || code === "EPERM" || code === "EACCES") {
263
+ const hint = "Stop `sfgraph serve` and any MCP client using sfgraph (Claude Code, Cursor, ...), then re-run with --rebuild.";
264
+ throw new Error(`rebuild: the graph file is in use by another process (${code}): ${dbPath}. ${hint}`, { cause: e });
265
+ }
266
+ throw e;
267
+ }
268
+ }
269
+ /** Rebuild backups kept per org (each is a full copy of the graph file). */
270
+ export const REBUILD_BACKUPS_KEPT = 2;
271
+ /**
272
+ * Copy the backup's snapshot tables into the rebuilt graph. A failure leaves
273
+ * the new graph without history (the backup still has it) and warns.
274
+ */
275
+ function carrySnapshots(store, backupPath, orgId) {
276
+ try {
277
+ const r = store.copySnapshotsFrom(backupPath);
278
+ if (r.skipped) {
279
+ console.warn(`REBUILD: snapshot history for ${orgId} not kept: ${r.skipped}`);
280
+ return 0;
281
+ }
282
+ if (r.copied > 0) {
283
+ console.warn(`REBUILD: kept ${r.copied} snapshot${r.copied === 1 ? "" : "s"} for ${orgId}`);
284
+ }
285
+ return r.copied;
286
+ }
287
+ catch (e) {
288
+ console.warn(`REBUILD: snapshot history for ${orgId} not kept (${e.message}); it is still in ${backupPath}`);
289
+ return 0;
290
+ }
291
+ }
292
+ /**
293
+ * Delete all but the newest {@link REBUILD_BACKUPS_KEPT} rebuild backups of
294
+ * one org (with their sidecars). The names carry an ISO stamp, so name order
295
+ * is time order. The newest backup that still holds snapshot history is always
296
+ * kept too: a rebuild whose snapshot copy failed leaves a backup without
297
+ * history, and two of those in a row must not push the real history out.
298
+ * Best-effort.
299
+ */
300
+ export async function pruneRebuildBackups(backupDir, orgId) {
301
+ const { readdirSync, unlinkSync } = await import("node:fs");
302
+ const prefix = `${orgId}.rebuild-`;
303
+ let names;
304
+ try {
305
+ names = readdirSync(backupDir).filter((f) => f.startsWith(prefix) && f.endsWith(".sqlite"));
306
+ }
307
+ catch {
308
+ return [];
309
+ }
310
+ const newestFirst = names.sort().reverse();
311
+ // -1 (unreadable) counts as holding history.
312
+ const history = newestFirst.find((f) => snapshotCountInFile(path.join(backupDir, f)) !== 0);
313
+ const old = newestFirst.slice(REBUILD_BACKUPS_KEPT).filter((f) => f !== history);
314
+ for (const f of old) {
315
+ for (const suffix of ["", ...SQLITE_SIDECAR_SUFFIXES]) {
316
+ try {
317
+ unlinkSync(path.join(backupDir, `${f}${suffix}`));
318
+ }
319
+ catch {
320
+ /* best-effort */
321
+ }
322
+ }
323
+ }
324
+ return old;
325
+ }
326
+ /** Move (or delete) the org's graph file; returns the backup's path when moved. */
122
327
  async function applyRebuild(dbPath, orgId, noBackup, logger) {
123
328
  const { existsSync, mkdirSync, renameSync, unlinkSync } = await import("node:fs");
124
329
  if (!existsSync(dbPath)) {
125
330
  logger.info(`rebuild: no existing graph at ${dbPath}; starting fresh`);
126
- return;
331
+ return undefined;
127
332
  }
128
333
  if (noBackup) {
129
- unlinkSync(dbPath);
334
+ onGraphFile(dbPath, () => unlinkSync(dbPath));
130
335
  for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
131
336
  const sidecar = `${dbPath}${suffix}`;
132
337
  if (existsSync(sidecar)) {
@@ -139,13 +344,13 @@ async function applyRebuild(dbPath, orgId, noBackup, logger) {
139
344
  }
140
345
  }
141
346
  console.warn(`REBUILD: existing graph for ${orgId} DELETED (no-backup); running full sync`);
142
- return;
347
+ return undefined;
143
348
  }
144
349
  const backupDir = path.join(getSfgraphPaths().data, "backups");
145
350
  mkdirSync(backupDir, { recursive: true });
146
351
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
147
352
  const backupPath = path.join(backupDir, `${orgId}.rebuild-${stamp}.sqlite`);
148
- renameSync(dbPath, backupPath);
353
+ onGraphFile(dbPath, () => renameSync(dbPath, backupPath));
149
354
  for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
150
355
  const sidecar = `${dbPath}${suffix}`;
151
356
  if (existsSync(sidecar)) {
@@ -165,9 +370,54 @@ async function applyRebuild(dbPath, orgId, noBackup, logger) {
165
370
  }
166
371
  }
167
372
  console.warn(`REBUILD: existing graph for ${orgId} moved to ${backupPath}; running full sync`);
373
+ const pruned = await pruneRebuildBackups(backupDir, orgId);
374
+ if (pruned.length > 0) {
375
+ console.warn(`REBUILD: removed ${pruned.length} older backup${pruned.length === 1 ? "" : "s"} for ${orgId} (keeping the newest ${REBUILD_BACKUPS_KEPT})`);
376
+ }
377
+ return backupPath;
378
+ }
379
+ /**
380
+ * Task 11c: one ingest per org at a time (`<dataDir>/<orgId>.ingest.lock`).
381
+ * Taken as soon as an org is resolved, before --rebuild moves its graph;
382
+ * released when the command ends. Re-taking a held org is a no-op.
383
+ * While a lock is held, SIGINT / SIGTERM release it and exit with 128 + signo
384
+ * (a stale lock is only detectable on the same host).
385
+ */
386
+ export function ingestLocks(o = {}) {
387
+ const held = new Map();
388
+ const signals = ["SIGINT", "SIGTERM"];
389
+ const releaseAll = () => {
390
+ for (const l of held.values())
391
+ l.release();
392
+ held.clear();
393
+ for (const s of signals)
394
+ process.off(s, onSig);
395
+ };
396
+ const onSig = (sig) => {
397
+ releaseAll();
398
+ if (o.onSignal)
399
+ o.onSignal(sig);
400
+ // Exit explicitly: re-raising relies on the default action, which other
401
+ // SIGINT / SIGTERM listeners would suppress (the run would go on unlocked).
402
+ else
403
+ process.exit(128 + (osConstants.signals[sig] ?? 0));
404
+ };
405
+ return {
406
+ take(orgId, alias) {
407
+ if (held.has(orgId))
408
+ return;
409
+ const dir = o.dir ?? getSfgraphPaths().data;
410
+ held.set(orgId, acquireIngestLock({ dir, orgId, orgLabel: alias }));
411
+ if (held.size === 1)
412
+ for (const s of signals)
413
+ process.on(s, onSig);
414
+ },
415
+ releaseAll,
416
+ };
168
417
  }
169
418
  export async function ingestCmd(opts) {
170
419
  const logger = new ConsoleLogger("info");
420
+ const locks = ingestLocks();
171
421
  if (opts.embedModel)
172
422
  process.env.SFGRAPH_EMBED_MODEL_PATH = opts.embedModel;
173
423
  if (opts.embedModelId)
@@ -188,6 +438,33 @@ export async function ingestCmd(opts) {
188
438
  poolOverrides.data = opts.dataPool;
189
439
  const applied = await configureDefaultPools(poolOverrides);
190
440
  logger.info(`ingest: pool concurrency tooling=${applied.tooling} metadata=${applied.metadata} data=${applied.data}`);
441
+ if (opts.delta && !opts.project)
442
+ throw new Error("--delta needs --project");
443
+ // An offline ingest reads every member of the checkout; a label filter has
444
+ // nothing to narrow (final review core M-3).
445
+ if (opts.project && (opts.only || opts.retrySkipped)) {
446
+ throw new Error(`${opts.only ? "--only" : "--retry-skipped"} does not work with --project: a project ingest always reads the whole checkout`);
447
+ }
448
+ if (opts.delta && opts.rebuild)
449
+ throw new Error("--delta and --rebuild are mutually exclusive");
450
+ if (opts.project && opts.delta) {
451
+ await projectDelta(opts, logger, locks.take);
452
+ return;
453
+ }
454
+ if (opts.project) {
455
+ const built = await buildProjectIngestOpts(opts, logger, { onResolved: locks.take });
456
+ const result = await liveIngest(built);
457
+ reportEmbeddingOutcome(result);
458
+ console.log(`ingest --project: ${result.membersProcessed} members, ${result.parseErrors} parse errors -> ${built.preResolved?.orgId}`);
459
+ const dp = built.metadataSource?.dataPackReport;
460
+ if (dp) {
461
+ console.log(`datapacks: ${JSON.stringify(dp.types)} skipped=${dp.skipped.length}`);
462
+ for (const s of dp.skipped.slice(0, 20))
463
+ console.log(` skipped ${s.path}: ${s.reason}`);
464
+ }
465
+ await built.graphStore.close();
466
+ return;
467
+ }
191
468
  // Determine the alias list (multi-org modes).
192
469
  let aliases = null;
193
470
  if (opts.all) {
@@ -219,14 +496,27 @@ export async function ingestCmd(opts) {
219
496
  }
220
497
  if (aliases !== null && aliases.length > 0) {
221
498
  const list = aliases;
222
- const summary = await multiOrgIngest({
223
- aliases: list,
224
- parallel: Boolean(opts.parallel),
225
- logger,
226
- buildOpts: (alias) => buildSingleIngestOpts(alias, opts, logger, { resolveOrg }),
227
- });
499
+ const build = (alias) => buildSingleIngestOpts(alias, opts, logger, { resolveOrg, onResolved: locks.take });
500
+ // One embedding worker (one model, one ORT arena) shared by every org.
501
+ const workerUrl = defaultEmbeddingWorkerUrl();
502
+ const sharedPool = workerUrl ? new EmbeddingPool({ workerUrl }) : null;
503
+ let summary;
504
+ try {
505
+ summary = await multiOrgIngest({
506
+ aliases: list,
507
+ parallel: Boolean(opts.parallel),
508
+ logger,
509
+ buildOpts: sharedPool ? shareEmbedder(build, sharedPool) : build,
510
+ });
511
+ }
512
+ finally {
513
+ await sharedPool?.close();
514
+ }
228
515
  console.log(`\nMulti-org ingest complete (${summary.parallel ? "parallel" : "sequential"}, ${summary.totalElapsedMs}ms):`);
229
516
  printResultsTable(summary.entries, opts.mode ?? "auto");
517
+ for (const e of summary.entries)
518
+ if (e.result)
519
+ reportEmbeddingOutcome(e.result);
230
520
  const failed = summary.entries.filter((e) => e.status === "error").length;
231
521
  if (failed > 0)
232
522
  process.exitCode = 1;
@@ -247,10 +537,14 @@ export async function ingestCmd(opts) {
247
537
  alias = detected;
248
538
  logger.info(`ingest: using default org from sf config: ${alias}`);
249
539
  }
250
- const built = await buildSingleIngestOpts(alias, opts, logger, { resolveOrg });
540
+ const built = await buildSingleIngestOpts(alias, opts, logger, {
541
+ resolveOrg,
542
+ onResolved: locks.take,
543
+ });
251
544
  const startedAt = Date.now();
252
545
  logger.info(`ingest: starting alias=${alias}`);
253
546
  const result = await liveIngest(built);
547
+ reportEmbeddingOutcome(result);
254
548
  logger.info("ingest: capabilities", result.capabilities);
255
549
  logger.info(`ingest: complete mode=${result.mode} members=${result.membersProcessed} deletions=${result.deletions} parseErrors=${result.parseErrors} elapsed=${Date.now() - startedAt}ms`);
256
550
  // Post-ingest auto-retry. If a substantial number of sources skipped
@@ -286,7 +580,12 @@ export async function ingestCmd(opts) {
286
580
  console.log(" Disable with --no-auto-retry-skipped or SFGRAPH_NO_AUTO_RETRY=1.");
287
581
  await new Promise((resolve) => setTimeout(resolve, delayMs));
288
582
  const retryStartedAt = Date.now();
289
- const retryResult = await liveIngest({ ...built, onlyLabels: labels });
583
+ // In-run auto-retry: not an explicit re-run, so the early stop stays on.
584
+ const retryResult = await liveIngest({
585
+ ...built,
586
+ onlyLabels: labels,
587
+ explicitOnlyLabels: false,
588
+ });
290
589
  logger.info(`ingest: auto-retry complete members=${retryResult.membersProcessed} parseErrors=${retryResult.parseErrors} elapsed=${Date.now() - retryStartedAt}ms`);
291
590
  }
292
591
  }
@@ -330,5 +629,49 @@ export async function ingestCmd(opts) {
330
629
  }
331
630
  process.exitCode = 1;
332
631
  }
632
+ finally {
633
+ locks.releaseAll();
634
+ }
635
+ }
636
+ /**
637
+ * `ingest --project <p> --delta`: re-derive only the changed members
638
+ * (core runDeltaIngest prints `delta: +A ~C -D (=U unchanged) in Ns`). A
639
+ * graph without member rows (older sfgraph, interrupted full ingest) gets a
640
+ * full rebuild instead. The caller's lock covers both.
641
+ */
642
+ async function projectDelta(opts, logger, takeLock) {
643
+ const { runDeltaIngest } = await import("@ryanstark24/sfgraph-core");
644
+ const built = await buildProjectIngestOpts({ ...opts, rebuild: false }, logger, {
645
+ onResolved: takeLock,
646
+ });
647
+ const store = built.graphStore;
648
+ let open = true;
649
+ try {
650
+ const r = await runDeltaIngest({
651
+ ...built,
652
+ graphStore: store,
653
+ preResolved: built.preResolved,
654
+ metadataSource: built.metadataSource,
655
+ fullRebuild: async () => {
656
+ await store.close();
657
+ open = false;
658
+ const full = await buildProjectIngestOpts({ ...opts, rebuild: true, delta: false }, logger);
659
+ try {
660
+ const result = await liveIngest(full);
661
+ reportEmbeddingOutcome(result);
662
+ console.log(`ingest --project: ${result.membersProcessed} members, ${result.parseErrors} parse errors -> ${full.preResolved?.orgId}`);
663
+ }
664
+ finally {
665
+ await full.graphStore.close();
666
+ }
667
+ },
668
+ });
669
+ if (r.result)
670
+ reportEmbeddingOutcome(r.result);
671
+ }
672
+ finally {
673
+ if (open)
674
+ await store.close();
675
+ }
333
676
  }
334
677
  //# sourceMappingURL=ingest.js.map