ai-foreman 1.7.19 → 1.7.21
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/README.md +29 -5
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +24 -1
- package/dist/buildRuns.js +216 -70
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -10
- package/dist/index.js +4 -1
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +293 -41
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
package/dist/buildRuns.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { loadQaPreparationPolicy } from "./qaEffectiveConfig.js";
|
|
2
|
+
export { withBuildInvocation as withBuildRecoveryInvocation, launchDigest as launchArgumentDigest } from "./buildAdmission.js";
|
|
1
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { hostname } from "node:os";
|
|
4
6
|
import { join, resolve } from "node:path";
|
|
5
7
|
import { execFileSync } from "node:child_process";
|
|
@@ -12,11 +14,27 @@ import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } fro
|
|
|
12
14
|
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
13
15
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
14
16
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
15
|
-
import {
|
|
17
|
+
import { classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
18
|
+
import { assertBuildAssignmentReconciled } from "./buildAssignment.js";
|
|
16
19
|
export const BUILD_RUN_VERSION = 3;
|
|
17
20
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
18
21
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
19
22
|
export const BUILD_LEASE_STALE_MS = 45_000;
|
|
23
|
+
const buildAuthority = Symbol("buildAuthority");
|
|
24
|
+
export function bindBuildAuthority(run, lease) {
|
|
25
|
+
return { ...run, [buildAuthority]: lease };
|
|
26
|
+
}
|
|
27
|
+
function assertBuildAuthority(workflow, run) {
|
|
28
|
+
const token = run[buildAuthority];
|
|
29
|
+
if (!token)
|
|
30
|
+
throw new Error("build mutation requires the caller's original workflow authority");
|
|
31
|
+
const held = workflow.currentLease();
|
|
32
|
+
const authorizedSupersession = run.status === "superseded" && run.supersededBy === held?.runId;
|
|
33
|
+
if (token && (!held || held.owner !== token.owner || held.generation !== token.generation || held.runId !== token.runId))
|
|
34
|
+
throw new Error("workflow lease ownership changed");
|
|
35
|
+
if (held && (!token || (held.runId !== run.runId && !authorizedSupersession)))
|
|
36
|
+
throw new Error("build mutation requires the caller's original workflow authority");
|
|
37
|
+
}
|
|
20
38
|
export function createBuildRun(input) {
|
|
21
39
|
const now = input.now ?? new Date();
|
|
22
40
|
// Complete the one-time legacy import before the new mutable build snapshot
|
|
@@ -32,6 +50,8 @@ export function createBuildRun(input) {
|
|
|
32
50
|
runId: input.runId ?? randomUUID(),
|
|
33
51
|
status: "running",
|
|
34
52
|
tickets: [...input.tickets],
|
|
53
|
+
authorizedBatch: input.authorizedBatch ?? (input.tickets.length && (input.branchMode ?? "current") === "current" ? { tickets: [...input.tickets], requestedSteps: input.tickets.length, scopeRevision: "explicit-create-selection", startedTickets: [] } : undefined),
|
|
54
|
+
builderCapabilities: input.builderCapabilities,
|
|
35
55
|
deliveryUnit: input.deliveryUnit,
|
|
36
56
|
branchMode: input.branchMode ?? "current",
|
|
37
57
|
checkpoint: "created",
|
|
@@ -61,45 +81,37 @@ export function createBuildRun(input) {
|
|
|
61
81
|
createdAt: stamp,
|
|
62
82
|
updatedAt: stamp,
|
|
63
83
|
};
|
|
64
|
-
const saved = saveBuildRun(input.repositoryRoot, run, now);
|
|
65
84
|
const workflow = new WorkflowDb(input.repositoryRoot);
|
|
66
85
|
try {
|
|
67
|
-
workflow.
|
|
86
|
+
workflow.ensureRun(run.runId, "build", now);
|
|
87
|
+
const authority = workflow.acquireLease(run.runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
88
|
+
workflow.qaPreparationStore().freezePolicy(run.runId, loadQaPreparationPolicy(input.repositoryRoot));
|
|
89
|
+
return saveBuildRun(input.repositoryRoot, bindBuildAuthority(run, authority), now);
|
|
68
90
|
}
|
|
69
91
|
finally {
|
|
70
92
|
workflow.close();
|
|
71
93
|
}
|
|
72
|
-
return saved;
|
|
73
94
|
}
|
|
74
95
|
export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
75
96
|
let existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
76
97
|
if (!existing)
|
|
77
98
|
throw new Error(`recoverable build run not found: ${runId}`);
|
|
78
|
-
if (
|
|
99
|
+
if (["completed", "cancelled", "superseded"].includes(existing.status))
|
|
79
100
|
throw new Error(`build run ${runId} is ${existing.status} and cannot resume`);
|
|
80
101
|
const workflow = new WorkflowDb(projectDir);
|
|
102
|
+
let authority;
|
|
81
103
|
try {
|
|
82
|
-
|
|
83
|
-
if (held && held.runId === runId && held.pid === process.pid && held.host === hostname() && held.processStart === processStartIdentity(process.pid)) {
|
|
84
|
-
workflow.heartbeatLease(held, now);
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
88
|
-
}
|
|
104
|
+
authority = workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
89
105
|
if (patch.expectedRecoveryDecisionDigest) {
|
|
90
106
|
const decision = workflow.getRun(runId)?.state.recoveryDecision;
|
|
91
107
|
if (createHash("sha256").update(JSON.stringify(decision ?? null)).digest("hex") !== patch.expectedRecoveryDecisionDigest) {
|
|
92
|
-
|
|
93
|
-
if (lease?.runId === runId && lease.pid === process.pid)
|
|
94
|
-
workflow.releaseLease(lease, now);
|
|
108
|
+
workflow.releaseLease(authority, now);
|
|
95
109
|
throw new Error("Recovery decision changed before the child supervisor acquired its lease; inspect and retry");
|
|
96
110
|
}
|
|
97
111
|
}
|
|
98
112
|
existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
99
|
-
if (!existing ||
|
|
100
|
-
|
|
101
|
-
if (lease?.runId === runId && lease.pid === process.pid)
|
|
102
|
-
workflow.releaseLease(lease, now);
|
|
113
|
+
if (!existing || ["completed", "cancelled", "superseded"].includes(existing.status)) {
|
|
114
|
+
workflow.releaseLease(authority, now);
|
|
103
115
|
throw new Error("Recovery run completed or disappeared before lease acquisition");
|
|
104
116
|
}
|
|
105
117
|
}
|
|
@@ -111,7 +123,7 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
|
111
123
|
? undefined
|
|
112
124
|
: patch.builderSessionRef?.sessionId ?? patch.builderSessionId ?? existing.builder?.sessionId;
|
|
113
125
|
return saveBuildRun(projectDir, {
|
|
114
|
-
...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
|
|
126
|
+
...bindBuildAuthority(existing, authority), status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
|
|
115
127
|
builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(builderSessionId ? { sessionId: builderSessionId } : {}) } : existing.builder,
|
|
116
128
|
qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
|
|
117
129
|
sessionBindings: patch.builderSessionRef ? upsertSessionBinding(existing.sessionBindings, patch.builderSessionRef) : existing.sessionBindings,
|
|
@@ -123,15 +135,28 @@ function normalizeCapturedSettings(settings) {
|
|
|
123
135
|
...settings,
|
|
124
136
|
session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"),
|
|
125
137
|
display_session_cost: settings.display_session_cost ?? false,
|
|
126
|
-
auto_compact_threshold_percent: settings.auto_compact_threshold_percent ??
|
|
138
|
+
auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 65,
|
|
127
139
|
compact_maximum: settings.compact_maximum ?? 10,
|
|
128
140
|
settings_revision: settings.settings_revision ?? 0,
|
|
129
141
|
};
|
|
130
142
|
}
|
|
131
143
|
export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
144
|
+
if (!run[buildAuthority])
|
|
145
|
+
throw new Error("build mutation requires the caller's original workflow authority");
|
|
146
|
+
if (canonicalSessionPath(projectDir) !== canonicalSessionPath(run.repository.root))
|
|
147
|
+
throw new Error("Build authority belongs to another project");
|
|
148
|
+
const original = run[buildAuthority];
|
|
149
|
+
const current = readCurrentWorkflowLease(projectDir);
|
|
150
|
+
if (!current || current.owner !== original.owner || current.generation !== original.generation || current.runId !== original.runId)
|
|
151
|
+
throw new Error("workflow lease ownership changed");
|
|
132
152
|
validateBuildRun(run);
|
|
133
153
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
134
|
-
|
|
154
|
+
const progress = ["recoverable", "interrupted", "blocked", "failed"].includes(run.status) ? currentBranchTicketProgress(projectDir, run) : undefined;
|
|
155
|
+
if (progress)
|
|
156
|
+
run = { ...run,
|
|
157
|
+
currentTicket: progress.pendingQa.find(ticket => run.tickets.includes(ticket)) ?? (run.currentTicket && progress.remaining.includes(run.currentTicket) ? run.currentTicket : progress.remaining[0]),
|
|
158
|
+
progress: { ...run.progress, completedTickets: progress.completed, remainingTickets: progress.remaining },
|
|
159
|
+
};
|
|
135
160
|
const upgraded = upgradeBuildRun(run, projectDir, now);
|
|
136
161
|
const completedOperations = Object.keys(upgraded.receipts).sort();
|
|
137
162
|
let next = {
|
|
@@ -142,13 +167,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
142
167
|
const workflow = new WorkflowDb(projectDir);
|
|
143
168
|
const target = join(directory, `${run.runId}.json`);
|
|
144
169
|
let publicationId;
|
|
170
|
+
let temporary;
|
|
145
171
|
try {
|
|
146
172
|
workflow.atomic(() => {
|
|
173
|
+
assertBuildAuthority(workflow, run);
|
|
147
174
|
const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
|
|
148
175
|
next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
|
|
149
176
|
const existing = workflow.getRun(next.runId);
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
const priorProgress = existing?.state.progress;
|
|
178
|
+
next = { ...next, progress: { ...next.progress, completedTickets: [...new Set([...next.progress.completedTickets, ...(priorProgress?.completedTickets ?? [])])] } };
|
|
179
|
+
if (existing && ["completed", "cancelled", "superseded"].includes(existing.status) && workflowStatus(next.status) !== existing.status)
|
|
180
|
+
throw new Error(`cannot rewrite a ${existing.status} build run`);
|
|
152
181
|
if (!existing)
|
|
153
182
|
workflow.createRun({
|
|
154
183
|
runId: next.runId, kind: "build", checkpoint: next.checkpoint,
|
|
@@ -167,9 +196,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
167
196
|
publicationId = workflow.beginPublication(next.runId, { operation: "build-run-projection", target,
|
|
168
197
|
revision: createHash("sha256").update(JSON.stringify(next)).digest("hex"), updatedAt: next.updatedAt }, {}, now).transactionId;
|
|
169
198
|
});
|
|
199
|
+
mkdirSync(directory, { recursive: true });
|
|
170
200
|
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
201
|
+
temporary = temp;
|
|
171
202
|
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
172
|
-
|
|
203
|
+
workflow.atomic(() => {
|
|
204
|
+
assertBuildAuthority(workflow, run);
|
|
205
|
+
if (JSON.stringify(workflow.getRun(run.runId)?.state) !== JSON.stringify(next))
|
|
206
|
+
throw new Error("Build projection changed before publication");
|
|
207
|
+
renameSync(temp, target);
|
|
208
|
+
temporary = undefined;
|
|
209
|
+
});
|
|
173
210
|
workflow.atomic(() => {
|
|
174
211
|
workflow.updatePublication(publicationId, "committed", now);
|
|
175
212
|
// Publishing the latest authoritative revision also reconciles any older
|
|
@@ -182,6 +219,8 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
182
219
|
});
|
|
183
220
|
}
|
|
184
221
|
finally {
|
|
222
|
+
if (temporary)
|
|
223
|
+
rmSync(temporary, { force: true });
|
|
185
224
|
workflow.close();
|
|
186
225
|
}
|
|
187
226
|
return next;
|
|
@@ -243,7 +282,7 @@ export function recordBuildReceipt(projectDir, run, operationId, detail) {
|
|
|
243
282
|
return next;
|
|
244
283
|
}
|
|
245
284
|
export function heartbeatBuildRun(projectDir, run, now = new Date()) {
|
|
246
|
-
const lease =
|
|
285
|
+
const lease = run[buildAuthority];
|
|
247
286
|
if (!lease || lease.runId !== run.runId)
|
|
248
287
|
throw new Error("workflow lease ownership changed");
|
|
249
288
|
const next = heartbeatCurrentWorkflowLease(projectDir, lease, now);
|
|
@@ -251,21 +290,45 @@ export function heartbeatBuildRun(projectDir, run, now = new Date()) {
|
|
|
251
290
|
}
|
|
252
291
|
export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
253
292
|
const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
|
|
254
|
-
releaseWorkflowLease(projectDir, run
|
|
293
|
+
releaseWorkflowLease(projectDir, run);
|
|
255
294
|
return saved;
|
|
256
295
|
}
|
|
257
296
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
297
|
+
assertBuildAssignmentReconciled(projectDir, run.runId);
|
|
298
|
+
if (run.status === "completed") {
|
|
299
|
+
const committed = readBuildRuns(projectDir).find(item => item.runId === run.runId);
|
|
300
|
+
if (committed?.status === "completed" && JSON.stringify(committed) === JSON.stringify(run))
|
|
301
|
+
return committed;
|
|
302
|
+
}
|
|
258
303
|
const workflow = new WorkflowDb(projectDir);
|
|
259
304
|
try {
|
|
260
305
|
workflow.assertQaRunFinalizable(run.runId);
|
|
306
|
+
if (workflow.pendingHumanDecisions(run.runId).length)
|
|
307
|
+
throw new Error("build run has unanswered decisions");
|
|
308
|
+
if (["builder", "qa"].some(role => workflow.unresolvedRoleDispatches(run.runId, role).length))
|
|
309
|
+
throw new Error("build run has unresolved provider dispatches");
|
|
261
310
|
}
|
|
262
311
|
finally {
|
|
263
312
|
workflow.close();
|
|
264
313
|
}
|
|
265
314
|
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
|
|
266
|
-
releaseWorkflowLease(projectDir, run
|
|
315
|
+
releaseWorkflowLease(projectDir, run, now);
|
|
267
316
|
return saved;
|
|
268
317
|
}
|
|
318
|
+
/** A recovered ticket boundary is complete only when the saved run scope is done. */
|
|
319
|
+
export function finishRecoveredTicketScope(projectDir, run, checkpoint) {
|
|
320
|
+
if (run.branchMode !== "current" || !run.tickets.length || !isTicketsInitialized(projectDir))
|
|
321
|
+
throw new Error("Ticket recovery completion requires an initialized current-branch run scope");
|
|
322
|
+
const { completed, remaining } = currentBranchTicketProgress(projectDir, run);
|
|
323
|
+
if (!remaining.length && !run.authorizedBatch)
|
|
324
|
+
return releaseBuildLease(projectDir, checkpointBuildRun(projectDir, run, "legacy-scope-reconciliation-required", { progress: { ...run.progress, completedTickets: completed, remainingTickets: [], nextAction: "Confirm the original authorized batch scope before declaring the build complete" } }), "recoverable");
|
|
325
|
+
if (!remaining.length)
|
|
326
|
+
return completeBuildRun(projectDir, run);
|
|
327
|
+
return releaseBuildLease(projectDir, checkpointBuildRun(projectDir, run, checkpoint, {
|
|
328
|
+
currentTicket: remaining[0],
|
|
329
|
+
progress: { ...run.progress, completedTickets: completed, remainingTickets: remaining, nextAction: `Resume ${remaining[0]}` },
|
|
330
|
+
}), "recoverable");
|
|
331
|
+
}
|
|
269
332
|
export function readBuildRuns(projectDir) {
|
|
270
333
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
271
334
|
const workflow = new WorkflowReader(projectDir);
|
|
@@ -282,6 +345,17 @@ export function readBuildRuns(projectDir) {
|
|
|
282
345
|
const value = stored.state;
|
|
283
346
|
if (value?.runId)
|
|
284
347
|
candidates.set(value.runId, value);
|
|
348
|
+
else if (!["completed", "superseded", "cancelled"].includes(stored.status) && !candidates.has(stored.runId)) {
|
|
349
|
+
// Supervision/readiness can stop before the first full build snapshot.
|
|
350
|
+
// Keep that attempt visible without inventing a provider session or baseline.
|
|
351
|
+
candidates.set(stored.runId, {
|
|
352
|
+
version: 1, runId: stored.runId, status: stored.status === "running" ? "running" : "recoverable",
|
|
353
|
+
tickets: [], branchMode: "current", checkpoint: stored.checkpoint,
|
|
354
|
+
repository: { root: resolve(projectDir), worktree: resolve(projectDir) }, receipts: {},
|
|
355
|
+
createdAt: stored.createdAt, updatedAt: stored.updatedAt,
|
|
356
|
+
failure: { category: "preparation-incomplete", summary: "Build preparation stopped before a complete build checkpoint", at: stored.updatedAt },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
285
359
|
}
|
|
286
360
|
const runs = [...candidates.values()].flatMap((projection) => {
|
|
287
361
|
try {
|
|
@@ -289,8 +363,9 @@ export function readBuildRuns(projectDir) {
|
|
|
289
363
|
const authoritative = workflow?.getRun(projection.runId)?.state;
|
|
290
364
|
const run = authoritative?.runId ? authoritative : projection;
|
|
291
365
|
let upgraded = upgradeBuildRun(run, projectDir);
|
|
292
|
-
|
|
293
|
-
|
|
366
|
+
const dbStatus = workflow.getRun(run.runId)?.status;
|
|
367
|
+
if (dbStatus === "superseded" || dbStatus === "completed" || dbStatus === "cancelled")
|
|
368
|
+
upgraded = { ...upgraded, status: dbStatus };
|
|
294
369
|
if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
|
|
295
370
|
for (const role of ["builder", "qa"]) {
|
|
296
371
|
const lease = workflow?.roleMutationLease(run.runId, role);
|
|
@@ -304,36 +379,53 @@ export function readBuildRuns(projectDir) {
|
|
|
304
379
|
catch {
|
|
305
380
|
return [];
|
|
306
381
|
}
|
|
307
|
-
}).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
382
|
+
}).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.runId.localeCompare(b.runId));
|
|
308
383
|
workflow?.close();
|
|
309
384
|
return runs;
|
|
310
385
|
}
|
|
311
386
|
export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
387
|
+
const reader = new WorkflowReader(projectDir);
|
|
388
|
+
const databaseLease = reader.currentLease();
|
|
389
|
+
const admission = reader.buildAdmission();
|
|
390
|
+
const unfinished = readBuildRuns(projectDir).filter(run => !["completed", "cancelled", "superseded"].includes(run.status));
|
|
391
|
+
const cleanupRuns = [...new Set(reader.readinessProcesses().map(row => row.run_id))].filter(id => !unfinished.some(run => run.runId === id)).map(runId => {
|
|
392
|
+
const run = reader.getRun(runId);
|
|
393
|
+
const at = run?.updatedAt ?? new Date(0).toISOString();
|
|
394
|
+
return { ...upgradeBuildRun({ version: 1, runId, status: "recoverable", tickets: [], branchMode: "current", checkpoint: "readiness-cleanup", repository: { root: resolve(projectDir), worktree: resolve(projectDir) }, receipts: {}, createdAt: run?.createdAt ?? at, updatedAt: at }, projectDir), cleanupOnly: true };
|
|
395
|
+
});
|
|
396
|
+
try {
|
|
397
|
+
return [...unfinished, ...cleanupRuns]
|
|
398
|
+
.map(run => {
|
|
399
|
+
const owners = [];
|
|
400
|
+
if (admission?.runId === run.runId)
|
|
401
|
+
owners.push(classifyProcess(admission.pid, admission.processStart, admission.host));
|
|
402
|
+
if (databaseLease?.runId === run.runId)
|
|
403
|
+
owners.push(classifyProcess(databaseLease.pid, databaseLease.processStart, databaseLease.host));
|
|
404
|
+
if (run.supervisor?.pid && ["running", "stopping"].includes(run.supervisor.status))
|
|
405
|
+
owners.push(classifyProcess(run.supervisor.pid, run.supervisor.processStart));
|
|
406
|
+
if (!owners.length && run.lease)
|
|
407
|
+
owners.push(classifyProcess(run.lease.pid, run.lease.processStart, run.lease.hostname));
|
|
408
|
+
let owner = owners.find(item => item.state === "live") ?? owners.find(item => item.state === "unknown") ?? { state: "dead", reason: "No live owner recorded" };
|
|
409
|
+
if (owner.state !== "live" && reader.pendingBuildLaunches(run.runId).some(launch => launch.state === "dispatching"))
|
|
410
|
+
owner = { state: "unknown", reason: "launch dispatch began but no child claim was confirmed; reconcile the launcher and child process evidence before retrying" };
|
|
411
|
+
return { ...run, active: owner.state === "live", ownership: owner.state, ownershipReason: owner.reason };
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
reader.close();
|
|
416
|
+
}
|
|
323
417
|
}
|
|
324
418
|
/**
|
|
325
|
-
*
|
|
326
|
-
* selected ticket. Recover it from Foreman's tracker events so --ticket can
|
|
327
|
-
* still address those durable runs without rewriting the legacy record.
|
|
419
|
+
* Timestamp correlations are diagnostic hints, never executable membership.
|
|
328
420
|
*/
|
|
329
|
-
function
|
|
330
|
-
if (
|
|
331
|
-
return
|
|
421
|
+
export function legacyTrackerWorkHints(projectDir, run) {
|
|
422
|
+
if (!isTicketsInitialized(projectDir))
|
|
423
|
+
return [];
|
|
332
424
|
let db;
|
|
333
425
|
try {
|
|
334
426
|
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
335
427
|
if (!existsSync(paths.stateDb))
|
|
336
|
-
return
|
|
428
|
+
return [];
|
|
337
429
|
db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
338
430
|
const rows = db.prepare(`
|
|
339
431
|
SELECT ticket_id
|
|
@@ -345,26 +437,18 @@ function inferLegacyRunTickets(projectDir, run) {
|
|
|
345
437
|
ORDER BY timestamp, id
|
|
346
438
|
`).all(run.createdAt, run.updatedAt);
|
|
347
439
|
const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
|
|
348
|
-
|
|
349
|
-
return run;
|
|
350
|
-
return { ...run, tickets, currentTicket: tickets.at(-1) };
|
|
440
|
+
return tickets;
|
|
351
441
|
}
|
|
352
442
|
catch {
|
|
353
|
-
return
|
|
443
|
+
return [];
|
|
354
444
|
}
|
|
355
445
|
finally {
|
|
356
446
|
db?.close();
|
|
357
447
|
}
|
|
358
448
|
}
|
|
359
449
|
export function isLeaseActive(run, now = new Date()) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const age = now.getTime() - new Date(run.lease.heartbeatAt).getTime();
|
|
363
|
-
if (age > BUILD_LEASE_STALE_MS)
|
|
364
|
-
return false;
|
|
365
|
-
if (run.lease.hostname !== hostname())
|
|
366
|
-
return true;
|
|
367
|
-
return isLiveProcessIdentity(run.lease.pid, run.lease.processStart);
|
|
450
|
+
void now;
|
|
451
|
+
return Boolean(run.lease && classifyProcess(run.lease.pid, run.lease.processStart, run.lease.hostname).state !== "dead");
|
|
368
452
|
}
|
|
369
453
|
export function buildRecoveryPreview(run) {
|
|
370
454
|
const completed = Object.keys(run.receipts).sort();
|
|
@@ -381,6 +465,59 @@ export function buildRecoveryPreview(run) {
|
|
|
381
465
|
`QA session ${buildRunSessionBinding(run, "qa") ? "QA session candidate requires validation" : "fresh QA session required"}`,
|
|
382
466
|
];
|
|
383
467
|
}
|
|
468
|
+
/** Saved context may be broader than the work authorized for this invocation. */
|
|
469
|
+
export function recoveryExecutionTickets(run, requested, frozen) {
|
|
470
|
+
const scope = frozen && run.recoveryDecision?.executionTickets !== undefined
|
|
471
|
+
? run.recoveryDecision.executionTickets
|
|
472
|
+
: requested.length ? requested : run.tickets;
|
|
473
|
+
if (scope.some(ticket => !run.tickets.includes(ticket)) || requested.some(ticket => !scope.includes(ticket)) || (run.tickets.length && !scope.length)) {
|
|
474
|
+
throw new Error("Recovery execution scope conflicts with the saved run or requested ticket");
|
|
475
|
+
}
|
|
476
|
+
return [...new Set(scope)];
|
|
477
|
+
}
|
|
478
|
+
/** Read-only reconciliation also handles crashes before the last progress checkpoint. */
|
|
479
|
+
export function currentBranchTicketProgress(projectDir, run) {
|
|
480
|
+
if (run.branchMode !== "current" || !run.tickets.length || !isTicketsInitialized(projectDir))
|
|
481
|
+
return undefined;
|
|
482
|
+
if (run.status === "completed")
|
|
483
|
+
return { completed: [...run.tickets], remaining: [], pendingQa: [] };
|
|
484
|
+
const workflow = new WorkflowReader(projectDir);
|
|
485
|
+
let pendingQa;
|
|
486
|
+
let questions;
|
|
487
|
+
try {
|
|
488
|
+
pendingQa = workflow.pendingQaTicketIds(run.runId);
|
|
489
|
+
questions = workflow.pendingHumanDecisions(run.runId).filter(item => item.interruptionId.startsWith("ticket:")).map(item => item.interruptionId.slice(7));
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
workflow.close();
|
|
493
|
+
}
|
|
494
|
+
let state;
|
|
495
|
+
let completed = [];
|
|
496
|
+
try {
|
|
497
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
498
|
+
state = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
499
|
+
const read = state.prepare("SELECT status FROM ticket_state WHERE ticket_id=?");
|
|
500
|
+
completed = run.tickets.filter(ticket => read.get(ticket)?.status === "done" && !pendingQa.includes(ticket) && !questions.includes(ticket));
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Legacy, missing or unreadable tracker evidence cannot authorize completion.
|
|
504
|
+
// Keep recovery inspectable; actual dispatch still requires a usable tracker.
|
|
505
|
+
completed = [];
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
state?.close();
|
|
509
|
+
}
|
|
510
|
+
return { completed, remaining: run.tickets.filter(ticket => !completed.includes(ticket)), pendingQa };
|
|
511
|
+
}
|
|
512
|
+
/** Match the frozen preview at both the resume wrapper and direct start boundary. */
|
|
513
|
+
export function currentBranchRecoveryTicket(projectDir, run) {
|
|
514
|
+
if (run.currentTicket && !run.tickets.includes(run.currentTicket))
|
|
515
|
+
throw new Error(`Recovery ticket ${run.currentTicket} is outside the saved run scope`);
|
|
516
|
+
const ticket = projectBuildRecovery(projectDir, run).ticketId;
|
|
517
|
+
if (ticket && !run.tickets.includes(ticket))
|
|
518
|
+
throw new Error(`Recovery ticket ${ticket} is outside the saved run scope`);
|
|
519
|
+
return ticket;
|
|
520
|
+
}
|
|
384
521
|
export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride, sessionAvailability) {
|
|
385
522
|
let operationNames = [];
|
|
386
523
|
let issues = [];
|
|
@@ -397,7 +534,13 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
|
|
|
397
534
|
}
|
|
398
535
|
}
|
|
399
536
|
const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
|
|
400
|
-
const
|
|
537
|
+
const progress = currentBranchTicketProgress(projectDir, run);
|
|
538
|
+
if (progress && stateTicket && !run.tickets.includes(stateTicket))
|
|
539
|
+
throw new Error(`Recovery ticket ${stateTicket} is outside the saved run scope`);
|
|
540
|
+
const savedTicket = stateTicket ?? run.currentTicket;
|
|
541
|
+
const ticketId = ticketOverride ?? (progress
|
|
542
|
+
? progress.pendingQa.find(ticket => run.tickets.includes(ticket)) ?? (savedTicket && progress.remaining.includes(savedTicket) ? savedTicket : progress.remaining[0])
|
|
543
|
+
: savedTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0]);
|
|
401
544
|
let ticketTitle;
|
|
402
545
|
if (ticketId && isTicketsInitialized(projectDir)) {
|
|
403
546
|
try {
|
|
@@ -420,15 +563,15 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
|
|
|
420
563
|
unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
|
|
421
564
|
if (branchChanged)
|
|
422
565
|
unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
|
|
423
|
-
const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
|
|
424
|
-
const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
|
|
566
|
+
const completed = [...new Set([...(progress?.completed ?? run.progress.completedTickets), ...run.progress.completedOperations, ...operationNames])];
|
|
567
|
+
const remaining = progress?.remaining ?? (run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run));
|
|
425
568
|
const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
|
|
426
569
|
const validation = run.progress.validation
|
|
427
570
|
? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
|
|
428
571
|
: run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
|
|
429
572
|
const nextAction = ticketOverride
|
|
430
573
|
? `Resume ${ticketOverride} from ${run.checkpoint}`
|
|
431
|
-
: run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
|
|
574
|
+
: (progress ? undefined : run.progress.nextAction) ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
|
|
432
575
|
const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
|
|
433
576
|
const stateSessionId = typeof workflowState.sessionId === "string" ? workflowState.sessionId : undefined;
|
|
434
577
|
const sessionCandidateRef = buildRunSessionBinding(run, "builder", stateSessionId ?? run.builder?.sessionId);
|
|
@@ -510,6 +653,8 @@ function validateBuildRun(run) {
|
|
|
510
653
|
}
|
|
511
654
|
}
|
|
512
655
|
function workflowStatus(status) {
|
|
656
|
+
if (status === "cancelled")
|
|
657
|
+
return "cancelled";
|
|
513
658
|
if (status === "completed")
|
|
514
659
|
return "completed";
|
|
515
660
|
if (status === "failed")
|
|
@@ -524,7 +669,7 @@ function workflowStatus(status) {
|
|
|
524
669
|
}
|
|
525
670
|
function remainingTickets(run) {
|
|
526
671
|
const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
|
|
527
|
-
return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
|
|
672
|
+
return run.status === "completed" ? [] : (run.branchMode === "current" ? run.tickets : run.tickets.slice(Math.max(0, current))).filter(ticket => run.branchMode !== "current" || !run.progress.completedTickets.includes(ticket));
|
|
528
673
|
}
|
|
529
674
|
function captureGitSnapshot(worktree, input) {
|
|
530
675
|
const git = (args) => {
|
|
@@ -619,11 +764,12 @@ function upgradeBuildRun(run, projectDir = run.repository.root, now = new Date()
|
|
|
619
764
|
deferredTickets: [],
|
|
620
765
|
};
|
|
621
766
|
}
|
|
622
|
-
function releaseWorkflowLease(projectDir,
|
|
767
|
+
function releaseWorkflowLease(projectDir, run, now = new Date()) {
|
|
623
768
|
const workflow = new WorkflowDb(projectDir);
|
|
624
769
|
try {
|
|
625
|
-
|
|
626
|
-
|
|
770
|
+
assertBuildAuthority(workflow, run);
|
|
771
|
+
const lease = run[buildAuthority];
|
|
772
|
+
if (lease)
|
|
627
773
|
workflow.releaseLease(lease, now);
|
|
628
774
|
}
|
|
629
775
|
finally {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
import { buildScopeRevision } from "./buildApproval.js";
|
|
4
|
+
/** Explicit unticketed CLI mode freezes each work definition before its first dispatch. */
|
|
5
|
+
export function initializeSyntheticWork(projectDir, runId, steps, authorization, approvedPlan = "Explicit unticketed work authorized by the host") {
|
|
6
|
+
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 10000)
|
|
7
|
+
throw new Error("Invalid synthetic work step limit");
|
|
8
|
+
if (!approvedPlan.trim() || Buffer.byteLength(approvedPlan) > 262144)
|
|
9
|
+
throw new Error("Synthetic work requires a bounded, frozen approved preflight plan");
|
|
10
|
+
const db = new WorkflowDb(projectDir);
|
|
11
|
+
try {
|
|
12
|
+
return db.atomic(() => {
|
|
13
|
+
db.ensureRun(runId);
|
|
14
|
+
const existing = db.workDefinitions(runId).filter(work => work.kind === "synthetic");
|
|
15
|
+
if (existing.length)
|
|
16
|
+
return existing.map(work => work.workId);
|
|
17
|
+
const ids = [];
|
|
18
|
+
for (let step = 1; step <= steps; step++) {
|
|
19
|
+
const id = `synthetic:${randomUUID()}`;
|
|
20
|
+
const definition = { id, order: step, title: `Authorized unticketed step ${step}`, area: "project", priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Implement step ${step} of the explicitly authorized project work`, acceptance: ["Satisfy the approved project requirements and the host preflight plan"], required_tests: ["Run the relevant project validation"], likely_files: [], notes: `Frozen approved preflight plan:\n${approvedPlan}` };
|
|
21
|
+
db.admitWork({ runId, kind: "synthetic", workId: id, definition, approvalId: `unticketed:${runId}`, scopeRevision: buildScopeRevision(projectDir), provenance: { userTurn: authorization, reason: "Explicit unticketed CLI invocation after approval" } });
|
|
22
|
+
ids.push(id);
|
|
23
|
+
}
|
|
24
|
+
return ids;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { type BuildWorkAdmissionV1 } from "rafi-spec";
|
|
3
|
+
export type { BuildWorkAdmissionV1 } from "rafi-spec";
|
|
4
|
+
export interface AdmitWorkInput {
|
|
5
|
+
runId: string;
|
|
6
|
+
workId?: string;
|
|
7
|
+
kind: "ticket" | "synthetic";
|
|
8
|
+
ticketId?: string;
|
|
9
|
+
definition: unknown;
|
|
10
|
+
approvalId: string;
|
|
11
|
+
scopeRevision: string;
|
|
12
|
+
provenance: BuildWorkAdmissionV1["provenance"];
|
|
13
|
+
}
|
|
14
|
+
export declare function admittedWork(db: Database.Database, runId: string, workId: string): BuildWorkAdmissionV1 | undefined;
|
|
15
|
+
export declare function assertAdmittedWork(db: Database.Database, runId: string, workId: string): BuildWorkAdmissionV1;
|
|
16
|
+
/** Only host approval/reconciliation callers hold this service; reads never call it. */
|
|
17
|
+
export declare function admitWork(db: Database.Database, input: AdmitWorkInput, metadataRepair?: boolean): BuildWorkAdmissionV1;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
3
|
+
import { validateBuildWorkAdmissionV1 } from "rafi-spec";
|
|
4
|
+
export function admittedWork(db, runId, workId) {
|
|
5
|
+
const row = db.prepare("SELECT a.record_json FROM build_work_admissions a JOIN build_work_scope s USING(run_id,work_id) WHERE a.run_id=? AND a.work_id=? AND s.state='admitted' ORDER BY a.sequence DESC LIMIT 1").get(runId, workId);
|
|
6
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
7
|
+
}
|
|
8
|
+
export function assertAdmittedWork(db, runId, workId) {
|
|
9
|
+
const admission = admittedWork(db, runId, workId);
|
|
10
|
+
if (!admission)
|
|
11
|
+
throw new Error(`Work ${runId}/${workId} is not admitted; ownership reconciliation is required before dispatch`);
|
|
12
|
+
return admission;
|
|
13
|
+
}
|
|
14
|
+
/** Only host approval/reconciliation callers hold this service; reads never call it. */
|
|
15
|
+
export function admitWork(db, input, metadataRepair = false) {
|
|
16
|
+
if (!input.approvalId || !input.scopeRevision || !input.provenance.reason || !(input.provenance.userTurn || input.provenance.decisionId || input.provenance.approvedPlanDigest))
|
|
17
|
+
throw new Error("Work admission requires explicit authorization provenance and requirements revision");
|
|
18
|
+
if (input.kind === "ticket" && (!input.ticketId || input.workId && input.workId !== input.ticketId || input.ticketId.startsWith("synthetic:")))
|
|
19
|
+
throw new Error("Ticket work identity is invalid");
|
|
20
|
+
const workId = input.kind === "ticket" ? input.ticketId : input.workId ?? `synthetic:${randomUUID()}`;
|
|
21
|
+
if (input.kind === "synthetic" && !/^synthetic:[a-f0-9-]{36}$/.test(workId))
|
|
22
|
+
throw new Error("Synthetic work requires a persisted opaque identity");
|
|
23
|
+
const requirementsDigest = qaDigest("admitted-requirements", input.definition);
|
|
24
|
+
return db.transaction(() => {
|
|
25
|
+
const prior = admittedWork(db, input.runId, workId);
|
|
26
|
+
if (prior?.requirementsDigest === requirementsDigest && prior.scopeRevision === input.scopeRevision)
|
|
27
|
+
return prior;
|
|
28
|
+
const run = db.prepare("SELECT status FROM workflow_runs WHERE run_id=?").get(input.runId);
|
|
29
|
+
if (!run || !metadataRepair && ["completed", "cancelled", "superseded"].includes(run.status))
|
|
30
|
+
throw new Error("Admission requires a nonterminal build");
|
|
31
|
+
const quarantined = db.prepare("SELECT state FROM build_work_scope WHERE run_id=? AND work_id=?").get(input.runId, workId);
|
|
32
|
+
if (quarantined?.state === "quarantined")
|
|
33
|
+
throw new Error("Historical work requires audited ownership reconciliation before admission");
|
|
34
|
+
db.prepare("INSERT OR IGNORE INTO build_work_scope VALUES(?,?,?,?, 'admitted',?)").run(input.runId, workId, input.kind, input.ticketId ?? null, JSON.stringify(input.definition));
|
|
35
|
+
const definitionBytes = Buffer.from(JSON.stringify(input.definition));
|
|
36
|
+
const definitionDigest = createHash("sha256").update(definitionBytes).digest("hex");
|
|
37
|
+
db.prepare("INSERT OR IGNORE INTO content_refs VALUES(?,'qa',?,?)").run(definitionDigest, definitionBytes, new Date().toISOString());
|
|
38
|
+
db.prepare("UPDATE build_work_scope SET definition_json=? WHERE run_id=? AND work_id=?").run(JSON.stringify(input.definition), input.runId, workId);
|
|
39
|
+
const projectId = db.prepare("SELECT project_id FROM build_project_identity").get().project_id;
|
|
40
|
+
const record = { version: 1, projectId, runId: input.runId, workId, kind: input.kind, ...(input.ticketId ? { ticketId: input.ticketId } : {}), assignmentId: randomUUID(), approvalId: input.approvalId, scopeRevision: input.scopeRevision, requirementsDigest, definitionDigest, admittedAt: new Date().toISOString(), admittedSequence: 0, provenance: input.provenance };
|
|
41
|
+
// Reserve sequence explicitly so immutable JSON and its ordinal agree.
|
|
42
|
+
record.admittedSequence = db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS n FROM build_work_admissions").get().n;
|
|
43
|
+
const validation = validateBuildWorkAdmissionV1(record);
|
|
44
|
+
if (!validation.valid)
|
|
45
|
+
throw new Error(`Invalid work admission: ${validation.errors.join("; ")}`);
|
|
46
|
+
db.prepare("INSERT INTO build_work_admissions VALUES(?,?,?,?,?,?,?,?,?,?)").run(record.admittedSequence, randomUUID(), record.runId, workId, record.assignmentId, record.approvalId, record.scopeRevision, requirementsDigest, JSON.stringify(record), record.admittedAt);
|
|
47
|
+
const snapshot = db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(input.runId);
|
|
48
|
+
const state = JSON.parse(snapshot.state_json);
|
|
49
|
+
if (metadataRepair && !Array.isArray(state.tickets))
|
|
50
|
+
state.tickets = [];
|
|
51
|
+
if ((metadataRepair || state.version !== undefined) && Array.isArray(state.tickets) && !state.tickets.includes(workId)) {
|
|
52
|
+
state.tickets.push(workId);
|
|
53
|
+
db.prepare("UPDATE workflow_runs SET state_json=? WHERE run_id=?").run(JSON.stringify(state), input.runId);
|
|
54
|
+
}
|
|
55
|
+
return record;
|
|
56
|
+
}).immediate();
|
|
57
|
+
}
|