@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.4

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 (90) hide show
  1. package/openapi.yaml +16 -115
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +23 -8
  4. package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
  5. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  6. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  7. package/src/__tests__/llm-resolver.test.ts +73 -0
  8. package/src/__tests__/plugin-external-api.test.ts +68 -0
  9. package/src/__tests__/published-app-updater.test.ts +138 -0
  10. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  11. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  12. package/src/cli/commands/memory-v3.ts +57 -199
  13. package/src/config/call-site-defaults.ts +0 -1
  14. package/src/config/llm-resolver.ts +39 -7
  15. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  16. package/src/config/schemas/call-site-catalog.ts +0 -7
  17. package/src/config/schemas/llm.ts +17 -1
  18. package/src/config/schemas/memory-v3.ts +58 -8
  19. package/src/config/seed-inference-profiles.ts +18 -0
  20. package/src/daemon/conversation-agent-loop.ts +9 -17
  21. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  22. package/src/daemon/tool-side-effects.ts +15 -0
  23. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  24. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  25. package/src/memory/v2/consolidation-job.ts +2 -72
  26. package/src/memory/v2/types.ts +5 -0
  27. package/src/plugins/defaults/compaction/window-manager.ts +56 -0
  28. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  30. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  31. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  32. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  33. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  34. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  43. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  44. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  45. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  46. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  47. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  48. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  49. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  50. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  51. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  52. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  53. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  54. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  55. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  56. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  57. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  58. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  59. package/src/plugins/external-api.ts +114 -0
  60. package/src/providers/anthropic/client.ts +9 -10
  61. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  62. package/src/providers/inference/logit-bias.ts +55 -0
  63. package/src/providers/openai/chat-completions-provider.ts +19 -1
  64. package/src/providers/retry.ts +22 -0
  65. package/src/providers/types.ts +6 -0
  66. package/src/runtime/routes/app-management-routes.ts +3 -0
  67. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  68. package/src/services/published-app-updater.ts +30 -8
  69. package/src/tools/skills/load.ts +1 -1
  70. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  71. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  72. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  73. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  74. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  75. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  76. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  77. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  78. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  79. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  80. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  81. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  82. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  83. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  84. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  85. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  86. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  87. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  88. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  89. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  90. package/src/util/map-limit.ts +0 -27
@@ -1,21 +1,19 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, test } from "bun:test";
5
2
 
6
3
  import { setOverridesForTesting } from "../../../../__tests__/feature-flag-test-helpers.js";
7
4
  import type { AssistantConfig } from "../../../../config/types.js";
8
5
  import type { MemoryJob } from "../../../../memory/jobs-store.js";
9
- import { readPage, writePage } from "../../../../memory/v2/page-store.js";
10
- import type { ConceptPage } from "../../../../memory/v2/types.js";
11
- import type { AssignPageResult, AssignPagesOptions } from "../assign.js";
6
+ import { renderCapabilityContent } from "../capabilities.js";
12
7
  import {
13
- type ClassifyCandidate,
14
- computeClassifyTargets,
8
+ backfillAllSections,
9
+ type BackfillJobDeps,
10
+ type ChangedPageCandidate,
11
+ computeChangedPages,
15
12
  maintainJob,
16
13
  type MaintainJobDeps,
17
14
  } from "../maintain-job.js";
18
- import type { LeafNode, LeafPath, LeafTree, Slug } from "../types.js";
15
+ import { buildSectionIndex } from "../sections.js";
16
+ import type { Section, SectionIndex, Slug } from "../types.js";
19
17
 
20
18
  const FLAG_SHADOW = "memory-v3-shadow";
21
19
  const FLAG_LIVE = "memory-v3-live";
@@ -25,68 +23,67 @@ const FLAG_LIVE = "memory-v3-live";
25
23
  // `setOverridesForTesting` below.
26
24
  const CONFIG = {} as AssistantConfig;
27
25
 
28
- function makeLeaf(path: LeafPath): LeafNode {
26
+ function makeSection(article: Slug, ordinal: number): Section {
29
27
  return {
30
- path,
31
- frontmatter: { path, in_core: false },
32
- description: `${path} description`,
33
- members: [],
34
- domain: path.split("/")[0],
28
+ article,
29
+ title: "",
30
+ text: `text for ${article}#${ordinal}`,
31
+ ordinal,
35
32
  };
36
33
  }
37
34
 
38
- function makeTree(paths: LeafPath[]): LeafTree {
39
- const leaves = new Map<LeafPath, LeafNode>();
40
- for (const path of paths) leaves.set(path, makeLeaf(path));
41
- return { leaves, byPage: new Map<Slug, LeafPath[]>() };
42
- }
43
-
44
- function makePage(slug: string, leaves?: string[]): ConceptPage {
45
- return {
46
- slug,
47
- frontmatter: {
48
- summary: `Summary for ${slug}`,
49
- edges: [],
50
- ref_files: [],
51
- ref_urls: [],
52
- leaves,
53
- },
54
- body: `Body content for ${slug}`,
55
- };
35
+ function makeIndex(slugs: Slug[]): SectionIndex {
36
+ const sections: Section[] = slugs.map((slug) => makeSection(slug, 0));
37
+ const byArticle = new Map<Slug, number[]>();
38
+ sections.forEach((s, i) => byArticle.set(s.article, [i]));
39
+ return { sections, byArticle };
56
40
  }
57
41
 
58
42
  const JOB = { id: "job-1", type: "memory_v3_maintain" } as unknown as MemoryJob;
59
43
 
60
44
  describe("maintainJob", () => {
61
- let workspaceDir: string;
62
-
63
- beforeEach(async () => {
64
- workspaceDir = await mkdtemp(join(tmpdir(), "v3-maintain-"));
65
- });
66
-
67
- afterEach(async () => {
45
+ afterEach(() => {
68
46
  setOverridesForTesting({});
69
- await rm(workspaceDir, { recursive: true, force: true });
70
47
  });
71
48
 
72
49
  function deps(overrides: Partial<MaintainJobDeps> = {}): {
73
50
  deps: MaintainJobDeps;
74
- calls: { assign: number; invalidate: number; commit: number };
51
+ calls: {
52
+ built: Slug[][];
53
+ deleted: string[];
54
+ upserted: Section[][];
55
+ invalidate: number;
56
+ commit: number;
57
+ };
75
58
  } {
76
- const calls = { assign: 0, invalidate: 0, commit: 0 };
59
+ const calls = {
60
+ built: [] as Slug[][],
61
+ deleted: [] as string[],
62
+ upserted: [] as Section[][],
63
+ invalidate: 0,
64
+ commit: 0,
65
+ };
77
66
  const base: MaintainJobDeps = {
78
- workspaceDir,
79
- loadTree: async () => makeTree(["domain/topic-a", "domain/topic-b"]),
80
- assignPages: async (
81
- _opts: AssignPagesOptions,
82
- ): Promise<AssignPageResult[]> => {
83
- calls.assign += 1;
84
- return [];
67
+ config: CONFIG,
68
+ selectChangedPages: async () => [],
69
+ buildSectionIndex: async (slugs) => {
70
+ calls.built.push(slugs);
71
+ return makeIndex(slugs);
72
+ },
73
+ readPageBody: async (slug) => `body for ${slug}`,
74
+ deleteSectionsForArticle: async (_config, article) => {
75
+ calls.deleted.push(article);
76
+ },
77
+ upsertSections: async (_config, sections) => {
78
+ calls.upserted.push(sections);
85
79
  },
86
- selectClassifyTargets: async () => [],
87
- commitClassifyHighWater: () => {
80
+ commitEmbedHighWater: () => {
88
81
  calls.commit += 1;
89
82
  },
83
+ // Prune stage off by default: no stored articles ⇒ nothing to prune. The
84
+ // dedicated prune tests below override both collaborators.
85
+ listSectionArticles: async () => [],
86
+ listIndexedSlugs: async () => [],
90
87
  invalidateLanes: () => {
91
88
  calls.invalidate += 1;
92
89
  },
@@ -96,193 +93,364 @@ describe("maintainJob", () => {
96
93
 
97
94
  test("no-op when both v3 flags are off", async () => {
98
95
  setOverridesForTesting({ [FLAG_SHADOW]: false, [FLAG_LIVE]: false });
99
- const { deps: d, calls } = deps();
96
+ const { deps: d, calls } = deps({
97
+ selectChangedPages: async () => ["page-a"],
98
+ });
100
99
  const outcome = await maintainJob(JOB, CONFIG, d);
101
100
  expect(outcome.disabled).toBe(true);
102
- expect(calls.assign).toBe(0);
101
+ expect(calls.built.length).toBe(0);
103
102
  expect(calls.invalidate).toBe(0);
104
103
  });
105
104
 
106
- test("runs assign + invalidate when shadow flag is on", async () => {
105
+ test("re-chunks + re-embeds changed pages and invalidates lanes (shadow on)", async () => {
107
106
  setOverridesForTesting({ [FLAG_SHADOW]: true });
108
- const { deps: d, calls } = deps();
107
+ const { deps: d, calls } = deps({
108
+ selectChangedPages: async () => ["page-a", "page-b"],
109
+ });
109
110
  const outcome = await maintainJob(JOB, CONFIG, d);
111
+
110
112
  expect(outcome.disabled).toBe(false);
111
- expect(calls.assign).toBe(1);
112
- expect(calls.invalidate).toBe(1);
113
+ expect(outcome.reembedded).toBe(2);
114
+ expect(outcome.reembedFailures).toBe(0);
113
115
  expect(outcome.invalidated).toBe(true);
116
+
117
+ // Each changed page: delete its stale sections, then upsert fresh ones.
118
+ expect(calls.built).toEqual([["page-a"], ["page-b"]]);
119
+ expect(calls.deleted).toEqual(["page-a", "page-b"]);
120
+ expect(calls.upserted.flat().map((s) => s.article)).toEqual([
121
+ "page-a",
122
+ "page-b",
123
+ ]);
124
+ expect(calls.invalidate).toBe(1);
125
+ expect(calls.commit).toBe(1);
114
126
  });
115
127
 
116
128
  test("runs when only the live flag is on", async () => {
117
129
  setOverridesForTesting({ [FLAG_LIVE]: true });
118
- const { deps: d, calls } = deps();
130
+ const { deps: d, calls } = deps({
131
+ selectChangedPages: async () => ["page-a"],
132
+ });
119
133
  await maintainJob(JOB, CONFIG, d);
120
- expect(calls.assign).toBe(1);
134
+ expect(calls.built).toEqual([["page-a"]]);
121
135
  expect(calls.invalidate).toBe(1);
122
136
  });
123
137
 
124
- test("counts pages newly assigned by the classify-union stage", async () => {
138
+ test("skips the dense store entirely when no pages changed", async () => {
125
139
  setOverridesForTesting({ [FLAG_SHADOW]: true });
126
- const { deps: d } = deps({
127
- assignPages: async () => [
128
- { slug: "p1", before: [], after: ["domain/topic-a"], failed: false },
129
- { slug: "p2", before: [], after: [], failed: true },
130
- {
131
- slug: "p3",
132
- before: ["domain/topic-a"],
133
- after: ["domain/topic-a"],
134
- failed: false,
135
- },
136
- ],
137
- });
140
+ const { deps: d, calls } = deps({ selectChangedPages: async () => [] });
138
141
  const outcome = await maintainJob(JOB, CONFIG, d);
139
- expect(outcome.assigned).toBe(1);
142
+ expect(outcome.reembedded).toBe(0);
143
+ expect(calls.built.length).toBe(0);
144
+ expect(calls.deleted.length).toBe(0);
145
+ expect(calls.upserted.length).toBe(0);
146
+ // The pass still advances the high-water mark and invalidates lanes.
147
+ expect(calls.commit).toBe(1);
148
+ expect(calls.invalidate).toBe(1);
140
149
  });
141
150
 
142
- test("prune drops dangling leaf refs and rewrites the page", async () => {
151
+ test("a single failing page is contained; other pages still re-embed", async () => {
143
152
  setOverridesForTesting({ [FLAG_SHADOW]: true });
144
- await writePage(
145
- workspaceDir,
146
- makePage("dangling", ["domain/topic-a", "domain/gone"]),
147
- );
148
- await writePage(workspaceDir, makePage("clean", ["domain/topic-b"]));
149
- await writePage(workspaceDir, makePage("empty", []));
150
-
151
- const { deps: d } = deps();
153
+ const { deps: d, calls } = deps({
154
+ selectChangedPages: async () => ["page-ok", "page-bad", "page-ok-2"],
155
+ upsertSections: async (_config, sections) => {
156
+ if (sections.some((s) => s.article === "page-bad")) {
157
+ throw new Error("embed boom");
158
+ }
159
+ calls.upserted.push(sections);
160
+ },
161
+ });
152
162
  const outcome = await maintainJob(JOB, CONFIG, d);
153
163
 
154
- expect(outcome.pruned).toBe(1);
155
- expect(outcome.prunedRefs).toBe(1);
156
-
157
- const dangling = await readPage(workspaceDir, "dangling");
158
- expect(dangling?.frontmatter.leaves).toEqual(["domain/topic-a"]);
159
-
160
- const clean = await readPage(workspaceDir, "clean");
161
- expect(clean?.frontmatter.leaves).toEqual(["domain/topic-b"]);
164
+ expect(outcome.reembedded).toBe(2);
165
+ expect(outcome.reembedFailures).toBe(1);
166
+ // Both good pages were upserted; the lanes were still invalidated.
167
+ expect(calls.upserted.flat().map((s) => s.article)).toEqual([
168
+ "page-ok",
169
+ "page-ok-2",
170
+ ]);
171
+ expect(calls.invalidate).toBe(1);
172
+ expect(outcome.invalidated).toBe(true);
173
+ // The checkpoint is HELD when any page failed: the failed page was
174
+ // delete-then-upsert'd (so its sections are gone), and advancing past its
175
+ // mtime would hide it from `computeChangedPages` forever. Holding the mark
176
+ // lets the next pass re-select and re-embed it.
177
+ expect(calls.commit).toBe(0);
162
178
  });
163
179
 
164
- test("contains an assign failure without aborting prune or invalidate", async () => {
180
+ test("a thrown re-embed stage does not abort lane invalidation", async () => {
165
181
  setOverridesForTesting({ [FLAG_SHADOW]: true });
166
- await writePage(
167
- workspaceDir,
168
- makePage("dangling", ["domain/topic-a", "domain/gone"]),
169
- );
170
182
  const { deps: d, calls } = deps({
171
- assignPages: async () => {
172
- throw new Error("assign boom");
183
+ selectChangedPages: async () => {
184
+ throw new Error("select boom");
173
185
  },
174
186
  });
175
187
  const outcome = await maintainJob(JOB, CONFIG, d);
176
- expect(outcome.failures).toContain("assign");
177
- expect(outcome.pruned).toBe(1);
188
+ expect(outcome.failures).toContain("reembed");
189
+ expect(calls.commit).toBe(0);
178
190
  expect(calls.invalidate).toBe(1);
179
191
  expect(outcome.invalidated).toBe(true);
180
192
  });
181
193
 
182
- test("contains a tree-load failure but still invalidates lanes", async () => {
194
+ test("advances the high-water mark after a successful re-embed pass", async () => {
195
+ setOverridesForTesting({ [FLAG_SHADOW]: true });
196
+ const { deps: d, calls } = deps({
197
+ selectChangedPages: async () => ["page-a"],
198
+ });
199
+ await maintainJob(JOB, CONFIG, d);
200
+ expect(calls.commit).toBe(1);
201
+ });
202
+
203
+ test("does not advance the high-water mark when selection throws", async () => {
183
204
  setOverridesForTesting({ [FLAG_SHADOW]: true });
184
205
  const { deps: d, calls } = deps({
185
- loadTree: async () => {
186
- throw new Error("load boom");
206
+ selectChangedPages: async () => {
207
+ throw new Error("select boom");
187
208
  },
188
209
  });
189
210
  const outcome = await maintainJob(JOB, CONFIG, d);
190
- expect(outcome.failures).toContain("load_tree");
191
- expect(calls.assign).toBe(0);
211
+ expect(outcome.failures).toContain("reembed");
212
+ expect(calls.commit).toBe(0);
213
+ });
214
+
215
+ test("prunes sections for an article absent from the page index", async () => {
216
+ setOverridesForTesting({ [FLAG_SHADOW]: true });
217
+ // The dense store holds points for a live page, a deleted page, and a
218
+ // synthetic capability row; the page index holds only the live + synthetic
219
+ // slugs. Only the deleted page's sections are pruned. `selectChangedPages`
220
+ // stays empty so `calls.deleted` reflects prune deletes only.
221
+ const { deps: d, calls } = deps({
222
+ listSectionArticles: async () => [
223
+ "page-live",
224
+ "page-deleted",
225
+ "skills/example",
226
+ ],
227
+ listIndexedSlugs: async () => ["page-live", "skills/example"],
228
+ });
229
+ const outcome = await maintainJob(JOB, CONFIG, d);
230
+
231
+ expect(outcome.pruned).toBe(1);
232
+ expect(outcome.pruneFailures).toBe(0);
233
+ // The live page and the synthetic capability row are NOT pruned.
234
+ expect(calls.deleted).toEqual(["page-deleted"]);
235
+ // The pass still invalidates the lanes.
192
236
  expect(calls.invalidate).toBe(1);
237
+ expect(outcome.invalidated).toBe(true);
193
238
  });
194
239
 
195
- test("classifies the selected delta targets (passes them to assignPages)", async () => {
240
+ test("prunes nothing when every stored article is still in the index", async () => {
196
241
  setOverridesForTesting({ [FLAG_SHADOW]: true });
197
- let seenSlugs: Slug[] | undefined;
198
- const { deps: d } = deps({
199
- selectClassifyTargets: async () => ["p-new", "p-edited"],
200
- assignPages: async (opts: AssignPagesOptions) => {
201
- seenSlugs = opts.slugs;
202
- return [];
203
- },
242
+ const { deps: d, calls } = deps({
243
+ listSectionArticles: async () => ["page-a", "page-b"],
244
+ listIndexedSlugs: async () => ["page-a", "page-b", "page-c"],
204
245
  });
205
- await maintainJob(JOB, CONFIG, d);
206
- expect(seenSlugs).toEqual(["p-new", "p-edited"]);
246
+ const outcome = await maintainJob(JOB, CONFIG, d);
247
+
248
+ expect(outcome.pruned).toBe(0);
249
+ expect(calls.deleted).toEqual([]);
207
250
  });
208
251
 
209
- test("advances the high-water mark after a successful classify pass", async () => {
252
+ test("a single failing prune delete is contained; other deletions proceed", async () => {
210
253
  setOverridesForTesting({ [FLAG_SHADOW]: true });
211
- const { deps: d, calls } = deps();
212
- await maintainJob(JOB, CONFIG, d);
213
- expect(calls.commit).toBe(1);
254
+ const { deps: d, calls } = deps({
255
+ listSectionArticles: async () => ["gone-1", "gone-bad", "gone-2"],
256
+ listIndexedSlugs: async () => [],
257
+ deleteSectionsForArticle: async (_config, article) => {
258
+ if (article === "gone-bad") throw new Error("delete boom");
259
+ calls.deleted.push(article);
260
+ },
261
+ });
262
+ const outcome = await maintainJob(JOB, CONFIG, d);
263
+
264
+ expect(outcome.pruned).toBe(2);
265
+ expect(outcome.pruneFailures).toBe(1);
266
+ // Both good deletions ran; the contained failure did not abort them.
267
+ expect(calls.deleted).toEqual(["gone-1", "gone-2"]);
268
+ // A contained per-article failure is NOT a stage failure, and the lanes
269
+ // were still invalidated.
270
+ expect(outcome.failures).not.toContain("prune");
271
+ expect(calls.invalidate).toBe(1);
214
272
  });
215
273
 
216
- test("does not advance the high-water mark when classify throws", async () => {
274
+ test("a thrown prune stage is contained and does not abort lane invalidation", async () => {
217
275
  setOverridesForTesting({ [FLAG_SHADOW]: true });
218
276
  const { deps: d, calls } = deps({
219
- assignPages: async () => {
220
- throw new Error("assign boom");
277
+ listSectionArticles: async () => {
278
+ throw new Error("scroll boom");
221
279
  },
222
280
  });
223
281
  const outcome = await maintainJob(JOB, CONFIG, d);
224
- expect(outcome.failures).toContain("assign");
225
- expect(calls.commit).toBe(0);
282
+
283
+ expect(outcome.failures).toContain("prune");
284
+ expect(outcome.pruned).toBe(0);
285
+ // Invalidation still runs after a thrown prune stage.
286
+ expect(calls.invalidate).toBe(1);
287
+ expect(outcome.invalidated).toBe(true);
226
288
  });
227
289
  });
228
290
 
229
- describe("computeClassifyTargets", () => {
230
- const page = (
231
- slug: string,
232
- modifiedAt: number,
233
- leaves: string[] = [],
234
- ): ClassifyCandidate => ({ slug, modifiedAt, leaves });
291
+ describe("computeChangedPages", () => {
292
+ const page = (slug: string, modifiedAt: number): ChangedPageCandidate => ({
293
+ slug,
294
+ modifiedAt,
295
+ });
235
296
 
236
- test("first run (null high-water) classifies only unassigned pages", () => {
237
- const targets = computeClassifyTargets(
238
- [page("unassigned", 100, []), page("assigned-recent", 200, ["domain/a"])],
297
+ test("first run (null high-water) selects every real page", () => {
298
+ const changed = computeChangedPages(
299
+ [page("page-a", 100), page("page-b", 200)],
239
300
  null,
240
301
  );
241
- expect(targets).toEqual(["unassigned"]);
302
+ expect(changed).toEqual(["page-a", "page-b"]);
242
303
  });
243
304
 
244
- test("re-classifies an already-assigned page edited since the high-water", () => {
245
- const targets = computeClassifyTargets(
246
- [page("assigned-edited", 300, ["domain/a"])],
305
+ test("selects only pages edited since the high-water", () => {
306
+ const changed = computeChangedPages(
307
+ [page("stale", 150), page("fresh", 300)],
247
308
  200,
248
309
  );
249
- expect(targets).toEqual(["assigned-edited"]);
310
+ expect(changed).toEqual(["fresh"]);
250
311
  });
251
312
 
252
- test("skips an assigned page untouched since the high-water (no self-trigger / drift)", () => {
253
- const targets = computeClassifyTargets(
254
- [page("assigned-stale", 150, ["domain/a"])],
255
- 200,
256
- );
257
- expect(targets).toEqual([]);
313
+ test("skips a page untouched since the high-water (no self-trigger)", () => {
314
+ const changed = computeChangedPages([page("stale", 150)], 200);
315
+ expect(changed).toEqual([]);
258
316
  });
259
317
 
260
- test("still classifies unassigned pages regardless of mtime", () => {
261
- const targets = computeClassifyTargets(
262
- [page("unassigned-old", 10, [])],
318
+ test("excludes synthetic skill/CLI rows (modifiedAt 0)", () => {
319
+ const changed = computeChangedPages(
320
+ [page("skills/meet-join", 0), page("real", 300)],
263
321
  200,
264
322
  );
265
- expect(targets).toEqual(["unassigned-old"]);
323
+ expect(changed).toEqual(["real"]);
266
324
  });
267
325
 
268
- test("excludes synthetic skill/CLI rows (modifiedAt 0) despite empty leaves", () => {
269
- const targets = computeClassifyTargets(
270
- [page("skills/meet-join", 0, []), page("real", 300, [])],
271
- 200,
326
+ test("excludes synthetic rows even on the first run", () => {
327
+ const changed = computeChangedPages(
328
+ [page("skills/x", 0), page("real", 10)],
329
+ null,
272
330
  );
273
- expect(targets).toEqual(["real"]);
331
+ expect(changed).toEqual(["real"]);
274
332
  });
333
+ });
275
334
 
276
- test("targets = unassigned ∪ recently-edited, excluding assigned-and-stale", () => {
277
- const targets = computeClassifyTargets(
278
- [
279
- page("unassigned-old", 10, []),
280
- page("assigned-stale", 150, ["domain/a"]),
281
- page("assigned-fresh", 300, ["domain/a"]),
282
- page("skills/x", 0, []),
283
- ],
284
- 200,
335
+ describe("backfillAllSections", () => {
336
+ afterEach(() => {
337
+ setOverridesForTesting({});
338
+ });
339
+
340
+ function deps(overrides: Partial<BackfillJobDeps> = {}): {
341
+ deps: BackfillJobDeps;
342
+ calls: {
343
+ ensured: number;
344
+ built: Slug[][];
345
+ deleted: string[];
346
+ upserted: Section[][];
347
+ committed: number[];
348
+ };
349
+ } {
350
+ const calls = {
351
+ ensured: 0,
352
+ built: [] as Slug[][],
353
+ deleted: [] as string[],
354
+ upserted: [] as Section[][],
355
+ committed: [] as number[],
356
+ };
357
+ const base: BackfillJobDeps = {
358
+ config: CONFIG,
359
+ selectAllPages: async () => [],
360
+ ensureSectionCollection: async () => {
361
+ calls.ensured += 1;
362
+ },
363
+ // Real builder + chunker so the synthetic capability content is genuinely
364
+ // turned into section points (not a hand-rolled stub).
365
+ buildSectionIndex: async (slugs, pageBody) => {
366
+ calls.built.push(slugs);
367
+ return buildSectionIndex(slugs, pageBody);
368
+ },
369
+ readPageBody: async (slug) => `body for ${slug}`,
370
+ deleteSectionsForArticle: async (_config, article) => {
371
+ calls.deleted.push(article);
372
+ },
373
+ upsertSections: async (_config, sections) => {
374
+ calls.upserted.push(sections);
375
+ },
376
+ commitEmbedHighWater: (ms) => {
377
+ calls.committed.push(ms);
378
+ },
379
+ nowMs: () => 4242,
380
+ };
381
+ return { deps: { ...base, ...overrides }, calls };
382
+ }
383
+
384
+ test("embeds EVERY page incl a synthetic capability slug, with its capability content", async () => {
385
+ // A capability-aware reader, exactly as the production default deps wire it:
386
+ // the real renderer resolves the synthetic skill row's content (injected
387
+ // resolvers keep it deterministic, mirroring `capabilities.test.ts`).
388
+ const resolvers = {
389
+ skill: (slug: Slug) =>
390
+ slug === "skills/example"
391
+ ? { id: "example", content: "example capability body" }
392
+ : null,
393
+ cli: () => null,
394
+ };
395
+ const readPageBody = async (slug: Slug): Promise<string> =>
396
+ renderCapabilityContent(slug, resolvers) ?? `body for ${slug}`;
397
+
398
+ const { deps: d, calls } = deps({
399
+ selectAllPages: async () => ["page-a", "skills/example"],
400
+ readPageBody,
401
+ });
402
+ const outcome = await backfillAllSections(CONFIG, d);
403
+
404
+ // Both the real page AND the synthetic capability row were embedded.
405
+ expect(outcome.articles).toBe(2);
406
+ expect(outcome.failures).toBe(0);
407
+ expect(calls.ensured).toBe(1);
408
+ expect(calls.built).toEqual([["page-a"], ["skills/example"]]);
409
+ expect(calls.deleted).toEqual(["page-a", "skills/example"]);
410
+
411
+ // The synthetic slug's upsert carries its rendered capability content, not a
412
+ // blank/on-disk read.
413
+ const syntheticSections = calls.upserted
414
+ .flat()
415
+ .filter((s) => s.article === "skills/example");
416
+ expect(syntheticSections.length).toBeGreaterThan(0);
417
+ expect(syntheticSections.map((s) => s.text).join("\n")).toContain(
418
+ "example capability body",
285
419
  );
286
- expect(targets).toEqual(["unassigned-old", "assigned-fresh"]);
420
+ expect(outcome.sections).toBe(calls.upserted.flat().length);
421
+ });
422
+
423
+ test("advances the high-water checkpoint to the injected now", async () => {
424
+ const { deps: d, calls } = deps({
425
+ selectAllPages: async () => ["page-a"],
426
+ nowMs: () => 99999,
427
+ });
428
+ await backfillAllSections(CONFIG, d);
429
+ expect(calls.committed).toEqual([99999]);
430
+ });
431
+
432
+ test("contains a single failing page; others still embed but checkpoint is HELD", async () => {
433
+ const { deps: d, calls } = deps({
434
+ selectAllPages: async () => ["page-ok", "page-bad", "page-ok-2"],
435
+ upsertSections: async (_config, sections) => {
436
+ if (sections.some((s) => s.article === "page-bad")) {
437
+ throw new Error("embed boom");
438
+ }
439
+ calls.upserted.push(sections);
440
+ },
441
+ });
442
+ const outcome = await backfillAllSections(CONFIG, d);
443
+
444
+ expect(outcome.articles).toBe(2);
445
+ expect(outcome.failures).toBe(1);
446
+ expect(calls.upserted.flat().map((s) => s.article)).toEqual([
447
+ "page-ok",
448
+ "page-ok-2",
449
+ ]);
450
+ // The checkpoint is HELD on any failure: the failed page was
451
+ // delete-then-upsert'd (sections gone), so advancing past its mtime would
452
+ // hide it from the incremental selector forever. Holding the mark lets the
453
+ // next pass re-embed it.
454
+ expect(calls.committed).toEqual([]);
287
455
  });
288
456
  });