@claudexor/control-api 3.0.4 → 3.1.0
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/artifact-serve-routes.d.ts +46 -1
- package/dist/artifact-serve-routes.d.ts.map +1 -1
- package/dist/artifact-serve-routes.js +172 -8
- package/dist/artifact-serve-routes.js.map +1 -1
- package/dist/candidates.d.ts.map +1 -1
- package/dist/candidates.js +14 -1
- package/dist/candidates.js.map +1 -1
- package/dist/daemon-server.d.ts +67 -3
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +349 -50
- package/dist/daemon-server.js.map +1 -1
- package/dist/operation-catalog.d.ts +10 -1
- package/dist/operation-catalog.d.ts.map +1 -1
- package/dist/operation-catalog.js +150 -18
- package/dist/operation-catalog.js.map +1 -1
- package/dist/operation-parameters.d.ts +34 -0
- package/dist/operation-parameters.d.ts.map +1 -0
- package/dist/operation-parameters.js +52 -0
- package/dist/operation-parameters.js.map +1 -0
- package/dist/problem-response.d.ts +43 -0
- package/dist/problem-response.d.ts.map +1 -1
- package/dist/problem-response.js +192 -0
- package/dist/problem-response.js.map +1 -1
- package/dist/project-routes.d.ts +1 -0
- package/dist/project-routes.d.ts.map +1 -1
- package/dist/project-routes.js +18 -1
- package/dist/project-routes.js.map +1 -1
- package/dist/run-apply-routes.d.ts +6 -1
- package/dist/run-apply-routes.d.ts.map +1 -1
- package/dist/run-apply-routes.js +32 -2
- package/dist/run-apply-routes.js.map +1 -1
- package/dist/run-events-stream.d.ts.map +1 -1
- package/dist/run-events-stream.js +99 -64
- package/dist/run-events-stream.js.map +1 -1
- package/dist/run-list.d.ts +74 -0
- package/dist/run-list.d.ts.map +1 -0
- package/dist/run-list.js +156 -0
- package/dist/run-list.js.map +1 -0
- package/dist/run-retry-routes.d.ts.map +1 -1
- package/dist/run-retry-routes.js +38 -1
- package/dist/run-retry-routes.js.map +1 -1
- package/dist/run-start.d.ts +14 -1
- package/dist/run-start.d.ts.map +1 -1
- package/dist/run-start.js +50 -1
- package/dist/run-start.js.map +1 -1
- package/dist/run-timeline.d.ts +44 -3
- package/dist/run-timeline.d.ts.map +1 -1
- package/dist/run-timeline.js +143 -24
- package/dist/run-timeline.js.map +1 -1
- package/dist/thread-projection.d.ts.map +1 -1
- package/dist/thread-projection.js +6 -0
- package/dist/thread-projection.js.map +1 -1
- package/dist/thread-turn-routes.d.ts.map +1 -1
- package/dist/thread-turn-routes.js +20 -23
- package/dist/thread-turn-routes.js.map +1 -1
- package/package.json +6 -6
package/dist/daemon-server.js
CHANGED
|
@@ -8,7 +8,7 @@ import { safeArtifactPath, safeArtifactRoot } from "./artifact-paths.js";
|
|
|
8
8
|
import { TERMINAL_STATES } from "./sse-shared.js";
|
|
9
9
|
import { streamRunEvents } from "./run-events-stream.js";
|
|
10
10
|
import { boundedArtifactText, outputReadyState, primaryOutput } from "./primary-output.js";
|
|
11
|
-
import { controlWebEvidence, eventPayload, latestPlanProgress, readRunEvents, timelineEvents, } from "./run-timeline.js";
|
|
11
|
+
import { controlWebEvidence, eventPayload, evidenceLevel, latestPlanProgress, readRunEvents, readRunEventsWithIntegrity, timelineEvents, } from "./run-timeline.js";
|
|
12
12
|
import { projectSession, projectThread, projectTurn, turnRunCard } from "./thread-projection.js";
|
|
13
13
|
import { chainThreadMutation, handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
|
|
14
14
|
import { assertThreadIdle, threadIdOfRun } from "./thread-mutation.js";
|
|
@@ -26,10 +26,11 @@ import { handleMaintenanceRoute } from "./maintenance-routes.js";
|
|
|
26
26
|
import { handleResourceRoute } from "./resource-routes.js";
|
|
27
27
|
import { handleArtifactServeRoute, listArtifacts, resolveProjectRoot, } from "./artifact-serve-routes.js";
|
|
28
28
|
import { requiredGateSpecsFromTaskArtifact } from "./task-contract-gates.js";
|
|
29
|
-
import { assertOnlyQueryParams, optionalBooleanQuery } from "./query.js";
|
|
30
|
-
import {
|
|
29
|
+
import { assertOnlyQueryParams, optionalBooleanQuery, singleQuery } from "./query.js";
|
|
30
|
+
import { lruGet, lruSet, parseRunListQuery, selectRunListPage, } from "./run-list.js";
|
|
31
|
+
import { controlProblemError, normalizeRequestValidationError, revertRefusedProblem, thrownProblemCode, } from "./problem-response.js";
|
|
31
32
|
import { handleSecurityRoute } from "./security-routes.js";
|
|
32
|
-
import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo,
|
|
33
|
+
import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunListResponse, ControlRunSummary, ControlRunResult, ControlBudgetSnapshot, PaidBudget, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlQuotaResponse, ControlCredentialProfilesResponse, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, requiredActionsFor, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
|
|
33
34
|
import { resolveControlProtocol } from "./operation-catalog.js";
|
|
34
35
|
import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, sha256, } from "@claudexor/util";
|
|
35
36
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
@@ -41,6 +42,13 @@ function setupEventProtocolError(message) {
|
|
|
41
42
|
requiredActions: ["resnapshot"],
|
|
42
43
|
});
|
|
43
44
|
}
|
|
45
|
+
function invalidRunCursor(message) {
|
|
46
|
+
return Object.assign(new Error(message), {
|
|
47
|
+
status: 400,
|
|
48
|
+
code: "invalid_run_event_cursor",
|
|
49
|
+
requiredActions: ["resnapshot"],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
44
52
|
function finiteHttpStatus(error, fallback) {
|
|
45
53
|
if (!error || typeof error !== "object" || !("status" in error))
|
|
46
54
|
return fallback;
|
|
@@ -248,7 +256,11 @@ export class DaemonControlApiServer {
|
|
|
248
256
|
return this.tokenMatches(m?.[1]?.trim());
|
|
249
257
|
}
|
|
250
258
|
requestError(res, err) {
|
|
251
|
-
|
|
259
|
+
// QA-053: at the request boundary, project a raw ZodError into structured
|
|
260
|
+
// fieldErrors + a single-line human message (see normalizeRequestValidationError).
|
|
261
|
+
// Typed errors and non-Zod errors pass through unchanged.
|
|
262
|
+
const normalized = normalizeRequestValidationError(err);
|
|
263
|
+
this.problem(res, finiteHttpStatus(normalized, 400), normalized, "invalid_request", false, "bad request");
|
|
252
264
|
}
|
|
253
265
|
json(res, status, body, contentType = "application/json") {
|
|
254
266
|
if (status >= 400 && contentType === "application/json") {
|
|
@@ -276,7 +288,25 @@ export class DaemonControlApiServer {
|
|
|
276
288
|
}
|
|
277
289
|
if (chunks.length === 0)
|
|
278
290
|
return {};
|
|
279
|
-
|
|
291
|
+
// QA-056: decode the complete byte body STRICTLY. `Buffer.toString("utf8")`
|
|
292
|
+
// is non-fatal — it silently replaces malformed bytes with U+FFFD, so an
|
|
293
|
+
// invalid octet (FF, a lone continuation byte, overlong/ truncated sequence)
|
|
294
|
+
// would slip through as a valid JS string and could pass Zod, the secret
|
|
295
|
+
// fence, idempotency hashing and a durable mutation storing a value that
|
|
296
|
+
// differs from the wire bytes. TextDecoder({fatal:true}) throws on any
|
|
297
|
+
// malformed byte; concatenating first keeps valid multibyte chars split
|
|
298
|
+
// across HTTP chunks intact.
|
|
299
|
+
let decoded;
|
|
300
|
+
try {
|
|
301
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw Object.assign(new Error("request body must be valid UTF-8"), {
|
|
305
|
+
status: 400,
|
|
306
|
+
code: "invalid_encoding",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const raw = decoded.trim();
|
|
280
310
|
if (!raw)
|
|
281
311
|
return {};
|
|
282
312
|
try {
|
|
@@ -287,12 +317,11 @@ export class DaemonControlApiServer {
|
|
|
287
317
|
}
|
|
288
318
|
}
|
|
289
319
|
onRequest(req, res) {
|
|
290
|
-
|
|
291
|
-
tracked = this.handle(req, res)
|
|
320
|
+
const tracked = this.handle(req, res)
|
|
292
321
|
.catch((err) => {
|
|
293
322
|
try {
|
|
294
323
|
if (!res.headersSent) {
|
|
295
|
-
this.problem(res, finiteHttpStatus(err, 500), err,
|
|
324
|
+
this.problem(res, finiteHttpStatus(err, 500), err, thrownProblemCode(err), false, "internal server error");
|
|
296
325
|
}
|
|
297
326
|
else
|
|
298
327
|
res.end();
|
|
@@ -375,10 +404,7 @@ export class DaemonControlApiServer {
|
|
|
375
404
|
}, req, res);
|
|
376
405
|
}
|
|
377
406
|
if (method === "GET" && path === "/runs") {
|
|
378
|
-
|
|
379
|
-
return this.json(res, 200, ControlRunListResponse.parse({
|
|
380
|
-
runs: runs.map((r) => this.summarizeRunOrDiagnostic(r)),
|
|
381
|
-
}));
|
|
407
|
+
return this.handleRunList(url, res);
|
|
382
408
|
}
|
|
383
409
|
if (await handleRunRetryRoute({
|
|
384
410
|
daemon: this.opts.daemon,
|
|
@@ -491,7 +517,7 @@ export class DaemonControlApiServer {
|
|
|
491
517
|
return this.json(res, 501, { error: "threads are not supported by this engine build" });
|
|
492
518
|
const { threads, problems } = await svc();
|
|
493
519
|
const runs = await this.opts.daemon.list();
|
|
494
|
-
const blocked = new Set(runs.filter((r) => this.
|
|
520
|
+
const blocked = new Set(runs.filter((r) => this.runNeedsAttention(r)).map((r) => r.runId ?? r.id));
|
|
495
521
|
return this.json(res, 200, ControlThreadListResponse.parse({
|
|
496
522
|
threads: threads.map((t) => projectThread(t, blocked.has(t.head_run_id ?? ""))),
|
|
497
523
|
problems: problems ?? [],
|
|
@@ -517,7 +543,7 @@ export class DaemonControlApiServer {
|
|
|
517
543
|
}
|
|
518
544
|
}
|
|
519
545
|
const headRec = byRun.get(thread.head_run_id ?? "");
|
|
520
|
-
const headNeedsHuman = headRec ? this.
|
|
546
|
+
const headNeedsHuman = headRec ? this.runNeedsAttention(headRec) : false;
|
|
521
547
|
return this.json(res, 200, ControlThreadDetail.parse({
|
|
522
548
|
thread: projectThread(detail.thread, headNeedsHuman),
|
|
523
549
|
sessions: detail.sessions.map(projectSession),
|
|
@@ -584,6 +610,7 @@ export class DaemonControlApiServer {
|
|
|
584
610
|
if (await handleArtifactServeRoute({
|
|
585
611
|
findRun: (id) => this.findRun(id),
|
|
586
612
|
resolveProjectRoot: (id) => resolveProjectRoot(this.opts.services?.listProjects, id),
|
|
613
|
+
resolveThreadWorkspace: (id) => this.resolveThreadWorkspace(id),
|
|
587
614
|
json: (response, status, body) => this.json(response, status, body),
|
|
588
615
|
}, method, path, res))
|
|
589
616
|
return;
|
|
@@ -610,6 +637,20 @@ export class DaemonControlApiServer {
|
|
|
610
637
|
const decisionAction = body.action;
|
|
611
638
|
try {
|
|
612
639
|
return await this.chainRunMutation(rec, async () => {
|
|
640
|
+
// D-16: a work_state veto (the model attested needs_input / incomplete)
|
|
641
|
+
// is NON-OVERRIDABLE — a risk acceptance cannot supply missing input,
|
|
642
|
+
// and the delivery gate refuses the override on that axis. Reject
|
|
643
|
+
// accept_risk with a TYPED problem here instead of recording a false
|
|
644
|
+
// "Apply is now available" ACK that the gate would then refuse.
|
|
645
|
+
const workVeto = this.runWorkStateVeto(rec);
|
|
646
|
+
if (workVeto) {
|
|
647
|
+
return this.json(res, 409, {
|
|
648
|
+
error: workVeto === "needs_input"
|
|
649
|
+
? "run reported it needs more input; a risk override cannot supply the missing input — re-run with the input (rerun_with_feedback)"
|
|
650
|
+
: "run reported the work is incomplete; a risk override cannot finish it — re-run until it completes (rerun_with_feedback)",
|
|
651
|
+
code: "work_state_needs_input",
|
|
652
|
+
});
|
|
653
|
+
}
|
|
613
654
|
// The override unblocks a NEEDS-DECISION run (review blocked /
|
|
614
655
|
// checks failed, D8); recording one elsewhere would claim an apply
|
|
615
656
|
// permission that does not exist.
|
|
@@ -650,7 +691,10 @@ export class DaemonControlApiServer {
|
|
|
650
691
|
return this.json(res, 200, ControlRunDecisionResponse.parse({
|
|
651
692
|
accepted: true,
|
|
652
693
|
status: "applied",
|
|
653
|
-
|
|
694
|
+
// Honest ACK (QA-032B): nothing is mutated at decision time. The
|
|
695
|
+
// risk is accepted for this exact patch; Apply is now available
|
|
696
|
+
// and a fresh final check runs just-in-time before any mutation.
|
|
697
|
+
message: `${decisionAction} recorded for this exact patch; Apply is now available and will run a fresh final check before changing the project`,
|
|
654
698
|
}));
|
|
655
699
|
});
|
|
656
700
|
}
|
|
@@ -682,13 +726,23 @@ export class DaemonControlApiServer {
|
|
|
682
726
|
}
|
|
683
727
|
const revert = await revertInPlaceFromAnchor(repoRoot, result.revertAnchorId);
|
|
684
728
|
if (!revert.reverted) {
|
|
729
|
+
// QA-051: emit a FULL ControlProblem. The raw workspace reason
|
|
730
|
+
// concatenates a stable English explanation with locale-
|
|
731
|
+
// dependent Git stderr; that vendor diagnostic must not BE the
|
|
732
|
+
// semantic message. Present a stable English message + typed
|
|
733
|
+
// reason code, and keep the redacted, bounded stderr as
|
|
734
|
+
// context evidence the client can inspect deliberately.
|
|
735
|
+
const problem = revertRefusedProblem(revert.reason, revert.reasonCode);
|
|
685
736
|
appendRunAuditEvent(rec, "control.rejected", {
|
|
686
737
|
decision: "revert_run",
|
|
687
|
-
reason:
|
|
738
|
+
reason: problem.context.reason,
|
|
739
|
+
detail: problem.context.detail,
|
|
688
740
|
});
|
|
689
|
-
throw Object.assign(new Error(
|
|
741
|
+
throw Object.assign(new Error(problem.message), {
|
|
690
742
|
status: 409,
|
|
691
743
|
code: "revert_refused",
|
|
744
|
+
retryable: false,
|
|
745
|
+
context: problem.context,
|
|
692
746
|
});
|
|
693
747
|
}
|
|
694
748
|
markRunApplyState(rec, "reverted");
|
|
@@ -874,7 +928,17 @@ export class DaemonControlApiServer {
|
|
|
874
928
|
}
|
|
875
929
|
const setupJobExtendMatch = /^\/setup\/jobs\/([^/]+)\/extend$/.exec(path);
|
|
876
930
|
if (method === "POST" && setupJobExtendMatch) {
|
|
877
|
-
|
|
931
|
+
// QA-075/Ф2: Idempotency-Key is OPTIONAL (installed macOS Extend sends
|
|
932
|
+
// none): present → replay-safe, absent → non-idempotent, malformed → 400.
|
|
933
|
+
let idempotencyKey;
|
|
934
|
+
try {
|
|
935
|
+
idempotencyKey = runStart.optionalIdempotencyKey(req);
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
return this.requestError(res, err);
|
|
939
|
+
}
|
|
940
|
+
const jobId = decodeURIComponent(setupJobExtendMatch[1]);
|
|
941
|
+
return this.service(res, "extendSetupJob", { jobId, ...(idempotencyKey !== undefined ? { idempotencyKey } : {}) }, ControlSetupJob);
|
|
878
942
|
}
|
|
879
943
|
const setupJobEventsMatch = /^\/setup\/jobs\/([^/]+)\/events$/.exec(path);
|
|
880
944
|
if (method === "GET" && setupJobEventsMatch) {
|
|
@@ -988,7 +1052,15 @@ export class DaemonControlApiServer {
|
|
|
988
1052
|
const eventsMatch = /^\/runs\/([^/]+)\/events$/.exec(path);
|
|
989
1053
|
if (method === "GET" && eventsMatch) {
|
|
990
1054
|
const id = decodeURIComponent(eventsMatch[1]);
|
|
991
|
-
|
|
1055
|
+
let last;
|
|
1056
|
+
try {
|
|
1057
|
+
last = this.lastEventId(req, url);
|
|
1058
|
+
}
|
|
1059
|
+
catch (err) {
|
|
1060
|
+
// Validate the resume cursor BEFORE committing SSE headers so a
|
|
1061
|
+
// malformed cursor is a typed 400, not a silent full replay (QA-061).
|
|
1062
|
+
return this.requestError(res, err);
|
|
1063
|
+
}
|
|
992
1064
|
return this.streamEvents(id, last, req, res);
|
|
993
1065
|
}
|
|
994
1066
|
return this.json(res, 404, { error: "not found" });
|
|
@@ -1003,7 +1075,7 @@ export class DaemonControlApiServer {
|
|
|
1003
1075
|
value = await fn(arg);
|
|
1004
1076
|
}
|
|
1005
1077
|
catch (err) {
|
|
1006
|
-
return this.problem(res, finiteHttpStatus(err, 500), err,
|
|
1078
|
+
return this.problem(res, finiteHttpStatus(err, 500), err, thrownProblemCode(err), false, "service failed");
|
|
1007
1079
|
}
|
|
1008
1080
|
try {
|
|
1009
1081
|
return this.json(res, 200, schema ? schema.parse(value) : value);
|
|
@@ -1219,14 +1291,61 @@ export class DaemonControlApiServer {
|
|
|
1219
1291
|
runDir: rec.runDir,
|
|
1220
1292
|
}));
|
|
1221
1293
|
}
|
|
1222
|
-
const status = TERMINAL_STATES.has(rec.state)
|
|
1223
|
-
const body = ControlQueuedRunInfo.parse({ jobId: rec.id, state: rec.state, error: rec.error });
|
|
1294
|
+
const { status, body } = runStart.unboundRunStartResponse(rec, TERMINAL_STATES.has(rec.state));
|
|
1224
1295
|
return this.json(res, status, body);
|
|
1225
1296
|
}
|
|
1226
1297
|
async findRun(id) {
|
|
1227
1298
|
const runs = await this.opts.daemon.list();
|
|
1228
1299
|
return runs.find((r) => r.id === id || r.runId === id) ?? null;
|
|
1229
1300
|
}
|
|
1301
|
+
/**
|
|
1302
|
+
* GET /v2/runs (QA-052): a bounded, newest-first, keyset-paginated page of run
|
|
1303
|
+
* summaries. Ordering, filtering, and slicing all happen on the raw daemon
|
|
1304
|
+
* records BEFORE any summary is materialized, so the expensive per-run
|
|
1305
|
+
* artifact fingerprint/projection work is bounded by `limit` (page size), not
|
|
1306
|
+
* by the total retained-record count. `limit`/`state`/`cursor` are typed and
|
|
1307
|
+
* strict — an unknown or malformed param is a typed 400, never silently
|
|
1308
|
+
* ignored. The bare (unparametered) call is still valid and now returns the
|
|
1309
|
+
* newest `RUN_LIST_DEFAULT_LIMIT` runs with `hasMore`/`nextCursor` to page the
|
|
1310
|
+
* rest.
|
|
1311
|
+
*/
|
|
1312
|
+
async handleRunList(url, res) {
|
|
1313
|
+
let query;
|
|
1314
|
+
try {
|
|
1315
|
+
query = parseRunListQuery(url);
|
|
1316
|
+
}
|
|
1317
|
+
catch (error) {
|
|
1318
|
+
return this.requestError(res, error);
|
|
1319
|
+
}
|
|
1320
|
+
const { page, hasMore, nextCursor } = selectRunListPage(await this.opts.daemon.list(), query);
|
|
1321
|
+
return this.json(res, 200, ControlRunListResponse.parse({
|
|
1322
|
+
runs: page.map((r) => this.summarizeRunOrDiagnostic(r)),
|
|
1323
|
+
nextCursor,
|
|
1324
|
+
hasMore,
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
/** Resolve a thread's workspace record (mode + isolated worktree path) from the
|
|
1328
|
+
* thread journal — the honest source for QA-038 isolated /produced resolution.
|
|
1329
|
+
* Trashed/purged threads are retained by getThread (purge nulls worktree_path),
|
|
1330
|
+
* so an isolated run's execution tree is diagnosable, never silently the live
|
|
1331
|
+
* project. Absent service or an unknown thread ⇒ null (non-thread fallback). */
|
|
1332
|
+
async resolveThreadWorkspace(threadId) {
|
|
1333
|
+
const svc = this.opts.services?.threadDetail;
|
|
1334
|
+
// No thread service / no workspace record ⇒ null (in-place / one-shot: live
|
|
1335
|
+
// root is correct). A THROWN lookup is NOT swallowed to null: for a run bound
|
|
1336
|
+
// to a thread that fails OPEN to the live project (QA-038), so it propagates
|
|
1337
|
+
// and `resolveProducedRoot` fails CLOSED with a typed authority-unavailable.
|
|
1338
|
+
if (!svc)
|
|
1339
|
+
return null;
|
|
1340
|
+
const { thread } = await svc(threadId);
|
|
1341
|
+
const ws = thread?.workspace;
|
|
1342
|
+
if (!ws)
|
|
1343
|
+
return null;
|
|
1344
|
+
return {
|
|
1345
|
+
mode: ws.mode === "isolated" ? "isolated" : "in_place",
|
|
1346
|
+
worktreePath: typeof ws.worktree_path === "string" && ws.worktree_path.trim() ? ws.worktree_path : null,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1230
1349
|
operatorDecisionFor(rec) {
|
|
1231
1350
|
return this.opts.services?.operatorDecision?.(rec.runId ?? rec.id, rec.params) ?? null;
|
|
1232
1351
|
}
|
|
@@ -1237,16 +1356,40 @@ export class DaemonControlApiServer {
|
|
|
1237
1356
|
const patch = readTextArtifact(rec, "final/patch.diff", false);
|
|
1238
1357
|
return patch !== null && decision.patchSha256 === sha256(patch) ? decision : null;
|
|
1239
1358
|
}
|
|
1240
|
-
/** The needs-
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1359
|
+
/** The RISK-OVERRIDABLE needs-decision signal: a terminal run whose review is
|
|
1360
|
+
* blocked or checks failed, with no valid operator decision recorded. This is
|
|
1361
|
+
* the gate for accept_risk / override_needs_human — a D-16 work_state veto is
|
|
1362
|
+
* NOT included here (a risk override cannot supply missing input); see
|
|
1363
|
+
* `runWorkStateVeto`. */
|
|
1243
1364
|
runNeedsDecision(rec) {
|
|
1244
1365
|
const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1245
|
-
const facts = runOutcomeFacts(rec, arb, readFailure(rec));
|
|
1366
|
+
const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
|
|
1246
1367
|
if (!facts)
|
|
1247
1368
|
return false;
|
|
1248
1369
|
return needsDecision(facts, this.validOperatorDecisionFor(rec) !== null);
|
|
1249
1370
|
}
|
|
1371
|
+
/** The needs-me / inbox signal (D8): EITHER a risk-overridable needs-decision
|
|
1372
|
+
* OR a non-overridable D-16 work_state needs-input veto. Inbox/head surfaces
|
|
1373
|
+
* fold both so a needs_input run still surfaces as needing the operator. */
|
|
1374
|
+
runNeedsAttention(rec) {
|
|
1375
|
+
const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1376
|
+
const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
|
|
1377
|
+
if (!facts)
|
|
1378
|
+
return false;
|
|
1379
|
+
return needsOperatorAttention(facts, this.validOperatorDecisionFor(rec) !== null);
|
|
1380
|
+
}
|
|
1381
|
+
/** D-16: the non-overridable work_state veto on a succeeded run — the model
|
|
1382
|
+
* attested it needs input / is incomplete. A risk override cannot resolve it,
|
|
1383
|
+
* so the decision endpoint rejects accept_risk with a typed problem instead of
|
|
1384
|
+
* a false "Apply is now available" ACK the delivery gate would then refuse. */
|
|
1385
|
+
runWorkStateVeto(rec) {
|
|
1386
|
+
const arb = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1387
|
+
const facts = runOutcomeFacts(rec, arb, readFailure(rec), winnerWorkStateFor(rec));
|
|
1388
|
+
if (!facts || facts.lifecycle !== "succeeded")
|
|
1389
|
+
return null;
|
|
1390
|
+
const state = facts.work_state?.state;
|
|
1391
|
+
return state === "needs_input" || state === "incomplete" ? state : null;
|
|
1392
|
+
}
|
|
1250
1393
|
recordOperatorDecision(rec, decision, idempotency) {
|
|
1251
1394
|
const record = this.opts.services?.recordOperatorDecision;
|
|
1252
1395
|
if (!record) {
|
|
@@ -1296,6 +1439,7 @@ export class DaemonControlApiServer {
|
|
|
1296
1439
|
chainMutation: (record, work) => this.chainRunMutation(record, work),
|
|
1297
1440
|
appendAudit: appendRunAuditEvent,
|
|
1298
1441
|
markApplied: (record) => markRunApplyState(record, "applied"),
|
|
1442
|
+
deliveredApplyState: (record) => controlRunResult(record).applyState,
|
|
1299
1443
|
};
|
|
1300
1444
|
}
|
|
1301
1445
|
threadLifecycleRouteCtx() {
|
|
@@ -1339,17 +1483,24 @@ export class DaemonControlApiServer {
|
|
|
1339
1483
|
* runs change only when their artifacts change, so summaries are cached on a
|
|
1340
1484
|
* state+artifact-mtime fingerprint.
|
|
1341
1485
|
*/
|
|
1486
|
+
static SUMMARY_CACHE_MAX = 1_000;
|
|
1342
1487
|
summarizeRunCached(rec) {
|
|
1343
1488
|
const fingerprint = summaryFingerprint(rec);
|
|
1344
|
-
const hit = this.summaryCache
|
|
1489
|
+
const hit = lruGet(this.summaryCache, rec.id);
|
|
1345
1490
|
if (hit && hit.fingerprint === fingerprint)
|
|
1346
1491
|
return hit.summary;
|
|
1347
1492
|
const summary = summarizeRun(rec);
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1493
|
+
// QA-052: LRU-bounded eviction (oldest entry drops) instead of the old
|
|
1494
|
+
// wholesale clear at the guard, so a large retained set never pays a full
|
|
1495
|
+
// cache rehydration wave.
|
|
1496
|
+
lruSet(this.summaryCache, rec.id, { fingerprint, summary }, DaemonControlApiServer.SUMMARY_CACHE_MAX);
|
|
1351
1497
|
return summary;
|
|
1352
1498
|
}
|
|
1499
|
+
/** Test-only observability: current summary-cache entry count. Proves QA-052
|
|
1500
|
+
* LRU boundedness (the cache never grows past SUMMARY_CACHE_MAX). */
|
|
1501
|
+
summaryCacheSizeForTests() {
|
|
1502
|
+
return this.summaryCache.size;
|
|
1503
|
+
}
|
|
1353
1504
|
/**
|
|
1354
1505
|
* Cached artifact projection + journal-backed waiting_on_user overlay. Pending
|
|
1355
1506
|
* interactions are not part of the run artifact fingerprint, so they must
|
|
@@ -1378,12 +1529,30 @@ export class DaemonControlApiServer {
|
|
|
1378
1529
|
});
|
|
1379
1530
|
}
|
|
1380
1531
|
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Strict per-run SSE resume cursor (QA-061). The run cursor is the durable
|
|
1534
|
+
* nonnegative integer event `seq` (ControlRunDetail.lastSeq). Mirrors the
|
|
1535
|
+
* global/project/setup streams' typed contract instead of permissive
|
|
1536
|
+
* `Number()` + fallback-0:
|
|
1537
|
+
* absent -> 0 (intentional full replay)
|
|
1538
|
+
* canonical decimal 0+ -> that safe integer
|
|
1539
|
+
* present but invalid -> typed HTTP 400 (before SSE headers)
|
|
1540
|
+
* A present-but-invalid header is NOT silently erased in favour of the query
|
|
1541
|
+
* alias; the header wins when present, the query is used only when it is absent.
|
|
1542
|
+
*/
|
|
1381
1543
|
lastEventId(req, url) {
|
|
1544
|
+
assertOnlyQueryParams(url, ["lastEventId"]);
|
|
1382
1545
|
const rawHeader = req.headers["last-event-id"];
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1546
|
+
if (Array.isArray(rawHeader))
|
|
1547
|
+
throw invalidRunCursor("Last-Event-ID may be specified only once");
|
|
1548
|
+
const rawQuery = singleQuery(url, "lastEventId");
|
|
1549
|
+
const raw = rawHeader !== undefined ? rawHeader : rawQuery;
|
|
1550
|
+
if (raw === undefined)
|
|
1551
|
+
return 0;
|
|
1552
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
|
|
1553
|
+
throw invalidRunCursor("run event cursor must be a nonnegative integer event seq; refetch the run snapshot and resume from its lastSeq");
|
|
1554
|
+
}
|
|
1555
|
+
return Number(raw);
|
|
1387
1556
|
}
|
|
1388
1557
|
async streamEvents(id, lastEventId, req, res) {
|
|
1389
1558
|
return streamRunEvents({
|
|
@@ -1459,8 +1628,23 @@ function appendRunAuditEvent(rec, type, payload) {
|
|
|
1459
1628
|
/* audit append must not change control behavior */
|
|
1460
1629
|
}
|
|
1461
1630
|
}
|
|
1631
|
+
/**
|
|
1632
|
+
* QA-052 observability: how many artifact-path probes the run-list fingerprinter
|
|
1633
|
+
* has performed (each probe is a synchronous filesystem stat-family lookup). The
|
|
1634
|
+
* single-parse test pattern (eventsParseCount) proves warm-list work is BOUNDED
|
|
1635
|
+
* — by page size (only sliced records are fingerprinted) and, for terminal runs,
|
|
1636
|
+
* by the one-probe short-circuit below (1 path instead of 12).
|
|
1637
|
+
*/
|
|
1638
|
+
let fingerprintProbeCount = 0;
|
|
1639
|
+
export function runListFingerprintProbeCountForTests() {
|
|
1640
|
+
return fingerprintProbeCount;
|
|
1641
|
+
}
|
|
1642
|
+
export function resetRunListFingerprintProbeCountForTests() {
|
|
1643
|
+
fingerprintProbeCount = 0;
|
|
1644
|
+
}
|
|
1462
1645
|
function summaryFingerprint(rec) {
|
|
1463
1646
|
const mtime = (rel) => {
|
|
1647
|
+
fingerprintProbeCount++;
|
|
1464
1648
|
if (!rec.runDir)
|
|
1465
1649
|
return 0;
|
|
1466
1650
|
const path = safeArtifactPath(rec.runDir, rel);
|
|
@@ -1473,11 +1657,22 @@ function summaryFingerprint(rec) {
|
|
|
1473
1657
|
return 0;
|
|
1474
1658
|
}
|
|
1475
1659
|
};
|
|
1660
|
+
const identity = [rec.state, paramsFingerprint(rec), rec.finishedAt ?? "", rec.error ?? ""];
|
|
1661
|
+
// QA-052 terminal short-circuit: a TERMINAL run's every projected artifact
|
|
1662
|
+
// (events.jsonl, arbitration, telemetry, failure, the final/* outputs,
|
|
1663
|
+
// work_product) is written before the daemon marks the run terminal and never
|
|
1664
|
+
// changes afterward. The SOLE post-terminal mutation is the delivery/apply
|
|
1665
|
+
// overlay (final/delivery_state.yaml), which the apply/revert route is the one
|
|
1666
|
+
// writer of [B7]. So a warm re-list of a terminal run fingerprints ONE path
|
|
1667
|
+
// instead of twelve — the cheap probe still detects an apply/revert, and every
|
|
1668
|
+
// other axis is frozen. Active (queued/running) runs keep the full
|
|
1669
|
+
// artifact-mtime fingerprint because their events.jsonl and final/* land while
|
|
1670
|
+
// the run is still live.
|
|
1671
|
+
if (TERMINAL_STATES.has(rec.state)) {
|
|
1672
|
+
return [...identity, mtime("final/delivery_state.yaml")].join("|");
|
|
1673
|
+
}
|
|
1476
1674
|
return [
|
|
1477
|
-
|
|
1478
|
-
paramsFingerprint(rec),
|
|
1479
|
-
rec.finishedAt ?? "",
|
|
1480
|
-
rec.error ?? "",
|
|
1675
|
+
...identity,
|
|
1481
1676
|
mtime("events.jsonl"),
|
|
1482
1677
|
mtime("arbitration/decision.yaml"),
|
|
1483
1678
|
mtime("final/telemetry.yaml"),
|
|
@@ -1626,13 +1821,42 @@ function markRunApplyState(rec, state) {
|
|
|
1626
1821
|
* lifecycle (a decision-less plan/readonly/crash terminal). Null while the run
|
|
1627
1822
|
* is not terminal. Lives here as the ONE control-plane projection; surfaces
|
|
1628
1823
|
* read `outcomeFacts`, never re-derive. */
|
|
1629
|
-
function runOutcomeFacts(rec, decision, failure
|
|
1824
|
+
function runOutcomeFacts(rec, decision, failure,
|
|
1825
|
+
/** D-16: the winning read-only/plan attempt's work_state (from telemetry),
|
|
1826
|
+
* folded into a DECISION-LESS terminal so a needs_input/incomplete ask
|
|
1827
|
+
* carries its veto axis just like an arbitrated agent run. */
|
|
1828
|
+
workState = null) {
|
|
1630
1829
|
if (!isTerminalLifecycle(rec.state))
|
|
1631
1830
|
return null;
|
|
1831
|
+
// Arbitrated runs already carry work_state on decision.facts (INV-116).
|
|
1632
1832
|
if (decision)
|
|
1633
1833
|
return decision.facts;
|
|
1634
1834
|
const lifecycle = rec.state;
|
|
1635
|
-
|
|
1835
|
+
const base = outcomeFactsFromFailure(lifecycle, failure?.category);
|
|
1836
|
+
if (!workState || lifecycle !== "succeeded")
|
|
1837
|
+
return base;
|
|
1838
|
+
const vetoed = workState.state === "needs_input" || workState.state === "incomplete";
|
|
1839
|
+
return {
|
|
1840
|
+
...base,
|
|
1841
|
+
work_state: workState,
|
|
1842
|
+
...(vetoed
|
|
1843
|
+
? {
|
|
1844
|
+
reason: base.reason ??
|
|
1845
|
+
(workState.state === "needs_input" ? "input_required" : "work_incomplete"),
|
|
1846
|
+
}
|
|
1847
|
+
: {}),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
/** D-16: the winning attempt's work_state from a run's telemetry.yaml, used to
|
|
1851
|
+
* fold the veto axis into a decision-less terminal's outcome facts. */
|
|
1852
|
+
function winnerWorkStateFor(rec) {
|
|
1853
|
+
const telemetry = safeReadStructuredArtifact(rec, "final/telemetry.yaml", RunTelemetry);
|
|
1854
|
+
if (!telemetry)
|
|
1855
|
+
return null;
|
|
1856
|
+
const winner = telemetry.final_attempt_id
|
|
1857
|
+
? telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)
|
|
1858
|
+
: undefined;
|
|
1859
|
+
return winner?.outcome.work_state ?? null;
|
|
1636
1860
|
}
|
|
1637
1861
|
function summarizeRun(rec, eventsSnapshot) {
|
|
1638
1862
|
const p = paramsRecord(rec);
|
|
@@ -1725,8 +1949,14 @@ function summarizeRun(rec, eventsSnapshot) {
|
|
|
1725
1949
|
outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, readFailure(rec)),
|
|
1726
1950
|
toolWarningsTotal: telemetry?.tool_warnings_total ?? 0,
|
|
1727
1951
|
result: controlRunResult(rec),
|
|
1728
|
-
outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec)
|
|
1952
|
+
outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec), telemetry?.final_attempt_id
|
|
1953
|
+
? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
|
|
1954
|
+
.work_state ?? null)
|
|
1955
|
+
: null),
|
|
1729
1956
|
route: controlRoute(telemetry, p),
|
|
1957
|
+
// QA-034: routing rationale projected verbatim from the engine telemetry —
|
|
1958
|
+
// surfaces never reconstruct the order from prose.
|
|
1959
|
+
routingRationale: telemetry?.routing_rationale ?? null,
|
|
1730
1960
|
tests: requestTests ?? (contractTests?.length ? contractTests : undefined),
|
|
1731
1961
|
createdAt: rec.createdAt,
|
|
1732
1962
|
startedAt: rec.startedAt,
|
|
@@ -1776,7 +2006,9 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
|
|
|
1776
2006
|
// RunDetail perf (D15): parse events.jsonl ONCE per detail request and thread
|
|
1777
2007
|
// the snapshot through every event-consuming projection (timeline, budget
|
|
1778
2008
|
// fallback, plan progress) — before this each field re-parsed the whole log.
|
|
1779
|
-
const events = rec.runDir
|
|
2009
|
+
const { events, integrity } = rec.runDir
|
|
2010
|
+
? readRunEventsWithIntegrity(rec)
|
|
2011
|
+
: { events: [], integrity: undefined };
|
|
1780
2012
|
const summary = summarizeRun(rec, events);
|
|
1781
2013
|
// Non-null exactly when the run has an applyable patch — the authoritative
|
|
1782
2014
|
// "something to apply" banner signal (a convergence patch may carry kind:patch
|
|
@@ -1794,8 +2026,8 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
|
|
|
1794
2026
|
lastSeq,
|
|
1795
2027
|
artifacts: rec.runDir ? listArtifacts(rec.runDir) : [],
|
|
1796
2028
|
primaryOutput: primaryOutput(rec, summary.mode, failure),
|
|
1797
|
-
timeline: timelineEvents(rec, events),
|
|
1798
|
-
budget: budgetSnapshot(rec, decision, events),
|
|
2029
|
+
timeline: timelineEvents(rec, events, integrity),
|
|
2030
|
+
budget: budgetSnapshot(rec, decision, events, integrity),
|
|
1799
2031
|
finalSummary: boundedArtifactText(rec, "final/summary.md"),
|
|
1800
2032
|
decision,
|
|
1801
2033
|
operatorDecision: operatorDecisionRaw,
|
|
@@ -1812,8 +2044,13 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
|
|
|
1812
2044
|
// review artifacts; empty for single-envelope modes.
|
|
1813
2045
|
candidates: rec.runDir ? candidatesFor(rec.runDir, decision) : [],
|
|
1814
2046
|
// Live plan checklist: the winner's (else last) plan.progress items.
|
|
1815
|
-
planProgress: latestPlanProgress(rec, decision?.winner ?? null, events),
|
|
2047
|
+
planProgress: latestPlanProgress(rec, decision?.winner ?? null, events, integrity),
|
|
1816
2048
|
failure,
|
|
2049
|
+
// Minimal typed required-actions (GH #29) for a succeeded-but-blocked run,
|
|
2050
|
+
// from the single status-projection owner: review-blocked / checks-failed /
|
|
2051
|
+
// needs-decision / work_state needs_input, keyed to the same validated
|
|
2052
|
+
// operator decision the needs-decision + apply gates consult.
|
|
2053
|
+
requiredActions: requiredActionsFor(summary.outcomeFacts ?? null, operator !== null),
|
|
1817
2054
|
});
|
|
1818
2055
|
}
|
|
1819
2056
|
function readTextArtifact(rec, relPath, redact = true) {
|
|
@@ -1837,8 +2074,44 @@ function parseAccessMaybe(value) {
|
|
|
1837
2074
|
const parsed = AccessProfile.safeParse(value);
|
|
1838
2075
|
return parsed.success ? parsed.data : undefined;
|
|
1839
2076
|
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Pure subscription-VALUATION fold over a run's events (QA-023c/QA-017b),
|
|
2079
|
+
* independent of the cash truth. The owner-locked ledger settles native-
|
|
2080
|
+
* subscription work to cash $0 while accumulating its token valuation on
|
|
2081
|
+
* `budget.cash.valuation_usd` (last-wins, cumulative). An UNKNOWN valuation (no
|
|
2082
|
+
* usage ever reported — e.g. a Cursor draft) stays NULL and is never coerced to
|
|
2083
|
+
* a fake $0. The legacy fallback for runs predating budget.cash is the summed
|
|
2084
|
+
* `budget.observation` spend. Vendor token valuation is at best an estimate.
|
|
2085
|
+
*/
|
|
2086
|
+
export function budgetValuationFromEvents(events) {
|
|
2087
|
+
let valuationUsd = null;
|
|
2088
|
+
let observation = 0;
|
|
2089
|
+
let sawObservation = false;
|
|
2090
|
+
for (const ev of events) {
|
|
2091
|
+
const payload = eventPayload(ev);
|
|
2092
|
+
if (ev["type"] === "budget.cash") {
|
|
2093
|
+
const val = payload["valuation_usd"];
|
|
2094
|
+
if (typeof val === "number" && Number.isFinite(val))
|
|
2095
|
+
valuationUsd = val;
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
if (ev["type"] === "budget.observation" && payload["kind"] === "spend") {
|
|
2099
|
+
const usd = payload["usd"];
|
|
2100
|
+
if (typeof usd === "number" && Number.isFinite(usd)) {
|
|
2101
|
+
observation += usd;
|
|
2102
|
+
sawObservation = true;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
if (valuationUsd === null && sawObservation)
|
|
2107
|
+
valuationUsd = observation;
|
|
2108
|
+
return {
|
|
2109
|
+
valuationUsd,
|
|
2110
|
+
valuationKnowledge: valuationUsd === null ? "unknown" : "estimated",
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
1840
2113
|
/** Typed severity per event type — no string matching over event names. */
|
|
1841
|
-
function budgetSnapshot(rec, decision, eventsSnapshot) {
|
|
2114
|
+
function budgetSnapshot(rec, decision, eventsSnapshot, integrity) {
|
|
1842
2115
|
const p = paramsRecord(rec);
|
|
1843
2116
|
// The ENGINE-EFFECTIVE cap lives in the immutable contract (request input,
|
|
1844
2117
|
// surface default, or the configured global per-run default); request params
|
|
@@ -1847,9 +2120,13 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
|
|
|
1847
2120
|
.paid_budget;
|
|
1848
2121
|
const paidBudget = PaidBudget.safeParse(p["paidBudget"]).data ??
|
|
1849
2122
|
contractBudget ?? { kind: "unlimited" };
|
|
2123
|
+
const evs = eventsSnapshot ?? readRunEvents(rec);
|
|
1850
2124
|
let spendUsd = decision?.budget_summary?.spend_usd ?? null;
|
|
1851
2125
|
let estimated = decision?.budget_summary?.estimated ?? false;
|
|
1852
2126
|
let source = spendUsd === null ? "unknown" : "decision";
|
|
2127
|
+
// Subscription VALUATION (QA-023c/QA-017b) beside cash — computed by the pure
|
|
2128
|
+
// event fold so an UNKNOWN valuation stays null, never a fabricated $0.
|
|
2129
|
+
const { valuationUsd, valuationKnowledge } = budgetValuationFromEvents(evs);
|
|
1853
2130
|
if (spendUsd === null) {
|
|
1854
2131
|
// The CASH truth for a decision-less run (plan/ask/explore — they never
|
|
1855
2132
|
// write a decision record) is the ledger's own `budget.cash` disclosure:
|
|
@@ -1866,7 +2143,7 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
|
|
|
1866
2143
|
let eventSpend = 0;
|
|
1867
2144
|
let sawCost = false;
|
|
1868
2145
|
let sawUsage = false;
|
|
1869
|
-
for (const ev of
|
|
2146
|
+
for (const ev of evs) {
|
|
1870
2147
|
const payload = eventPayload(ev);
|
|
1871
2148
|
if (ev["type"] === "budget.cash") {
|
|
1872
2149
|
const cash = payload["cash_spend_usd"];
|
|
@@ -1921,7 +2198,20 @@ function budgetSnapshot(rec, decision, eventsSnapshot) {
|
|
|
1921
2198
|
const remainingUsd = paidBudget.kind === "finite" && spendUsd !== null
|
|
1922
2199
|
? Math.max(0, paidBudget.maxUsd - spendUsd)
|
|
1923
2200
|
: null;
|
|
1924
|
-
|
|
2201
|
+
// Disclose when the spend fallback read incomplete/unreadable canonical
|
|
2202
|
+
// events (QA-074): a partial event set can silently drop a spend tick, so the
|
|
2203
|
+
// projected spend must never present as clean when its evidence was not.
|
|
2204
|
+
const evidence = integrity ? evidenceLevel(integrity) : "complete";
|
|
2205
|
+
return ControlBudgetSnapshot.parse({
|
|
2206
|
+
paidBudget,
|
|
2207
|
+
spendUsd,
|
|
2208
|
+
valuationUsd,
|
|
2209
|
+
valuationKnowledge,
|
|
2210
|
+
remainingUsd,
|
|
2211
|
+
estimated,
|
|
2212
|
+
source,
|
|
2213
|
+
evidence,
|
|
2214
|
+
});
|
|
1925
2215
|
}
|
|
1926
2216
|
function readStructured(text, ext, schema) {
|
|
1927
2217
|
if (text === null)
|
|
@@ -1979,9 +2269,10 @@ function applyGateError(rec, patch, targetRepoRoot, operatorDecision, finalVerif
|
|
|
1979
2269
|
});
|
|
1980
2270
|
}
|
|
1981
2271
|
function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
|
|
2272
|
+
const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
|
|
1982
2273
|
return {
|
|
1983
2274
|
state: rec.state,
|
|
1984
|
-
decision
|
|
2275
|
+
decision,
|
|
1985
2276
|
workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
|
|
1986
2277
|
patch,
|
|
1987
2278
|
originalRepoRoot: runRepoRoot(rec),
|
|
@@ -1989,6 +2280,14 @@ function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
|
|
|
1989
2280
|
operatorDecision: operatorDecision
|
|
1990
2281
|
? { action: operatorDecision.action, patch_sha256: operatorDecision.patchSha256 }
|
|
1991
2282
|
: null,
|
|
2283
|
+
// Effective mutable delivery/apply state from the SAME owner that projects
|
|
2284
|
+
// summary.result.applyState (delivery_state overlay → work_product snapshot):
|
|
2285
|
+
// an already-applied / reverted run gets a terminal eligibility disposition
|
|
2286
|
+
// instead of a stale "rerun a fresh check" (QA-021).
|
|
2287
|
+
applyState: controlRunResult(rec).applyState,
|
|
2288
|
+
// D-16 work_state veto (INV-116): thread the model-attested work outcome so
|
|
2289
|
+
// the gate refuses a needs_input/incomplete winner even with a clean review.
|
|
2290
|
+
workState: decision?.facts?.work_state ?? null,
|
|
1992
2291
|
};
|
|
1993
2292
|
}
|
|
1994
2293
|
/**
|