@kylecheng3146/agent-ops 0.2.1 → 0.2.2
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.
|
@@ -53,6 +53,15 @@ export function claudeHookOutput(event, result) {
|
|
|
53
53
|
if (result.action === "continue" && result.status === "PASS") {
|
|
54
54
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
55
55
|
}
|
|
56
|
+
// An advisory PreToolUse result carries nothing the user can act on: the
|
|
57
|
+
// command runs either way. Saying so on every unparsed command — a heredoc,
|
|
58
|
+
// a substitution — trains the reader to ignore the hook that also refuses
|
|
59
|
+
// stops. A result with a remedy still speaks.
|
|
60
|
+
if (event === "PreToolUse" &&
|
|
61
|
+
result.action === "continue" &&
|
|
62
|
+
result.remedy === undefined) {
|
|
63
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
64
|
+
}
|
|
56
65
|
if (event === "PreToolUse" && result.action === "block") {
|
|
57
66
|
return json({
|
|
58
67
|
hookSpecificOutput: {
|
|
@@ -3,7 +3,7 @@ import { sha256 } from "../fs/hash.js";
|
|
|
3
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
4
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
5
5
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
6
|
-
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
+
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
8
8
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
9
9
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -76,6 +76,38 @@ export class CompletionGateService {
|
|
|
76
76
|
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
77
77
|
return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The fingerprint the task's evidence should carry. Committed work leaves an
|
|
81
|
+
* empty worktree surface: there is nothing left to measure there, and the
|
|
82
|
+
* evidence names the `--base` range `task complete` was given instead. That
|
|
83
|
+
* range is recomputed here rather than trusted, so evidence for a range that
|
|
84
|
+
* no longer ends at HEAD still fails.
|
|
85
|
+
*/
|
|
86
|
+
async #evidenceFingerprint(completionBase) {
|
|
87
|
+
const worktree = await this.#fingerprint();
|
|
88
|
+
if (completionBase === null) {
|
|
89
|
+
return worktree;
|
|
90
|
+
}
|
|
91
|
+
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
92
|
+
if (surface.paths.length > 0) {
|
|
93
|
+
return worktree;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
|
|
97
|
+
if (changedFiles.length === 0) {
|
|
98
|
+
return worktree;
|
|
99
|
+
}
|
|
100
|
+
return await calculateSourceFingerprint(this.#options.root, {
|
|
101
|
+
mode: "base",
|
|
102
|
+
baseRef: completionBase,
|
|
103
|
+
resolvedBase: completionBase,
|
|
104
|
+
changedFiles
|
|
105
|
+
}, this.#options.gitRunner);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return worktree;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
79
111
|
async initialize(sessionId) {
|
|
80
112
|
const fingerprint = await this.#fingerprint();
|
|
81
113
|
const state = await this.#store.mutate(sessionId, (current) => current ?? {
|
|
@@ -103,7 +135,7 @@ export class CompletionGateService {
|
|
|
103
135
|
event.event === "command-batch" ? event.commands : [];
|
|
104
136
|
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
105
137
|
}
|
|
106
|
-
async #validateTask(sessionId
|
|
138
|
+
async #validateTask(sessionId) {
|
|
107
139
|
let stored;
|
|
108
140
|
try {
|
|
109
141
|
stored = await this.#options.taskService.status({ sessionId });
|
|
@@ -120,7 +152,11 @@ export class CompletionGateService {
|
|
|
120
152
|
if (unfinished !== undefined) {
|
|
121
153
|
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
122
154
|
}
|
|
123
|
-
const
|
|
155
|
+
const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
|
|
156
|
+
const problem = await checkTaskCompletionEvidence(stored, {
|
|
157
|
+
...this.#options,
|
|
158
|
+
sourceFingerprint
|
|
159
|
+
});
|
|
124
160
|
if (problem !== null) {
|
|
125
161
|
return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
|
|
126
162
|
}
|
|
@@ -154,7 +190,7 @@ export class CompletionGateService {
|
|
|
154
190
|
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
|
|
155
191
|
}
|
|
156
192
|
if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
|
|
157
|
-
const failure = await this.#validateTask(sessionId
|
|
193
|
+
const failure = await this.#validateTask(sessionId);
|
|
158
194
|
if (failure !== null)
|
|
159
195
|
return failure;
|
|
160
196
|
}
|
|
@@ -2,19 +2,179 @@ import { normalizeHookEvent } from "./normalize.js";
|
|
|
2
2
|
const MAX_COMMAND_LENGTH = 16 * 1024;
|
|
3
3
|
const MAX_COMMANDS = 64;
|
|
4
4
|
const MAX_WORDS = 256;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** Substitutions nest; a deeper stack is pathological input, not a script. */
|
|
6
|
+
const MAX_SUBSTITUTION_DEPTH = 8;
|
|
7
|
+
/** Stands in for a substitution's value in the word that contained it. */
|
|
8
|
+
const SUBSTITUTION_PLACEHOLDER = "_";
|
|
9
|
+
/**
|
|
10
|
+
* Walks from the opening parenthesis of a `$(...)` to the one that closes it,
|
|
11
|
+
* honouring quotes on the way. A quoted `)` is text, not structure: counting it
|
|
12
|
+
* as structure loses the whole command, and a lost command is one the policy
|
|
13
|
+
* never sees.
|
|
14
|
+
*/
|
|
15
|
+
function scanParenthesis(input, open) {
|
|
16
|
+
let depth = 0;
|
|
17
|
+
let index = open;
|
|
18
|
+
while (index < input.length) {
|
|
19
|
+
const character = input[index];
|
|
20
|
+
if (character === "\\") {
|
|
21
|
+
index += 2;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (character === "'") {
|
|
25
|
+
const end = input.indexOf("'", index + 1);
|
|
26
|
+
if (end < 0)
|
|
27
|
+
return null;
|
|
28
|
+
index = end + 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (character === "\"") {
|
|
32
|
+
const end = scanDoubleQuote(input, index + 1);
|
|
33
|
+
if (end === null)
|
|
34
|
+
return null;
|
|
35
|
+
index = end + 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (character === "`") {
|
|
39
|
+
const end = scanBacktick(input, index + 1);
|
|
40
|
+
if (end === null)
|
|
41
|
+
return null;
|
|
42
|
+
index = end + 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (character === "(") {
|
|
46
|
+
depth += 1;
|
|
47
|
+
}
|
|
48
|
+
else if (character === ")") {
|
|
49
|
+
depth -= 1;
|
|
50
|
+
if (depth === 0)
|
|
51
|
+
return index;
|
|
52
|
+
}
|
|
53
|
+
index += 1;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** Index of the `"` that closes the one already consumed. */
|
|
58
|
+
function scanDoubleQuote(input, start) {
|
|
59
|
+
let index = start;
|
|
60
|
+
while (index < input.length) {
|
|
61
|
+
const character = input[index];
|
|
62
|
+
if (character === "\\") {
|
|
63
|
+
index += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (character === "`") {
|
|
67
|
+
const end = scanBacktick(input, index + 1);
|
|
68
|
+
if (end === null)
|
|
69
|
+
return null;
|
|
70
|
+
index = end + 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character === "$" && input[index + 1] === "(") {
|
|
74
|
+
const end = scanParenthesis(input, index + 1);
|
|
75
|
+
if (end === null)
|
|
76
|
+
return null;
|
|
77
|
+
index = end + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (character === "\"")
|
|
81
|
+
return index;
|
|
82
|
+
index += 1;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function scanBacktick(input, start) {
|
|
87
|
+
let index = start;
|
|
88
|
+
while (index < input.length) {
|
|
89
|
+
if (input[index] === "\\") {
|
|
90
|
+
index += 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (input[index] === "`")
|
|
94
|
+
return index;
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The substitutions in text the shell expands but does not tokenize as a
|
|
101
|
+
* command line — the body of an unquoted heredoc. Quotes there are literal
|
|
102
|
+
* characters; only `$(...)` and backticks run anything.
|
|
103
|
+
*/
|
|
104
|
+
function collectExpansions(input) {
|
|
105
|
+
const found = [];
|
|
106
|
+
let index = 0;
|
|
107
|
+
while (index < input.length) {
|
|
108
|
+
const character = input[index];
|
|
109
|
+
if (character === "\\") {
|
|
110
|
+
index += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (character === "$" && input[index + 1] === "(") {
|
|
114
|
+
const end = scanParenthesis(input, index + 1);
|
|
115
|
+
if (end === null)
|
|
116
|
+
return null;
|
|
117
|
+
found.push(input.slice(index + 2, end));
|
|
118
|
+
index = end + 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (character === "`") {
|
|
122
|
+
const end = scanBacktick(input, index + 1);
|
|
123
|
+
if (end === null)
|
|
124
|
+
return null;
|
|
125
|
+
found.push(input.slice(index + 1, end));
|
|
126
|
+
index = end + 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
index += 1;
|
|
130
|
+
}
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
/** Reads the `<<WORD`, `<<-WORD`, `<<'WORD'` that follows a `<<`. */
|
|
134
|
+
function readHeredocHeader(input, start) {
|
|
135
|
+
let index = start;
|
|
136
|
+
if (input[index] === "-")
|
|
137
|
+
index += 1;
|
|
138
|
+
while (input[index] === " " || input[index] === "\t")
|
|
139
|
+
index += 1;
|
|
140
|
+
const quote = input[index];
|
|
141
|
+
if (quote === "'" || quote === "\"") {
|
|
142
|
+
const end = input.indexOf(quote, index + 1);
|
|
143
|
+
if (end < 0)
|
|
144
|
+
return null;
|
|
145
|
+
const delimiter = input.slice(index + 1, end);
|
|
146
|
+
return delimiter.length === 0
|
|
147
|
+
? null
|
|
148
|
+
: { marker: { delimiter, expands: false }, end: end + 1 };
|
|
149
|
+
}
|
|
150
|
+
let delimiter = "";
|
|
151
|
+
while (index < input.length && /[A-Za-z0-9_.-]/u.test(input[index] ?? "")) {
|
|
152
|
+
delimiter += input[index];
|
|
153
|
+
index += 1;
|
|
8
154
|
}
|
|
155
|
+
return delimiter.length === 0
|
|
156
|
+
? null
|
|
157
|
+
: { marker: { delimiter, expands: true }, end: index };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One quote-aware pass over a command line. Everything that decides whether a
|
|
161
|
+
* character is structure or text — quotes, escapes, heredocs, substitutions —
|
|
162
|
+
* is decided here, once, so no later pass can disagree with this one about
|
|
163
|
+
* where a quote begins.
|
|
164
|
+
*/
|
|
165
|
+
function parseLine(input) {
|
|
9
166
|
const commands = [];
|
|
167
|
+
const substitutions = [];
|
|
168
|
+
const pending = [];
|
|
10
169
|
let words = [];
|
|
11
170
|
let word = "";
|
|
12
|
-
let
|
|
13
|
-
let
|
|
171
|
+
let started = false;
|
|
172
|
+
let index = 0;
|
|
14
173
|
const finishWord = () => {
|
|
15
|
-
if (
|
|
174
|
+
if (started) {
|
|
16
175
|
words.push(word);
|
|
17
176
|
word = "";
|
|
177
|
+
started = false;
|
|
18
178
|
}
|
|
19
179
|
};
|
|
20
180
|
const finishCommand = () => {
|
|
@@ -24,66 +184,154 @@ function parseShellWords(input) {
|
|
|
24
184
|
words = [];
|
|
25
185
|
}
|
|
26
186
|
};
|
|
27
|
-
|
|
187
|
+
const addText = (text) => {
|
|
188
|
+
word += text;
|
|
189
|
+
started = true;
|
|
190
|
+
};
|
|
191
|
+
while (index < input.length) {
|
|
28
192
|
const character = input[index];
|
|
29
|
-
if (character === undefined) {
|
|
193
|
+
if (character === undefined || character === "\0") {
|
|
30
194
|
return null;
|
|
31
195
|
}
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
196
|
+
if (word.length > MAX_COMMAND_LENGTH) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (character === "\\") {
|
|
200
|
+
const next = input[index + 1];
|
|
201
|
+
if (next === undefined)
|
|
202
|
+
return null;
|
|
203
|
+
if (next !== "\n")
|
|
204
|
+
addText(next);
|
|
205
|
+
index += 2;
|
|
35
206
|
continue;
|
|
36
207
|
}
|
|
37
|
-
if (character === "
|
|
38
|
-
|
|
208
|
+
if (character === "'") {
|
|
209
|
+
const end = input.indexOf("'", index + 1);
|
|
210
|
+
if (end < 0)
|
|
211
|
+
return null;
|
|
212
|
+
addText(input.slice(index + 1, end));
|
|
213
|
+
index = end + 1;
|
|
39
214
|
continue;
|
|
40
215
|
}
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
216
|
+
if (character === "\"") {
|
|
217
|
+
const end = scanDoubleQuote(input, index + 1);
|
|
218
|
+
if (end === null)
|
|
219
|
+
return null;
|
|
220
|
+
const quoted = input.slice(index + 1, end);
|
|
221
|
+
const expansions = collectExpansions(quoted);
|
|
222
|
+
if (expansions === null)
|
|
223
|
+
return null;
|
|
224
|
+
substitutions.push(...expansions);
|
|
225
|
+
addText(quoted);
|
|
226
|
+
index = end + 1;
|
|
227
|
+
continue;
|
|
44
228
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
229
|
+
if (character === "$" && input[index + 1] === "(") {
|
|
230
|
+
const end = scanParenthesis(input, index + 1);
|
|
231
|
+
if (end === null)
|
|
232
|
+
return null;
|
|
233
|
+
substitutions.push(input.slice(index + 2, end));
|
|
234
|
+
addText(SUBSTITUTION_PLACEHOLDER);
|
|
235
|
+
index = end + 1;
|
|
52
236
|
continue;
|
|
53
237
|
}
|
|
54
|
-
if (character === "
|
|
55
|
-
|
|
238
|
+
if (character === "`") {
|
|
239
|
+
const end = scanBacktick(input, index + 1);
|
|
240
|
+
if (end === null)
|
|
241
|
+
return null;
|
|
242
|
+
substitutions.push(input.slice(index + 1, end));
|
|
243
|
+
addText(SUBSTITUTION_PLACEHOLDER);
|
|
244
|
+
index = end + 1;
|
|
56
245
|
continue;
|
|
57
246
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
247
|
+
if (character === "<" && input[index + 1] === "<") {
|
|
248
|
+
const header = readHeredocHeader(input, index + 2);
|
|
249
|
+
if (header === null) {
|
|
250
|
+
// `<<` with no delimiter is a here-string or an unfinished line; either
|
|
251
|
+
// way there is no body to skip.
|
|
252
|
+
addText("<<");
|
|
253
|
+
index += 2;
|
|
254
|
+
continue;
|
|
62
255
|
}
|
|
256
|
+
pending.push(header.marker);
|
|
257
|
+
addText(input.slice(index, header.end));
|
|
258
|
+
index = header.end;
|
|
63
259
|
continue;
|
|
64
260
|
}
|
|
65
|
-
if (character === "
|
|
261
|
+
if (character === "\n") {
|
|
66
262
|
finishCommand();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
263
|
+
index += 1;
|
|
264
|
+
while (pending.length > 0) {
|
|
265
|
+
const marker = pending.shift();
|
|
266
|
+
if (marker === undefined)
|
|
267
|
+
break;
|
|
268
|
+
const body = [];
|
|
269
|
+
let closed = false;
|
|
270
|
+
while (index <= input.length) {
|
|
271
|
+
const lineEnd = input.indexOf("\n", index);
|
|
272
|
+
const line = lineEnd < 0 ? input.slice(index) : input.slice(index, lineEnd);
|
|
273
|
+
index = lineEnd < 0 ? input.length : lineEnd + 1;
|
|
274
|
+
if (line.trim() === marker.delimiter) {
|
|
275
|
+
closed = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
body.push(line);
|
|
279
|
+
if (lineEnd < 0)
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
// An unterminated heredoc means the command is still being written.
|
|
283
|
+
if (!closed)
|
|
284
|
+
return null;
|
|
285
|
+
if (marker.expands) {
|
|
286
|
+
const expansions = collectExpansions(body.join("\n"));
|
|
287
|
+
if (expansions === null)
|
|
288
|
+
return null;
|
|
289
|
+
substitutions.push(...expansions);
|
|
290
|
+
}
|
|
70
291
|
}
|
|
71
292
|
continue;
|
|
72
293
|
}
|
|
73
|
-
if (character
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
294
|
+
if (/\s/u.test(character)) {
|
|
295
|
+
finishWord();
|
|
296
|
+
index += 1;
|
|
297
|
+
continue;
|
|
77
298
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
299
|
+
if (character === ";" || character === "|" || character === "&") {
|
|
300
|
+
finishCommand();
|
|
301
|
+
index += input[index + 1] === character ? 2 : 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Grouping and subshells separate commands; their contents are commands.
|
|
305
|
+
if (character === "(" || character === ")") {
|
|
306
|
+
finishCommand();
|
|
307
|
+
index += 1;
|
|
308
|
+
continue;
|
|
81
309
|
}
|
|
310
|
+
addText(character);
|
|
311
|
+
index += 1;
|
|
82
312
|
}
|
|
83
|
-
|
|
313
|
+
finishCommand();
|
|
314
|
+
return pending.length > 0 ? null : { commands, substitutions };
|
|
315
|
+
}
|
|
316
|
+
function parseShellWords(input, depth = 0) {
|
|
317
|
+
if (input.length === 0 ||
|
|
318
|
+
input.length > MAX_COMMAND_LENGTH ||
|
|
319
|
+
depth > MAX_SUBSTITUTION_DEPTH) {
|
|
84
320
|
return null;
|
|
85
321
|
}
|
|
86
|
-
|
|
322
|
+
const parsed = parseLine(input);
|
|
323
|
+
if (parsed === null) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const commands = [...parsed.commands];
|
|
327
|
+
for (const substitution of parsed.substitutions) {
|
|
328
|
+
if (substitution.trim().length === 0)
|
|
329
|
+
continue;
|
|
330
|
+
const nested = parseShellWords(substitution, depth + 1);
|
|
331
|
+
if (nested === null)
|
|
332
|
+
return null;
|
|
333
|
+
commands.push(...nested);
|
|
334
|
+
}
|
|
87
335
|
if (commands.length === 0 ||
|
|
88
336
|
commands.length > MAX_COMMANDS ||
|
|
89
337
|
commands.some((command) => command.length > MAX_WORDS)) {
|
|
@@ -124,7 +124,8 @@ export class TaskService {
|
|
|
124
124
|
completedAt: null,
|
|
125
125
|
archivedAt: null,
|
|
126
126
|
failureFingerprint: null,
|
|
127
|
-
policyConfigHash: input.policyConfigHash ?? null
|
|
127
|
+
policyConfigHash: input.policyConfigHash ?? null,
|
|
128
|
+
completionBase: null
|
|
128
129
|
};
|
|
129
130
|
state.tasks.push(record);
|
|
130
131
|
if (input.sessionId !== undefined) {
|
|
@@ -235,10 +236,14 @@ export class TaskService {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
}
|
|
239
|
+
let completionBase = null;
|
|
238
240
|
const fingerprint = async () => {
|
|
239
241
|
try {
|
|
240
242
|
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
241
243
|
...(completion.base === undefined ? {} : { base: completion.base }) });
|
|
244
|
+
// Recorded so the completion gate can recompute this exact range once
|
|
245
|
+
// the work is committed and the worktree has nothing left to measure.
|
|
246
|
+
completionBase = scope.mode === "base" ? scope.resolvedBase : null;
|
|
242
247
|
return await calculateSourceFingerprint(completion.root, scope, completion.gitRunner);
|
|
243
248
|
}
|
|
244
249
|
catch (error) {
|
|
@@ -282,7 +287,8 @@ export class TaskService {
|
|
|
282
287
|
status: "complete",
|
|
283
288
|
evidence,
|
|
284
289
|
updatedAt: now,
|
|
285
|
-
completedAt: now
|
|
290
|
+
completedAt: now,
|
|
291
|
+
completionBase
|
|
286
292
|
};
|
|
287
293
|
replaceTask(state, completed);
|
|
288
294
|
return cloneRecord(completed);
|
|
@@ -64,12 +64,13 @@ function parseTaskRecord(value) {
|
|
|
64
64
|
"task",
|
|
65
65
|
"updatedAt"
|
|
66
66
|
];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
// Every record written before an optional field existed must still parse, so
|
|
68
|
+
// the accepted shapes are every combination of them.
|
|
69
|
+
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
|
|
70
|
+
const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
|
|
71
|
+
...baseKeys,
|
|
72
|
+
...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
|
|
73
|
+
].sort().join("\0")));
|
|
73
74
|
if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
|
|
74
75
|
return invalidState("Task state contains an invalid task record.");
|
|
75
76
|
}
|
|
@@ -138,6 +139,13 @@ function parseTaskRecord(value) {
|
|
|
138
139
|
(typeof policyConfigHash !== "string" || !/^[a-f0-9]{64}$/u.test(policyConfigHash))) {
|
|
139
140
|
return invalidState("Task state contains an invalid policy config hash.");
|
|
140
141
|
}
|
|
142
|
+
const completionBase = value.completionBase === undefined
|
|
143
|
+
? null
|
|
144
|
+
: value.completionBase;
|
|
145
|
+
if (completionBase !== null &&
|
|
146
|
+
(typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
|
|
147
|
+
return invalidState("Task state contains an invalid completion base.");
|
|
148
|
+
}
|
|
141
149
|
return {
|
|
142
150
|
task: task.value,
|
|
143
151
|
status,
|
|
@@ -147,7 +155,8 @@ function parseTaskRecord(value) {
|
|
|
147
155
|
completedAt: value.completedAt,
|
|
148
156
|
archivedAt: value.archivedAt,
|
|
149
157
|
failureFingerprint,
|
|
150
|
-
policyConfigHash
|
|
158
|
+
policyConfigHash,
|
|
159
|
+
completionBase
|
|
151
160
|
};
|
|
152
161
|
}
|
|
153
162
|
function parseSession(value) {
|