@stigmer/runner 3.1.2 → 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/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 +29 -0
- package/dist/activities/execute-cursor/capture-flow.js +59 -0
- 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 +358 -557
- 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-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-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 +73 -4
- 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/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/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/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-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 +38 -0
- package/dist/shared/filereview/cas-substrate.js +48 -15
- 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/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 +48 -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 +5 -2
- package/dist/shared/filereview/index.js +5 -2
- 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 +167 -0
- package/dist/shared/filereview/progress.js +196 -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 +15 -4
- package/dist/shared/plan-mode-prompt.js +28 -5
- 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 +3 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- 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__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +78 -0
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +419 -602
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- 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__/prompt-builder.test.ts +11 -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 +91 -4
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -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 +338 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +79 -18
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +114 -0
- package/src/shared/filereview/index.ts +36 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +310 -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 +28 -5
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DD-28 approved-command turn qualification rule — the harness-agnostic core
|
|
3
|
+
* of the auto-keep policy's runner-side facts.
|
|
4
|
+
*
|
|
5
|
+
* A turn whose ONLY mutation source was shell commands the human had already
|
|
6
|
+
* authorized should not re-gate its file effects at the turn-boundary review —
|
|
7
|
+
* the user consented to the command, and the command's file effects are the
|
|
8
|
+
* consented outcome. The server cannot derive this itself (tool calls carry no
|
|
9
|
+
* turn marker), so the runner — the only component that owns turn scoping —
|
|
10
|
+
* derives the facts and attaches them to the CANDIDATE_CAPTURED event as
|
|
11
|
+
* {@link TurnCommandProvenance}.
|
|
12
|
+
*
|
|
13
|
+
* This module owns the *qualification rule* both harnesses share; each harness
|
|
14
|
+
* supplies only what genuinely differs — how "this turn's tool calls" are scoped,
|
|
15
|
+
* and how a single executed command's DIRECT consent row is identified — via
|
|
16
|
+
* {@link CommandProvenanceCoreInputs}. Keeping the rule in one place is what makes
|
|
17
|
+
* the Cursor and deep-agent qualifications provably identical (a second copy would
|
|
18
|
+
* drift on edge cases — the divergence the AI-Engineer role warns against).
|
|
19
|
+
*
|
|
20
|
+
* TRUST BOUNDARY. This module asserts turn FACTS (which tools ran this turn),
|
|
21
|
+
* the same trust level as the captured bytes themselves. It never asserts
|
|
22
|
+
* CONSENT: a resolved consent id merely POINTS at a transcript row whose
|
|
23
|
+
* `approval_action` was authored by the server's SubmitApproval (and is
|
|
24
|
+
* preserved against runner writes) — the backend re-verifies every claimed row
|
|
25
|
+
* against that server-owned record before authoring the policy decision, so a
|
|
26
|
+
* runner cannot mint authorization it was never given.
|
|
27
|
+
*
|
|
28
|
+
* FAIL-CLOSED. Every uncertainty disqualifies (returns undefined → the set
|
|
29
|
+
* reviews manually, exactly as before DD-28): a file-tool call, an MCP tool, a
|
|
30
|
+
* sub-agent delegation, an unrecognized tool name, or an executed shell command
|
|
31
|
+
* with no provable consent source. Being conservative here costs only an extra
|
|
32
|
+
* review; being permissive would silently waive one.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { create } from "@bufbuild/protobuf";
|
|
36
|
+
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
37
|
+
import { TurnCommandProvenanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
38
|
+
import type { TurnCommandProvenance } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
39
|
+
import { ApprovalAction, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
40
|
+
import { classifyTool, toolApprovalCategory } from "../tool-kind.js";
|
|
41
|
+
import { isToolCallRowHidden } from "../tool-row.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Tool kinds that can never mutate the workspace: their presence in a turn is
|
|
45
|
+
* irrelevant to change-set provenance. Everything OUTSIDE this set is either a
|
|
46
|
+
* consent-mapped shell command or a disqualifier — never silently ignored.
|
|
47
|
+
*/
|
|
48
|
+
export const NON_MUTATING_KINDS: ReadonlySet<ToolKind> = new Set([
|
|
49
|
+
ToolKind.FILE_READ,
|
|
50
|
+
ToolKind.SEARCH,
|
|
51
|
+
ToolKind.LIST,
|
|
52
|
+
ToolKind.FETCH,
|
|
53
|
+
ToolKind.WEB_SEARCH,
|
|
54
|
+
ToolKind.THINK,
|
|
55
|
+
ToolKind.TODO,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The harness-supplied inputs to the shared DD-28 qualification rule. The two
|
|
60
|
+
* function inputs are exactly the two things that differ between harnesses
|
|
61
|
+
* (turn scoping is expressed by which calls appear in {@link turnToolCalls}).
|
|
62
|
+
*/
|
|
63
|
+
export interface CommandProvenanceCoreInputs {
|
|
64
|
+
/**
|
|
65
|
+
* This turn's tool calls, already scoped by the harness (Cursor: the positional
|
|
66
|
+
* slice from the turn's first streamed message; deep-agent: the calls whose id
|
|
67
|
+
* is new since a pre-stream settled snapshot). Order is preserved but irrelevant.
|
|
68
|
+
*/
|
|
69
|
+
readonly turnToolCalls: readonly ToolCall[];
|
|
70
|
+
/**
|
|
71
|
+
* The WHOLE transcript, for the APPROVE_ALL lease lookup — a lease is
|
|
72
|
+
* run-lifetime and its granting row usually lives in a prior turn.
|
|
73
|
+
*/
|
|
74
|
+
readonly messages: readonly AgentMessage[];
|
|
75
|
+
/**
|
|
76
|
+
* Whether a (shell) tool call actually EXECUTED this turn, as opposed to being
|
|
77
|
+
* a pending/denied gate that produced no mutation. A non-executed shell neither
|
|
78
|
+
* needs consent nor disqualifies (Cursor: not in the denial ledger; deep-agent:
|
|
79
|
+
* status is COMPLETED).
|
|
80
|
+
*/
|
|
81
|
+
readonly isExecutedCommand: (tc: ToolCall) => boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The tool-call id of the row whose server-authored `approval_action` DIRECTLY
|
|
84
|
+
* authorized this executed command, or undefined when none does (then the lease
|
|
85
|
+
* / global-bypass fallbacks apply). Cursor maps a reinvocation grant token back
|
|
86
|
+
* to its consent row; the deep-agent's gated command IS its own consent row
|
|
87
|
+
* (same id, `approval_action` written in place).
|
|
88
|
+
*/
|
|
89
|
+
readonly resolveDirectConsent: (tc: ToolCall) => string | undefined;
|
|
90
|
+
/** True when the pre-armed spec.auto_approve_all bypassed the gate. */
|
|
91
|
+
readonly globalBypass: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Apply the DD-28 qualification rule to a harness-scoped turn, returning the
|
|
96
|
+
* {@link TurnCommandProvenance} to attach to the candidate — or undefined when
|
|
97
|
+
* the turn does not qualify (any non-shell mutation-capable call, any unknown
|
|
98
|
+
* tool, any executed command without a provable consent source, or no executed
|
|
99
|
+
* consented command at all).
|
|
100
|
+
*/
|
|
101
|
+
export function qualifyTurnCommandProvenance(
|
|
102
|
+
inputs: CommandProvenanceCoreInputs,
|
|
103
|
+
): TurnCommandProvenance | undefined {
|
|
104
|
+
const { turnToolCalls, messages, isExecutedCommand, resolveDirectConsent, globalBypass } = inputs;
|
|
105
|
+
|
|
106
|
+
const consentIds = new Set<string>();
|
|
107
|
+
let authorizedByAutoApproveAll = false;
|
|
108
|
+
let executedCommandCount = 0;
|
|
109
|
+
|
|
110
|
+
for (const tc of turnToolCalls) {
|
|
111
|
+
// A hidden row is a collapsed twin/reaction — it never executed.
|
|
112
|
+
if (isToolCallRowHidden(tc)) continue;
|
|
113
|
+
|
|
114
|
+
const kind = classifyTool(tc.name, tc.mcpServerSlug);
|
|
115
|
+
if (NON_MUTATING_KINDS.has(kind)) continue;
|
|
116
|
+
|
|
117
|
+
if (kind !== ToolKind.SHELL) {
|
|
118
|
+
// A file tool (mixed turn), a sub-agent (unattributable mutations), an
|
|
119
|
+
// MCP tool (opaque side effects), or an unrecognized name — the turn's
|
|
120
|
+
// mutations cannot be attributed to consented commands. Fail closed.
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isExecutedCommand(tc)) {
|
|
125
|
+
// A pending / denied shell — this turn's gate, not a mutation source.
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
executedCommandCount++;
|
|
129
|
+
|
|
130
|
+
const consentId = resolveDirectConsent(tc);
|
|
131
|
+
if (consentId) {
|
|
132
|
+
consentIds.add(consentId);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const leaseConsentId = findLeaseConsentId(messages, tc.name);
|
|
136
|
+
if (leaseConsentId) {
|
|
137
|
+
consentIds.add(leaseConsentId);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (globalBypass) {
|
|
141
|
+
authorizedByAutoApproveAll = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// An executed shell command with no grant, no lease, and no bypass should
|
|
145
|
+
// be impossible (every un-consented shell is gated); if it ever happens,
|
|
146
|
+
// the honest answer is a manual review, not a waived one.
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A turn that executed no consented command has nothing to attribute the
|
|
151
|
+
// change set to — whatever changed came from somewhere else. Manual review.
|
|
152
|
+
if (executedCommandCount === 0) return undefined;
|
|
153
|
+
|
|
154
|
+
return create(TurnCommandProvenanceSchema, {
|
|
155
|
+
consentToolCallIds: [...consentIds],
|
|
156
|
+
authorizedByAutoApproveAll,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find the transcript row whose APPROVE_ALL authored the run-lifetime lease
|
|
162
|
+
* covering `toolName`'s category — the consent row a lease-executed command
|
|
163
|
+
* cites. Searched across the whole transcript (a lease usually originates in a
|
|
164
|
+
* prior turn); the first (earliest) APPROVE_ALL of the category is the lease's
|
|
165
|
+
* origin. Returns undefined when no lease row exists for the category.
|
|
166
|
+
*/
|
|
167
|
+
export function findLeaseConsentId(
|
|
168
|
+
messages: readonly AgentMessage[],
|
|
169
|
+
toolName: string,
|
|
170
|
+
): string | undefined {
|
|
171
|
+
const category = toolApprovalCategory(toolName);
|
|
172
|
+
if (!category) return undefined;
|
|
173
|
+
for (const msg of messages) {
|
|
174
|
+
for (const tc of msg.toolCalls) {
|
|
175
|
+
if (tc.approvalAction !== ApprovalAction.APPROVE_ALL) continue;
|
|
176
|
+
if (toolApprovalCategory(tc.name) === category) return tc.id;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
FileReviewFailureKind,
|
|
43
43
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
44
44
|
import { aggregateDigest, fileDigest, sha256Bytes } from "./digest.js";
|
|
45
|
+
import { countLineChanges, type LineChangeCounts } from "./line-counts.js";
|
|
45
46
|
import { looksBinary } from "../file-change.js";
|
|
46
47
|
|
|
47
48
|
// The runner is the author of capture/reconcile/failure events. "runner" (not
|
|
@@ -150,6 +151,15 @@ export interface CapturedChangeInput {
|
|
|
150
151
|
* this UNSPECIFIED. See {@link FileReviewBlockReason}.
|
|
151
152
|
*/
|
|
152
153
|
readonly blockedReason?: FileReviewBlockReason;
|
|
154
|
+
/**
|
|
155
|
+
* Display line counts for this change, for producers whose bodies are not
|
|
156
|
+
* inline here (the CAS substrate counts from the in-memory bytes before
|
|
157
|
+
* offloading them as blob refs). Omit for inline content —
|
|
158
|
+
* {@link buildCapturedFileChange} then counts the inline sides itself, with
|
|
159
|
+
* the same shared {@link countLineChanges}. Informational only, never folded
|
|
160
|
+
* into the digests.
|
|
161
|
+
*/
|
|
162
|
+
readonly lineCounts?: LineChangeCounts;
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
/** Normalize the string-shorthand to a {@link CapturedContent}. */
|
|
@@ -175,6 +185,37 @@ export function contentSha256(content: CapturedContent): string {
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Count the display `+N −M` from a change's inline sides, or `undefined` when
|
|
190
|
+
* counting is not honest here: a side that exists without inline text (a `ref`
|
|
191
|
+
* whose bytes were already offloaded, or a `binary` side with no body), or
|
|
192
|
+
* inline text that would render as binary (the UI shows "Binary file changed"
|
|
193
|
+
* for it, never a line diff — a count would describe a diff nobody sees).
|
|
194
|
+
* An absent side is fine — it is the empty document of an ADD/DELETE.
|
|
195
|
+
*/
|
|
196
|
+
function countInlineSides(
|
|
197
|
+
before: CapturedContent | undefined,
|
|
198
|
+
after: CapturedContent | undefined,
|
|
199
|
+
): LineChangeCounts | undefined {
|
|
200
|
+
const beforeText = inlineTextOrNull(before);
|
|
201
|
+
const afterText = inlineTextOrNull(after);
|
|
202
|
+
if (beforeText === null || afterText === null) return undefined;
|
|
203
|
+
return countLineChanges(beforeText, afterText);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A side's countable inline text: the text for a non-binary inline side,
|
|
208
|
+
* `undefined` for an absent side (countable as the empty document), and `null`
|
|
209
|
+
* when the side exists but cannot be counted (ref / binary / binary-looking).
|
|
210
|
+
*/
|
|
211
|
+
function inlineTextOrNull(
|
|
212
|
+
content: CapturedContent | undefined,
|
|
213
|
+
): string | undefined | null {
|
|
214
|
+
if (content === undefined) return undefined;
|
|
215
|
+
if (content.kind !== "inline" || looksBinary(content.text)) return null;
|
|
216
|
+
return content.text;
|
|
217
|
+
}
|
|
218
|
+
|
|
178
219
|
/** Build the proto {@link FileContent} for one content side (inline, ref, or binary). */
|
|
179
220
|
function toFileContent(content: CapturedContent): FileContent {
|
|
180
221
|
if (content.kind === "inline") {
|
|
@@ -212,12 +253,19 @@ function toFileContent(content: CapturedContent): FileContent {
|
|
|
212
253
|
* `file_digest`) over the captured bytes. Content may be inline (git) or a blob
|
|
213
254
|
* ref (CAS) — the digests are identical either way, so the aggregate digest and
|
|
214
255
|
* the reconcile enforcement compose across both substrates.
|
|
256
|
+
*
|
|
257
|
+
* Display line counts are stamped here too — the single seam every substrate
|
|
258
|
+
* flows through — from `input.lineCounts` when the producer counted at its own
|
|
259
|
+
* source (CAS), else counted from the inline sides. Counting happens BEFORE any
|
|
260
|
+
* persist-time offload, so the counts survive a body being elided later. They
|
|
261
|
+
* never enter `fileDigest` (informational, not enforcement).
|
|
215
262
|
*/
|
|
216
263
|
export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFileChange {
|
|
217
264
|
const before = normalizeContent(input.before);
|
|
218
265
|
const after = normalizeContent(input.after);
|
|
219
266
|
const beforeSha256 = before ? contentSha256(before) : "";
|
|
220
267
|
const afterSha256 = after ? contentSha256(after) : "";
|
|
268
|
+
const counts = input.lineCounts ?? countInlineSides(before, after);
|
|
221
269
|
|
|
222
270
|
const fc = create(CapturedFileChangeSchema, {
|
|
223
271
|
id: input.id,
|
|
@@ -229,6 +277,8 @@ export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFil
|
|
|
229
277
|
afterSha256,
|
|
230
278
|
diffComplete: input.diffComplete ?? true,
|
|
231
279
|
blockedReason: input.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
|
|
280
|
+
linesAdded: counts?.linesAdded ?? 0,
|
|
281
|
+
linesRemoved: counts?.linesRemoved ?? 0,
|
|
232
282
|
fileDigest: fileDigest({
|
|
233
283
|
pathBefore: input.pathBefore,
|
|
234
284
|
pathAfter: input.pathAfter,
|
|
@@ -308,6 +308,87 @@ export async function captureChangeSet(
|
|
|
308
308
|
return { baselineTree, afterTree, changes };
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* One file's slim, content-free progress delta: paths + kind + line counts, NO
|
|
313
|
+
* bytes. The mid-run analogue of {@link GitSubstrateChange} (which carries the
|
|
314
|
+
* before/after {@link CapturedContent}); this deliberately carries neither side,
|
|
315
|
+
* so it can never leak content. `linesAdded`/`linesRemoved` are 0 when
|
|
316
|
+
* uncountable (a binary file — numstat reports `-`).
|
|
317
|
+
*/
|
|
318
|
+
export interface GitProgressEntry {
|
|
319
|
+
/** Path before the change (repo-relative). Empty for a CREATE. */
|
|
320
|
+
readonly pathBefore: string;
|
|
321
|
+
/** Path after the change (repo-relative). Empty for a DELETE. */
|
|
322
|
+
readonly pathAfter: string;
|
|
323
|
+
readonly changeType: FileChangeType;
|
|
324
|
+
readonly linesAdded: number;
|
|
325
|
+
readonly linesRemoved: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Result of {@link captureProgressDelta} — the git-numstat delta. Distinct from
|
|
330
|
+
* the substrate-neutral `ProgressDelta` (in {@link ./progress.js}) that the
|
|
331
|
+
* git/cas/hybrid substrates emit: this carries the working-tree sha (for the
|
|
332
|
+
* short-circuit) and git's `FileChangeType` entries; the neutral one carries
|
|
333
|
+
* proto `FileChangeKind` entries and an honest total.
|
|
334
|
+
*/
|
|
335
|
+
export interface GitProgressDelta {
|
|
336
|
+
/** The post-diff working-tree sha, for the caller's short-circuit cache. */
|
|
337
|
+
readonly afterTree: string;
|
|
338
|
+
/** One count-only entry per changed file (no content). */
|
|
339
|
+
readonly entries: readonly GitProgressEntry[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Capture a NON-AUTHORITATIVE, content-free snapshot of the working-tree delta
|
|
344
|
+
* against `baselineTree` — the substrate for the live "N files changed so far"
|
|
345
|
+
* surface (mid-run live capture). Unlike {@link captureChangeSet} this reads NO
|
|
346
|
+
* file bytes: it stages the working tree into a temp index, writes its tree, and
|
|
347
|
+
* asks git for `--numstat` + `--name-status` only. Cheap enough to run on a
|
|
348
|
+
* debounce during the turn.
|
|
349
|
+
*
|
|
350
|
+
* Short-circuit: returns `undefined` when the working tree is unchanged since
|
|
351
|
+
* `lastTreeSha` (the caller passes back the previous result's `afterTree`), so a
|
|
352
|
+
* quiet turn pays only one `write-tree`, never a diff.
|
|
353
|
+
*
|
|
354
|
+
* Uses a dedicated temp-index label ("progress") so it never collides with the
|
|
355
|
+
* `baseline`/`capture`/`approved` indexes. `--no-renames` matches
|
|
356
|
+
* {@link captureChangeSet} (a rename surfaces as delete + create). A torn read of
|
|
357
|
+
* a file being written mid-turn is acceptable — the snapshot is non-authoritative
|
|
358
|
+
* and self-corrects on the next capture.
|
|
359
|
+
*/
|
|
360
|
+
export async function captureProgressDelta(
|
|
361
|
+
gitRoot: string,
|
|
362
|
+
executionId: string,
|
|
363
|
+
baselineTree: string,
|
|
364
|
+
excludePaths: readonly string[] = [],
|
|
365
|
+
lastTreeSha?: string,
|
|
366
|
+
): Promise<GitProgressDelta | undefined> {
|
|
367
|
+
const gitDir = await resolveGitDir(gitRoot);
|
|
368
|
+
const afterTree = await writeWorkingTree(gitRoot, gitDir, "progress", executionId, excludePaths);
|
|
369
|
+
if (lastTreeSha !== undefined && afterTree === lastTreeSha) return undefined;
|
|
370
|
+
|
|
371
|
+
const [nameStatusRaw, numstatRaw] = await Promise.all([
|
|
372
|
+
git(gitRoot, ["diff", "--no-renames", "--name-status", "-z", baselineTree, afterTree]),
|
|
373
|
+
git(gitRoot, ["diff", "--no-renames", "--numstat", "-z", baselineTree, afterTree]),
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
const counts = parseNumstatZ(numstatRaw);
|
|
377
|
+
const entries: GitProgressEntry[] = [];
|
|
378
|
+
for (const { status, path } of parseNameStatusZ(nameStatusRaw)) {
|
|
379
|
+
const changeType = nameStatusToChangeType(status);
|
|
380
|
+
const count = counts.get(path);
|
|
381
|
+
entries.push({
|
|
382
|
+
pathBefore: changeType === FileChangeType.CREATE ? "" : path,
|
|
383
|
+
pathAfter: changeType === FileChangeType.DELETE ? "" : path,
|
|
384
|
+
changeType,
|
|
385
|
+
linesAdded: count?.added ?? 0,
|
|
386
|
+
linesRemoved: count?.removed ?? 0,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return { afterTree, entries };
|
|
390
|
+
}
|
|
391
|
+
|
|
311
392
|
/**
|
|
312
393
|
* Restore the given files to their `baselineTree` (pre-turn) bytes. On resume
|
|
313
394
|
* this reverts the REJECTED/undecided subset so a discarded change snaps back
|
|
@@ -462,6 +543,39 @@ function parseNameStatusZ(raw: string): NameStatusEntry[] {
|
|
|
462
543
|
return entries;
|
|
463
544
|
}
|
|
464
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Parse `git diff --numstat -z` into a path -> {added, removed} map. Each record
|
|
548
|
+
* is a NUL-terminated `<added>\t<removed>\t<path>` triple; a binary file reports
|
|
549
|
+
* `-` for both counts, which we surface as 0 (no count) per the display contract.
|
|
550
|
+
*/
|
|
551
|
+
function parseNumstatZ(raw: string): Map<string, { added: number; removed: number }> {
|
|
552
|
+
const counts = new Map<string, { added: number; removed: number }>();
|
|
553
|
+
for (const record of raw.split("\u0000")) {
|
|
554
|
+
if (!record) continue;
|
|
555
|
+
const tab = record.indexOf("\t");
|
|
556
|
+
const tab2 = record.indexOf("\t", tab + 1);
|
|
557
|
+
if (tab < 0 || tab2 < 0) continue;
|
|
558
|
+
const addedStr = record.slice(0, tab);
|
|
559
|
+
const removedStr = record.slice(tab + 1, tab2);
|
|
560
|
+
const path = record.slice(tab2 + 1);
|
|
561
|
+
if (!path) continue;
|
|
562
|
+
const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10) || 0;
|
|
563
|
+
const removed = removedStr === "-" ? 0 : Number.parseInt(removedStr, 10) || 0;
|
|
564
|
+
counts.set(path, { added, removed });
|
|
565
|
+
}
|
|
566
|
+
return counts;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Map a `--name-status` status letter to a {@link FileChangeType} — the same
|
|
571
|
+
* A -> CREATE, D -> DELETE, else MODIFY rule {@link buildCapturedChange} uses.
|
|
572
|
+
*/
|
|
573
|
+
function nameStatusToChangeType(status: string): FileChangeType {
|
|
574
|
+
if (status === "A") return FileChangeType.CREATE;
|
|
575
|
+
if (status === "D") return FileChangeType.DELETE;
|
|
576
|
+
return FileChangeType.MODIFY;
|
|
577
|
+
}
|
|
578
|
+
|
|
465
579
|
/** Read a blob's exact BYTES from a tree, or `undefined` when absent. */
|
|
466
580
|
async function readBlobBytes(
|
|
467
581
|
gitRoot: string,
|
|
@@ -33,19 +33,54 @@ export {
|
|
|
33
33
|
type CaptureResumeResult,
|
|
34
34
|
} from "./capture.js";
|
|
35
35
|
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
captureProgressDelta,
|
|
38
|
+
isGitWorkTree,
|
|
39
|
+
type GitProgressDelta,
|
|
40
|
+
type GitProgressEntry,
|
|
41
|
+
} from "./git-substrate.js";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
buildFileChangeProgress,
|
|
45
|
+
captureFileChangeProgress,
|
|
46
|
+
createGitProgressSubstrate,
|
|
47
|
+
createHybridProgressSubstrate,
|
|
48
|
+
newProgressCaptureState,
|
|
49
|
+
PROGRESS_CAPTURE_MIN_INTERVAL_MS,
|
|
50
|
+
PROGRESS_MAX_ENTRIES,
|
|
51
|
+
shouldCaptureProgress,
|
|
52
|
+
type ProgressCapture,
|
|
53
|
+
type ProgressCaptureState,
|
|
54
|
+
type ProgressDelta,
|
|
55
|
+
type ProgressEntry,
|
|
56
|
+
type ProgressSubstrate,
|
|
57
|
+
} from "./progress.js";
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
createCasProgressSubstrate,
|
|
61
|
+
type CasTouchedReader,
|
|
62
|
+
type CasTouchedSnapshot,
|
|
63
|
+
} from "./cas-progress.js";
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
countLineChanges,
|
|
67
|
+
LINE_COUNT_MAX_BYTES,
|
|
68
|
+
type LineChangeCounts,
|
|
69
|
+
} from "./line-counts.js";
|
|
37
70
|
|
|
38
71
|
export {
|
|
39
72
|
applyCasApproved,
|
|
40
73
|
casBlobKey,
|
|
41
74
|
casBlobReader,
|
|
42
75
|
casManifestKey,
|
|
76
|
+
classifyCasChange,
|
|
43
77
|
loadCasManifest,
|
|
44
78
|
restoreCasToBaseline,
|
|
45
79
|
snapshotCasChangeSet,
|
|
46
80
|
type BlobReader,
|
|
47
81
|
type CasBlobRef,
|
|
48
82
|
type CasCapturedFile,
|
|
83
|
+
type CasChangeClassification,
|
|
49
84
|
type CasManifest,
|
|
50
85
|
type CasPathCapture,
|
|
51
86
|
type CasSnapshotRef,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-time line counting for the file-review subsystem: the `+N −M` stamped
|
|
3
|
+
* onto each {@link CapturedFileChange} so review surfaces can decorate file
|
|
4
|
+
* lists without the file bodies.
|
|
5
|
+
*
|
|
6
|
+
* WHY THE RUNNER COUNTS, AND WHY WITH JSDIFF. The counts exist because the
|
|
7
|
+
* review UI's compact file lists must show per-file magnitude, but by the time
|
|
8
|
+
* the UI renders, large before/after bodies may have been offloaded to artifact
|
|
9
|
+
* storage (status-offload) — a client cannot count what it would first have to
|
|
10
|
+
* download. So the counts are computed once, at capture, where the bytes are
|
|
11
|
+
* always in hand. They are computed with the SAME `diff` (jsdiff) Myers
|
|
12
|
+
* algorithm the SDK's diff renderer uses (`computeDiff` → `structuredPatch`),
|
|
13
|
+
* NOT with `git diff --numstat`: two counting authorities can disagree on edge
|
|
14
|
+
* cases (trailing newlines being the classic), and a list that says `+37 −1`
|
|
15
|
+
* beside a rendered diff showing `+37 −0` is a self-contradicting UI. One
|
|
16
|
+
* algorithm, consistent by construction.
|
|
17
|
+
*
|
|
18
|
+
* The counts are INFORMATIONAL display data only — never an enforcement input,
|
|
19
|
+
* never folded into `file_digest`/`aggregate_digest` (the same contract as
|
|
20
|
+
* `blocked_reason`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { structuredPatch } from "diff";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-side byte ceiling above which counting is skipped. Myers is O(ND) and a
|
|
27
|
+
* pathological many-MB text pair must never stall the capture activity just to
|
|
28
|
+
* decorate a list; above this the counts stay absent and the UI hides the stat.
|
|
29
|
+
* Generous relative to the 128 KiB per-side inline-persist cap — a file can be
|
|
30
|
+
* several times that and still count quickly — while bounding the worst case.
|
|
31
|
+
*/
|
|
32
|
+
export const LINE_COUNT_MAX_BYTES = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** The `+N −M` pair for one file change. */
|
|
35
|
+
export interface LineChangeCounts {
|
|
36
|
+
readonly linesAdded: number;
|
|
37
|
+
readonly linesRemoved: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Count the added/removed lines between two text sides, exactly as the SDK's
|
|
42
|
+
* diff renderer would show them.
|
|
43
|
+
*
|
|
44
|
+
* An absent side is the empty document — an ADD counts every `after` line as
|
|
45
|
+
* added, a DELETE every `before` line as removed — mirroring how the renderer
|
|
46
|
+
* diffs a create/delete. `` markers are metadata,
|
|
47
|
+
* not content, and are excluded (the same rule as the SDK's `mapPatchHunks`).
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` when counting is not possible or meaningful: both sides
|
|
50
|
+
* absent, or either side over {@link LINE_COUNT_MAX_BYTES}. Callers leave the
|
|
51
|
+
* proto counts at zero in that case, which consumers render as "no stat".
|
|
52
|
+
*/
|
|
53
|
+
export function countLineChanges(
|
|
54
|
+
before: string | undefined,
|
|
55
|
+
after: string | undefined,
|
|
56
|
+
): LineChangeCounts | undefined {
|
|
57
|
+
if (before === undefined && after === undefined) return undefined;
|
|
58
|
+
const beforeText = before ?? "";
|
|
59
|
+
const afterText = after ?? "";
|
|
60
|
+
if (
|
|
61
|
+
Buffer.byteLength(beforeText, "utf8") > LINE_COUNT_MAX_BYTES ||
|
|
62
|
+
Buffer.byteLength(afterText, "utf8") > LINE_COUNT_MAX_BYTES
|
|
63
|
+
) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Zero context: the +/- line population is independent of context width, and
|
|
68
|
+
// omitting context lines keeps the walk (and the patch object) minimal.
|
|
69
|
+
const patch = structuredPatch("a", "b", beforeText, afterText, "", "", {
|
|
70
|
+
context: 0,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let linesAdded = 0;
|
|
74
|
+
let linesRemoved = 0;
|
|
75
|
+
for (const hunk of patch.hunks) {
|
|
76
|
+
for (const line of hunk.lines) {
|
|
77
|
+
if (line.startsWith("\\")) continue;
|
|
78
|
+
if (line[0] === "+") linesAdded++;
|
|
79
|
+
else if (line[0] === "-") linesRemoved++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { linesAdded, linesRemoved };
|
|
83
|
+
}
|