@stigmer/runner 3.1.2 → 3.1.3
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +121 -139
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +30 -3
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +42 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +3 -1
- package/dist/shared/filereview/index.js +3 -1
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +6 -4
- package/dist/shared/implement-plan-prompt.js +38 -6
- package/dist/shared/implement-plan-prompt.js.map +1 -1
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +6 -4
- package/dist/shared/plan-mode-prompt.js +10 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/package.json +3 -2
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/index.ts +130 -153
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/index.ts +34 -3
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +39 -8
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +10 -5
|
@@ -292,5 +292,16 @@ describe("buildEnhancedSystemPrompt", () => {
|
|
|
292
292
|
|
|
293
293
|
expect(prompt).not.toContain("## Implement the approved plan");
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
|
|
297
|
+
const prompt = buildEnhancedSystemPrompt({
|
|
298
|
+
...base,
|
|
299
|
+
buildFromPlan: true,
|
|
300
|
+
injectedFiles: [planFile],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(prompt).toContain("to-do list");
|
|
304
|
+
expect(prompt).toContain("break the plan into");
|
|
305
|
+
});
|
|
295
306
|
});
|
|
296
307
|
});
|
|
@@ -50,6 +50,10 @@ import {
|
|
|
50
50
|
captureBaselineToLedger,
|
|
51
51
|
captureCandidateToLedger,
|
|
52
52
|
} from "../../shared/filereview/capture.js";
|
|
53
|
+
import {
|
|
54
|
+
captureFileChangeProgress,
|
|
55
|
+
newProgressCaptureState,
|
|
56
|
+
} from "../../shared/filereview/progress.js";
|
|
53
57
|
import { hasCandidateCaptured } from "../../shared/filereview/events.js";
|
|
54
58
|
import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
|
|
55
59
|
import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
|
|
@@ -319,6 +323,15 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
319
323
|
});
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// Per-turn state for mid-run live capture (DD-32): the last progress tree
|
|
327
|
+
// sha (short-circuit) + last capture time (floor), threaded across the
|
|
328
|
+
// streaming loop's persists via the beforePersist hook below. The capture
|
|
329
|
+
// flags are hoisted so the deferred hook does not depend on `setup`
|
|
330
|
+
// (non-null here, but not narrowable inside a later-invoked closure).
|
|
331
|
+
const progressState = newProgressCaptureState();
|
|
332
|
+
const progressCaptureMode = setup.captureMode;
|
|
333
|
+
const progressGitWorkspace = setup.gitWorkspace;
|
|
334
|
+
|
|
322
335
|
const cancellationSignal = Context.current().cancellationSignal;
|
|
323
336
|
|
|
324
337
|
const result: StreamResult = await streamExecution({
|
|
@@ -344,6 +357,23 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
344
357
|
globalBypass: setup.globalBypass,
|
|
345
358
|
},
|
|
346
359
|
streamVersion: setup.streamVersion,
|
|
360
|
+
// Mid-run live capture (DD-32): attach file_change_progress before each
|
|
361
|
+
// scheduled persist. Git capture mode only (a pinned baseline exists);
|
|
362
|
+
// deep-agent writes no runner-owned gate files into the tree, so no
|
|
363
|
+
// excludePaths — matching its turn-boundary candidate capture.
|
|
364
|
+
beforePersist: async (status) => {
|
|
365
|
+
if (!progressCaptureMode || !progressGitWorkspace || !captureBaselineTree) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
await captureFileChangeProgress({
|
|
369
|
+
status,
|
|
370
|
+
gitRoot,
|
|
371
|
+
executionId,
|
|
372
|
+
changeSetId,
|
|
373
|
+
baselineTree: captureBaselineTree,
|
|
374
|
+
state: progressState,
|
|
375
|
+
});
|
|
376
|
+
},
|
|
347
377
|
});
|
|
348
378
|
|
|
349
379
|
await processPostStream({
|
|
@@ -556,9 +586,10 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
556
586
|
}
|
|
557
587
|
|
|
558
588
|
// Plan mode: the agent's final message is the plan. Publish it as a
|
|
559
|
-
// first-class plan
|
|
560
|
-
// card and a follow-up Implement run can
|
|
561
|
-
// produces no file to auto-publish, so
|
|
589
|
+
// first-class plan artifact (named from the plan's title) so the UI can
|
|
590
|
+
// render a reviewable Plan card and a follow-up Implement run can
|
|
591
|
+
// reference it. Read-only mode produces no file to auto-publish, so
|
|
592
|
+
// this is the only artifact path.
|
|
562
593
|
// Requires artifact storage; with none (proxy misconfig) the plan text
|
|
563
594
|
// still lives in the final message — only the reviewable artifact is
|
|
564
595
|
// skipped (mirrors Cursor's storage-guarded plan publish).
|
|
@@ -118,10 +118,15 @@ export async function streamExecutionV3(
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
statusBuilder.syncSubAgentExecutions();
|
|
121
|
+
// Mid-run live capture (DD-32): attach file_change_progress to the live
|
|
122
|
+
// status before it is persisted. Injected so this loop stays ignorant of
|
|
123
|
+
// file-review specifics; a no-op when the hook is absent or nothing changed.
|
|
124
|
+
const statusToPersist = statusBuilder.currentStatus;
|
|
125
|
+
await deps.beforePersist?.(statusToPersist);
|
|
121
126
|
const signal = await persistStatus(
|
|
122
127
|
client,
|
|
123
128
|
executionId,
|
|
124
|
-
|
|
129
|
+
statusToPersist,
|
|
125
130
|
{ offload, retry: retryOptions },
|
|
126
131
|
);
|
|
127
132
|
scheduler.markUpdateSent(eventsProcessed);
|
|
@@ -83,6 +83,13 @@ export interface StreamDependencies {
|
|
|
83
83
|
readonly approvalProvider?: ApprovalPolicyProvider;
|
|
84
84
|
/** Streaming protocol version. Defaults to "v2" if unset. */
|
|
85
85
|
readonly streamVersion?: "v2" | "v3";
|
|
86
|
+
/**
|
|
87
|
+
* Optional hook invoked with the live status just before each scheduled
|
|
88
|
+
* persist. Used by mid-run live capture (DD-32) to attach file_change_progress.
|
|
89
|
+
* Kept as an injected callback (closing over the turn's baseline tree / change
|
|
90
|
+
* set id in index.ts) so this loop stays ignorant of file-review specifics.
|
|
91
|
+
*/
|
|
92
|
+
readonly beforePersist?: (status: AgentExecutionStatus) => Promise<void>;
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
export interface StreamResult {
|
|
@@ -189,10 +196,14 @@ async function streamExecutionV2(
|
|
|
189
196
|
statusBuilder.clearForceFlag();
|
|
190
197
|
}
|
|
191
198
|
|
|
199
|
+
// Mid-run live capture (DD-32): attach file_change_progress to the live
|
|
200
|
+
// status before persisting (see streaming-v3.ts for the rationale).
|
|
201
|
+
const statusToPersist = statusBuilder.currentStatus;
|
|
202
|
+
await deps.beforePersist?.(statusToPersist);
|
|
192
203
|
const signal = await persistStatus(
|
|
193
204
|
client,
|
|
194
205
|
executionId,
|
|
195
|
-
|
|
206
|
+
statusToPersist,
|
|
196
207
|
{ offload, retry: retryOptions },
|
|
197
208
|
);
|
|
198
209
|
scheduler.markUpdateSent(eventsProcessed);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "../implement-plan-prompt.js";
|
|
6
6
|
|
|
7
7
|
describe("findApprovedPlanPath", () => {
|
|
8
|
-
it("finds the plan by
|
|
8
|
+
it("finds the plan by the legacy filename among attachment paths", () => {
|
|
9
9
|
expect(
|
|
10
10
|
findApprovedPlanPath([
|
|
11
11
|
".stigmer/inputs/data.csv",
|
|
@@ -14,15 +14,28 @@ describe("findApprovedPlanPath", () => {
|
|
|
14
14
|
).toBe(".stigmer/inputs/plan.md");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
it("finds a title-named <slug>_<id>.plan.md plan", () => {
|
|
18
|
+
expect(
|
|
19
|
+
findApprovedPlanPath([
|
|
20
|
+
".stigmer/inputs/data.csv",
|
|
21
|
+
".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md",
|
|
22
|
+
]),
|
|
23
|
+
).toBe(".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md");
|
|
24
|
+
});
|
|
25
|
+
|
|
17
26
|
it("returns undefined when no plan attachment resolved", () => {
|
|
18
27
|
expect(findApprovedPlanPath([".stigmer/inputs/data.csv"])).toBeUndefined();
|
|
19
28
|
expect(findApprovedPlanPath([])).toBeUndefined();
|
|
20
29
|
});
|
|
21
30
|
|
|
22
|
-
it("matches the
|
|
31
|
+
it("matches the plan convention only — a plan-adjacent filename is not the plan", () => {
|
|
23
32
|
expect(
|
|
24
33
|
findApprovedPlanPath([".stigmer/inputs/my-plan.md.bak"]),
|
|
25
34
|
).toBeUndefined();
|
|
35
|
+
// A file merely containing "plan" is not a plan artifact.
|
|
36
|
+
expect(
|
|
37
|
+
findApprovedPlanPath([".stigmer/inputs/myplan.md"]),
|
|
38
|
+
).toBeUndefined();
|
|
26
39
|
});
|
|
27
40
|
});
|
|
28
41
|
|
|
@@ -45,4 +58,29 @@ describe("buildImplementPlanDirective", () => {
|
|
|
45
58
|
expect(directive).toContain("conversation above");
|
|
46
59
|
expect(directive).not.toContain("plan.md");
|
|
47
60
|
});
|
|
61
|
+
|
|
62
|
+
// Tier 3 (plan-driven build progress): both variants instruct the agent to
|
|
63
|
+
// derive its to-do list from the plan and keep it current — the agent's todo
|
|
64
|
+
// tool is the single writer of status.todos, so this instruction is the
|
|
65
|
+
// entire plan→progress linkage.
|
|
66
|
+
it("instructs plan-derived progress tracking in the attached variant", () => {
|
|
67
|
+
const directive = buildImplementPlanDirective(".stigmer/inputs/plan.md");
|
|
68
|
+
|
|
69
|
+
expect(directive).toContain("to-do list");
|
|
70
|
+
expect(directive).toContain("break the plan into");
|
|
71
|
+
expect(directive).toContain("in progress");
|
|
72
|
+
expect(directive).toContain("completed");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("instructs plan-derived progress tracking in the conversation-only variant", () => {
|
|
76
|
+
const directive = buildImplementPlanDirective(undefined);
|
|
77
|
+
|
|
78
|
+
expect(directive).toContain("to-do list");
|
|
79
|
+
expect(directive).toContain("break the plan into");
|
|
80
|
+
expect(directive).toContain("in progress");
|
|
81
|
+
expect(directive).toContain("completed");
|
|
82
|
+
// The progress block must never name the file — this variant exists
|
|
83
|
+
// because no plan file materialized.
|
|
84
|
+
expect(directive).not.toContain("plan.md");
|
|
85
|
+
});
|
|
48
86
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { describe, it, expect } from "vitest";
|
|
2
3
|
import { create } from "@bufbuild/protobuf";
|
|
3
4
|
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
@@ -11,8 +12,9 @@ import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-
|
|
|
11
12
|
import {
|
|
12
13
|
extractFinalPlanText,
|
|
13
14
|
publishPlanArtifact,
|
|
15
|
+
planArtifactName,
|
|
16
|
+
isPlanArtifactName,
|
|
14
17
|
PLAN_ARTIFACT_NAME,
|
|
15
|
-
PLAN_ARTIFACT_SANDBOX_PATH,
|
|
16
18
|
} from "../plan-artifact.js";
|
|
17
19
|
|
|
18
20
|
/** Records uploads and returns deterministic download URLs (canonical double + shim). */
|
|
@@ -27,6 +29,11 @@ function fakeStorage(): ArtifactStorage & { uploads: { key: string; content: Buf
|
|
|
27
29
|
return Object.assign(storage, { uploads });
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Mirrors the runner's content-hash discriminator so tests can assert exact names. */
|
|
33
|
+
function planId(text: string): string {
|
|
34
|
+
return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function statusWith(...messages: { type: MessageType; content: string }[]) {
|
|
31
38
|
return create(AgentExecutionStatusSchema, {
|
|
32
39
|
messages: messages.map((m) =>
|
|
@@ -59,30 +66,129 @@ describe("extractFinalPlanText", () => {
|
|
|
59
66
|
});
|
|
60
67
|
});
|
|
61
68
|
|
|
69
|
+
describe("planArtifactName", () => {
|
|
70
|
+
it("derives a hyphenated <slug>_<id>.plan.md name from the plan's leading # H1", () => {
|
|
71
|
+
const text = "# Plan Card UX Cleanup\n\nBody";
|
|
72
|
+
// "Plan Card…" has no label separator, so the genuine word "plan" survives.
|
|
73
|
+
expect(planArtifactName(text)).toBe(`plan-card-ux-cleanup_${planId(text)}.plan.md`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("strips a leading 'Plan:' LABEL (colon/dash-anchored) from the slug", () => {
|
|
77
|
+
const text = "# Plan: Fix the download UX\n\nbody";
|
|
78
|
+
expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("collapses runs of punctuation/whitespace and trims edge hyphens", () => {
|
|
82
|
+
const text = "# Fix: the (download) UX!! \nx";
|
|
83
|
+
expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("unwraps a ```markdown fenced plan before reading the title (SDK parity)", () => {
|
|
87
|
+
const text = "```markdown\n# Named Plan\n\nbody\n```";
|
|
88
|
+
expect(planArtifactName(text)).toBe(`named-plan_${planId(text)}.plan.md`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("unwraps a bare ``` fenced plan before reading the title (SDK parity)", () => {
|
|
92
|
+
const text = "```\n# Bare Fenced Plan\n\nbody\n```";
|
|
93
|
+
expect(planArtifactName(text)).toBe(`bare-fenced-plan_${planId(text)}.plan.md`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("caps the slug length so filenames stay bounded", () => {
|
|
97
|
+
const longTitle = "# " + "word ".repeat(40); // ~200 chars of words
|
|
98
|
+
const name = planArtifactName(longTitle);
|
|
99
|
+
const match = /^(.+)_[0-9a-f]{8}\.plan\.md$/.exec(name);
|
|
100
|
+
expect(match).not.toBeNull();
|
|
101
|
+
const slug = match![1];
|
|
102
|
+
expect(slug.length).toBeLessThanOrEqual(60);
|
|
103
|
+
expect(slug.endsWith("-")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("falls back to a bare <id>.plan.md when there is no leading H1", () => {
|
|
107
|
+
const noTitle = "No title here\n\nbody";
|
|
108
|
+
expect(planArtifactName(noTitle)).toBe(`${planId(noTitle)}.plan.md`);
|
|
109
|
+
// An H1 further down is body content, not the title.
|
|
110
|
+
const later = "intro\n\n# Later Heading";
|
|
111
|
+
expect(planArtifactName(later)).toBe(`${planId(later)}.plan.md`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to a bare <id>.plan.md when the title is only the 'Plan:' label", () => {
|
|
115
|
+
const text = "# Plan:\n\nbody";
|
|
116
|
+
expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to a bare <id>.plan.md when the title has no alphanumerics", () => {
|
|
120
|
+
const text = "# ---\n\nbody";
|
|
121
|
+
expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never freshly emits the legacy plan.md name", () => {
|
|
125
|
+
for (const text of ["# A Title\n\nbody", "no heading", "# Plan:\n\nx"]) {
|
|
126
|
+
expect(planArtifactName(text)).not.toBe(PLAN_ARTIFACT_NAME);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("isPlanArtifactName", () => {
|
|
132
|
+
it("accepts the legacy exact name and any *.plan.md", () => {
|
|
133
|
+
expect(isPlanArtifactName("plan.md")).toBe(true);
|
|
134
|
+
expect(isPlanArtifactName("feature_x.plan.md")).toBe(true);
|
|
135
|
+
expect(isPlanArtifactName("fix-the-download-ux_a1b2c3d4.plan.md")).toBe(true);
|
|
136
|
+
expect(isPlanArtifactName("a1b2c3d4.plan.md")).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects non-plan filenames", () => {
|
|
140
|
+
expect(isPlanArtifactName("notes.md")).toBe(false);
|
|
141
|
+
expect(isPlanArtifactName("plan.md.bak")).toBe(false);
|
|
142
|
+
expect(isPlanArtifactName("myplan.md")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
62
146
|
describe("publishPlanArtifact", () => {
|
|
63
|
-
it("uploads plan
|
|
147
|
+
it("uploads the named plan and registers a FILE artifact on the status", async () => {
|
|
64
148
|
const status = create(AgentExecutionStatusSchema, {});
|
|
65
149
|
const storage = fakeStorage();
|
|
66
150
|
|
|
151
|
+
const planText = "# Plan Card UX Cleanup\n1. step one\n2. step two\n";
|
|
152
|
+
const name = `plan-card-ux-cleanup_${planId(planText)}.plan.md`;
|
|
67
153
|
await publishPlanArtifact({
|
|
68
154
|
status,
|
|
69
155
|
executionId: "aex_123",
|
|
70
|
-
planText
|
|
156
|
+
planText,
|
|
71
157
|
artifactStorage: storage,
|
|
72
158
|
});
|
|
73
159
|
|
|
74
160
|
expect(storage.uploads).toHaveLength(1);
|
|
75
|
-
expect(storage.uploads[0].key).toBe(
|
|
161
|
+
expect(storage.uploads[0].key).toBe(`artifacts/aex_123/${name}`);
|
|
76
162
|
expect(storage.uploads[0].contentType).toBe("text/markdown");
|
|
77
163
|
|
|
78
164
|
expect(status.artifacts).toHaveLength(1);
|
|
79
165
|
const artifact = status.artifacts[0];
|
|
80
|
-
expect(artifact.name).toBe(
|
|
81
|
-
expect(artifact.sandboxPath).toBe(
|
|
166
|
+
expect(artifact.name).toBe(name);
|
|
167
|
+
expect(artifact.sandboxPath).toBe(`.stigmer/plans/${name}`);
|
|
82
168
|
expect(artifact.kind).toBe(ExecutionArtifactKind.FILE);
|
|
83
|
-
expect(artifact.storageKey).toBe(
|
|
169
|
+
expect(artifact.storageKey).toBe(`artifacts/aex_123/${name}`);
|
|
84
170
|
expect(artifact.sizeBytes).toBeGreaterThan(0n);
|
|
85
171
|
expect(artifact.contentHash).toHaveLength(64);
|
|
172
|
+
// The filename's <id> is the leading 8 hex of the full content hash.
|
|
173
|
+
expect(artifact.contentHash.startsWith(planId(planText))).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("uses a bare <id>.plan.md name for a titleless plan (never the legacy plan.md)", async () => {
|
|
177
|
+
const status = create(AgentExecutionStatusSchema, {});
|
|
178
|
+
const storage = fakeStorage();
|
|
179
|
+
|
|
180
|
+
const planText = "just a paragraph, no heading";
|
|
181
|
+
const name = `${planId(planText)}.plan.md`;
|
|
182
|
+
await publishPlanArtifact({
|
|
183
|
+
status,
|
|
184
|
+
executionId: "aex_notitle",
|
|
185
|
+
planText,
|
|
186
|
+
artifactStorage: storage,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(status.artifacts[0].name).toBe(name);
|
|
190
|
+
expect(status.artifacts[0].name).not.toBe(PLAN_ARTIFACT_NAME);
|
|
191
|
+
expect(storage.uploads[0].key).toBe(`artifacts/aex_notitle/${name}`);
|
|
86
192
|
});
|
|
87
193
|
|
|
88
194
|
it("is a no-op for empty plan text (nothing to publish)", async () => {
|
|
@@ -100,20 +206,47 @@ describe("publishPlanArtifact", () => {
|
|
|
100
206
|
expect(status.artifacts).toHaveLength(0);
|
|
101
207
|
});
|
|
102
208
|
|
|
103
|
-
it("replaces an existing plan
|
|
209
|
+
it("replaces an existing plan rather than appending a duplicate", async () => {
|
|
104
210
|
const status = create(AgentExecutionStatusSchema, {});
|
|
105
211
|
const storage = fakeStorage();
|
|
106
212
|
|
|
107
213
|
await publishPlanArtifact({ status, executionId: "aex_re", planText: "v1", artifactStorage: storage });
|
|
108
214
|
await publishPlanArtifact({ status, executionId: "aex_re", planText: "v2 longer plan", artifactStorage: storage });
|
|
109
215
|
|
|
216
|
+
// Replacement is at the STATUS level, matched by the *.plan.md predicate:
|
|
217
|
+
// exactly one plan artifact survives. Because names are content-derived,
|
|
218
|
+
// the two differing-content writes land under DIFFERENT keys (the first is
|
|
219
|
+
// an orphan blob) — but this synthetic double-publish never happens in
|
|
220
|
+
// production, where publishPlanArtifact runs once per execution finalization
|
|
221
|
+
// and a same-content retry re-writes the same key.
|
|
110
222
|
expect(status.artifacts).toHaveLength(1);
|
|
111
223
|
expect(storage.uploads).toHaveLength(2);
|
|
112
|
-
// The surviving artifact reflects the latest content hash.
|
|
113
224
|
const latestHash = storage.uploads[1].content;
|
|
114
225
|
expect(status.artifacts[0].sizeBytes).toBe(BigInt(latestHash.length));
|
|
115
226
|
});
|
|
116
227
|
|
|
228
|
+
it("replaces a prior plan even when the derived name changed (predicate match)", async () => {
|
|
229
|
+
const status = create(AgentExecutionStatusSchema, {});
|
|
230
|
+
const storage = fakeStorage();
|
|
231
|
+
|
|
232
|
+
await publishPlanArtifact({
|
|
233
|
+
status,
|
|
234
|
+
executionId: "aex_rename",
|
|
235
|
+
planText: "# First Title\n\nbody",
|
|
236
|
+
artifactStorage: storage,
|
|
237
|
+
});
|
|
238
|
+
const secondText = "# Second Title\n\nbody";
|
|
239
|
+
await publishPlanArtifact({
|
|
240
|
+
status,
|
|
241
|
+
executionId: "aex_rename",
|
|
242
|
+
planText: secondText,
|
|
243
|
+
artifactStorage: storage,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(status.artifacts).toHaveLength(1);
|
|
247
|
+
expect(status.artifacts[0].name).toBe(`second-title_${planId(secondText)}.plan.md`);
|
|
248
|
+
});
|
|
249
|
+
|
|
117
250
|
it("never throws when the upload fails (publish is non-fatal)", async () => {
|
|
118
251
|
const status = create(AgentExecutionStatusSchema, {});
|
|
119
252
|
const { storage: failing } = makeInMemoryArtifactStorage();
|
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
16
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
-
import { clone, create } from "@bufbuild/protobuf";
|
|
17
|
+
import { clone, create, toBinary } from "@bufbuild/protobuf";
|
|
18
18
|
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
19
19
|
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
20
20
|
import {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
FileCaptureClass,
|
|
29
29
|
FileChangeKind,
|
|
30
30
|
FileChangeSetStatus,
|
|
31
|
+
FileChangeType,
|
|
31
32
|
FileDecisionAction,
|
|
32
33
|
FileDecisionScope,
|
|
33
34
|
FileReviewBlockReason,
|
|
@@ -41,7 +42,9 @@ import {
|
|
|
41
42
|
captureBaselineToLedger,
|
|
42
43
|
captureCandidateToLedger,
|
|
43
44
|
deriveCaptureMode,
|
|
45
|
+
partitionGitChangesBySecret,
|
|
44
46
|
} from "../capture.js";
|
|
47
|
+
import type { GitSubstrateChange } from "../git-substrate.js";
|
|
45
48
|
import { casBlobReader } from "../cas-substrate.js";
|
|
46
49
|
|
|
47
50
|
const execFileAsync = promisify(execFile);
|
|
@@ -609,6 +612,184 @@ describe("capture orchestration — secret-blocked DIFF_UNREVIEWABLE (DD-E)", ()
|
|
|
609
612
|
});
|
|
610
613
|
});
|
|
611
614
|
|
|
615
|
+
// A git-TRACKED secret-like file (a committed credentials.json, .env.example,
|
|
616
|
+
// ...) FLOWS in capture mode (the gate allows tracked mutations with no secret
|
|
617
|
+
// check), so it reaches the turn boundary in the git diff WITH real bytes. Those
|
|
618
|
+
// bytes must never enter the ledger: the capture seam authors it content-less
|
|
619
|
+
// (SECRET_WITHHELD / GIT_TRACKED), discard-only, and the git substrate reverts
|
|
620
|
+
// it byte-exact on reject. This is the one place that can withhold a DELETE's
|
|
621
|
+
// baseline (before) secret too — the gate never sees a delete's content.
|
|
622
|
+
// (DD-26 follow-up #3.)
|
|
623
|
+
describe("capture orchestration — git-tracked secret withheld (DD-26 follow-up #3)", () => {
|
|
624
|
+
const SECRET_PATH = "config/credentials.json";
|
|
625
|
+
const BASELINE_SECRET = "OLD_API_KEY=sk-live-OLDSECRET-do-not-persist-111\n";
|
|
626
|
+
const NEW_SECRET = "NEW_API_KEY=sk-live-NEWSECRET-do-not-persist-222\n";
|
|
627
|
+
|
|
628
|
+
async function commitBaselineSecret(): Promise<void> {
|
|
629
|
+
await write(SECRET_PATH, BASELINE_SECRET);
|
|
630
|
+
await git(["add", "-A"]);
|
|
631
|
+
await git(["commit", "-q", "-m", "add tracked secret"]);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Serialize the WHOLE persisted status (ledger + any rows) and assert neither
|
|
635
|
+
// the baseline nor the new secret bytes survive anywhere — the leak-scan that
|
|
636
|
+
// would FAIL before this fix (the git diff carried the inline body).
|
|
637
|
+
function assertNoSecretBytes(status: AgentExecutionStatus): void {
|
|
638
|
+
const wire = Buffer.from(toBinary(AgentExecutionStatusSchema, status));
|
|
639
|
+
expect(wire.includes(Buffer.from(BASELINE_SECRET))).toBe(false);
|
|
640
|
+
expect(wire.includes(Buffer.from(NEW_SECRET))).toBe(false);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
it("MODIFY: content-less SECRET_WITHHELD/GIT_TRACKED entry; PARTIAL_BLOCKED; nothing persisted", async () => {
|
|
644
|
+
await commitBaselineSecret();
|
|
645
|
+
const status = newStatus();
|
|
646
|
+
const baseline = await captureBaselineToLedger({
|
|
647
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
|
|
648
|
+
});
|
|
649
|
+
await write(SECRET_PATH, NEW_SECRET); // the agent edits the committed secret
|
|
650
|
+
|
|
651
|
+
await captureCandidateToLedger({
|
|
652
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
653
|
+
baselineTree: baseline, harnessId: HARNESS,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
|
|
657
|
+
expect(entry).toBeDefined();
|
|
658
|
+
expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
|
|
659
|
+
expect(entry.kind).toBe(FileChangeKind.MODIFY);
|
|
660
|
+
expect(entry.diffComplete).toBe(false);
|
|
661
|
+
expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
|
|
662
|
+
// No content and no enforcement digests on EITHER side (before is secret too).
|
|
663
|
+
expect(entry.before).toBeUndefined();
|
|
664
|
+
expect(entry.after).toBeUndefined();
|
|
665
|
+
expect(entry.beforeSha256).toBe("");
|
|
666
|
+
expect(entry.afterSha256).toBe("");
|
|
667
|
+
expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
|
|
668
|
+
assertNoSecretBytes(status);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("CREATE: a newly-created tracked secret is content-less (kind ADD, no after bytes)", async () => {
|
|
672
|
+
const status = newStatus();
|
|
673
|
+
const baseline = await captureBaselineToLedger({
|
|
674
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
|
|
675
|
+
});
|
|
676
|
+
await write(SECRET_PATH, NEW_SECRET); // brand-new secret file this turn
|
|
677
|
+
|
|
678
|
+
await captureCandidateToLedger({
|
|
679
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
680
|
+
baselineTree: baseline, harnessId: HARNESS,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
|
|
684
|
+
expect(entry.kind).toBe(FileChangeKind.ADD);
|
|
685
|
+
expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
|
|
686
|
+
expect(entry.diffComplete).toBe(false);
|
|
687
|
+
expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
|
|
688
|
+
expect(entry.after).toBeUndefined();
|
|
689
|
+
expect(entry.afterSha256).toBe("");
|
|
690
|
+
expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
|
|
691
|
+
assertNoSecretBytes(status);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("DELETE: withholds the deleted file's baseline (before) secret bytes (kind DELETE)", async () => {
|
|
695
|
+
await commitBaselineSecret();
|
|
696
|
+
const status = newStatus();
|
|
697
|
+
const baseline = await captureBaselineToLedger({
|
|
698
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
|
|
699
|
+
});
|
|
700
|
+
await rm(join(repo, SECRET_PATH), { force: true }); // the agent deletes the secret
|
|
701
|
+
|
|
702
|
+
await captureCandidateToLedger({
|
|
703
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
704
|
+
baselineTree: baseline, harnessId: HARNESS,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const entry = candidateChanges(status).find((c) => c.pathBefore === SECRET_PATH)!;
|
|
708
|
+
expect(entry).toBeDefined();
|
|
709
|
+
expect(entry.kind).toBe(FileChangeKind.DELETE);
|
|
710
|
+
expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
|
|
711
|
+
expect(entry.diffComplete).toBe(false);
|
|
712
|
+
// The DELETE's `before` side is the committed secret — the leak only this
|
|
713
|
+
// seam can withhold (the gate never sees a delete's content).
|
|
714
|
+
expect(entry.before).toBeUndefined();
|
|
715
|
+
expect(entry.beforeSha256).toBe("");
|
|
716
|
+
expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
|
|
717
|
+
assertNoSecretBytes(status);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("mixed set: the non-secret file stays keepable; the secret reverts byte-exact on reject", async () => {
|
|
721
|
+
await commitBaselineSecret();
|
|
722
|
+
const status = newStatus();
|
|
723
|
+
const baseline = await captureBaselineToLedger({
|
|
724
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
|
|
725
|
+
});
|
|
726
|
+
await write("notes.md", "planton notes\n"); // reviewable tracked change
|
|
727
|
+
await write(SECRET_PATH, NEW_SECRET); // withheld tracked secret change
|
|
728
|
+
|
|
729
|
+
await captureCandidateToLedger({
|
|
730
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
731
|
+
baselineTree: baseline, harnessId: HARNESS,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const byPath = new Map(candidateChanges(status).map((c) => [c.pathAfter || c.pathBefore, c]));
|
|
735
|
+
// The non-secret file is fully reviewable/keepable (content present).
|
|
736
|
+
expect(byPath.get("notes.md")!.diffComplete).toBe(true);
|
|
737
|
+
expect(byPath.get("notes.md")!.after).toBeDefined();
|
|
738
|
+
expect(byPath.get("notes.md")!.blockedReason).toBe(FileReviewBlockReason.UNSPECIFIED);
|
|
739
|
+
// The secret rides alongside content-less, forcing the set PARTIAL_BLOCKED.
|
|
740
|
+
expect(byPath.get(SECRET_PATH)!.diffComplete).toBe(false);
|
|
741
|
+
expect(byPath.get(SECRET_PATH)!.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
|
|
742
|
+
expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
|
|
743
|
+
assertNoSecretBytes(status);
|
|
744
|
+
|
|
745
|
+
// Keep the reviewable file; discard the secret. The git substrate reverts the
|
|
746
|
+
// secret to its committed baseline BYTE-EXACT (not deleted) and keeps notes.md.
|
|
747
|
+
const changeSet = decidedChangeSet(status, {
|
|
748
|
+
"notes.md": FileDecisionAction.APPROVE,
|
|
749
|
+
[SECRET_PATH]: FileDecisionAction.REJECT,
|
|
750
|
+
});
|
|
751
|
+
const result = await applyCaptureDecisions({
|
|
752
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
expect(result.failed).toBe(false);
|
|
756
|
+
expect(result.approvedPaths).toEqual(["notes.md"]);
|
|
757
|
+
expect(result.rejectedPaths).toEqual([SECRET_PATH]);
|
|
758
|
+
expect(await read("notes.md")).toBe("planton notes\n");
|
|
759
|
+
expect(await read(SECRET_PATH)).toBe(BASELINE_SECRET); // reverted byte-exact
|
|
760
|
+
// Even after reconcile, no secret bytes are anywhere in the persisted status.
|
|
761
|
+
assertNoSecretBytes(status);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// partitionGitChangesBySecret: the pure split the composition relies on.
|
|
766
|
+
describe("partitionGitChangesBySecret (pure)", () => {
|
|
767
|
+
const mk = (path: string): GitSubstrateChange => ({
|
|
768
|
+
path,
|
|
769
|
+
changeType: FileChangeType.MODIFY,
|
|
770
|
+
before: { kind: "inline", text: "a" },
|
|
771
|
+
after: { kind: "inline", text: "b" },
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("routes secret-like paths to `secret` and everything else to `safe`", () => {
|
|
775
|
+
const { safe, secret } = partitionGitChangesBySecret([
|
|
776
|
+
mk("src/main.ts"),
|
|
777
|
+
mk("config/credentials.json"),
|
|
778
|
+
mk(".env"),
|
|
779
|
+
mk("README.md"),
|
|
780
|
+
mk("keys/id_rsa"),
|
|
781
|
+
]);
|
|
782
|
+
expect(safe.map((c) => c.path)).toEqual(["src/main.ts", "README.md"]);
|
|
783
|
+
expect(secret.map((c) => c.path)).toEqual(["config/credentials.json", ".env", "keys/id_rsa"]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("returns everything safe when no path is secret-like", () => {
|
|
787
|
+
const { safe, secret } = partitionGitChangesBySecret([mk("a.ts"), mk("b/c.md")]);
|
|
788
|
+
expect(safe).toHaveLength(2);
|
|
789
|
+
expect(secret).toHaveLength(0);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
612
793
|
// Slice 2a (DD-21 D2): the shared orchestration must capture and reconcile a
|
|
613
794
|
// NON-GIT workspace entirely from the CAS manifest — no git refs, no whole-tree
|
|
614
795
|
// snapshot, bounded to the paths the observer actually touched. These exercise
|
|
@@ -97,6 +97,40 @@ describe("snapshotCasChangeSet — classification", () => {
|
|
|
97
97
|
expect(del.before?.sha256).toBe(sha256Bytes(bytes("BYE=1")));
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
it("counts display line changes from the in-memory bytes at capture", async () => {
|
|
101
|
+
const { storage } = makeFakeStorage();
|
|
102
|
+
const { manifest } = await snapshotCasChangeSet({
|
|
103
|
+
storage,
|
|
104
|
+
executionId: EXEC,
|
|
105
|
+
changeSetId: CHANGE_SET,
|
|
106
|
+
captures: [
|
|
107
|
+
{ path: "created.env", before: null, after: bytes("A=1\nB=2\n"), captureClass: IGNORED },
|
|
108
|
+
{ path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
|
|
109
|
+
// Binary side: no text diff exists, so no counts may claim one does.
|
|
110
|
+
{ path: "blob.bin", before: null, after: new Uint8Array([0, 1, 2]), captureClass: IGNORED },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
const byPath = new Map(manifest.files.map((f) => [f.pathAfter || f.pathBefore, f]));
|
|
114
|
+
|
|
115
|
+
expect(byPath.get("created.env")!.lineCounts).toEqual({ linesAdded: 2, linesRemoved: 0 });
|
|
116
|
+
expect(byPath.get("changed.env")!.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 1 });
|
|
117
|
+
expect(byPath.get("blob.bin")!.lineCounts).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps line counts out of the persisted manifest (display data, not an enforcement record)", async () => {
|
|
121
|
+
const { storage, readBlob } = makeFakeStorage();
|
|
122
|
+
const { ref } = await snapshotCasChangeSet({
|
|
123
|
+
storage,
|
|
124
|
+
executionId: EXEC,
|
|
125
|
+
changeSetId: CHANGE_SET,
|
|
126
|
+
captures: [
|
|
127
|
+
{ path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
const roundTripped = await loadCasManifest({ readBlob, ref });
|
|
131
|
+
expect(roundTripped.files[0].lineCounts).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
100
134
|
it("drops a touched-but-unchanged path (before == after)", async () => {
|
|
101
135
|
const { storage } = makeFakeStorage();
|
|
102
136
|
const { manifest } = await snapshotCasChangeSet({
|