@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.
- package/openapi.yaml +16 -115
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +23 -8
- package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/llm-resolver.test.ts +73 -0
- package/src/__tests__/plugin-external-api.test.ts +68 -0
- package/src/__tests__/published-app-updater.test.ts +138 -0
- package/src/api/responses/memory-v3-selection-log.ts +19 -10
- package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
- package/src/cli/commands/memory-v3.ts +57 -199
- package/src/config/call-site-defaults.ts +0 -1
- package/src/config/llm-resolver.ts +39 -7
- package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/llm.ts +17 -1
- package/src/config/schemas/memory-v3.ts +58 -8
- package/src/config/seed-inference-profiles.ts +18 -0
- package/src/daemon/conversation-agent-loop.ts +9 -17
- package/src/daemon/external-plugins-bootstrap.ts +8 -3
- package/src/daemon/tool-side-effects.ts +15 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
- package/src/memory/v2/__tests__/page-store.test.ts +22 -0
- package/src/memory/v2/consolidation-job.ts +2 -72
- package/src/memory/v2/types.ts +5 -0
- package/src/plugins/defaults/compaction/window-manager.ts +56 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
- package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
- package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
- package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
- package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
- package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
- package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
- package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
- package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
- package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
- package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
- package/src/plugins/external-api.ts +114 -0
- package/src/providers/anthropic/client.ts +9 -10
- package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
- package/src/providers/inference/logit-bias.ts +55 -0
- package/src/providers/openai/chat-completions-provider.ts +19 -1
- package/src/providers/retry.ts +22 -0
- package/src/providers/types.ts +6 -0
- package/src/runtime/routes/app-management-routes.ts +3 -0
- package/src/runtime/routes/memory-v3-routes.ts +64 -314
- package/src/services/published-app-updater.ts +30 -8
- package/src/tools/skills/load.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
- package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
- package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
- package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
- package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
- package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
- package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
- package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
- package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
- package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
- package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
- package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
- package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
- package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
- package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
- package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
- package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
- package/src/util/map-limit.ts +0 -27
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
26
|
+
function makeSection(article: Slug, ordinal: number): Section {
|
|
29
27
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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: {
|
|
51
|
+
calls: {
|
|
52
|
+
built: Slug[][];
|
|
53
|
+
deleted: string[];
|
|
54
|
+
upserted: Section[][];
|
|
55
|
+
invalidate: number;
|
|
56
|
+
commit: number;
|
|
57
|
+
};
|
|
75
58
|
} {
|
|
76
|
-
const calls = {
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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.
|
|
101
|
+
expect(calls.built.length).toBe(0);
|
|
103
102
|
expect(calls.invalidate).toBe(0);
|
|
104
103
|
});
|
|
105
104
|
|
|
106
|
-
test("
|
|
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(
|
|
112
|
-
expect(
|
|
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.
|
|
134
|
+
expect(calls.built).toEqual([["page-a"]]);
|
|
121
135
|
expect(calls.invalidate).toBe(1);
|
|
122
136
|
});
|
|
123
137
|
|
|
124
|
-
test("
|
|
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.
|
|
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("
|
|
151
|
+
test("a single failing page is contained; other pages still re-embed", async () => {
|
|
143
152
|
setOverridesForTesting({ [FLAG_SHADOW]: true });
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
155
|
-
expect(outcome.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
172
|
-
throw new Error("
|
|
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("
|
|
177
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
186
|
-
throw new Error("
|
|
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("
|
|
191
|
-
expect(calls.
|
|
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("
|
|
240
|
+
test("prunes nothing when every stored article is still in the index", async () => {
|
|
196
241
|
setOverridesForTesting({ [FLAG_SHADOW]: true });
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
213
|
-
|
|
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("
|
|
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
|
-
|
|
220
|
-
throw new Error("
|
|
277
|
+
listSectionArticles: async () => {
|
|
278
|
+
throw new Error("scroll boom");
|
|
221
279
|
},
|
|
222
280
|
});
|
|
223
281
|
const outcome = await maintainJob(JOB, CONFIG, d);
|
|
224
|
-
|
|
225
|
-
expect(
|
|
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("
|
|
230
|
-
const page = (
|
|
231
|
-
slug
|
|
232
|
-
modifiedAt
|
|
233
|
-
|
|
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)
|
|
237
|
-
const
|
|
238
|
-
[page("
|
|
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(
|
|
302
|
+
expect(changed).toEqual(["page-a", "page-b"]);
|
|
242
303
|
});
|
|
243
304
|
|
|
244
|
-
test("
|
|
245
|
-
const
|
|
246
|
-
[page("
|
|
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(
|
|
310
|
+
expect(changed).toEqual(["fresh"]);
|
|
250
311
|
});
|
|
251
312
|
|
|
252
|
-
test("skips
|
|
253
|
-
const
|
|
254
|
-
|
|
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("
|
|
261
|
-
const
|
|
262
|
-
[page("
|
|
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(
|
|
323
|
+
expect(changed).toEqual(["real"]);
|
|
266
324
|
});
|
|
267
325
|
|
|
268
|
-
test("excludes synthetic
|
|
269
|
-
const
|
|
270
|
-
[page("skills/
|
|
271
|
-
|
|
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(
|
|
331
|
+
expect(changed).toEqual(["real"]);
|
|
274
332
|
});
|
|
333
|
+
});
|
|
275
334
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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(
|
|
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
|
});
|