@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
|
@@ -138,6 +138,111 @@ describe("buildCapturedFileChange", () => {
|
|
|
138
138
|
}
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it("stamps display line counts from inline sides", () => {
|
|
142
|
+
const modify = buildCapturedFileChange({
|
|
143
|
+
id: "fc-counts",
|
|
144
|
+
pathBefore: "src/a.ts",
|
|
145
|
+
pathAfter: "src/a.ts",
|
|
146
|
+
kind: FileChangeKind.MODIFY,
|
|
147
|
+
captureClass: FileCaptureClass.GIT_TRACKED,
|
|
148
|
+
before: "one\ntwo\n",
|
|
149
|
+
after: "one\nTWO\nthree\n",
|
|
150
|
+
});
|
|
151
|
+
expect(modify.linesAdded).toBe(2);
|
|
152
|
+
expect(modify.linesRemoved).toBe(1);
|
|
153
|
+
|
|
154
|
+
const add = buildCapturedFileChange({
|
|
155
|
+
id: "fc-add-counts",
|
|
156
|
+
pathBefore: "",
|
|
157
|
+
pathAfter: "src/new.ts",
|
|
158
|
+
kind: FileChangeKind.ADD,
|
|
159
|
+
captureClass: FileCaptureClass.GIT_TRACKED,
|
|
160
|
+
after: "a\nb\nc\n",
|
|
161
|
+
});
|
|
162
|
+
expect(add.linesAdded).toBe(3);
|
|
163
|
+
expect(add.linesRemoved).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("prefers explicit lineCounts (the CAS substrate's capture-time counts) over inline counting", () => {
|
|
167
|
+
const change = buildCapturedFileChange({
|
|
168
|
+
id: "fc-explicit",
|
|
169
|
+
pathBefore: "notes.txt",
|
|
170
|
+
pathAfter: "notes.txt",
|
|
171
|
+
kind: FileChangeKind.MODIFY,
|
|
172
|
+
captureClass: FileCaptureClass.NON_GIT_CAS,
|
|
173
|
+
before: {
|
|
174
|
+
kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 10, isBinary: false,
|
|
175
|
+
},
|
|
176
|
+
after: {
|
|
177
|
+
kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 12, isBinary: false,
|
|
178
|
+
},
|
|
179
|
+
lineCounts: { linesAdded: 7, linesRemoved: 4 },
|
|
180
|
+
});
|
|
181
|
+
expect(change.linesAdded).toBe(7);
|
|
182
|
+
expect(change.linesRemoved).toBe(4);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("leaves counts at zero when a side is not countable (binary / ref without explicit counts / withheld)", () => {
|
|
186
|
+
// Binary side: no text diff exists, so no count may claim one does.
|
|
187
|
+
const binary = binaryChange("bin");
|
|
188
|
+
expect(binary.linesAdded).toBe(0);
|
|
189
|
+
expect(binary.linesRemoved).toBe(0);
|
|
190
|
+
|
|
191
|
+
// Ref side without explicit counts: the bytes are not here to count.
|
|
192
|
+
const ref = buildCapturedFileChange({
|
|
193
|
+
id: "fc-ref",
|
|
194
|
+
pathBefore: "big.txt",
|
|
195
|
+
pathAfter: "big.txt",
|
|
196
|
+
kind: FileChangeKind.MODIFY,
|
|
197
|
+
captureClass: FileCaptureClass.NON_GIT_CAS,
|
|
198
|
+
before: { kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 9, isBinary: false },
|
|
199
|
+
after: { kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 9, isBinary: false },
|
|
200
|
+
});
|
|
201
|
+
expect(ref.linesAdded).toBe(0);
|
|
202
|
+
expect(ref.linesRemoved).toBe(0);
|
|
203
|
+
|
|
204
|
+
// Secret-withheld: content-less by design.
|
|
205
|
+
const withheld = unavailableChange("secret");
|
|
206
|
+
expect(withheld.linesAdded).toBe(0);
|
|
207
|
+
expect(withheld.linesRemoved).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps line counts out of the enforcement digests (informational only)", () => {
|
|
211
|
+
const common = {
|
|
212
|
+
id: "fc-digest",
|
|
213
|
+
pathBefore: "a",
|
|
214
|
+
pathAfter: "a",
|
|
215
|
+
kind: FileChangeKind.MODIFY,
|
|
216
|
+
captureClass: FileCaptureClass.NON_GIT_CAS,
|
|
217
|
+
before: {
|
|
218
|
+
kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 3, isBinary: false,
|
|
219
|
+
},
|
|
220
|
+
after: {
|
|
221
|
+
kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 3, isBinary: false,
|
|
222
|
+
},
|
|
223
|
+
} as const;
|
|
224
|
+
// Identical content with and without counts must be digest-indistinguishable —
|
|
225
|
+
// structurally guaranteed (FileDigestInput excludes counts), locked here so a
|
|
226
|
+
// future refactor cannot silently fold display data into enforcement.
|
|
227
|
+
const withoutCounts = buildCapturedFileChange(common);
|
|
228
|
+
const withCounts = buildCapturedFileChange({
|
|
229
|
+
...common,
|
|
230
|
+
lineCounts: { linesAdded: 5, linesRemoved: 2 },
|
|
231
|
+
});
|
|
232
|
+
expect(withCounts.fileDigest).toBe(withoutCounts.fileDigest);
|
|
233
|
+
|
|
234
|
+
const aggWithout = buildCandidateCapturedEvent(ctx, undefined, [withoutCounts]);
|
|
235
|
+
const aggWith = buildCandidateCapturedEvent(ctx, undefined, [withCounts]);
|
|
236
|
+
if (
|
|
237
|
+
aggWithout.payload.case === "candidateCaptured" &&
|
|
238
|
+
aggWith.payload.case === "candidateCaptured"
|
|
239
|
+
) {
|
|
240
|
+
expect(aggWith.payload.value.aggregateDigest).toBe(
|
|
241
|
+
aggWithout.payload.value.aggregateDigest,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
141
246
|
it("omits the before side for an ADD and the after side for a DELETE", () => {
|
|
142
247
|
const add = buildCapturedFileChange({
|
|
143
248
|
id: "fc-add",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-time line counting: the `+N −M` semantics must mirror what the SDK's
|
|
3
|
+
* jsdiff-based diff renderer shows (same algorithm, same "\ No newline"
|
|
4
|
+
* exclusion), with absent sides treated as the empty document and a byte-size
|
|
5
|
+
* guard that skips counting rather than stalling capture.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { countLineChanges, LINE_COUNT_MAX_BYTES } from "../line-counts.js";
|
|
10
|
+
|
|
11
|
+
describe("countLineChanges", () => {
|
|
12
|
+
it("counts a plain modify", () => {
|
|
13
|
+
expect(countLineChanges("a\nb\nc\n", "a\nB\nc\nd\n")).toEqual({
|
|
14
|
+
linesAdded: 2,
|
|
15
|
+
linesRemoved: 1,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("counts an ADD (absent before) as all lines added", () => {
|
|
20
|
+
expect(countLineChanges(undefined, "one\ntwo\nthree\n")).toEqual({
|
|
21
|
+
linesAdded: 3,
|
|
22
|
+
linesRemoved: 0,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("counts a DELETE (absent after) as all lines removed", () => {
|
|
27
|
+
expect(countLineChanges("one\ntwo\n", undefined)).toEqual({
|
|
28
|
+
linesAdded: 0,
|
|
29
|
+
linesRemoved: 2,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns zero counts for identical sides", () => {
|
|
34
|
+
expect(countLineChanges("same\n", "same\n")).toEqual({
|
|
35
|
+
linesAdded: 0,
|
|
36
|
+
linesRemoved: 0,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns undefined when both sides are absent", () => {
|
|
41
|
+
expect(countLineChanges(undefined, undefined)).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("excludes the no-trailing-newline marker from the counts", () => {
|
|
45
|
+
// "a" -> "a\n": jsdiff models this as the "a" line replaced (one removed,
|
|
46
|
+
// one added) plus "\ No newline" metadata, which must not be counted —
|
|
47
|
+
// exactly the population the SDK's mapPatchHunks renders.
|
|
48
|
+
expect(countLineChanges("a", "a\n")).toEqual({
|
|
49
|
+
linesAdded: 1,
|
|
50
|
+
linesRemoved: 1,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("counts an added file with no trailing newline by its real line count", () => {
|
|
55
|
+
expect(countLineChanges(undefined, "one\ntwo")).toEqual({
|
|
56
|
+
linesAdded: 2,
|
|
57
|
+
linesRemoved: 0,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips counting when a side exceeds the byte guard", () => {
|
|
62
|
+
const huge = "x\n".repeat(LINE_COUNT_MAX_BYTES / 2 + 1);
|
|
63
|
+
expect(countLineChanges(huge, "small\n")).toBeUndefined();
|
|
64
|
+
expect(countLineChanges("small\n", huge)).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("guards on UTF-8 byte length, not character count", () => {
|
|
68
|
+
// Each "€" is 3 bytes; a string just over the guard in bytes while well
|
|
69
|
+
// under it in characters must still be skipped.
|
|
70
|
+
const wide = "€".repeat(Math.ceil(LINE_COUNT_MAX_BYTES / 3) + 1);
|
|
71
|
+
expect(countLineChanges(wide, "")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for mid-run live capture (DD-32):
|
|
3
|
+
* - `captureProgressDelta` (git-substrate) against a REAL temp git repo — the
|
|
4
|
+
* only faithful test for git plumbing (kinds, counts, binary, rename→add+del,
|
|
5
|
+
* tree-unchanged short-circuit, excludePaths).
|
|
6
|
+
* - `buildFileChangeProgress` (pure) — secret zeroing, entry cap with honest
|
|
7
|
+
* totals, aggregate sums.
|
|
8
|
+
* - `shouldCaptureProgress` (pure) — the debounce floor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { FileChangeKind, FileChangeType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
18
|
+
import {
|
|
19
|
+
captureProgressDelta,
|
|
20
|
+
snapshotBaseline,
|
|
21
|
+
dropCaptureRefs,
|
|
22
|
+
type GitProgressEntry,
|
|
23
|
+
type ProgressDelta,
|
|
24
|
+
} from "../git-substrate.js";
|
|
25
|
+
import {
|
|
26
|
+
buildFileChangeProgress,
|
|
27
|
+
PROGRESS_MAX_ENTRIES,
|
|
28
|
+
shouldCaptureProgress,
|
|
29
|
+
} from "../progress.js";
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const EXEC_ID = "exec-progress-1";
|
|
33
|
+
const CHANGE_SET_ID = "exec-progress-1:0";
|
|
34
|
+
const RUNNER_OWNED_PATHS: readonly string[] = [".cursor/hooks.json"];
|
|
35
|
+
|
|
36
|
+
let repo: string;
|
|
37
|
+
|
|
38
|
+
async function git(args: string[]): Promise<void> {
|
|
39
|
+
await execFileAsync("git", args, { cwd: repo });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function write(rel: string, content: string): Promise<void> {
|
|
43
|
+
await mkdir(join(repo, rel, ".."), { recursive: true });
|
|
44
|
+
await writeFile(join(repo, rel), content, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function writeBytes(rel: string, bytes: Uint8Array): Promise<void> {
|
|
48
|
+
await mkdir(join(repo, rel, ".."), { recursive: true });
|
|
49
|
+
await writeFile(join(repo, rel), bytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function entryFor(delta: ProgressDelta, path: string): GitProgressEntry | undefined {
|
|
53
|
+
return delta.entries.find((e) => e.pathAfter === path || e.pathBefore === path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
repo = await mkdtemp(join(tmpdir(), "stigmer-progress-"));
|
|
58
|
+
await git(["init", "-q"]);
|
|
59
|
+
await git(["config", "user.email", "t@t.local"]);
|
|
60
|
+
await git(["config", "user.name", "t"]);
|
|
61
|
+
await write("src/main.ts", "export const x = 1;\n");
|
|
62
|
+
await write("keep.txt", "line1\nline2\nline3\n");
|
|
63
|
+
await git(["add", "-A"]);
|
|
64
|
+
await git(["commit", "-q", "-m", "initial"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
await dropCaptureRefs(repo, EXEC_ID).catch(() => {});
|
|
69
|
+
await rm(repo, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("captureProgressDelta (real git repo)", () => {
|
|
73
|
+
it("reports ADD / MODIFY / DELETE with line counts and honest path sides", async () => {
|
|
74
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
75
|
+
|
|
76
|
+
await write("src/new.ts", "a\nb\nc\n"); // ADD (3 added)
|
|
77
|
+
await write("src/main.ts", "export const x = 2;\nexport const y = 3;\n"); // MODIFY
|
|
78
|
+
await rm(join(repo, "keep.txt")); // DELETE (3 removed)
|
|
79
|
+
|
|
80
|
+
const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
|
|
81
|
+
expect(delta).toBeDefined();
|
|
82
|
+
expect(delta!.entries).toHaveLength(3);
|
|
83
|
+
|
|
84
|
+
const added = entryFor(delta!, "src/new.ts")!;
|
|
85
|
+
expect(added.changeType).toBe(FileChangeType.CREATE);
|
|
86
|
+
expect(added.pathBefore).toBe("");
|
|
87
|
+
expect(added.pathAfter).toBe("src/new.ts");
|
|
88
|
+
expect(added.linesAdded).toBe(3);
|
|
89
|
+
expect(added.linesRemoved).toBe(0);
|
|
90
|
+
|
|
91
|
+
const modified = entryFor(delta!, "src/main.ts")!;
|
|
92
|
+
expect(modified.changeType).toBe(FileChangeType.MODIFY);
|
|
93
|
+
expect(modified.pathBefore).toBe("src/main.ts");
|
|
94
|
+
expect(modified.pathAfter).toBe("src/main.ts");
|
|
95
|
+
expect(modified.linesAdded).toBeGreaterThan(0);
|
|
96
|
+
|
|
97
|
+
const deleted = entryFor(delta!, "keep.txt")!;
|
|
98
|
+
expect(deleted.changeType).toBe(FileChangeType.DELETE);
|
|
99
|
+
expect(deleted.pathBefore).toBe("keep.txt");
|
|
100
|
+
expect(deleted.pathAfter).toBe("");
|
|
101
|
+
expect(deleted.linesRemoved).toBe(3);
|
|
102
|
+
expect(deleted.linesAdded).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("surfaces a binary change with zero counts (numstat reports '-')", async () => {
|
|
106
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
107
|
+
await writeBytes("blob.bin", new Uint8Array([0, 1, 2, 0, 255, 0, 3]));
|
|
108
|
+
|
|
109
|
+
const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
|
|
110
|
+
const bin = entryFor(delta!, "blob.bin")!;
|
|
111
|
+
expect(bin.changeType).toBe(FileChangeType.CREATE);
|
|
112
|
+
expect(bin.linesAdded).toBe(0);
|
|
113
|
+
expect(bin.linesRemoved).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("splits a rename into a delete + create (--no-renames)", async () => {
|
|
117
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
118
|
+
await rm(join(repo, "keep.txt"));
|
|
119
|
+
await write("renamed.txt", "line1\nline2\nline3\n");
|
|
120
|
+
|
|
121
|
+
const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
|
|
122
|
+
expect(entryFor(delta!, "keep.txt")!.changeType).toBe(FileChangeType.DELETE);
|
|
123
|
+
expect(entryFor(delta!, "renamed.txt")!.changeType).toBe(FileChangeType.CREATE);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("short-circuits (returns undefined) when the tree is unchanged since lastTreeSha", async () => {
|
|
127
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
128
|
+
await write("src/new.ts", "a\nb\n");
|
|
129
|
+
|
|
130
|
+
const first = await captureProgressDelta(repo, EXEC_ID, baseline);
|
|
131
|
+
expect(first).toBeDefined();
|
|
132
|
+
|
|
133
|
+
const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
|
|
134
|
+
expect(second).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("recomputes (0 entries) after the agent reverts its own edits", async () => {
|
|
138
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
139
|
+
await write("src/new.ts", "a\nb\n");
|
|
140
|
+
const first = await captureProgressDelta(repo, EXEC_ID, baseline);
|
|
141
|
+
|
|
142
|
+
await rm(join(repo, "src/new.ts")); // revert to clean
|
|
143
|
+
const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
|
|
144
|
+
expect(second).toBeDefined();
|
|
145
|
+
expect(second!.entries).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("excludes runner-owned gate files from the delta", async () => {
|
|
149
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID, RUNNER_OWNED_PATHS);
|
|
150
|
+
await write(".cursor/hooks.json", "{}\n");
|
|
151
|
+
await write("real.ts", "x\n");
|
|
152
|
+
|
|
153
|
+
const delta = await captureProgressDelta(repo, EXEC_ID, baseline, RUNNER_OWNED_PATHS);
|
|
154
|
+
expect(entryFor(delta!, ".cursor/hooks.json")).toBeUndefined();
|
|
155
|
+
expect(entryFor(delta!, "real.ts")).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("buildFileChangeProgress (pure)", () => {
|
|
160
|
+
function delta(entries: GitProgressEntry[]): ProgressDelta {
|
|
161
|
+
return { afterTree: "tree-sha", entries };
|
|
162
|
+
}
|
|
163
|
+
function entry(path: string, added: number, removed: number): GitProgressEntry {
|
|
164
|
+
return {
|
|
165
|
+
pathBefore: path,
|
|
166
|
+
pathAfter: path,
|
|
167
|
+
changeType: FileChangeType.MODIFY,
|
|
168
|
+
linesAdded: added,
|
|
169
|
+
linesRemoved: removed,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it("sums aggregate counts and maps kind", () => {
|
|
174
|
+
const progress = buildFileChangeProgress(
|
|
175
|
+
delta([
|
|
176
|
+
{ pathBefore: "", pathAfter: "a.ts", changeType: FileChangeType.CREATE, linesAdded: 5, linesRemoved: 0 },
|
|
177
|
+
entry("b.ts", 2, 3),
|
|
178
|
+
]),
|
|
179
|
+
CHANGE_SET_ID,
|
|
180
|
+
);
|
|
181
|
+
expect(progress.changeSetId).toBe(CHANGE_SET_ID);
|
|
182
|
+
expect(progress.filesChanged).toBe(2);
|
|
183
|
+
expect(progress.linesAdded).toBe(7);
|
|
184
|
+
expect(progress.linesRemoved).toBe(3);
|
|
185
|
+
expect(progress.entries[0].kind).toBe(FileChangeKind.ADD);
|
|
186
|
+
expect(progress.entries[1].kind).toBe(FileChangeKind.MODIFY);
|
|
187
|
+
expect(progress.capturedAt).not.toBe("");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("zeroes counts for secret-like paths (path visible, magnitude withheld)", () => {
|
|
191
|
+
const progress = buildFileChangeProgress(
|
|
192
|
+
delta([entry(".env", 10, 2), entry("app.ts", 4, 1)]),
|
|
193
|
+
CHANGE_SET_ID,
|
|
194
|
+
);
|
|
195
|
+
// The secret path is still counted as a changed file and its path is present…
|
|
196
|
+
expect(progress.filesChanged).toBe(2);
|
|
197
|
+
const secret = progress.entries.find((e) => e.pathAfter === ".env")!;
|
|
198
|
+
expect(secret.linesAdded).toBe(0);
|
|
199
|
+
expect(secret.linesRemoved).toBe(0);
|
|
200
|
+
// …but its magnitude is excluded from the aggregate.
|
|
201
|
+
expect(progress.linesAdded).toBe(4);
|
|
202
|
+
expect(progress.linesRemoved).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("caps entries but keeps files_changed and totals honest over all files", () => {
|
|
206
|
+
const entries: GitProgressEntry[] = [];
|
|
207
|
+
for (let i = 0; i < PROGRESS_MAX_ENTRIES + 25; i++) {
|
|
208
|
+
entries.push(entry(`f${i}.ts`, 1, 1));
|
|
209
|
+
}
|
|
210
|
+
const progress = buildFileChangeProgress(delta(entries), CHANGE_SET_ID);
|
|
211
|
+
expect(progress.entries).toHaveLength(PROGRESS_MAX_ENTRIES);
|
|
212
|
+
expect(progress.filesChanged).toBe(PROGRESS_MAX_ENTRIES + 25);
|
|
213
|
+
expect(progress.linesAdded).toBe(PROGRESS_MAX_ENTRIES + 25);
|
|
214
|
+
expect(progress.linesRemoved).toBe(PROGRESS_MAX_ENTRIES + 25);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("produces a zero-file snapshot for an empty delta (revert-to-clean)", () => {
|
|
218
|
+
const progress = buildFileChangeProgress(delta([]), CHANGE_SET_ID);
|
|
219
|
+
expect(progress.filesChanged).toBe(0);
|
|
220
|
+
expect(progress.entries).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("shouldCaptureProgress (pure)", () => {
|
|
225
|
+
it("always captures on the first call (lastAtMs = 0, real clock)", () => {
|
|
226
|
+
// The caller starts a fresh turn with lastAtMs = 0 and now = Date.now(),
|
|
227
|
+
// which is always far past any floor.
|
|
228
|
+
expect(shouldCaptureProgress(0, Date.now(), 2000)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("gates within the floor and allows once it elapses", () => {
|
|
232
|
+
expect(shouldCaptureProgress(1000, 2500, 2000)).toBe(false);
|
|
233
|
+
expect(shouldCaptureProgress(1000, 3000, 2000)).toBe(true);
|
|
234
|
+
expect(shouldCaptureProgress(1000, 3001, 2000)).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
type CasPathCapture,
|
|
78
78
|
type CasSnapshotRef,
|
|
79
79
|
} from "./cas-substrate.js";
|
|
80
|
+
import { isSecretLikePath } from "./secret-paths.js";
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Whether a turn runs in apply-then-review CAPTURE mode (file edits flow and are
|
|
@@ -244,21 +245,41 @@ export async function captureCandidateToLedger(opts: {
|
|
|
244
245
|
|
|
245
246
|
const unreviewable = unreviewablePaths ?? [];
|
|
246
247
|
|
|
248
|
+
// Secret handling is three-way across the substrates, and this is the last of
|
|
249
|
+
// the three (the other two happen upstream at the harness gate):
|
|
250
|
+
// 1. A gitignored secret WRITE never flows — hard-blocked at the gate /
|
|
251
|
+
// deny-gate (DD-12/DD-30). It arrives here only as a path in
|
|
252
|
+
// `unreviewablePaths` (recorded by the gate), authored content-less below.
|
|
253
|
+
// 2. A gitignored secret that flowed under the global bypass is withheld from
|
|
254
|
+
// CAS by `partitionIgnoredPathsBySecret` (secret-paths.ts), so its bytes
|
|
255
|
+
// never reach `casCaptures`.
|
|
256
|
+
// 3. A git-TRACKED secret DID flow (the capture-mode gate allows tracked
|
|
257
|
+
// mutations with no secret check) and is present in `gitChanges` with real
|
|
258
|
+
// bytes. We split it out here and author it content-less, so its content
|
|
259
|
+
// never reaches the ledger. Unlike a gitignored secret it HAS a reversible
|
|
260
|
+
// substrate (the git refs), so it is withheld-and-reverted rather than
|
|
261
|
+
// never-applied — same never-persisted guarantee, appropriate mechanism.
|
|
262
|
+
const { safe: safeGitChanges, secret: secretGitChanges } =
|
|
263
|
+
partitionGitChangesBySecret(gitChanges);
|
|
264
|
+
|
|
247
265
|
// A turn that changed nothing (no tracked/ignored change and no blocked path)
|
|
248
|
-
// authors no event.
|
|
266
|
+
// authors no event. A secret-only tracked turn is NOT a no-op — its file is in
|
|
267
|
+
// `gitChanges`, so the guard below is false and it authors a blocked entry.
|
|
249
268
|
if (gitChanges.length === 0 && casFiles.length === 0 && unreviewable.length === 0) {
|
|
250
269
|
return gitChanges;
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
// One combined change set: git-tracked (inline bodies) +
|
|
254
|
-
// secret-blocked (content-less
|
|
255
|
-
// all (buildCandidateCapturedEvent sorts by file
|
|
256
|
-
// and its identity span every substrate.
|
|
257
|
-
// per-file signals up three ways: any
|
|
258
|
-
// PARTIAL_BLOCKED; a set blocked only by
|
|
259
|
-
// (keepable in one acknowledged action); else
|
|
272
|
+
// One combined change set: git-tracked (inline bodies) + git-tracked-secret
|
|
273
|
+
// (content-less) + CAS (blob refs) + gitignored-secret-blocked (content-less).
|
|
274
|
+
// The aggregate digest folds all (buildCandidateCapturedEvent sorts by file
|
|
275
|
+
// digest), so the reviewed diff and its identity span every substrate.
|
|
276
|
+
// deriveDiffCompleteness then rolls the per-file signals up three ways: any
|
|
277
|
+
// content-less (non-binary) entry forces PARTIAL_BLOCKED; a set blocked only by
|
|
278
|
+
// binaries is BINARY_SUMMARY_ONLY (keepable in one acknowledged action); else
|
|
279
|
+
// COMPLETE.
|
|
260
280
|
const captured = [
|
|
261
|
-
...
|
|
281
|
+
...safeGitChanges.map((c) => buildCapturedFileChange(toCapturedChangeInput(changeSetId, c))),
|
|
282
|
+
...secretGitChanges.map((c) => buildCapturedFileChange(trackedSecretChangeInput(changeSetId, c))),
|
|
262
283
|
...casFiles.map((f) => buildCapturedFileChange(casToCapturedChangeInput(changeSetId, f))),
|
|
263
284
|
...unreviewable.map((p) => buildCapturedFileChange(unreviewableChangeInput(changeSetId, p, unreviewableCaptureClass))),
|
|
264
285
|
];
|
|
@@ -622,7 +643,9 @@ function candidateCasRef(changeSet: FileChangeSet): CasSnapshotRef | undefined {
|
|
|
622
643
|
* after bodies are carried as blob REFS (already offloaded to artifact storage
|
|
623
644
|
* by the CAS substrate), never re-inlined — so the CANDIDATE event stays small
|
|
624
645
|
* and the bytes are stored exactly once. Enforcement digests come from the blob
|
|
625
|
-
* content addresses, identical in form to the git path.
|
|
646
|
+
* content addresses, identical in form to the git path. The display line counts
|
|
647
|
+
* ride along explicitly — a ref side has no inline text for the producer to
|
|
648
|
+
* count, so the substrate's capture-time counts are the only honest source.
|
|
626
649
|
*/
|
|
627
650
|
function casToCapturedChangeInput(
|
|
628
651
|
changeSetId: string,
|
|
@@ -634,6 +657,7 @@ function casToCapturedChangeInput(
|
|
|
634
657
|
pathAfter: file.pathAfter,
|
|
635
658
|
kind: file.kind,
|
|
636
659
|
captureClass: file.captureClass,
|
|
660
|
+
lineCounts: file.lineCounts,
|
|
637
661
|
before: file.before
|
|
638
662
|
? {
|
|
639
663
|
kind: "ref",
|
|
@@ -657,37 +681,117 @@ function casToCapturedChangeInput(
|
|
|
657
681
|
}
|
|
658
682
|
|
|
659
683
|
/**
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
684
|
+
* The single content-less `SECRET_WITHHELD` producer input — the one shape every
|
|
685
|
+
* secret-blocked change (gitignored OR git-tracked) is authored with, so the two
|
|
686
|
+
* producers can never drift (a divergence in `diffComplete`/`blockedReason` would
|
|
687
|
+
* make the backend gate treat one kind of secret differently from the other).
|
|
688
|
+
*
|
|
689
|
+
* Both sides are absent (empty enforcement digests) and `diffComplete=false`.
|
|
690
|
+
* Being non-binary incomplete with no keepable bytes, it forces the change set to
|
|
691
|
+
* PARTIAL_BLOCKED (never BINARY_SUMMARY_ONLY) — approval is blocked and the path
|
|
692
|
+
* is surfaced honestly, while its CONTENT never enters the ledger or storage.
|
|
693
|
+
* `blockedReason=SECRET_WITHHELD` records the honest cause so the review UI can say
|
|
694
|
+
* *why* rather than a cause-agnostic "unavailable" (doc 15). Callers supply the
|
|
695
|
+
* id / paths / kind / captureClass so each producer keeps its own honest identity.
|
|
672
696
|
*/
|
|
673
|
-
function
|
|
674
|
-
|
|
675
|
-
|
|
697
|
+
function secretWithheldChangeInput(
|
|
698
|
+
id: string,
|
|
699
|
+
pathBefore: string,
|
|
700
|
+
pathAfter: string,
|
|
701
|
+
kind: FileChangeKind,
|
|
676
702
|
captureClass: FileCaptureClass,
|
|
677
703
|
): CapturedChangeInput {
|
|
678
704
|
return {
|
|
679
|
-
id
|
|
680
|
-
pathBefore
|
|
681
|
-
pathAfter
|
|
682
|
-
kind
|
|
705
|
+
id,
|
|
706
|
+
pathBefore,
|
|
707
|
+
pathAfter,
|
|
708
|
+
kind,
|
|
683
709
|
captureClass,
|
|
684
710
|
diffComplete: false,
|
|
685
711
|
blockedReason: FileReviewBlockReason.SECRET_WITHHELD,
|
|
686
712
|
};
|
|
687
713
|
}
|
|
688
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Map a secret-blocked GITIGNORED path (the write never flowed — hard-blocked at
|
|
717
|
+
* the harness gate, design doc 12 / DD-E) to a content-less entry. The write was
|
|
718
|
+
* blocked before it ran, so create-vs-modify is unknown and irrelevant (nothing
|
|
719
|
+
* is ever applied or reconciled for this entry) — kind is MODIFY. `captureClass`
|
|
720
|
+
* is the turn's CAS substrate class (GIT_IGNORED_CAPTURED | NON_GIT_CAS).
|
|
721
|
+
*/
|
|
722
|
+
function unreviewableChangeInput(
|
|
723
|
+
changeSetId: string,
|
|
724
|
+
path: string,
|
|
725
|
+
captureClass: FileCaptureClass,
|
|
726
|
+
): CapturedChangeInput {
|
|
727
|
+
return secretWithheldChangeInput(
|
|
728
|
+
`${changeSetId}:${path}`,
|
|
729
|
+
path,
|
|
730
|
+
path,
|
|
731
|
+
FileChangeKind.MODIFY,
|
|
732
|
+
captureClass,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Map a secret-like GIT-TRACKED change to a content-less entry (DD-26 follow-up
|
|
738
|
+
* #3). Unlike a gitignored secret, a tracked secret write actually FLOWED (the
|
|
739
|
+
* capture-mode gate allows tracked mutations), so it is present in the git diff
|
|
740
|
+
* with real bytes — which must never be persisted into the ledger. We author it
|
|
741
|
+
* content-less here so its CONTENT never reaches the ledger / Temporal history /
|
|
742
|
+
* storage, while the honest kind + paths (a CREATE/MODIFY/DELETE, mirroring
|
|
743
|
+
* {@link toCapturedChangeInput}'s id/path derivation so the resume reconcile
|
|
744
|
+
* lookup matches) and `GIT_TRACKED` provenance are preserved. It is discard-only
|
|
745
|
+
* (PARTIAL_BLOCKED): on resume it can only be rejected, and the git substrate
|
|
746
|
+
* reverts it byte-exact from the baseline ref (the reversible substrate a
|
|
747
|
+
* gitignored secret lacks — so "withhold + revert" upholds never-persisted here
|
|
748
|
+
* without the gate hard-block). A DELETE is handled here too: the git diff
|
|
749
|
+
* resurrects the deleted file's baseline (secret) bytes as the `before` side, a
|
|
750
|
+
* leak only this seam can withhold (the gate sees no content for a delete).
|
|
751
|
+
*/
|
|
752
|
+
function trackedSecretChangeInput(
|
|
753
|
+
changeSetId: string,
|
|
754
|
+
change: GitCapturedChange,
|
|
755
|
+
): CapturedChangeInput {
|
|
756
|
+
const isCreate = change.changeType === FileChangeType.CREATE;
|
|
757
|
+
const isDelete = change.changeType === FileChangeType.DELETE;
|
|
758
|
+
const pathBefore = isCreate ? "" : change.path;
|
|
759
|
+
const pathAfter = isDelete ? "" : change.path;
|
|
760
|
+
return secretWithheldChangeInput(
|
|
761
|
+
`${changeSetId}:${pathAfter || pathBefore}`,
|
|
762
|
+
pathBefore,
|
|
763
|
+
pathAfter,
|
|
764
|
+
toFileChangeKind(change.changeType),
|
|
765
|
+
FileCaptureClass.GIT_TRACKED,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Split a turn's git-tracked changes by the secret gate: a secret-like tracked
|
|
771
|
+
* path (a committed `credentials.json`, `.env.example`, `*.tfvars`, ...) goes to
|
|
772
|
+
* `secret` (authored content-less via {@link trackedSecretChangeInput}); every
|
|
773
|
+
* other tracked change stays in `safe` (its diff is captured normally). Pure and
|
|
774
|
+
* exported for direct unit testing. Keyed on the same {@link isSecretLikePath}
|
|
775
|
+
* classifier the CAS/gitignored path uses ({@link partitionIgnoredPathsBySecret}
|
|
776
|
+
* in secret-paths.ts), so a path is classified identically everywhere. NOTE: the
|
|
777
|
+
* classifier is path-based (DD-12 D2), so a rename to an innocuous name defeats
|
|
778
|
+
* it — an intentional, cross-substrate limitation, not introduced here.
|
|
779
|
+
*/
|
|
780
|
+
export function partitionGitChangesBySecret(changes: readonly GitCapturedChange[]): {
|
|
781
|
+
readonly safe: readonly GitCapturedChange[];
|
|
782
|
+
readonly secret: readonly GitCapturedChange[];
|
|
783
|
+
} {
|
|
784
|
+
const safe: GitCapturedChange[] = [];
|
|
785
|
+
const secret: GitCapturedChange[] = [];
|
|
786
|
+
for (const change of changes) {
|
|
787
|
+
if (isSecretLikePath(change.path)) secret.push(change);
|
|
788
|
+
else safe.push(change);
|
|
789
|
+
}
|
|
790
|
+
return { safe, secret };
|
|
791
|
+
}
|
|
792
|
+
|
|
689
793
|
/** Map the git capture kind to the file-review {@link FileChangeKind}. */
|
|
690
|
-
function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
|
|
794
|
+
export function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
|
|
691
795
|
switch (changeType) {
|
|
692
796
|
case FileChangeType.CREATE:
|
|
693
797
|
return FileChangeKind.ADD;
|