@stigmer/runner 3.1.3 → 3.1.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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
- package/dist/activities/execute-cursor/capture-flow.js +56 -18
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
- package/dist/activities/execute-cursor/command-provenance.js +25 -115
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +296 -477
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
- package/dist/activities/execute-cursor/todo-tracker.js +15 -43
- package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
- package/dist/activities/execute-cursor/turn-stream.js +249 -0
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
- package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
- package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
- package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +60 -18
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +8 -1
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/otel.js +10 -0
- package/dist/otel.js.map +1 -1
- package/dist/shared/filereview/cas-progress.d.ts +63 -0
- package/dist/shared/filereview/cas-progress.js +128 -0
- package/dist/shared/filereview/cas-progress.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +29 -0
- package/dist/shared/filereview/cas-substrate.js +46 -21
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/command-provenance.d.ts +93 -0
- package/dist/shared/filereview/command-provenance.js +132 -0
- package/dist/shared/filereview/command-provenance.js.map +1 -0
- package/dist/shared/filereview/git-substrate.d.ts +9 -3
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +4 -3
- package/dist/shared/filereview/index.js +3 -2
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/progress.d.ts +105 -34
- package/dist/shared/filereview/progress.js +96 -34
- package/dist/shared/filereview/progress.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +9 -0
- package/dist/shared/plan-mode-prompt.js +18 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/dist/shared/todos.d.ts +56 -0
- package/dist/shared/todos.js +98 -0
- package/dist/shared/todos.js.map +1 -0
- package/dist/shared/tool-row.d.ts +16 -0
- package/dist/shared/tool-row.js +31 -0
- package/dist/shared/tool-row.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/capture-flow.ts +71 -25
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +344 -504
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-stream.ts +418 -0
- package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
- package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
- package/src/activities/execute-deep-agent/index.ts +74 -18
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
- package/src/shared/filereview/__tests__/progress.test.ts +112 -10
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +69 -24
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/git-substrate.ts +9 -3
- package/src/shared/filereview/index.ts +15 -1
- package/src/shared/filereview/progress.ts +171 -47
- package/src/shared/plan-mode-prompt.ts +18 -0
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
casBlobKey,
|
|
25
25
|
casBlobReader,
|
|
26
26
|
casManifestKey,
|
|
27
|
+
classifyCasChange,
|
|
27
28
|
loadCasManifest,
|
|
28
29
|
restoreCasToBaseline,
|
|
29
30
|
snapshotCasChangeSet,
|
|
@@ -436,3 +437,68 @@ describe("digest independence from capture class", () => {
|
|
|
436
437
|
expect(files.map((f) => f.pathAfter)).toEqual(["a", "m", "z"]);
|
|
437
438
|
});
|
|
438
439
|
});
|
|
440
|
+
|
|
441
|
+
// The single classification authority for a CAS before/after pair. Both the
|
|
442
|
+
// turn-boundary capture (buildCasCapturedFile, exercised via snapshotCasChangeSet)
|
|
443
|
+
// and the mid-run progress producer (cas-progress) route through it, so these
|
|
444
|
+
// cases lock the shape the strip and the reviewed set share.
|
|
445
|
+
describe("classifyCasChange (pure)", () => {
|
|
446
|
+
const buf = (s: string): Buffer => Buffer.from(s, "utf8");
|
|
447
|
+
|
|
448
|
+
it("classifies ADD from a null before and blanks the before path", () => {
|
|
449
|
+
const c = classifyCasChange("new.ts", null, buf("a\nb\nc\n"))!;
|
|
450
|
+
expect(c.kind).toBe(FileChangeKind.ADD);
|
|
451
|
+
expect(c.pathBefore).toBe("");
|
|
452
|
+
expect(c.pathAfter).toBe("new.ts");
|
|
453
|
+
expect(c.isBinary).toBe(false);
|
|
454
|
+
expect(c.lineCounts).toEqual({ linesAdded: 3, linesRemoved: 0 });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("classifies DELETE from a null after and blanks the after path", () => {
|
|
458
|
+
const c = classifyCasChange("gone.ts", buf("x\ny\n"), null)!;
|
|
459
|
+
expect(c.kind).toBe(FileChangeKind.DELETE);
|
|
460
|
+
expect(c.pathBefore).toBe("gone.ts");
|
|
461
|
+
expect(c.pathAfter).toBe("");
|
|
462
|
+
expect(c.lineCounts).toEqual({ linesAdded: 0, linesRemoved: 2 });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("classifies MODIFY from two present sides", () => {
|
|
466
|
+
const c = classifyCasChange("m.ts", buf("a\n"), buf("a\nb\n"))!;
|
|
467
|
+
expect(c.kind).toBe(FileChangeKind.MODIFY);
|
|
468
|
+
expect(c.pathBefore).toBe("m.ts");
|
|
469
|
+
expect(c.pathAfter).toBe("m.ts");
|
|
470
|
+
expect(c.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 0 });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("returns undefined for both-null and for a no-op touch (unchanged bytes)", () => {
|
|
474
|
+
expect(classifyCasChange("p", null, null)).toBeUndefined();
|
|
475
|
+
expect(classifyCasChange("p", buf("same\n"), buf("same\n"))).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("marks a binary side and withholds line counts", () => {
|
|
479
|
+
const bin = Buffer.from([0, 1, 2, 0, 255, 0]);
|
|
480
|
+
const c = classifyCasChange("blob.bin", null, bin)!;
|
|
481
|
+
expect(c.kind).toBe(FileChangeKind.ADD);
|
|
482
|
+
expect(c.isBinary).toBe(true);
|
|
483
|
+
expect(c.lineCounts).toBeUndefined();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("is the authority snapshotCasChangeSet uses (parity guard)", async () => {
|
|
487
|
+
const { storage } = makeFakeStorage();
|
|
488
|
+
const before = bytes("one\ntwo\n");
|
|
489
|
+
const after = bytes("one\ntwo\nthree\n");
|
|
490
|
+
const { manifest } = await snapshotCasChangeSet({
|
|
491
|
+
storage, executionId: EXEC, changeSetId: CHANGE_SET,
|
|
492
|
+
captures: [{ path: "m.ts", before, after, captureClass: IGNORED }],
|
|
493
|
+
});
|
|
494
|
+
const file = manifest.files[0];
|
|
495
|
+
const direct = classifyCasChange("m.ts", Buffer.from(before), Buffer.from(after))!;
|
|
496
|
+
// The captured file's display classification is exactly what the shared
|
|
497
|
+
// classifier produces — no second authority that could drift.
|
|
498
|
+
expect(file.kind).toBe(direct.kind);
|
|
499
|
+
expect(file.pathBefore).toBe(direct.pathBefore);
|
|
500
|
+
expect(file.pathAfter).toBe(direct.pathAfter);
|
|
501
|
+
expect(file.diffComplete).toBe(!direct.isBinary);
|
|
502
|
+
expect(file.lineCounts).toEqual(direct.lineCounts);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shared DD-28 qualification rule
|
|
3
|
+
* ({@link qualifyTurnCommandProvenance}) — the harness-agnostic core both the
|
|
4
|
+
* Cursor and deep-agent adapters delegate to.
|
|
5
|
+
*
|
|
6
|
+
* These exercise the rule in isolation over synthetic `turnToolCalls` (the
|
|
7
|
+
* harness supplies the scoping + consent resolvers); each harness's own mapping
|
|
8
|
+
* is covered by its adapter suite. The rule: a turn qualifies only when every
|
|
9
|
+
* mutation-capable call it executed was a shell command with a provable consent
|
|
10
|
+
* source (direct / lease / auto_approve_all), and every uncertainty fails closed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
|
15
|
+
import {
|
|
16
|
+
AgentMessageSchema,
|
|
17
|
+
ToolCallSchema,
|
|
18
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
19
|
+
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
20
|
+
import {
|
|
21
|
+
ApprovalAction,
|
|
22
|
+
MessageType,
|
|
23
|
+
ToolCallStatus,
|
|
24
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
25
|
+
|
|
26
|
+
import { qualifyTurnCommandProvenance, findLeaseConsentId } from "../command-provenance.js";
|
|
27
|
+
|
|
28
|
+
function toolCall(overrides: MessageInitShape<typeof ToolCallSchema>): ToolCall {
|
|
29
|
+
return create(ToolCallSchema, {
|
|
30
|
+
id: "call-1",
|
|
31
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function aiMessageWith(toolCalls: ToolCall[]): AgentMessage {
|
|
37
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default resolvers for the common "no direct consent, every listed call ran"
|
|
41
|
+
// case — individual tests override as needed.
|
|
42
|
+
const executedAlways = (): boolean => true;
|
|
43
|
+
const noDirectConsent = (): undefined => undefined;
|
|
44
|
+
|
|
45
|
+
function shell(id: string, cmd = "seq 1 5 > out.txt"): ToolCall {
|
|
46
|
+
return toolCall({ id, name: "shell", args: { command: cmd } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("qualifyTurnCommandProvenance", () => {
|
|
50
|
+
it("cites the direct consent id the resolver returns", () => {
|
|
51
|
+
const tc = shell("shell-1");
|
|
52
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
53
|
+
turnToolCalls: [tc],
|
|
54
|
+
messages: [aiMessageWith([tc])],
|
|
55
|
+
isExecutedCommand: executedAlways,
|
|
56
|
+
resolveDirectConsent: (t) => (t.id === "shell-1" ? "consent-row" : undefined),
|
|
57
|
+
globalBypass: false,
|
|
58
|
+
});
|
|
59
|
+
expect(provenance).toBeDefined();
|
|
60
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
61
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("falls back to the APPROVE_ALL lease row when there is no direct consent", () => {
|
|
65
|
+
const leaseRow = toolCall({
|
|
66
|
+
id: "lease-row",
|
|
67
|
+
name: "shell",
|
|
68
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
69
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
70
|
+
});
|
|
71
|
+
const leased = shell("shell-leased");
|
|
72
|
+
const messages = [aiMessageWith([leaseRow]), aiMessageWith([leased])];
|
|
73
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
74
|
+
turnToolCalls: [leased],
|
|
75
|
+
messages,
|
|
76
|
+
isExecutedCommand: executedAlways,
|
|
77
|
+
resolveDirectConsent: noDirectConsent,
|
|
78
|
+
globalBypass: false,
|
|
79
|
+
});
|
|
80
|
+
expect(provenance).toBeDefined();
|
|
81
|
+
expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("flags auto_approve_all when only the global bypass authorized the command", () => {
|
|
85
|
+
const tc = shell("shell-1");
|
|
86
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
87
|
+
turnToolCalls: [tc],
|
|
88
|
+
messages: [aiMessageWith([tc])],
|
|
89
|
+
isExecutedCommand: executedAlways,
|
|
90
|
+
resolveDirectConsent: noDirectConsent,
|
|
91
|
+
globalBypass: true,
|
|
92
|
+
});
|
|
93
|
+
expect(provenance).toBeDefined();
|
|
94
|
+
expect(provenance!.consentToolCallIds).toEqual([]);
|
|
95
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("prefers direct consent over auto_approve_all when both are available", () => {
|
|
99
|
+
const tc = shell("shell-1");
|
|
100
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
101
|
+
turnToolCalls: [tc],
|
|
102
|
+
messages: [aiMessageWith([tc])],
|
|
103
|
+
isExecutedCommand: executedAlways,
|
|
104
|
+
resolveDirectConsent: () => "consent-row",
|
|
105
|
+
globalBypass: true,
|
|
106
|
+
});
|
|
107
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
108
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails closed on a file-tool call (mixed turn)", () => {
|
|
112
|
+
const tc = shell("shell-1");
|
|
113
|
+
const edit = toolCall({ id: "edit-1", name: "edit", args: { path: "a.txt" } });
|
|
114
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
115
|
+
turnToolCalls: [tc, edit],
|
|
116
|
+
messages: [aiMessageWith([tc, edit])],
|
|
117
|
+
isExecutedCommand: executedAlways,
|
|
118
|
+
resolveDirectConsent: () => "consent-row",
|
|
119
|
+
globalBypass: false,
|
|
120
|
+
});
|
|
121
|
+
expect(provenance).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("fails closed on an MCP tool", () => {
|
|
125
|
+
const tc = shell("shell-1");
|
|
126
|
+
const mcp = toolCall({ id: "mcp-1", name: "apply_resource", mcpServerSlug: "planton" });
|
|
127
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
128
|
+
turnToolCalls: [tc, mcp],
|
|
129
|
+
messages: [aiMessageWith([tc, mcp])],
|
|
130
|
+
isExecutedCommand: executedAlways,
|
|
131
|
+
resolveDirectConsent: () => "consent-row",
|
|
132
|
+
globalBypass: false,
|
|
133
|
+
});
|
|
134
|
+
expect(provenance).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("fails closed on a sub-agent delegation (task -> SUBAGENT kind)", () => {
|
|
138
|
+
const tc = shell("shell-1");
|
|
139
|
+
const task = toolCall({ id: "task-1", name: "task", args: { prompt: "go" } });
|
|
140
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
141
|
+
turnToolCalls: [tc, task],
|
|
142
|
+
messages: [aiMessageWith([tc, task])],
|
|
143
|
+
isExecutedCommand: executedAlways,
|
|
144
|
+
resolveDirectConsent: () => "consent-row",
|
|
145
|
+
globalBypass: false,
|
|
146
|
+
});
|
|
147
|
+
expect(provenance).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("fails closed on an unrecognized tool name", () => {
|
|
151
|
+
const tc = shell("shell-1");
|
|
152
|
+
const unknown = toolCall({ id: "u-1", name: "mystery_tool" });
|
|
153
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
154
|
+
turnToolCalls: [tc, unknown],
|
|
155
|
+
messages: [aiMessageWith([tc, unknown])],
|
|
156
|
+
isExecutedCommand: executedAlways,
|
|
157
|
+
resolveDirectConsent: () => "consent-row",
|
|
158
|
+
globalBypass: false,
|
|
159
|
+
});
|
|
160
|
+
expect(provenance).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("fails closed on an executed command with no provable consent source", () => {
|
|
164
|
+
const tc = shell("shell-1");
|
|
165
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
166
|
+
turnToolCalls: [tc],
|
|
167
|
+
messages: [aiMessageWith([tc])],
|
|
168
|
+
isExecutedCommand: executedAlways,
|
|
169
|
+
resolveDirectConsent: noDirectConsent,
|
|
170
|
+
globalBypass: false,
|
|
171
|
+
});
|
|
172
|
+
expect(provenance).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns undefined when no command executed (only a pending shell)", () => {
|
|
176
|
+
const pending = shell("shell-pending");
|
|
177
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
178
|
+
turnToolCalls: [pending],
|
|
179
|
+
messages: [aiMessageWith([pending])],
|
|
180
|
+
isExecutedCommand: () => false, // pending / denied — never executed
|
|
181
|
+
resolveDirectConsent: () => "consent-row",
|
|
182
|
+
globalBypass: false,
|
|
183
|
+
});
|
|
184
|
+
expect(provenance).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("skips read-only tools and hidden collapsed rows, then qualifies", () => {
|
|
188
|
+
const read = toolCall({ id: "read-1", name: "read", args: { path: "a.txt" } });
|
|
189
|
+
const hidden = toolCall({
|
|
190
|
+
id: "hidden-1",
|
|
191
|
+
name: "shell",
|
|
192
|
+
status: ToolCallStatus.TOOL_CALL_SKIPPED,
|
|
193
|
+
args: undefined,
|
|
194
|
+
});
|
|
195
|
+
const executed = shell("shell-executed");
|
|
196
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
197
|
+
turnToolCalls: [read, hidden, executed],
|
|
198
|
+
messages: [aiMessageWith([read, hidden, executed])],
|
|
199
|
+
isExecutedCommand: executedAlways,
|
|
200
|
+
resolveDirectConsent: (t) => (t.id === "shell-executed" ? "consent-row" : undefined),
|
|
201
|
+
globalBypass: false,
|
|
202
|
+
});
|
|
203
|
+
expect(provenance).toBeDefined();
|
|
204
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("dedupes consent ids across multiple commands citing the same lease", () => {
|
|
208
|
+
const leaseRow = toolCall({
|
|
209
|
+
id: "lease-row",
|
|
210
|
+
name: "shell",
|
|
211
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
212
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
213
|
+
});
|
|
214
|
+
const a = shell("shell-a");
|
|
215
|
+
const b = shell("shell-b");
|
|
216
|
+
const messages = [aiMessageWith([leaseRow]), aiMessageWith([a, b])];
|
|
217
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
218
|
+
turnToolCalls: [a, b],
|
|
219
|
+
messages,
|
|
220
|
+
isExecutedCommand: executedAlways,
|
|
221
|
+
resolveDirectConsent: noDirectConsent,
|
|
222
|
+
globalBypass: false,
|
|
223
|
+
});
|
|
224
|
+
expect(provenance).toBeDefined();
|
|
225
|
+
expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("findLeaseConsentId", () => {
|
|
230
|
+
it("returns the earliest APPROVE_ALL row of the tool's category", () => {
|
|
231
|
+
const first = toolCall({ id: "lease-1", name: "shell", approvalAction: ApprovalAction.APPROVE_ALL });
|
|
232
|
+
const second = toolCall({ id: "lease-2", name: "bash", approvalAction: ApprovalAction.APPROVE_ALL });
|
|
233
|
+
const messages = [aiMessageWith([first]), aiMessageWith([second])];
|
|
234
|
+
expect(findLeaseConsentId(messages, "execute")).toBe("lease-1");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns undefined when no APPROVE_ALL lease exists for the category", () => {
|
|
238
|
+
const writeApproveAll = toolCall({
|
|
239
|
+
id: "w-1",
|
|
240
|
+
name: "write",
|
|
241
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
242
|
+
});
|
|
243
|
+
const messages = [aiMessageWith([writeApproveAll])];
|
|
244
|
+
// A write-category lease does not authorize a shell command.
|
|
245
|
+
expect(findLeaseConsentId(messages, "shell")).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns undefined for a non-gated (read-only) tool name", () => {
|
|
249
|
+
const messages = [aiMessageWith([shell("s")])];
|
|
250
|
+
expect(findLeaseConsentId(messages, "read")).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -20,12 +20,18 @@ import {
|
|
|
20
20
|
snapshotBaseline,
|
|
21
21
|
dropCaptureRefs,
|
|
22
22
|
type GitProgressEntry,
|
|
23
|
-
type
|
|
23
|
+
type GitProgressDelta,
|
|
24
24
|
} from "../git-substrate.js";
|
|
25
25
|
import {
|
|
26
26
|
buildFileChangeProgress,
|
|
27
|
+
createGitProgressSubstrate,
|
|
28
|
+
createHybridProgressSubstrate,
|
|
27
29
|
PROGRESS_MAX_ENTRIES,
|
|
28
30
|
shouldCaptureProgress,
|
|
31
|
+
type ProgressCapture,
|
|
32
|
+
type ProgressDelta,
|
|
33
|
+
type ProgressEntry,
|
|
34
|
+
type ProgressSubstrate,
|
|
29
35
|
} from "../progress.js";
|
|
30
36
|
|
|
31
37
|
const execFileAsync = promisify(execFile);
|
|
@@ -49,7 +55,7 @@ async function writeBytes(rel: string, bytes: Uint8Array): Promise<void> {
|
|
|
49
55
|
await writeFile(join(repo, rel), bytes);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
function entryFor(delta:
|
|
58
|
+
function entryFor(delta: GitProgressDelta, path: string): GitProgressEntry | undefined {
|
|
53
59
|
return delta.entries.find((e) => e.pathAfter === path || e.pathBefore === path);
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -157,23 +163,28 @@ describe("captureProgressDelta (real git repo)", () => {
|
|
|
157
163
|
});
|
|
158
164
|
|
|
159
165
|
describe("buildFileChangeProgress (pure)", () => {
|
|
160
|
-
function delta(entries:
|
|
161
|
-
return {
|
|
166
|
+
function delta(entries: ProgressEntry[], totalFilesChanged?: number): ProgressDelta {
|
|
167
|
+
return totalFilesChanged === undefined ? { entries } : { entries, totalFilesChanged };
|
|
162
168
|
}
|
|
163
|
-
function entry(
|
|
169
|
+
function entry(
|
|
170
|
+
path: string,
|
|
171
|
+
added: number,
|
|
172
|
+
removed: number,
|
|
173
|
+
kind: FileChangeKind = FileChangeKind.MODIFY,
|
|
174
|
+
): ProgressEntry {
|
|
164
175
|
return {
|
|
165
|
-
pathBefore: path,
|
|
176
|
+
pathBefore: kind === FileChangeKind.ADD ? "" : path,
|
|
166
177
|
pathAfter: path,
|
|
167
|
-
|
|
178
|
+
kind,
|
|
168
179
|
linesAdded: added,
|
|
169
180
|
linesRemoved: removed,
|
|
170
181
|
};
|
|
171
182
|
}
|
|
172
183
|
|
|
173
|
-
it("sums aggregate counts and
|
|
184
|
+
it("sums aggregate counts and carries the per-entry kind through unchanged", () => {
|
|
174
185
|
const progress = buildFileChangeProgress(
|
|
175
186
|
delta([
|
|
176
|
-
|
|
187
|
+
entry("a.ts", 5, 0, FileChangeKind.ADD),
|
|
177
188
|
entry("b.ts", 2, 3),
|
|
178
189
|
]),
|
|
179
190
|
CHANGE_SET_ID,
|
|
@@ -187,6 +198,18 @@ describe("buildFileChangeProgress (pure)", () => {
|
|
|
187
198
|
expect(progress.capturedAt).not.toBe("");
|
|
188
199
|
});
|
|
189
200
|
|
|
201
|
+
it("reports totalFilesChanged when a substrate capped its reads, else the entry count", () => {
|
|
202
|
+
// git substrate leaves totalFilesChanged undefined -> filesChanged = entries.length.
|
|
203
|
+
const gitLike = buildFileChangeProgress(delta([entry("a.ts", 1, 0)]), CHANGE_SET_ID);
|
|
204
|
+
expect(gitLike.filesChanged).toBe(1);
|
|
205
|
+
|
|
206
|
+
// cas substrate read only a bounded prefix -> filesChanged reflects the honest
|
|
207
|
+
// total even though fewer entries were emitted.
|
|
208
|
+
const casLike = buildFileChangeProgress(delta([entry("a.ts", 1, 0)], 7), CHANGE_SET_ID);
|
|
209
|
+
expect(casLike.filesChanged).toBe(7);
|
|
210
|
+
expect(casLike.entries).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
190
213
|
it("zeroes counts for secret-like paths (path visible, magnitude withheld)", () => {
|
|
191
214
|
const progress = buildFileChangeProgress(
|
|
192
215
|
delta([entry(".env", 10, 2), entry("app.ts", 4, 1)]),
|
|
@@ -203,7 +226,7 @@ describe("buildFileChangeProgress (pure)", () => {
|
|
|
203
226
|
});
|
|
204
227
|
|
|
205
228
|
it("caps entries but keeps files_changed and totals honest over all files", () => {
|
|
206
|
-
const entries:
|
|
229
|
+
const entries: ProgressEntry[] = [];
|
|
207
230
|
for (let i = 0; i < PROGRESS_MAX_ENTRIES + 25; i++) {
|
|
208
231
|
entries.push(entry(`f${i}.ts`, 1, 1));
|
|
209
232
|
}
|
|
@@ -234,3 +257,82 @@ describe("shouldCaptureProgress (pure)", () => {
|
|
|
234
257
|
expect(shouldCaptureProgress(1000, 3001, 2000)).toBe(true);
|
|
235
258
|
});
|
|
236
259
|
});
|
|
260
|
+
|
|
261
|
+
describe("createGitProgressSubstrate (real git repo)", () => {
|
|
262
|
+
it("reports changed:true then reuses the cached full delta on an unchanged tree", async () => {
|
|
263
|
+
const baseline = await snapshotBaseline(repo, EXEC_ID);
|
|
264
|
+
const sub = createGitProgressSubstrate({
|
|
265
|
+
workspaceRoot: repo,
|
|
266
|
+
executionId: EXEC_ID,
|
|
267
|
+
baselineTree: baseline,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await write("src/new.ts", "a\nb\n");
|
|
271
|
+
const first = await sub.capture();
|
|
272
|
+
expect(first.changed).toBe(true);
|
|
273
|
+
expect(first.delta.entries.length).toBeGreaterThan(0);
|
|
274
|
+
|
|
275
|
+
// Nothing moved since the last capture: the tree-sha short-circuits, but the
|
|
276
|
+
// full delta is still returned (changed:false) so a hybrid can merge it.
|
|
277
|
+
const second = await sub.capture();
|
|
278
|
+
expect(second.changed).toBe(false);
|
|
279
|
+
expect(second.delta.entries).toEqual(first.delta.entries);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/** A fake substrate returning a fixed capture — for merge/short-circuit logic. */
|
|
284
|
+
function fakeSubstrate(capture: ProgressCapture): ProgressSubstrate {
|
|
285
|
+
return { capture: () => Promise.resolve(capture) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function progressEntry(path: string): ProgressEntry {
|
|
289
|
+
return { pathBefore: "", pathAfter: path, kind: FileChangeKind.ADD, linesAdded: 1, linesRemoved: 0 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
describe("createHybridProgressSubstrate", () => {
|
|
293
|
+
it("concatenates disjoint git + cas slices and sums the honest totals", async () => {
|
|
294
|
+
const git = fakeSubstrate({
|
|
295
|
+
delta: { entries: [progressEntry("tracked.ts")] }, // git: total = entries.length
|
|
296
|
+
changed: true,
|
|
297
|
+
});
|
|
298
|
+
const cas = fakeSubstrate({
|
|
299
|
+
delta: { entries: [progressEntry(".env.local")], totalFilesChanged: 3 }, // cas capped
|
|
300
|
+
changed: true,
|
|
301
|
+
});
|
|
302
|
+
const hybrid = createHybridProgressSubstrate(git, cas);
|
|
303
|
+
|
|
304
|
+
const { delta, changed } = await hybrid.capture();
|
|
305
|
+
expect(changed).toBe(true);
|
|
306
|
+
expect(delta.entries.map((e) => e.pathAfter)).toEqual(["tracked.ts", ".env.local"]);
|
|
307
|
+
// 1 (git entries) + 3 (cas honest total) = 4.
|
|
308
|
+
expect(delta.totalFilesChanged).toBe(4);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("still emits the full git slice when only the cas slice changed", async () => {
|
|
312
|
+
// Git unchanged returns its cached full delta with changed:false; the hybrid
|
|
313
|
+
// must keep it (a `ProgressDelta | undefined` contract would have dropped it).
|
|
314
|
+
const git = fakeSubstrate({
|
|
315
|
+
delta: { entries: [progressEntry("tracked.ts")] },
|
|
316
|
+
changed: false,
|
|
317
|
+
});
|
|
318
|
+
const cas = fakeSubstrate({
|
|
319
|
+
delta: { entries: [progressEntry("build/out.js")], totalFilesChanged: 1 },
|
|
320
|
+
changed: true,
|
|
321
|
+
});
|
|
322
|
+
const hybrid = createHybridProgressSubstrate(git, cas);
|
|
323
|
+
|
|
324
|
+
const { delta, changed } = await hybrid.capture();
|
|
325
|
+
expect(changed).toBe(true);
|
|
326
|
+
expect(delta.entries.map((e) => e.pathAfter)).toEqual(["tracked.ts", "build/out.js"]);
|
|
327
|
+
expect(delta.totalFilesChanged).toBe(2);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("reports changed:false only when NEITHER slice moved", async () => {
|
|
331
|
+
const git = fakeSubstrate({ delta: { entries: [] }, changed: false });
|
|
332
|
+
const cas = fakeSubstrate({ delta: { entries: [], totalFilesChanged: 0 }, changed: false });
|
|
333
|
+
const hybrid = createHybridProgressSubstrate(git, cas);
|
|
334
|
+
|
|
335
|
+
const { changed } = await hybrid.capture();
|
|
336
|
+
expect(changed).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-run progress substrate for the CAS (content-addressed) domain — the
|
|
3
|
+
* non-git and gitignored half of DD-33. The git substrate ({@link ./progress.js}
|
|
4
|
+
* `createGitProgressSubstrate`) covers git-tracked changes cheaply via
|
|
5
|
+
* `--numstat`; this covers the paths git cannot see, sourced from the same
|
|
6
|
+
* per-turn observer the turn-boundary CAS capture reads:
|
|
7
|
+
* - a non-git workspace: EVERY tool-mediated write (the whole change set), and
|
|
8
|
+
* - a git tree's gitignored writes (the CAS half of a HYBRID turn).
|
|
9
|
+
*
|
|
10
|
+
* COST MODEL (why this is not as cheap as git)
|
|
11
|
+
* --------------------------------------------
|
|
12
|
+
* `git --numstat` yields exact `+N −M` with zero byte reads; CAS has no such free
|
|
13
|
+
* lunch — kind, no-op exclusion, and counts all need the after-bytes. So we bound
|
|
14
|
+
* the work: only the sorted read-PREFIX (up to {@link PROGRESS_MAX_ENTRIES}) has
|
|
15
|
+
* its after-bytes read and counted; the tail contributes to `totalFilesChanged`
|
|
16
|
+
* (the honest count) only. `files_changed` is therefore net-exact for a normal
|
|
17
|
+
* turn (≤ the budget) and an upper bound beyond it — matching git where git is
|
|
18
|
+
* free, diverging only where the substrate cannot be. A `size+mtime` signature
|
|
19
|
+
* (the stats are collected anyway) short-circuits an unchanged capture.
|
|
20
|
+
*
|
|
21
|
+
* CONSISTENCY & SECRET SAFETY
|
|
22
|
+
* ---------------------------
|
|
23
|
+
* Counts come from {@link classifyCasChange} — the SAME classifier the reviewed
|
|
24
|
+
* set uses — so the strip and the change set never disagree. Secret-like paths
|
|
25
|
+
* are excluded up front via {@link partitionIgnoredPathsBySecret} (and zeroed
|
|
26
|
+
* again in `buildFileChangeProgress` as a backstop); no file bodies are ever
|
|
27
|
+
* carried on the wire. Only tool-mediated writes are observed, exactly the scope
|
|
28
|
+
* of the non-git / HYBRID turn-boundary capture — no new divergence.
|
|
29
|
+
*
|
|
30
|
+
* @since File-Change HITL Redesign (non-git + hybrid mid-run progress / DD-33)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFile, stat } from "node:fs/promises";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
36
|
+
import { classifyCasChange } from "./cas-substrate.js";
|
|
37
|
+
import { LINE_COUNT_MAX_BYTES } from "./line-counts.js";
|
|
38
|
+
import {
|
|
39
|
+
PROGRESS_MAX_ENTRIES,
|
|
40
|
+
type ProgressCapture,
|
|
41
|
+
type ProgressDelta,
|
|
42
|
+
type ProgressEntry,
|
|
43
|
+
type ProgressSubstrate,
|
|
44
|
+
} from "./progress.js";
|
|
45
|
+
import { partitionIgnoredPathsBySecret } from "./secret-paths.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A snapshot of the turn's CAS-observed paths, read on each capture:
|
|
49
|
+
* - `before`: first-touched paths' pre-turn bytes (`null` = the path did not
|
|
50
|
+
* exist before → an ADD). The KEYS are the touched-path set.
|
|
51
|
+
* - `blockedSecretPaths`: paths the gate hard-blocked as secret-like — excluded
|
|
52
|
+
* from capture.
|
|
53
|
+
*
|
|
54
|
+
* Deep-agent supplies a synchronous copy of its live `CasCaptureObserver`; Cursor
|
|
55
|
+
* supplies a reader over the hook's on-disk sidecar. The reader MUST return an
|
|
56
|
+
* atomic snapshot (copy the live map before any await) so a concurrent sub-agent
|
|
57
|
+
* write cannot mutate it mid-capture.
|
|
58
|
+
*/
|
|
59
|
+
export interface CasTouchedSnapshot {
|
|
60
|
+
readonly before: ReadonlyMap<string, Uint8Array | null>;
|
|
61
|
+
readonly blockedSecretPaths: ReadonlySet<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Produces a {@link CasTouchedSnapshot} for one capture. */
|
|
65
|
+
export type CasTouchedReader = () => CasTouchedSnapshot | Promise<CasTouchedSnapshot>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The CAS progress substrate. Reads the touched set on each capture, computes
|
|
69
|
+
* kind + `+N −M` for the bounded read-prefix via {@link classifyCasChange}, and
|
|
70
|
+
* reports `totalFilesChanged` over the full capturable set. Owns a `size+mtime`
|
|
71
|
+
* signature so an unchanged capture returns `changed:false` (the cached delta is
|
|
72
|
+
* reused, letting the hybrid still merge it with a changed git slice).
|
|
73
|
+
*/
|
|
74
|
+
export function createCasProgressSubstrate(opts: {
|
|
75
|
+
readonly workspaceRoot: string;
|
|
76
|
+
readonly read: CasTouchedReader;
|
|
77
|
+
/** Read budget / display cap; defaults to {@link PROGRESS_MAX_ENTRIES}. Injectable for tests. */
|
|
78
|
+
readonly maxEntries?: number;
|
|
79
|
+
}): ProgressSubstrate {
|
|
80
|
+
const maxEntries = opts.maxEntries ?? PROGRESS_MAX_ENTRIES;
|
|
81
|
+
let cachedFull: ProgressDelta = { entries: [], totalFilesChanged: 0 };
|
|
82
|
+
let lastSignature: string | undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
async capture(): Promise<ProgressCapture> {
|
|
86
|
+
const snapshot = await opts.read();
|
|
87
|
+
const { capturablePaths } = partitionIgnoredPathsBySecret(
|
|
88
|
+
snapshot.before.keys(),
|
|
89
|
+
snapshot.blockedSecretPaths,
|
|
90
|
+
);
|
|
91
|
+
// Deterministic order so the read prefix and entry order are stable.
|
|
92
|
+
const sorted = [...capturablePaths].sort();
|
|
93
|
+
const prefix = sorted.slice(0, maxEntries);
|
|
94
|
+
|
|
95
|
+
const entries: ProgressEntry[] = [];
|
|
96
|
+
// The signature keys on the capturable count (catches a new touch) plus the
|
|
97
|
+
// read prefix's size+mtime (catches a content edit to a shown file). A
|
|
98
|
+
// change to an UNshown tail file has no display effect, so it is not keyed.
|
|
99
|
+
const sigParts: string[] = [`${capturablePaths.length}`];
|
|
100
|
+
|
|
101
|
+
for (const relPath of prefix) {
|
|
102
|
+
const abs = join(opts.workspaceRoot, relPath);
|
|
103
|
+
const st = await statOrNull(abs);
|
|
104
|
+
sigParts.push(`${relPath}\u0000${st ? `${st.size}:${st.mtimeMs}` : "\u2205"}`);
|
|
105
|
+
|
|
106
|
+
const beforeBytes = snapshot.before.get(relPath) ?? null;
|
|
107
|
+
const beforeBuf = beforeBytes === null ? null : Buffer.from(beforeBytes);
|
|
108
|
+
|
|
109
|
+
// Oversized after: derive the kind without reading (bound the I/O) and
|
|
110
|
+
// leave counts at zero — the same "no stat" shape a binary/oversized side
|
|
111
|
+
// takes in the reviewed set. A same-size no-op cannot be excluded here; an
|
|
112
|
+
// accepted overstatement only for very large files.
|
|
113
|
+
if (st && st.size > LINE_COUNT_MAX_BYTES) {
|
|
114
|
+
const kind = beforeBuf === null ? FileChangeKind.ADD : FileChangeKind.MODIFY;
|
|
115
|
+
entries.push({
|
|
116
|
+
pathBefore: kind === FileChangeKind.ADD ? "" : relPath,
|
|
117
|
+
pathAfter: relPath,
|
|
118
|
+
kind,
|
|
119
|
+
linesAdded: 0,
|
|
120
|
+
linesRemoved: 0,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const afterBytes = st ? await readFileOrNull(abs) : null;
|
|
126
|
+
const afterBuf = afterBytes === null ? null : Buffer.from(afterBytes);
|
|
127
|
+
const cls = classifyCasChange(relPath, beforeBuf, afterBuf);
|
|
128
|
+
if (!cls) continue; // no-op (unchanged touch) or both sides absent
|
|
129
|
+
entries.push({
|
|
130
|
+
pathBefore: cls.pathBefore,
|
|
131
|
+
pathAfter: cls.pathAfter,
|
|
132
|
+
kind: cls.kind,
|
|
133
|
+
linesAdded: cls.lineCounts?.linesAdded ?? 0,
|
|
134
|
+
linesRemoved: cls.lineCounts?.linesRemoved ?? 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Honest total: the capturable set minus the no-ops detected in the read
|
|
139
|
+
// prefix. Net-exact for a turn within the read budget; an upper bound beyond
|
|
140
|
+
// it (tail no-ops are not read, so cannot be excluded).
|
|
141
|
+
const prefixNoOps = prefix.length - entries.length;
|
|
142
|
+
const totalFilesChanged = capturablePaths.length - prefixNoOps;
|
|
143
|
+
|
|
144
|
+
const signature = sigParts.join("|");
|
|
145
|
+
if (signature === lastSignature) {
|
|
146
|
+
return { delta: cachedFull, changed: false };
|
|
147
|
+
}
|
|
148
|
+
lastSignature = signature;
|
|
149
|
+
cachedFull = { entries, totalFilesChanged };
|
|
150
|
+
return { delta: cachedFull, changed: true };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function statOrNull(abs: string): Promise<{ size: number; mtimeMs: number } | null> {
|
|
156
|
+
try {
|
|
157
|
+
const s = await stat(abs);
|
|
158
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readFileOrNull(abs: string): Promise<Uint8Array | null> {
|
|
165
|
+
try {
|
|
166
|
+
return await readFile(abs);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|