ai-foreman 1.7.20 → 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 +12 -2
- 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 +90 -4
- package/dist/buildAdmission.js +2 -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/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 +6 -0
- package/dist/buildRuns.js +23 -17
- 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.js +28 -8
- package/dist/cli/start.js +152 -48
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +11 -0
- package/dist/continuity.js +22 -6
- package/dist/foreman.d.ts +10 -2
- package/dist/foreman.js +330 -39
- 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 -11
- package/dist/index.js +2 -0
- 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.js +7 -0
- 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 +234 -24
- 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.js +12 -5
- package/dist/recoveryGuidance.d.ts +9 -0
- package/dist/recoveryGuidance.js +13 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +1 -0
- package/dist/runtimeReadiness.js +13 -1
- 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 +50 -1
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.js +18 -8
- 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/workflowDb.d.ts +51 -5
- package/dist/workflowDb.js +338 -7
- package/dist/workflowReader.d.ts +109 -2
- package/dist/workflowReader.js +48 -4
- package/package.json +84 -7
- 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,3 +1,4 @@
|
|
|
1
|
+
import { loadQaPreparationPolicy } from "./qaEffectiveConfig.js";
|
|
1
2
|
export { withBuildInvocation as withBuildRecoveryInvocation, launchDigest as launchArgumentDigest } from "./buildAdmission.js";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -14,6 +15,7 @@ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./t
|
|
|
14
15
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
15
16
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
16
17
|
import { classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
18
|
+
import { assertBuildAssignmentReconciled } from "./buildAssignment.js";
|
|
17
19
|
export const BUILD_RUN_VERSION = 3;
|
|
18
20
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
19
21
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
@@ -48,6 +50,8 @@ export function createBuildRun(input) {
|
|
|
48
50
|
runId: input.runId ?? randomUUID(),
|
|
49
51
|
status: "running",
|
|
50
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,
|
|
51
55
|
deliveryUnit: input.deliveryUnit,
|
|
52
56
|
branchMode: input.branchMode ?? "current",
|
|
53
57
|
checkpoint: "created",
|
|
@@ -81,6 +85,7 @@ export function createBuildRun(input) {
|
|
|
81
85
|
try {
|
|
82
86
|
workflow.ensureRun(run.runId, "build", now);
|
|
83
87
|
const authority = workflow.acquireLease(run.runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
88
|
+
workflow.qaPreparationStore().freezePolicy(run.runId, loadQaPreparationPolicy(input.repositoryRoot));
|
|
84
89
|
return saveBuildRun(input.repositoryRoot, bindBuildAuthority(run, authority), now);
|
|
85
90
|
}
|
|
86
91
|
finally {
|
|
@@ -130,7 +135,7 @@ function normalizeCapturedSettings(settings) {
|
|
|
130
135
|
...settings,
|
|
131
136
|
session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"),
|
|
132
137
|
display_session_cost: settings.display_session_cost ?? false,
|
|
133
|
-
auto_compact_threshold_percent: settings.auto_compact_threshold_percent ??
|
|
138
|
+
auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 65,
|
|
134
139
|
compact_maximum: settings.compact_maximum ?? 10,
|
|
135
140
|
settings_revision: settings.settings_revision ?? 0,
|
|
136
141
|
};
|
|
@@ -169,6 +174,8 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
169
174
|
const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
|
|
170
175
|
next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
|
|
171
176
|
const existing = workflow.getRun(next.runId);
|
|
177
|
+
const priorProgress = existing?.state.progress;
|
|
178
|
+
next = { ...next, progress: { ...next.progress, completedTickets: [...new Set([...next.progress.completedTickets, ...(priorProgress?.completedTickets ?? [])])] } };
|
|
172
179
|
if (existing && ["completed", "cancelled", "superseded"].includes(existing.status) && workflowStatus(next.status) !== existing.status)
|
|
173
180
|
throw new Error(`cannot rewrite a ${existing.status} build run`);
|
|
174
181
|
if (!existing)
|
|
@@ -287,6 +294,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
|
287
294
|
return saved;
|
|
288
295
|
}
|
|
289
296
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
297
|
+
assertBuildAssignmentReconciled(projectDir, run.runId);
|
|
290
298
|
if (run.status === "completed") {
|
|
291
299
|
const committed = readBuildRuns(projectDir).find(item => item.runId === run.runId);
|
|
292
300
|
if (committed?.status === "completed" && JSON.stringify(committed) === JSON.stringify(run))
|
|
@@ -312,6 +320,8 @@ export function finishRecoveredTicketScope(projectDir, run, checkpoint) {
|
|
|
312
320
|
if (run.branchMode !== "current" || !run.tickets.length || !isTicketsInitialized(projectDir))
|
|
313
321
|
throw new Error("Ticket recovery completion requires an initialized current-branch run scope");
|
|
314
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");
|
|
315
325
|
if (!remaining.length)
|
|
316
326
|
return completeBuildRun(projectDir, run);
|
|
317
327
|
return releaseBuildLease(projectDir, checkpointBuildRun(projectDir, run, checkpoint, {
|
|
@@ -354,10 +364,8 @@ export function readBuildRuns(projectDir) {
|
|
|
354
364
|
const run = authoritative?.runId ? authoritative : projection;
|
|
355
365
|
let upgraded = upgradeBuildRun(run, projectDir);
|
|
356
366
|
const dbStatus = workflow.getRun(run.runId)?.status;
|
|
357
|
-
if (dbStatus === "superseded" || dbStatus === "completed")
|
|
367
|
+
if (dbStatus === "superseded" || dbStatus === "completed" || dbStatus === "cancelled")
|
|
358
368
|
upgraded = { ...upgraded, status: dbStatus };
|
|
359
|
-
if (dbStatus === "cancelled")
|
|
360
|
-
return [];
|
|
361
369
|
if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
|
|
362
370
|
for (const role of ["builder", "qa"]) {
|
|
363
371
|
const lease = workflow?.roleMutationLease(run.runId, role);
|
|
@@ -379,7 +387,7 @@ export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
|
379
387
|
const reader = new WorkflowReader(projectDir);
|
|
380
388
|
const databaseLease = reader.currentLease();
|
|
381
389
|
const admission = reader.buildAdmission();
|
|
382
|
-
const unfinished = readBuildRuns(projectDir).filter(run => !["completed", "superseded"].includes(run.status));
|
|
390
|
+
const unfinished = readBuildRuns(projectDir).filter(run => !["completed", "cancelled", "superseded"].includes(run.status));
|
|
383
391
|
const cleanupRuns = [...new Set(reader.readinessProcesses().map(row => row.run_id))].filter(id => !unfinished.some(run => run.runId === id)).map(runId => {
|
|
384
392
|
const run = reader.getRun(runId);
|
|
385
393
|
const at = run?.updatedAt ?? new Date(0).toISOString();
|
|
@@ -400,7 +408,7 @@ export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
|
400
408
|
let owner = owners.find(item => item.state === "live") ?? owners.find(item => item.state === "unknown") ?? { state: "dead", reason: "No live owner recorded" };
|
|
401
409
|
if (owner.state !== "live" && reader.pendingBuildLaunches(run.runId).some(launch => launch.state === "dispatching"))
|
|
402
410
|
owner = { state: "unknown", reason: "launch dispatch began but no child claim was confirmed; reconcile the launcher and child process evidence before retrying" };
|
|
403
|
-
return { ...
|
|
411
|
+
return { ...run, active: owner.state === "live", ownership: owner.state, ownershipReason: owner.reason };
|
|
404
412
|
});
|
|
405
413
|
}
|
|
406
414
|
finally {
|
|
@@ -408,18 +416,16 @@ export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
|
408
416
|
}
|
|
409
417
|
}
|
|
410
418
|
/**
|
|
411
|
-
*
|
|
412
|
-
* selected ticket. Recover it from Foreman's tracker events so --ticket can
|
|
413
|
-
* still address those durable runs without rewriting the legacy record.
|
|
419
|
+
* Timestamp correlations are diagnostic hints, never executable membership.
|
|
414
420
|
*/
|
|
415
|
-
function
|
|
416
|
-
if (
|
|
417
|
-
return
|
|
421
|
+
export function legacyTrackerWorkHints(projectDir, run) {
|
|
422
|
+
if (!isTicketsInitialized(projectDir))
|
|
423
|
+
return [];
|
|
418
424
|
let db;
|
|
419
425
|
try {
|
|
420
426
|
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
421
427
|
if (!existsSync(paths.stateDb))
|
|
422
|
-
return
|
|
428
|
+
return [];
|
|
423
429
|
db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
424
430
|
const rows = db.prepare(`
|
|
425
431
|
SELECT ticket_id
|
|
@@ -431,12 +437,10 @@ function inferLegacyRunTickets(projectDir, run) {
|
|
|
431
437
|
ORDER BY timestamp, id
|
|
432
438
|
`).all(run.createdAt, run.updatedAt);
|
|
433
439
|
const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
|
|
434
|
-
|
|
435
|
-
return run;
|
|
436
|
-
return { ...run, tickets, currentTicket: tickets.at(-1) };
|
|
440
|
+
return tickets;
|
|
437
441
|
}
|
|
438
442
|
catch {
|
|
439
|
-
return
|
|
443
|
+
return [];
|
|
440
444
|
}
|
|
441
445
|
finally {
|
|
442
446
|
db?.close();
|
|
@@ -649,6 +653,8 @@ function validateBuildRun(run) {
|
|
|
649
653
|
}
|
|
650
654
|
}
|
|
651
655
|
function workflowStatus(status) {
|
|
656
|
+
if (status === "cancelled")
|
|
657
|
+
return "cancelled";
|
|
652
658
|
if (status === "completed")
|
|
653
659
|
return "completed";
|
|
654
660
|
if (status === "failed")
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
2
|
+
import { buildScopeRevision } from "./buildApproval.js";
|
|
3
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
4
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
5
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
6
|
+
/** Called by the CLI only after its source-bound approval gate succeeds. */
|
|
7
|
+
export function admitApprovedTicket(projectDir, runId, ticketId, approvedRevision, authorization) {
|
|
8
|
+
const ticket = loadTickets(resolveTicketPaths(loadTicketsConfig(projectDir), projectDir).tickets).find(ticket => ticket.id === ticketId);
|
|
9
|
+
if (!ticket || buildScopeRevision(projectDir) !== approvedRevision)
|
|
10
|
+
throw new Error("Ticket requirements changed after approval; reselect and approve current work");
|
|
11
|
+
const db = new WorkflowDb(projectDir);
|
|
12
|
+
try {
|
|
13
|
+
db.atomic(() => {
|
|
14
|
+
const current = loadTickets(resolveTicketPaths(loadTicketsConfig(projectDir), projectDir).tickets).find(ticket => ticket.id === ticketId);
|
|
15
|
+
if (!current || buildScopeRevision(projectDir) !== approvedRevision || JSON.stringify(current) !== JSON.stringify(ticket))
|
|
16
|
+
throw new Error("Ticket eligibility or requirements changed during admission; reselect approved work");
|
|
17
|
+
db.admitWork({ runId, kind: "ticket", ticketId, definition: ticket, approvalId: qaDigest("build-work-approval", { runId, approvedRevision, authorization }), scopeRevision: approvedRevision, provenance: { userTurn: authorization, reason: "CLI source-bound build approval gate succeeded" } });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
db.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Full authoritative definition, shared by initial/resumed/remediation paths. */
|
|
2
|
+
export function renderBuildWorkContext(ticket, checklist) {
|
|
3
|
+
return ["Full assigned work definition (scope authority):", JSON.stringify(ticket, null, 2),
|
|
4
|
+
"Project validation checklist:", ...(checklist.length ? checklist.map((item, index) => `${index + 1}. ${item}`) : ["No additional project checklist configured."]),
|
|
5
|
+
"Implement this assigned work and provide verification evidence. Final QA runs independently in a separate disposable snapshot and fresh reviewer conversation."].join("\n");
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
export declare const BUILD_WORK_SCHEMA_VERSION = 4;
|
|
3
|
+
/** A copied database must be rebound by state transfer, never by an ordinary writer. */
|
|
4
|
+
export declare function assertBuildProject(db: Database.Database, projectDir: string): void;
|
|
5
|
+
/** Classify legacy records without converting observed progress into permission. */
|
|
6
|
+
export declare function migrateBuildWork(db: Database.Database, projectDir: string, faults?: {
|
|
7
|
+
beforeGuards?(): void;
|
|
8
|
+
beforeCommit?(): void;
|
|
9
|
+
}): void;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { classifyProcess } from "./processIdentity.js";
|
|
5
|
+
export const BUILD_WORK_SCHEMA_VERSION = 4;
|
|
6
|
+
const migration = "004_admitted_build_work";
|
|
7
|
+
/** A copied database must be rebound by state transfer, never by an ordinary writer. */
|
|
8
|
+
export function assertBuildProject(db, projectDir) {
|
|
9
|
+
const identity = db.prepare("SELECT canonical_root FROM build_project_identity WHERE singleton=1").get();
|
|
10
|
+
if (identity.canonical_root !== realpathSync(projectDir))
|
|
11
|
+
throw new Error("Build project identity requires explicit state-transfer rebinding");
|
|
12
|
+
}
|
|
13
|
+
/** Classify legacy records without converting observed progress into permission. */
|
|
14
|
+
export function migrateBuildWork(db, projectDir, faults) {
|
|
15
|
+
if (db.prepare("SELECT 1 FROM recovery_schema_migrations WHERE migration=?").get(migration)) {
|
|
16
|
+
assertBuildProject(db, projectDir);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const lease = db.prepare("SELECT pid,process_start,host FROM project_lease").get();
|
|
20
|
+
if (lease && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
|
|
21
|
+
throw new Error("Work admission upgrade requires stopped, verified writers and a consistent backup");
|
|
22
|
+
const owner = db.prepare("SELECT record_json FROM build_admission").get();
|
|
23
|
+
if (owner) {
|
|
24
|
+
const identity = JSON.parse(owner.record_json);
|
|
25
|
+
if (classifyProcess(identity.pid, identity.processStart, identity.host).state !== "dead")
|
|
26
|
+
throw new Error("Work admission upgrade requires a stopped, verified build owner");
|
|
27
|
+
}
|
|
28
|
+
const backup = db.serialize();
|
|
29
|
+
// The coherent WAL-visible backup survives even a rolled-back upgrade.
|
|
30
|
+
// Publish the packet copy and manifest before enabling any new writer.
|
|
31
|
+
const backupDigest = createHash("sha256").update(backup).digest("hex");
|
|
32
|
+
const backupRoot = join(projectDir, ".rafi", "backups", "work-admission-v4");
|
|
33
|
+
const backupPath = join(backupRoot, backupDigest);
|
|
34
|
+
mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
|
|
35
|
+
if (!existsSync(backupPath)) {
|
|
36
|
+
const staging = join(backupRoot, `${backupDigest}.${randomUUID()}.tmp`);
|
|
37
|
+
mkdirSync(staging, { mode: 0o700 });
|
|
38
|
+
const file = join(staging, "recovery.sqlite3");
|
|
39
|
+
writeFileSync(file, backup, { mode: 0o600 });
|
|
40
|
+
const descriptor = openSync(file, "r");
|
|
41
|
+
try {
|
|
42
|
+
fsyncSync(descriptor);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
closeSync(descriptor);
|
|
46
|
+
}
|
|
47
|
+
const packets = join(projectDir, ".foreman", "qa-report-recovery");
|
|
48
|
+
if (existsSync(packets))
|
|
49
|
+
cpSync(packets, join(staging, "qa-report-recovery"), { recursive: true, dereference: false });
|
|
50
|
+
const packetFiles = syncBackupTree(staging);
|
|
51
|
+
const manifest = join(staging, "manifest.json");
|
|
52
|
+
writeFileSync(manifest, JSON.stringify({ version: 1, databaseDigest: backupDigest, canonicalRoot: realpathSync(projectDir), createdAt: new Date().toISOString(), packetCopy: existsSync(packets), files: packetFiles }), { mode: 0o600 });
|
|
53
|
+
syncFile(manifest);
|
|
54
|
+
syncDirectory(staging);
|
|
55
|
+
renameSync(staging, backupPath);
|
|
56
|
+
syncDirectory(backupRoot);
|
|
57
|
+
}
|
|
58
|
+
if (createHash("sha256").update(readFileSync(join(backupPath, "recovery.sqlite3"))).digest("hex") !== backupDigest)
|
|
59
|
+
throw new Error("Work admission backup failed integrity verification; preserve state before retry");
|
|
60
|
+
try {
|
|
61
|
+
db.transaction(() => {
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE build_work_upgrade_backup(singleton INTEGER PRIMARY KEY CHECK(singleton=1),database_bytes BLOB NOT NULL,created_at TEXT NOT NULL);
|
|
64
|
+
CREATE TABLE build_project_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),project_id TEXT NOT NULL UNIQUE,canonical_root TEXT NOT NULL);
|
|
65
|
+
CREATE TABLE build_work_scope(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,kind TEXT NOT NULL CHECK(kind IN ('ticket','synthetic')),ticket_id TEXT,state TEXT NOT NULL CHECK(state IN ('admitted','quarantined')),definition_json TEXT NOT NULL,PRIMARY KEY(run_id,work_id),UNIQUE(run_id,ticket_id),CHECK((kind='ticket' AND ticket_id=work_id) OR (kind='synthetic' AND ticket_id IS NULL)));
|
|
66
|
+
CREATE TABLE build_work_admissions(sequence INTEGER PRIMARY KEY AUTOINCREMENT,admission_id TEXT NOT NULL UNIQUE,run_id TEXT NOT NULL,work_id TEXT NOT NULL,assignment_id TEXT NOT NULL,approval_id TEXT NOT NULL,scope_revision TEXT NOT NULL,requirements_digest TEXT NOT NULL,record_json TEXT NOT NULL,admitted_at TEXT NOT NULL,FOREIGN KEY(run_id,work_id) REFERENCES build_work_scope(run_id,work_id));
|
|
67
|
+
CREATE TABLE build_assignments(assignment_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,work_id TEXT NOT NULL,operation_id TEXT NOT NULL UNIQUE,record_json TEXT NOT NULL,FOREIGN KEY(run_id,work_id) REFERENCES build_work_scope(run_id,work_id));
|
|
68
|
+
CREATE TABLE build_ownership_conflicts(conflict_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,work_id TEXT NOT NULL,status TEXT NOT NULL,revision INTEGER NOT NULL,record_json TEXT NOT NULL,FOREIGN KEY(run_id,work_id) REFERENCES build_work_scope(run_id,work_id));
|
|
69
|
+
CREATE TABLE build_work_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,table_name TEXT NOT NULL,record_key TEXT NOT NULL,action TEXT NOT NULL,record_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
70
|
+
CREATE TABLE build_reconciliations(reconciliation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),expected_revision TEXT NOT NULL,record_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
71
|
+
`);
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
db.prepare("INSERT INTO build_work_upgrade_backup VALUES(1,?,?)").run(backup, now);
|
|
74
|
+
db.prepare("INSERT INTO build_project_identity VALUES(1,?,?)").run(randomUUID(), realpathSync(projectDir));
|
|
75
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'qa_%'").all().filter(({ name }) => /^[a-z_]+$/.test(name));
|
|
76
|
+
for (const { name } of tables) {
|
|
77
|
+
const columns = db.prepare(`PRAGMA table_info(${name})`).all();
|
|
78
|
+
if (!columns.some(column => column.name === "run_id") || !columns.some(column => column.name === "ticket_id"))
|
|
79
|
+
continue;
|
|
80
|
+
for (const row of db.prepare(`SELECT DISTINCT run_id,ticket_id FROM ${name}`).all()) {
|
|
81
|
+
db.prepare("INSERT OR IGNORE INTO build_work_scope VALUES(?,?,'ticket',?,'quarantined','{}')").run(row.run_id, row.ticket_id, row.ticket_id);
|
|
82
|
+
db.prepare("INSERT OR IGNORE INTO build_ownership_conflicts VALUES(?,?,?,'unresolved',1,?)").run(`legacy:${row.run_id}:${row.ticket_id}`, row.run_id, row.ticket_id, JSON.stringify({ reason: "Legacy QA progress is not proof of run-bound approval and assignment", classification: "ownership_unestablished", observedTable: name }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
faults?.beforeGuards?.();
|
|
86
|
+
// Replace V3 guards atomically. Old connections register 3 and fail even on heartbeat writes.
|
|
87
|
+
for (const trigger of db.prepare("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND name LIKE 'handback_v3_%'").all()) {
|
|
88
|
+
db.exec(`DROP TRIGGER "${trigger.name}"`);
|
|
89
|
+
db.exec(trigger.sql.replace("rafi_writer_protocol() != 3", "rafi_writer_protocol() != 4"));
|
|
90
|
+
}
|
|
91
|
+
db.function("rafi_writer_protocol", () => 4);
|
|
92
|
+
for (const { name } of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'build_%'").all()) {
|
|
93
|
+
if (!/^[a-z_]+$/.test(name))
|
|
94
|
+
throw new Error("Unexpected build storage table");
|
|
95
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"])
|
|
96
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS work_v4_protocol_${name}_${action} BEFORE ${action} ON ${name} BEGIN SELECT CASE WHEN rafi_writer_protocol()<>4 THEN RAISE(ABORT,'incompatible work admission writer') END; END`);
|
|
97
|
+
}
|
|
98
|
+
for (const { name } of tables) {
|
|
99
|
+
const columns = db.prepare(`PRAGMA table_info(${name})`).all();
|
|
100
|
+
let scope;
|
|
101
|
+
if (columns.some(column => column.name === "run_id") && columns.some(column => column.name === "ticket_id"))
|
|
102
|
+
scope = target => `s.run_id=${target}.run_id AND s.work_id=${target}.ticket_id`;
|
|
103
|
+
else if (columns.some(column => column.name === "run_id") && columns.some(column => column.name === "work_id"))
|
|
104
|
+
scope = target => `s.run_id=${target}.run_id AND s.work_id=${target}.work_id`;
|
|
105
|
+
else if (name === "qa_preparation_attempts" || name === "qa_preparation_progress")
|
|
106
|
+
scope = target => `EXISTS(SELECT 1 FROM qa_preparation_budgets b WHERE b.id=${target}.budget_id AND b.run_id=s.run_id AND b.work_id=s.work_id)`;
|
|
107
|
+
else if (name === "qa_contract_receipts")
|
|
108
|
+
scope = target => `EXISTS(SELECT 1 FROM qa_verification_contracts c WHERE c.digest=${target}.contract_digest AND c.run_id=s.run_id AND c.work_id=s.work_id)`;
|
|
109
|
+
else if (columns.some(column => column.name === "report_occurrence_id"))
|
|
110
|
+
scope = target => `EXISTS(SELECT 1 FROM qa_reports r WHERE r.report_occurrence_id=${target}.report_occurrence_id AND r.run_id=s.run_id AND r.ticket_id=s.work_id)`;
|
|
111
|
+
else if (columns.some(column => column.name === "operation_id"))
|
|
112
|
+
scope = target => `EXISTS(SELECT 1 FROM qa_turns t WHERE t.operation_id=${target}.operation_id AND t.run_id=s.run_id AND t.ticket_id=s.work_id)`;
|
|
113
|
+
else
|
|
114
|
+
continue;
|
|
115
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
116
|
+
const target = action === "DELETE" ? "OLD" : "NEW";
|
|
117
|
+
db.exec(`CREATE TRIGGER work_v4_scope_${name}_${action} BEFORE ${action} ON ${name} WHEN NOT EXISTS(SELECT 1 FROM build_work_scope s WHERE ${scope(target)} AND s.state='admitted') BEGIN SELECT RAISE(ABORT,'QA work is not admitted; reconcile ownership first'); END`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const { name } of tables) {
|
|
121
|
+
const columns = db.prepare(`PRAGMA table_info(${name})`).all();
|
|
122
|
+
if (!columns.some(column => column.name === "run_id") || !columns.some(column => column.name === "ticket_id"))
|
|
123
|
+
continue;
|
|
124
|
+
const keys = columns.filter(column => column.pk).sort((a, b) => a.pk - b.pk).map(column => `NEW.${column.name}`);
|
|
125
|
+
const retained = columns.filter(column => column.pk || ["status", "state", "disposition", "review_number", "from_revision", "to_revision", "source_digest", "source_state_digest", "report_digest", "review_basis_digest", "certificate_id", "generation", "request_digest", "response_digest", "summary_digest", "event_json"].includes(column.name)).flatMap(column => [`'${column.name}'`, `NEW.${column.name}`]);
|
|
126
|
+
for (const action of ["INSERT", "UPDATE"])
|
|
127
|
+
db.exec(`CREATE TRIGGER work_v4_order_${name}_${action} AFTER ${action} ON ${name} BEGIN INSERT INTO build_work_events(run_id,work_id,table_name,record_key,action,record_json,created_at) VALUES(NEW.run_id,NEW.ticket_id,'${name}',json_array(${keys.join(",")}), '${action.toLowerCase()}',json_object(${retained.join(",")}),strftime('%Y-%m-%dT%H:%M:%fZ','now')); END`);
|
|
128
|
+
}
|
|
129
|
+
for (const { name } of tables) {
|
|
130
|
+
const columns = db.prepare(`PRAGMA table_info(${name})`).all();
|
|
131
|
+
if (columns.some(column => column.name === "run_id") && columns.some(column => column.name === "ticket_id"))
|
|
132
|
+
db.exec(`CREATE TRIGGER work_v4_identity_${name} BEFORE UPDATE ON ${name} WHEN NEW.run_id<>OLD.run_id OR NEW.ticket_id<>OLD.ticket_id BEGIN SELECT RAISE(ABORT,'Scoped QA identity is immutable'); END`);
|
|
133
|
+
}
|
|
134
|
+
for (const action of ["INSERT", "UPDATE"]) {
|
|
135
|
+
db.exec(`CREATE TRIGGER work_v4_report_parent_${action} BEFORE ${action} ON qa_reports WHEN NOT EXISTS(SELECT 1 FROM qa_review_attempts p WHERE p.run_id=NEW.run_id AND p.ticket_id=NEW.ticket_id AND p.review_number=NEW.review_number AND p.source_digest=NEW.source_state_digest AND p.report_digest=NEW.report_digest) BEGIN SELECT RAISE(ABORT,'QA report parent ownership mismatch'); END;
|
|
136
|
+
CREATE TRIGGER work_v4_chain_parent_${action} BEFORE ${action} ON qa_report_chains WHEN NOT EXISTS(SELECT 1 FROM qa_reports p JOIN qa_reports s ON p.run_id=s.run_id AND p.ticket_id=s.ticket_id WHERE p.report_occurrence_id=NEW.predecessor_occurrence_id AND s.report_occurrence_id=NEW.successor_occurrence_id AND p.report_digest=NEW.predecessor_report_digest AND s.report_digest=NEW.successor_report_digest) BEGIN SELECT RAISE(ABORT,'QA report chain parent ownership mismatch'); END;
|
|
137
|
+
CREATE TRIGGER work_v4_assignment_${action} BEFORE ${action} ON operation_journal WHEN NEW.kind='build-assignment' AND NOT EXISTS(SELECT 1 FROM build_work_scope s WHERE s.run_id=NEW.run_id AND s.work_id=json_extract(NEW.intent_json,'$.ticketId') AND s.state='admitted') BEGIN SELECT RAISE(ABORT,'Builder assignment is not admitted'); END;
|
|
138
|
+
CREATE TRIGGER work_v4_branch_${action} BEFORE ${action} ON branch_resume_sessions WHEN NOT EXISTS(SELECT 1 FROM build_work_scope s WHERE s.run_id=NEW.run_id AND s.work_id=NEW.ticket AND s.state='admitted') BEGIN SELECT RAISE(ABORT,'Branch work is not admitted'); END;`);
|
|
139
|
+
}
|
|
140
|
+
db.exec(`CREATE TRIGGER work_v4_membership_update BEFORE UPDATE OF state_json ON workflow_runs WHEN EXISTS(SELECT 1 FROM build_work_admissions a WHERE a.run_id=NEW.run_id AND NOT EXISTS(SELECT 1 FROM json_each(NEW.state_json,'$.tickets') j WHERE j.value=a.work_id)) AND json_extract(NEW.state_json,'$.version') IS NOT NULL BEGIN SELECT RAISE(ABORT,'Build snapshot cannot drop admitted work'); END;
|
|
141
|
+
CREATE TRIGGER work_v4_admission_update BEFORE UPDATE ON build_work_admissions BEGIN SELECT RAISE(ABORT,'Work admissions are immutable'); END;
|
|
142
|
+
CREATE TRIGGER work_v4_admission_delete BEFORE DELETE ON build_work_admissions BEGIN SELECT RAISE(ABORT,'Work admissions are immutable'); END;
|
|
143
|
+
CREATE TRIGGER work_v4_scope_identity BEFORE UPDATE ON build_work_scope WHEN NEW.run_id<>OLD.run_id OR NEW.work_id<>OLD.work_id OR NEW.kind<>OLD.kind OR NEW.ticket_id IS NOT OLD.ticket_id BEGIN SELECT RAISE(ABORT,'Work identity is immutable'); END;
|
|
144
|
+
CREATE TRIGGER work_v4_scope_delete BEFORE DELETE ON build_work_scope BEGIN SELECT RAISE(ABORT,'Work scope must be retained'); END;`);
|
|
145
|
+
for (const table of ["build_work_scope", "build_work_admissions", "build_assignments", "build_ownership_conflicts", "build_reconciliations"]) {
|
|
146
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"])
|
|
147
|
+
db.exec(`CREATE TRIGGER work_v4_authority_${table}_${action} BEFORE ${action} ON ${table} WHEN rafi_work_authority()=0 BEGIN SELECT RAISE(ABORT,'Restricted work admission authority required'); END`);
|
|
148
|
+
}
|
|
149
|
+
for (const action of ["INSERT", "UPDATE"])
|
|
150
|
+
db.exec(`CREATE TRIGGER work_v4_remediation_parent_${action} BEFORE ${action} ON qa_remediation_attempts WHEN NOT EXISTS(SELECT 1 FROM qa_review_attempts p WHERE p.attempt_id=NEW.review_attempt_id AND p.run_id=NEW.run_id AND p.ticket_id=NEW.ticket_id) BEGIN SELECT RAISE(ABORT,'Remediation parent ownership mismatch'); END;
|
|
151
|
+
CREATE TRIGGER work_v4_finalization_parent_${action} BEFORE ${action} ON qa_finalization_steps WHEN NOT EXISTS(SELECT 1 FROM qa_pass_certificates p WHERE p.certificate_id=NEW.certificate_id AND p.run_id=NEW.run_id AND p.ticket_id=NEW.ticket_id) BEGIN SELECT RAISE(ABORT,'Finalization parent ownership mismatch'); END;
|
|
152
|
+
CREATE TRIGGER work_v4_handoff_parent_${action} BEFORE ${action} ON qa_failure_handoffs WHEN NOT EXISTS(SELECT 1 FROM qa_reports p JOIN qa_review_attempts a ON a.run_id=p.run_id AND a.ticket_id=p.ticket_id AND a.review_number=p.review_number WHERE p.report_occurrence_id=NEW.report_occurrence_id AND p.run_id=NEW.run_id AND p.ticket_id=NEW.ticket_id AND p.report_digest=NEW.report_digest AND a.attempt_id=NEW.review_attempt_id) BEGIN SELECT RAISE(ABORT,'Failure handoff parent ownership mismatch'); END;
|
|
153
|
+
CREATE TRIGGER work_v4_authorization_parent_${action} BEFORE ${action} ON qa_remediation_authorizations WHEN NOT EXISTS(SELECT 1 FROM qa_review_attempts p WHERE p.attempt_id=NEW.review_attempt_id AND p.run_id=NEW.run_id AND p.ticket_id=NEW.ticket_id) BEGIN SELECT RAISE(ABORT,'Authorization parent ownership mismatch'); END;`);
|
|
154
|
+
for (const action of ["INSERT", "UPDATE"]) {
|
|
155
|
+
db.exec(`CREATE TRIGGER work_v4_delivery_parent_${action} BEFORE ${action} ON qa_delivery_turns WHEN NOT EXISTS(SELECT 1 FROM qa_failure_handoffs h WHERE h.operation_id=NEW.operation_id AND h.report_occurrence_id=NEW.report_occurrence_id) BEGIN SELECT RAISE(ABORT,'QA delivery parent ownership mismatch'); END`);
|
|
156
|
+
for (const table of ["qa_findings", "qa_report_dispositions"])
|
|
157
|
+
db.exec(`CREATE TRIGGER work_v4_occurrence_digest_${table}_${action} BEFORE ${action} ON ${table} WHEN NOT EXISTS(SELECT 1 FROM qa_reports r WHERE r.report_occurrence_id=NEW.report_occurrence_id AND r.report_digest=NEW.report_digest) BEGIN SELECT RAISE(ABORT,'QA occurrence digest ownership mismatch'); END`);
|
|
158
|
+
}
|
|
159
|
+
db.exec(`CREATE TRIGGER work_v4_delivery_identity BEFORE UPDATE ON qa_delivery_turns WHEN NEW.operation_id<>OLD.operation_id OR NEW.report_occurrence_id<>OLD.report_occurrence_id OR NEW.turn_index<>OLD.turn_index OR NEW.turn_record_id<>OLD.turn_record_id BEGIN SELECT RAISE(ABORT,'QA delivery identity is immutable'); END`);
|
|
160
|
+
for (const action of ["INSERT", "UPDATE"])
|
|
161
|
+
db.exec(`CREATE TRIGGER work_v4_certificate_parent_${action} BEFORE ${action} ON qa_pass_certificates WHEN NOT EXISTS(SELECT 1 FROM qa_turns t JOIN qa_turn_events e ON e.operation_id=t.operation_id AND e.event_index=0 WHERE t.run_id=NEW.run_id AND t.ticket_id=NEW.ticket_id AND t.source_state_digest=NEW.source_state_digest AND t.review_basis_digest=NEW.review_basis_digest AND t.status='completed' AND e.event_digest=NEW.turn_receipt_digest AND e.event_json=t.receipt_json AND json_extract(t.receipt_json,'$.terminalEventObserved')=1 AND length(json_extract(t.receipt_json,'$.providerTurnId'))>0) BEGIN SELECT RAISE(ABORT,'QA certificate completion receipt ownership mismatch'); END`);
|
|
162
|
+
for (const table of ["build_reconciliations", "build_work_events"])
|
|
163
|
+
for (const action of ["UPDATE", "DELETE"])
|
|
164
|
+
db.exec(`CREATE TRIGGER work_v4_immutable_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT RAISE(ABORT,'Build audit history is immutable'); END`);
|
|
165
|
+
faults?.beforeCommit?.();
|
|
166
|
+
if (db.pragma("foreign_key_check").length || db.pragma("integrity_check", { simple: true }) !== "ok")
|
|
167
|
+
throw new Error("Work admission upgrade integrity failure");
|
|
168
|
+
db.prepare("INSERT INTO recovery_schema_migrations VALUES(?,?)").run(migration, now);
|
|
169
|
+
db.pragma("user_version = 4");
|
|
170
|
+
}).immediate();
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
db.function("rafi_writer_protocol", () => 3);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function syncFile(path) { const descriptor = openSync(path, "r"); try {
|
|
178
|
+
fsyncSync(descriptor);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
closeSync(descriptor);
|
|
182
|
+
} }
|
|
183
|
+
function syncDirectory(path) { if (process.platform !== "win32")
|
|
184
|
+
syncFile(path); }
|
|
185
|
+
function syncBackupTree(root) {
|
|
186
|
+
const files = [];
|
|
187
|
+
const visit = (directory) => {
|
|
188
|
+
chmodSync(directory, 0o700);
|
|
189
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
190
|
+
const path = join(directory, entry.name);
|
|
191
|
+
if (entry.isDirectory())
|
|
192
|
+
visit(path);
|
|
193
|
+
else {
|
|
194
|
+
const bytes = entry.isSymbolicLink() ? Buffer.from(readlinkSync(path)) : readFileSync(path);
|
|
195
|
+
if (!entry.isSymbolicLink()) {
|
|
196
|
+
chmodSync(path, 0o600);
|
|
197
|
+
syncFile(path);
|
|
198
|
+
}
|
|
199
|
+
files.push({ path: relative(root, path), kind: entry.isSymbolicLink() ? "symlink" : "file", digest: createHash("sha256").update(bytes).digest("hex") });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
syncDirectory(directory);
|
|
203
|
+
};
|
|
204
|
+
visit(root);
|
|
205
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
206
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { type BuildOwnershipRepairV1 } from "rafi-spec";
|
|
3
|
+
export type { BuildOwnershipRepairV1 } from "rafi-spec";
|
|
4
|
+
export declare function validateOwnershipRepair(value: unknown): asserts value is BuildOwnershipRepairV1;
|
|
5
|
+
/** Audited metadata repair; source separation/restoration must be inspected before this commit. */
|
|
6
|
+
export declare function reconcileWork(db: Database.Database, request: BuildOwnershipRepairV1, input: {
|
|
7
|
+
revision: string;
|
|
8
|
+
sourceDigest: string;
|
|
9
|
+
definition: unknown;
|
|
10
|
+
requiredPaths: string[];
|
|
11
|
+
userTurn: string;
|
|
12
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { admitWork } from "./buildWorkAdmission.js";
|
|
3
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
4
|
+
import { validateBuildOwnershipRepairV1 } from "rafi-spec";
|
|
5
|
+
export function validateOwnershipRepair(value) {
|
|
6
|
+
const validation = validateBuildOwnershipRepairV1(value);
|
|
7
|
+
if (!validation.valid)
|
|
8
|
+
throw new Error(`Invalid ownership repair: ${validation.errors.join("; ")}`);
|
|
9
|
+
const request = value;
|
|
10
|
+
if ([request.authorization, request.attestation].some(text => !text.trim() || Buffer.byteLength(text) > 16384))
|
|
11
|
+
throw new Error("Repair requires bounded authorization and attestation");
|
|
12
|
+
if (new Set(request.mapping.map(item => item.path)).size !== request.mapping.length)
|
|
13
|
+
throw new Error("Repair mapping paths must be unique");
|
|
14
|
+
}
|
|
15
|
+
/** Audited metadata repair; source separation/restoration must be inspected before this commit. */
|
|
16
|
+
export function reconcileWork(db, request, input) {
|
|
17
|
+
const prior = db.prepare("SELECT record_json FROM build_reconciliations WHERE reconciliation_id=?").get(request.requestId);
|
|
18
|
+
if (prior) {
|
|
19
|
+
const receipt = JSON.parse(prior.record_json);
|
|
20
|
+
if (receipt.requestDigest !== qaDigest("ownership-repair-request", request))
|
|
21
|
+
throw new Error("Repair idempotency key collision");
|
|
22
|
+
return receipt;
|
|
23
|
+
}
|
|
24
|
+
if (request.expectedRevision !== input.revision || request.inspectedSourceDigest !== input.sourceDigest)
|
|
25
|
+
throw new Error("Ownership or inspected source changed; inspect the current revision before repair");
|
|
26
|
+
if (input.requiredPaths.some(path => !request.mapping.some(item => item.path === path && item.workId === request.workId)))
|
|
27
|
+
throw new Error("Every preserved change must have an inspected mapping to the authorized target work");
|
|
28
|
+
if (request.mapping.some(item => item.workId !== request.workId || !input.requiredPaths.includes(item.path)))
|
|
29
|
+
throw new Error("Mapping contains a foreign work identity or an uninspected path");
|
|
30
|
+
const uncertain = db.prepare("SELECT 1 FROM operation_journal WHERE run_id=? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain')").get(request.runId);
|
|
31
|
+
if (uncertain)
|
|
32
|
+
throw new Error("Provider dispatch must be reconciled separately before ownership repair; attestation cannot establish nonexecution");
|
|
33
|
+
if (request.choice === "link_provenance") {
|
|
34
|
+
const decision = request.decisionId ? db.prepare("SELECT * FROM human_decisions WHERE decision_id=? AND run_id=? AND status='answered'").get(request.decisionId, request.runId) : undefined;
|
|
35
|
+
const approval = decision ? JSON.parse(decision.decision_json) : undefined;
|
|
36
|
+
const assignments = db.prepare("SELECT intent_json,result_json FROM operation_journal WHERE run_id=? AND kind='build-assignment' AND json_extract(intent_json,'$.ticketId')=? AND status='confirmed'").all(request.runId, request.workId);
|
|
37
|
+
const assignment = assignments.some(row => {
|
|
38
|
+
const intent = JSON.parse(row.intent_json), result = JSON.parse(row.result_json), session = result.providerMetadata?.sessionRef;
|
|
39
|
+
if (result.rejection || result.sourceError || !result.turnId || !result.responseDigest || !result.after?.digest || !intent.before?.digest || intent.requirementsDigest !== qaDigest("admitted-requirements", input.definition) || !session || session.source !== "observed" || !session.validatedAt || session.role !== "builder" || session.ticketId !== request.workId)
|
|
40
|
+
return false;
|
|
41
|
+
try {
|
|
42
|
+
if (realpathSync(session.cwd) !== realpathSync(intent.worktree))
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return !!db.prepare("SELECT 1 FROM provider_sessions WHERE run_id=? AND role='builder' AND session_id=? AND session_ref_json=?").get(request.runId, session.sessionId, JSON.stringify(session));
|
|
49
|
+
});
|
|
50
|
+
const checkpoint = db.prepare("SELECT 1 FROM workflow_events WHERE run_id=? AND (json_extract(payload_json,'$.ticketId')=? OR json_extract(payload_json,'$.currentTicket')=?)").get(request.runId, request.workId, request.workId);
|
|
51
|
+
if (!decision?.decision_key.includes(request.scopeRevision) || approval?.selectedChoiceId !== "proceed" || !assignment || !checkpoint)
|
|
52
|
+
throw new Error("Approval, assignment and checkpoint provenance is incomplete; use explicit mapped-work authorization instead");
|
|
53
|
+
}
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
const state = db.prepare("SELECT state FROM qa_ticket_heads WHERE run_id=? AND ticket_id=?").get(request.runId, request.workId)?.state;
|
|
56
|
+
const requiresFreshQa = request.choice !== "quarantine" && state !== "completed" && state !== "waived";
|
|
57
|
+
const receipt = { version: 1, reconciliationId: request.requestId, runId: request.runId, workId: request.workId, requestDigest: qaDigest("ownership-repair-request", request), request, userTurn: input.userTurn, beforeRevision: input.revision, sourceDigest: input.sourceDigest, historicalAuthorityProven: request.choice === "link_provenance", requiresFreshQa, executionDisposition: request.choice === "quarantine" ? "quarantined" : state === "completed" ? "terminal_metadata_repaired" : state === "waived" ? "existing_waiver_policy" : "fresh_qa_required", executionReopened: false, providerReplayAuthorized: false, createdAt: now };
|
|
58
|
+
db.prepare("INSERT INTO build_reconciliations VALUES(?,?,?,?,?)").run(request.requestId, request.runId, request.expectedRevision, JSON.stringify(receipt), now);
|
|
59
|
+
if (request.choice === "quarantine") {
|
|
60
|
+
db.prepare("UPDATE build_work_scope SET state='quarantined' WHERE run_id=? AND work_id=?").run(request.runId, request.workId);
|
|
61
|
+
db.prepare("UPDATE build_ownership_conflicts SET record_json=json_set(record_json,'$.manualDecision',?),revision=revision+1 WHERE run_id=? AND work_id=? AND status='unresolved'").run(request.requestId, request.runId, request.workId);
|
|
62
|
+
return receipt;
|
|
63
|
+
}
|
|
64
|
+
db.prepare("UPDATE build_work_scope SET state='admitted' WHERE run_id=? AND work_id=?").run(request.runId, request.workId);
|
|
65
|
+
admitWork(db, { runId: request.runId, kind: "ticket", ticketId: request.workId, definition: input.definition, approvalId: request.requestId, scopeRevision: request.scopeRevision, provenance: { userTurn: input.userTurn, decisionId: request.decisionId, reason: request.choice === "link_provenance" ? "Audited run-bound authorization provenance" : "Operator explicitly authorized inspected preserved changes for fresh verification" } }, true);
|
|
66
|
+
db.prepare("UPDATE build_ownership_conflicts SET status='reconciled',revision=revision+1,record_json=json_set(record_json,'$.reconciliationId',?) WHERE run_id=? AND work_id=? AND status='unresolved'").run(request.requestId, request.runId, request.workId);
|
|
67
|
+
// Retain every old byte and outcome. The audit permits fresh QA, never replay of this turn.
|
|
68
|
+
db.prepare("UPDATE operation_journal SET result_json=json_set(COALESCE(result_json,'{}'),'$.reconciliationId',?,'$.replayForbidden',1),updated_at=? WHERE run_id=? AND kind='build-assignment' AND json_extract(intent_json,'$.ticketId')=?").run(request.requestId, now, request.runId, request.workId);
|
|
69
|
+
return receipt;
|
|
70
|
+
}
|