@cassiomc1/forgeloop 1.2.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +1 -0
- package/AGENT_COMPATIBILITY.md +4 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +14 -0
- package/EXECUTION_STATE.md +48 -0
- package/LOOP_ENGINEERING.md +55 -6
- package/LOOP_SYSTEM_DESIGN.md +32 -1
- package/PROTOCOL_INTEGRATION.md +71 -0
- package/README.md +86 -0
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/THREAT_MODEL.md +22 -1
- package/docs/ARTIFACT_REFERENCE.md +54 -0
- package/docs/CLI_REFERENCE.md +177 -5
- package/docs/CROSS_HARNESS_CONTINUITY.md +34 -0
- package/docs/DOCUMENTATION_GUIDE.md +31 -0
- package/docs/GETTING_STARTED.md +10 -0
- package/docs/MCP.md +126 -0
- package/docs/RECIPES.md +87 -1
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +243 -40
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- package/package.json +17 -3
- package/schemas/execution.schema.json +11 -1
- package/schemas/task-recovery.schema.json +61 -0
- package/schemas/work-state.schema.json +1 -0
- package/src/cli.js +182 -337
- package/src/commands/audit.js +5 -0
- package/src/commands/doctor.js +22 -0
- package/src/commands/inspect.js +6 -0
- package/src/commands/migrate-protocol.js +18 -0
- package/src/commands/progress.js +6 -2
- package/src/commands/protocol-info.js +16 -0
- package/src/commands/run-check.js +2 -0
- package/src/commands/status.js +17 -0
- package/src/commands/task-create.js +42 -3
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +29 -0
- package/src/commands/task-recover.js +202 -0
- package/src/commands/task-repair-legacy-recovery.js +417 -0
- package/src/commands/task-resume.js +172 -0
- package/src/commands/task-scope.js +23 -4
- package/src/commands/task-show.js +21 -7
- package/src/commands/task-unlock.js +8 -6
- package/src/commands/validate-protocol.js +19 -2
- package/src/core/artifact-registry.js +12 -0
- package/src/core/artifacts.js +17 -4
- package/src/core/audit.js +20 -4
- package/src/core/bundles.js +15 -0
- package/src/core/cli-command-definitions.js +94 -4
- package/src/core/command-executors.js +387 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +106 -0
- package/src/core/completion-artifacts.js +17 -6
- package/src/core/completion-ownership.js +88 -0
- package/src/core/completion.js +15 -3
- package/src/core/diagnosis.js +15 -11
- package/src/core/error-codes.js +136 -1
- package/src/core/events.js +239 -9
- package/src/core/execution.js +73 -9
- package/src/core/filesystem.js +75 -8
- package/src/core/inspect.js +27 -0
- package/src/core/integration-invocation-policy.js +170 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +127 -0
- package/src/core/next-action-model.js +60 -0
- package/src/core/next-action.js +31 -0
- package/src/core/phase.js +10 -3
- package/src/core/project-root.js +21 -0
- package/src/core/protocol-info.js +54 -0
- package/src/core/protocol-migration.js +59 -0
- package/src/core/reconcile-closure.js +54 -14
- package/src/core/recovery-history.js +116 -0
- package/src/core/resumability.js +8 -6
- package/src/core/schema-validation.js +1 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +8 -4
- package/src/core/task-conflict-inspection.js +321 -0
- package/src/core/task-context.js +32 -29
- package/src/core/task-discovery.js +14 -1
- package/src/core/task-lock.js +248 -18
- package/src/core/task-migration.js +24 -1
- package/src/core/task-paths.js +6 -3
- package/src/core/task-recovery-migration.js +192 -0
- package/src/core/task-recovery.js +205 -0
- package/src/core/task-scope.js +33 -1
- package/src/core/templates.js +1 -0
- package/src/core/transaction.js +285 -0
- package/src/core/work-state.js +70 -6
- package/src/integration.js +47 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
2
|
+
import { LEGACY_RECOVERY_MIGRATION_EVENT } from "./task-recovery-migration.js";
|
|
3
|
+
import { readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
4
|
+
import {
|
|
5
|
+
E_TASK_RECOVERY_AUTHORITY_INVALID,
|
|
6
|
+
} from "./error-codes.js";
|
|
7
|
+
import { taskArtifactPath } from "./task-paths.js";
|
|
8
|
+
import { getActiveTaskTransaction } from "./transaction.js";
|
|
9
|
+
import { classifyRecoveryHistory } from "./recovery-history.js";
|
|
10
|
+
|
|
11
|
+
export const TASK_RECOVERY_SCHEMA_VERSION = 1;
|
|
12
|
+
export const TASK_RECOVERY_EVENT_TYPES = Object.freeze(new Set([
|
|
13
|
+
"TASK_RECOVERY_RECORDED",
|
|
14
|
+
"OPERATOR_RECOVERY_RECORDED",
|
|
15
|
+
LEGACY_RECOVERY_MIGRATION_EVENT,
|
|
16
|
+
]));
|
|
17
|
+
|
|
18
|
+
export function createTaskRecovery({
|
|
19
|
+
taskId,
|
|
20
|
+
recoveredAt,
|
|
21
|
+
recoveryId,
|
|
22
|
+
recoveryEventSeq,
|
|
23
|
+
classificationAtRecovery,
|
|
24
|
+
reasonCodes,
|
|
25
|
+
releasedClaims,
|
|
26
|
+
previousPhase,
|
|
27
|
+
previousRevision,
|
|
28
|
+
repositoryFingerprint,
|
|
29
|
+
authority,
|
|
30
|
+
}) {
|
|
31
|
+
const recovery = {
|
|
32
|
+
schemaVersion: TASK_RECOVERY_SCHEMA_VERSION,
|
|
33
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
34
|
+
taskId,
|
|
35
|
+
status: "RECOVERED",
|
|
36
|
+
recoveredAt,
|
|
37
|
+
recoveryId,
|
|
38
|
+
recoveryEventSeq,
|
|
39
|
+
classificationAtRecovery,
|
|
40
|
+
reasonCodes: [...reasonCodes],
|
|
41
|
+
releasedClaims: [...releasedClaims],
|
|
42
|
+
previousPhase,
|
|
43
|
+
previousRevision,
|
|
44
|
+
repositoryFingerprint: { ...repositoryFingerprint },
|
|
45
|
+
authority: { ...authority },
|
|
46
|
+
};
|
|
47
|
+
assertTaskRecoveryAuthority(recovery);
|
|
48
|
+
return recovery;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function assertTaskRecoveryAuthority(recovery) {
|
|
52
|
+
const authority = recovery?.authority;
|
|
53
|
+
const validCaller = authority?.kind === "CALLER_ACKNOWLEDGED" && authority.grantRef === undefined;
|
|
54
|
+
const validHost = authority?.kind === "HOST_ATTESTED"
|
|
55
|
+
&& typeof authority.grantRef === "string"
|
|
56
|
+
&& authority.grantRef.trim() !== "";
|
|
57
|
+
if (validCaller || validHost) return recovery;
|
|
58
|
+
const error = new Error(
|
|
59
|
+
"Recovery authority must be caller acknowledgement or host attestation with a trusted grant reference",
|
|
60
|
+
);
|
|
61
|
+
error.code = E_TASK_RECOVERY_AUTHORITY_INVALID;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readTaskRecovery(target, { taskId, packageRoot } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const artifact = await readJsonArtifact(target, taskArtifactPath(taskId, "recovery"), "task-recovery", packageRoot);
|
|
68
|
+
assertTaskRecoveryAuthority(artifact.value);
|
|
69
|
+
return artifact;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code === "ARTIFACT_MISSING") return null;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeTaskRecovery(target, recovery, packageRoot) {
|
|
77
|
+
assertTaskRecoveryAuthority(recovery);
|
|
78
|
+
return writeJsonArtifact(
|
|
79
|
+
target,
|
|
80
|
+
taskArtifactPath(recovery.taskId, "recovery"),
|
|
81
|
+
recovery,
|
|
82
|
+
"task-recovery",
|
|
83
|
+
packageRoot,
|
|
84
|
+
{ taskId: recovery.taskId, operation: "write-task-recovery" },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function clearTaskRecovery(target, { taskId } = {}) {
|
|
89
|
+
const transaction = getActiveTaskTransaction();
|
|
90
|
+
if (!transaction) {
|
|
91
|
+
throw new Error("clearTaskRecovery requires an active task transaction");
|
|
92
|
+
}
|
|
93
|
+
await transaction.stageDelete(taskArtifactPath(taskId, "recovery"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isTaskRecovered(recovery) {
|
|
97
|
+
return recovery?.status === "RECOVERED";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function assertTaskNotRecovered(target, { taskId, packageRoot } = {}) {
|
|
101
|
+
const { assertTaskMutationAllowed } = await import("./task-claim-state.js");
|
|
102
|
+
return assertTaskMutationAllowed(target, { taskId, packageRoot });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function effectiveTaskClaims({
|
|
106
|
+
validatedClaimState = null,
|
|
107
|
+
historicalWriteClaims = null,
|
|
108
|
+
writeClaims = [],
|
|
109
|
+
} = {}) {
|
|
110
|
+
const historical = historicalWriteClaims ?? writeClaims;
|
|
111
|
+
// Only VALIDATED canonical ownership may release claims. A bare
|
|
112
|
+
// `phase === "COMPLETE"` never proves completion ownership.
|
|
113
|
+
const validatedRelease = validatedClaimState?.valid === true
|
|
114
|
+
&& ["RELEASED_BY_COMPLETION", "RELEASED_BY_RECOVERY"].includes(validatedClaimState.claimState);
|
|
115
|
+
return validatedRelease ? [] : [...historical];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function taskClaimProjection({
|
|
119
|
+
validatedClaimState = null,
|
|
120
|
+
historicalWriteClaims: suppliedHistoricalClaims = null,
|
|
121
|
+
writeClaims = [],
|
|
122
|
+
} = {}) {
|
|
123
|
+
const historicalWriteClaims = [...(suppliedHistoricalClaims ?? writeClaims)];
|
|
124
|
+
const effectiveWriteClaims = effectiveTaskClaims({
|
|
125
|
+
validatedClaimState,
|
|
126
|
+
historicalWriteClaims,
|
|
127
|
+
});
|
|
128
|
+
const claimState = validatedClaimState?.valid === true
|
|
129
|
+
? validatedClaimState.claimState
|
|
130
|
+
: "ACTIVE";
|
|
131
|
+
return {
|
|
132
|
+
writeClaims: effectiveWriteClaims,
|
|
133
|
+
historicalWriteClaims,
|
|
134
|
+
effectiveWriteClaims,
|
|
135
|
+
claimState,
|
|
136
|
+
mutationAllowed: claimState === "ACTIVE",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sameList(left, right) {
|
|
141
|
+
return Array.isArray(left)
|
|
142
|
+
&& Array.isArray(right)
|
|
143
|
+
&& left.length === right.length
|
|
144
|
+
&& left.every((value, index) => value === right[index]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recoveryConsistencyError(message) {
|
|
148
|
+
return { code: "E_TASK_RECOVERY_INCONSISTENT", message };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function validateTaskRecoveryConsistency({
|
|
152
|
+
taskId,
|
|
153
|
+
recovery = null,
|
|
154
|
+
events = [],
|
|
155
|
+
historicalWriteClaims = null,
|
|
156
|
+
recoveryHistory = null,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const history = recoveryHistory ?? classifyRecoveryHistory(events);
|
|
159
|
+
const errors = [...history.errors];
|
|
160
|
+
|
|
161
|
+
if (!recovery) {
|
|
162
|
+
if (history.valid && history.activeRecovery) {
|
|
163
|
+
errors.push(recoveryConsistencyError(
|
|
164
|
+
`Recovery event ${history.activeRecovery.recoveryId} has neither an active recovery artifact nor a resume event`,
|
|
165
|
+
));
|
|
166
|
+
}
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
if (!history.activeRecovery || history.activeRecovery.recoveryId !== recovery.recoveryId) {
|
|
170
|
+
errors.push(recoveryConsistencyError(
|
|
171
|
+
`Recovery artifact ${recovery.recoveryId ?? "unknown"} does not match the active recovery history`,
|
|
172
|
+
));
|
|
173
|
+
}
|
|
174
|
+
if (recovery.taskId !== taskId) {
|
|
175
|
+
errors.push(recoveryConsistencyError(`Recovery artifact belongs to ${recovery.taskId}, not ${taskId}`));
|
|
176
|
+
}
|
|
177
|
+
const event = events.find((candidate) => candidate.seq === recovery.recoveryEventSeq);
|
|
178
|
+
if (!event || !TASK_RECOVERY_EVENT_TYPES.has(event.event)) {
|
|
179
|
+
errors.push(recoveryConsistencyError(
|
|
180
|
+
`Recovery artifact references missing recovery event seq ${recovery.recoveryEventSeq}`,
|
|
181
|
+
));
|
|
182
|
+
return errors;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const comparisons = [
|
|
186
|
+
[event.taskId === recovery.taskId, "taskId"],
|
|
187
|
+
[event.at === recovery.recoveredAt, "recoveredAt"],
|
|
188
|
+
[event.details?.recoveryId === recovery.recoveryId, "recoveryId"],
|
|
189
|
+
[event.details?.classification === recovery.classificationAtRecovery, "classificationAtRecovery"],
|
|
190
|
+
[sameList(event.details?.reasonCodes, recovery.reasonCodes), "reasonCodes"],
|
|
191
|
+
[sameList(event.details?.releasedClaims, recovery.releasedClaims), "releasedClaims"],
|
|
192
|
+
[event.details?.previousPhase === recovery.previousPhase, "previousPhase"],
|
|
193
|
+
[event.details?.previousRevision === recovery.previousRevision, "previousRevision"],
|
|
194
|
+
[event.details?.currentBranch === recovery.repositoryFingerprint?.branch, "repositoryFingerprint.branch"],
|
|
195
|
+
[event.details?.currentHead === recovery.repositoryFingerprint?.head, "repositoryFingerprint.head"],
|
|
196
|
+
[event.details?.authorityKind === recovery.authority?.kind, "authority.kind"],
|
|
197
|
+
];
|
|
198
|
+
for (const [matches, field] of comparisons) {
|
|
199
|
+
if (!matches) errors.push(recoveryConsistencyError(`Recovery artifact and ledger disagree on ${field}`));
|
|
200
|
+
}
|
|
201
|
+
if (historicalWriteClaims && !sameList(historicalWriteClaims, recovery.releasedClaims)) {
|
|
202
|
+
errors.push(recoveryConsistencyError("Recovery releasedClaims do not match the task descriptor's historical claims"));
|
|
203
|
+
}
|
|
204
|
+
return errors;
|
|
205
|
+
}
|
package/src/core/task-scope.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import {
|
|
3
3
|
E_TASK_CHANGE_OUTSIDE_SCOPE,
|
|
4
|
+
E_TASK_CLAIM_OWNERSHIP_INCONSISTENT,
|
|
4
5
|
E_TASK_DESCRIPTOR_INVALID,
|
|
5
6
|
E_TASK_SCOPE_CONFLICT,
|
|
6
7
|
E_TASK_SCOPE_DIRTY,
|
|
@@ -93,7 +94,9 @@ export function checkScopeConflicts(newClaims, existingTasks = [], currentTaskId
|
|
|
93
94
|
// Only non-COMPLETE tasks hold active write claims
|
|
94
95
|
if (task.phase === "COMPLETE") continue;
|
|
95
96
|
|
|
96
|
-
const taskClaims = normalizeWriteClaims(task.
|
|
97
|
+
const taskClaims = normalizeWriteClaims(task.claimState === undefined
|
|
98
|
+
? (task.writeClaims ?? task.descriptor?.writeClaims ?? [])
|
|
99
|
+
: (task.effectiveWriteClaims ?? []));
|
|
97
100
|
for (const newClaim of normalizedNew) {
|
|
98
101
|
for (const existingClaim of taskClaims) {
|
|
99
102
|
if (claimsOverlap(newClaim, existingClaim)) {
|
|
@@ -112,6 +115,35 @@ export function checkScopeConflicts(newClaims, existingTasks = [], currentTaskId
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
export function assertNoScopeConflicts(newClaims, existingTasks = [], currentTaskId = null) {
|
|
118
|
+
const normalizedNew = normalizeWriteClaims(newClaims);
|
|
119
|
+
if (normalizedNew.length > 0) {
|
|
120
|
+
const ownershipBlockers = existingTasks.filter((task) => {
|
|
121
|
+
if (task.taskId === currentTaskId) return false;
|
|
122
|
+
if (task.healthy === false) return true;
|
|
123
|
+
if (task.claimState !== "INCONSISTENT" && task.ownershipValid !== false) return false;
|
|
124
|
+
const retainedClaims = normalizeWriteClaims(
|
|
125
|
+
task.effectiveWriteClaims ?? task.writeClaims ?? task.descriptor?.writeClaims ?? [],
|
|
126
|
+
);
|
|
127
|
+
return retainedClaims.length === 0
|
|
128
|
+
|| normalizedNew.some((claim) => retainedClaims.some((retained) => claimsOverlap(claim, retained)));
|
|
129
|
+
});
|
|
130
|
+
if (ownershipBlockers.length > 0) {
|
|
131
|
+
const error = new Error(
|
|
132
|
+
`Cannot acquire write claims while task ownership is inconsistent: ${ownershipBlockers
|
|
133
|
+
.map((task) => task.taskId ?? task.taskKey)
|
|
134
|
+
.join(", ")}`,
|
|
135
|
+
);
|
|
136
|
+
error.code = E_TASK_CLAIM_OWNERSHIP_INCONSISTENT;
|
|
137
|
+
error.tasks = ownershipBlockers.map((task) => ({
|
|
138
|
+
taskId: task.taskId ?? null,
|
|
139
|
+
taskKey: task.taskKey ?? null,
|
|
140
|
+
claimState: task.claimState ?? "INCONSISTENT",
|
|
141
|
+
reasonCodes: task.reasonCodes ?? [task.error?.code].filter(Boolean),
|
|
142
|
+
}));
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
115
147
|
const conflicts = checkScopeConflicts(newClaims, existingTasks, currentTaskId);
|
|
116
148
|
if (conflicts.length > 0) {
|
|
117
149
|
const first = conflicts[0];
|
package/src/core/templates.js
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { mkdir, open, readdir, readFile, rename, stat, truncate, unlink } from "node:fs/promises";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { assertSafePath, ensureWithin, fileExists, writeFileAtomic } from "./filesystem.js";
|
|
8
|
+
import { withTaskLock } from "./task-lock.js";
|
|
9
|
+
import { E_TASK_LOCKED } from "./error-codes.js";
|
|
10
|
+
|
|
11
|
+
const TRANSACTION_ROOT = ".forgeloop/.txn";
|
|
12
|
+
const LOCK_WAIT_TIMEOUT_MS = 5_000;
|
|
13
|
+
const transactionContext = new AsyncLocalStorage();
|
|
14
|
+
|
|
15
|
+
export function getActiveTaskTransaction() {
|
|
16
|
+
return transactionContext.getStore() ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function transactionPath(transactionId) {
|
|
20
|
+
return `${TRANSACTION_ROOT}/${transactionId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function writeManifest(target, relativePath, manifest) {
|
|
24
|
+
await writeFileAtomic(ensureWithin(target, relativePath), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function findIncompleteTransactions(target) {
|
|
28
|
+
const root = ensureWithin(target, TRANSACTION_ROOT);
|
|
29
|
+
if (!(await fileExists(root))) return [];
|
|
30
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
31
|
+
const found = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(await readFile(path.join(root, entry.name, "manifest.json"), "utf8"));
|
|
36
|
+
if (manifest.status !== "COMMITTED") found.push(manifest);
|
|
37
|
+
} catch {
|
|
38
|
+
found.push({ transactionId: entry.name, status: "ABANDONED", malformed: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writePath(entry) {
|
|
45
|
+
return typeof entry === "string" ? entry : entry.path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function rollbackPublishedWrites(target, root, manifest) {
|
|
49
|
+
const writes = [...manifest.writes].reverse();
|
|
50
|
+
for (const entry of writes) {
|
|
51
|
+
if (!entry || typeof entry !== "object") continue;
|
|
52
|
+
const relativePath = writePath(entry);
|
|
53
|
+
if (entry.kind === "APPEND") {
|
|
54
|
+
if (!entry.appendStarted) continue;
|
|
55
|
+
const destination = ensureWithin(target, relativePath);
|
|
56
|
+
const originalSize = Number.isInteger(entry.originalSize) && entry.originalSize >= 0 ? entry.originalSize : 0;
|
|
57
|
+
if (await fileExists(destination)) {
|
|
58
|
+
if (originalSize === 0) await unlink(destination);
|
|
59
|
+
else await truncate(destination, originalSize);
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!entry.published && !entry.backupPending && !entry.backupCreated) continue;
|
|
64
|
+
const destination = ensureWithin(target, relativePath);
|
|
65
|
+
const backup = ensureWithin(target, `${root}/backup/${relativePath}`);
|
|
66
|
+
if (entry.published && await fileExists(destination)) await unlink(destination);
|
|
67
|
+
if (entry.hadPrevious && await fileExists(backup)) {
|
|
68
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
69
|
+
await rename(backup, destination);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function recoverIncompleteTransactions(target) {
|
|
75
|
+
const rootPath = ensureWithin(target, TRANSACTION_ROOT);
|
|
76
|
+
if (!(await fileExists(rootPath))) return [];
|
|
77
|
+
const recovered = [];
|
|
78
|
+
for (const entry of await readdir(rootPath, { withFileTypes: true })) {
|
|
79
|
+
if (!entry.isDirectory()) continue;
|
|
80
|
+
const root = transactionPath(entry.name);
|
|
81
|
+
const manifestPath = `${root}/manifest.json`;
|
|
82
|
+
try {
|
|
83
|
+
const manifest = JSON.parse(await readFile(ensureWithin(target, manifestPath), "utf8"));
|
|
84
|
+
if (manifest.status !== "COMMITTING") continue;
|
|
85
|
+
await rollbackPublishedWrites(target, root, manifest);
|
|
86
|
+
manifest.status = "ROLLED_BACK";
|
|
87
|
+
manifest.recoveredAt = new Date().toISOString();
|
|
88
|
+
await writeManifest(target, manifestPath, manifest);
|
|
89
|
+
recovered.push({ transactionId: manifest.transactionId, status: manifest.status });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
recovered.push({ transactionId: entry.name, status: "RECOVERY_FAILED", error: error.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return recovered;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function withTaskTransaction({
|
|
98
|
+
target,
|
|
99
|
+
taskId,
|
|
100
|
+
lockTaskId = taskId,
|
|
101
|
+
operation = "mutation",
|
|
102
|
+
packageRoot,
|
|
103
|
+
recordCommitEvent = false,
|
|
104
|
+
} = {}, callback) {
|
|
105
|
+
if (!target || !taskId) throw new Error("target and taskId are required for a task transaction");
|
|
106
|
+
const started = Date.now();
|
|
107
|
+
const runWithLock = async () => withTaskLock(target, lockTaskId, operation, async (lock) => {
|
|
108
|
+
const transactionId = `txn-${randomUUID()}`;
|
|
109
|
+
const root = transactionPath(transactionId);
|
|
110
|
+
const stageRoot = `${root}/stage`;
|
|
111
|
+
const manifestPath = `${root}/manifest.json`;
|
|
112
|
+
await assertSafePath(target, manifestPath);
|
|
113
|
+
await mkdir(ensureWithin(target, stageRoot), { recursive: true });
|
|
114
|
+
const manifest = { schemaVersion: 1, transactionId, taskId, operation, lockId: lock.lockId, startedAt: new Date().toISOString(), status: "STAGING", writes: [] };
|
|
115
|
+
await writeManifest(target, manifestPath, manifest);
|
|
116
|
+
const tx = {
|
|
117
|
+
transactionId,
|
|
118
|
+
lock,
|
|
119
|
+
async readText(relativePath) {
|
|
120
|
+
await assertSafePath(target, relativePath);
|
|
121
|
+
if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind === "DELETE")) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}`);
|
|
125
|
+
if (await fileExists(staged)) return readFile(staged, "utf8");
|
|
126
|
+
const appendStaged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
127
|
+
const appendText = await fileExists(appendStaged) ? await readFile(appendStaged, "utf8") : "";
|
|
128
|
+
const destination = ensureWithin(target, relativePath);
|
|
129
|
+
if (!(await fileExists(destination))) return appendText || null;
|
|
130
|
+
return `${await readFile(destination, "utf8")}${appendText}`;
|
|
131
|
+
},
|
|
132
|
+
async stageText(relativePath, text) {
|
|
133
|
+
await assertSafePath(target, relativePath);
|
|
134
|
+
const conflicting = manifest.writes.find((entry) => writePath(entry) === relativePath);
|
|
135
|
+
if (conflicting?.kind === "APPEND" || conflicting?.kind === "DELETE") {
|
|
136
|
+
throw new Error(`cannot replace ${conflicting.kind.toLowerCase()}-staged path: ${relativePath}`);
|
|
137
|
+
}
|
|
138
|
+
const staged = `${stageRoot}/${relativePath}`;
|
|
139
|
+
await assertSafePath(target, staged);
|
|
140
|
+
await writeFileAtomic(ensureWithin(target, staged), text);
|
|
141
|
+
if (!manifest.writes.some((entry) => writePath(entry) === relativePath)) {
|
|
142
|
+
manifest.writes.push({ path: relativePath, hadPrevious: false, published: false });
|
|
143
|
+
}
|
|
144
|
+
await writeManifest(target, manifestPath, manifest);
|
|
145
|
+
},
|
|
146
|
+
async appendText(relativePath, text) {
|
|
147
|
+
await assertSafePath(target, relativePath);
|
|
148
|
+
if (typeof text !== "string" || text.length === 0) throw new Error("transaction append text must be a non-empty string");
|
|
149
|
+
if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind !== "APPEND")) {
|
|
150
|
+
throw new Error(`cannot append to replace-staged path: ${relativePath}`);
|
|
151
|
+
}
|
|
152
|
+
const destination = ensureWithin(target, relativePath);
|
|
153
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
154
|
+
const existing = await fileExists(staged) ? await readFile(staged, "utf8") : "";
|
|
155
|
+
await writeFileAtomic(staged, `${existing}${text}`);
|
|
156
|
+
let entry = manifest.writes.find((candidate) => writePath(candidate) === relativePath);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
entry = {
|
|
159
|
+
path: relativePath,
|
|
160
|
+
kind: "APPEND",
|
|
161
|
+
originalSize: (await fileExists(destination)) ? (await stat(destination)).size : 0,
|
|
162
|
+
appendStarted: false,
|
|
163
|
+
published: false,
|
|
164
|
+
};
|
|
165
|
+
manifest.writes.push(entry);
|
|
166
|
+
}
|
|
167
|
+
await writeManifest(target, manifestPath, manifest);
|
|
168
|
+
},
|
|
169
|
+
async stageDelete(relativePath) {
|
|
170
|
+
await assertSafePath(target, relativePath);
|
|
171
|
+
if (manifest.writes.some((entry) => writePath(entry) === relativePath)) {
|
|
172
|
+
throw new Error(`cannot delete write-staged path: ${relativePath}`);
|
|
173
|
+
}
|
|
174
|
+
const destination = ensureWithin(target, relativePath);
|
|
175
|
+
if (!(await fileExists(destination))) {
|
|
176
|
+
throw new Error(`transaction delete target is missing: ${relativePath}`);
|
|
177
|
+
}
|
|
178
|
+
manifest.writes.push({
|
|
179
|
+
path: relativePath,
|
|
180
|
+
kind: "DELETE",
|
|
181
|
+
hadPrevious: false,
|
|
182
|
+
published: false,
|
|
183
|
+
});
|
|
184
|
+
await writeManifest(target, manifestPath, manifest);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
const result = await transactionContext.run(tx, async () => {
|
|
189
|
+
const callbackResult = await callback(tx);
|
|
190
|
+
if (recordCommitEvent) {
|
|
191
|
+
// Import lazily to keep the transaction/event dependency directional
|
|
192
|
+
// at module initialization. The event is staged while this task's
|
|
193
|
+
// transaction context is still active and is published last below.
|
|
194
|
+
const { appendProtocolEvent } = await import("./events.js");
|
|
195
|
+
await appendProtocolEvent(target, {
|
|
196
|
+
taskId,
|
|
197
|
+
event: "TRANSACTION_COMMITTED",
|
|
198
|
+
details: { transactionId, operation },
|
|
199
|
+
}, packageRoot, { taskId });
|
|
200
|
+
}
|
|
201
|
+
return callbackResult;
|
|
202
|
+
});
|
|
203
|
+
manifest.status = "COMMITTING";
|
|
204
|
+
await writeManifest(target, manifestPath, manifest);
|
|
205
|
+
// The ledger is the commit witness. Publishing it last means that a
|
|
206
|
+
// visible TRANSACTION_COMMITTED event cannot precede a required staged
|
|
207
|
+
// artifact in the same transaction.
|
|
208
|
+
const writes = [...manifest.writes].sort((left, right) => {
|
|
209
|
+
const leftIsLedger = writePath(left).endsWith("events.ndjson");
|
|
210
|
+
const rightIsLedger = writePath(right).endsWith("events.ndjson");
|
|
211
|
+
return Number(leftIsLedger) - Number(rightIsLedger);
|
|
212
|
+
});
|
|
213
|
+
for (const entry of writes) {
|
|
214
|
+
const relativePath = writePath(entry);
|
|
215
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}`);
|
|
216
|
+
const destination = ensureWithin(target, relativePath);
|
|
217
|
+
if (entry.kind === "APPEND") {
|
|
218
|
+
const appendStaged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
219
|
+
const appendText = await readFile(appendStaged, "utf8");
|
|
220
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
221
|
+
entry.appendStarted = true;
|
|
222
|
+
await writeManifest(target, manifestPath, manifest);
|
|
223
|
+
const handle = await open(destination, "a", 0o644);
|
|
224
|
+
try {
|
|
225
|
+
await handle.writeFile(appendText);
|
|
226
|
+
await handle.sync();
|
|
227
|
+
} finally {
|
|
228
|
+
await handle.close();
|
|
229
|
+
}
|
|
230
|
+
entry.published = true;
|
|
231
|
+
await writeManifest(target, manifestPath, manifest);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const backup = ensureWithin(target, `${root}/backup/${relativePath}`);
|
|
235
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
236
|
+
entry.hadPrevious = await fileExists(destination);
|
|
237
|
+
if (entry.hadPrevious) {
|
|
238
|
+
entry.backupPending = true;
|
|
239
|
+
await writeManifest(target, manifestPath, manifest);
|
|
240
|
+
await mkdir(path.dirname(backup), { recursive: true });
|
|
241
|
+
await rename(destination, backup);
|
|
242
|
+
entry.backupCreated = true;
|
|
243
|
+
}
|
|
244
|
+
await writeManifest(target, manifestPath, manifest);
|
|
245
|
+
if (entry.kind === "DELETE") {
|
|
246
|
+
entry.published = true;
|
|
247
|
+
await writeManifest(target, manifestPath, manifest);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
await rename(staged, destination);
|
|
251
|
+
entry.published = true;
|
|
252
|
+
await writeManifest(target, manifestPath, manifest);
|
|
253
|
+
}
|
|
254
|
+
manifest.status = "COMMITTED";
|
|
255
|
+
manifest.committedAt = new Date().toISOString();
|
|
256
|
+
await writeManifest(target, manifestPath, manifest);
|
|
257
|
+
return result;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (manifest.status === "COMMITTING") {
|
|
260
|
+
try {
|
|
261
|
+
await rollbackPublishedWrites(target, root, manifest);
|
|
262
|
+
manifest.status = "ROLLED_BACK";
|
|
263
|
+
manifest.recoveredAt = new Date().toISOString();
|
|
264
|
+
} catch (rollbackError) {
|
|
265
|
+
manifest.status = "ABANDONED";
|
|
266
|
+
manifest.rollbackError = { message: rollbackError.message };
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
manifest.status = "ABANDONED";
|
|
270
|
+
}
|
|
271
|
+
manifest.failedAt = new Date().toISOString();
|
|
272
|
+
manifest.error = { message: error.message };
|
|
273
|
+
await writeManifest(target, manifestPath, manifest);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
while (true) {
|
|
278
|
+
try {
|
|
279
|
+
return await runWithLock();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.code !== E_TASK_LOCKED || Date.now() - started >= LOCK_WAIT_TIMEOUT_MS) throw error;
|
|
282
|
+
await delay(20);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/core/work-state.js
CHANGED
|
@@ -19,6 +19,7 @@ import { assertJsonBytes } from "./json-safety.js";
|
|
|
19
19
|
import { assertCoverageList } from "./coverage.js";
|
|
20
20
|
import { canonicalFingerprint } from "./artifacts.js";
|
|
21
21
|
import { taskArtifactPath } from "./task-paths.js";
|
|
22
|
+
import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
22
23
|
|
|
23
24
|
export const WORK_STATE_PATH = ".forgeloop/work-state.json";
|
|
24
25
|
|
|
@@ -163,6 +164,9 @@ export function assertWorkStateSemantics(state) {
|
|
|
163
164
|
if (state.verificationCycle !== undefined && (!Number.isInteger(state.verificationCycle) || state.verificationCycle < 1)) {
|
|
164
165
|
throw new WorkStateError("verificationCycle must be a positive integer");
|
|
165
166
|
}
|
|
167
|
+
if (state.revision !== undefined && (!Number.isInteger(state.revision) || state.revision < 0)) {
|
|
168
|
+
throw new WorkStateError("revision must be a non-negative integer");
|
|
169
|
+
}
|
|
166
170
|
if (state.lastCompletionAttempt !== undefined) {
|
|
167
171
|
if (!state.lastCompletionAttempt || typeof state.lastCompletionAttempt !== "object" || Array.isArray(state.lastCompletionAttempt)) {
|
|
168
172
|
throw new WorkStateError("lastCompletionAttempt must be an object");
|
|
@@ -216,6 +220,7 @@ export function createWorkState(input) {
|
|
|
216
220
|
blockers: [...(input.blockers ?? [])],
|
|
217
221
|
verificationEvidence: [...(input.verificationEvidence ?? [])],
|
|
218
222
|
lastUpdated: input.lastUpdated ?? new Date().toISOString(),
|
|
223
|
+
revision: input.revision ?? 0,
|
|
219
224
|
};
|
|
220
225
|
if (input.verificationCycle !== undefined) state.verificationCycle = input.verificationCycle;
|
|
221
226
|
if (input.lastCompletionAttempt !== undefined) state.lastCompletionAttempt = structuredClone(input.lastCompletionAttempt);
|
|
@@ -238,12 +243,20 @@ export async function readWorkState(target, options = {}) {
|
|
|
238
243
|
|
|
239
244
|
await assertSafePath(target, relPath);
|
|
240
245
|
const statePath = ensureWithin(target, relPath);
|
|
241
|
-
if (!(await fileExists(statePath))) return null;
|
|
242
246
|
let state;
|
|
243
247
|
try {
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
const transaction = getActiveTaskTransaction();
|
|
249
|
+
const staged = transaction ? await transaction.readText(relPath) : null;
|
|
250
|
+
if (staged === null) {
|
|
251
|
+
if (!(await fileExists(statePath))) return null;
|
|
252
|
+
const bytes = await readBytes(statePath);
|
|
253
|
+
assertJsonBytes(bytes, relPath);
|
|
254
|
+
state = JSON.parse(bytes.toString("utf8"));
|
|
255
|
+
} else {
|
|
256
|
+
const bytes = Buffer.from(staged, "utf8");
|
|
257
|
+
assertJsonBytes(bytes, relPath);
|
|
258
|
+
state = JSON.parse(staged);
|
|
259
|
+
}
|
|
247
260
|
} catch (error) {
|
|
248
261
|
throw new WorkStateError(`Unable to parse ${relPath}: ${error.message}`);
|
|
249
262
|
}
|
|
@@ -276,11 +289,62 @@ export async function writeWorkState(target, state, options = {}) {
|
|
|
276
289
|
|
|
277
290
|
await validateStoredState(state, packageRoot);
|
|
278
291
|
await assertSafePath(target, relPath);
|
|
279
|
-
const
|
|
280
|
-
|
|
292
|
+
const serialized = `${JSON.stringify(state, null, 2)}\n`;
|
|
293
|
+
const transaction = getActiveTaskTransaction();
|
|
294
|
+
if (!dryRun && transaction) {
|
|
295
|
+
await transaction.stageText(relPath, serialized);
|
|
296
|
+
} else {
|
|
297
|
+
const statePath = ensureWithin(target, relPath);
|
|
298
|
+
await writeFileAtomic(statePath, serialized, { dryRun });
|
|
299
|
+
}
|
|
281
300
|
return state;
|
|
282
301
|
}
|
|
283
302
|
|
|
303
|
+
export async function mutateWorkState(target, { expectedRevision, packageRoot = getPackageRoot(), taskId, statePath } = {}, updater) {
|
|
304
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
305
|
+
const error = new WorkStateError("expectedRevision must be a non-negative integer");
|
|
306
|
+
error.code = "E_STATE_REVISION_CONFLICT";
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (!getActiveTaskTransaction()) {
|
|
310
|
+
return withTaskTransaction({
|
|
311
|
+
target,
|
|
312
|
+
taskId: taskId ?? "legacy-work-state",
|
|
313
|
+
lockTaskId: taskId ?? "legacy-work-state",
|
|
314
|
+
operation: "mutate-work-state",
|
|
315
|
+
}, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
|
|
316
|
+
}
|
|
317
|
+
const current = await readWorkState(target, { packageRoot, taskId, statePath });
|
|
318
|
+
if (!current || (current.revision ?? 0) !== expectedRevision) {
|
|
319
|
+
const error = new WorkStateError("Work state revision does not match expected revision");
|
|
320
|
+
error.code = "E_STATE_REVISION_CONFLICT";
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const updated = await updater(structuredClone(current));
|
|
324
|
+
const next = createWorkState({
|
|
325
|
+
...updated,
|
|
326
|
+
revision: expectedRevision + 1,
|
|
327
|
+
lastUpdated: updated.lastUpdated ?? new Date().toISOString(),
|
|
328
|
+
});
|
|
329
|
+
await writeWorkState(target, next, { packageRoot, taskId, statePath });
|
|
330
|
+
return next;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function initializeWorkState(target, state, { packageRoot = getPackageRoot(), taskId, statePath } = {}) {
|
|
334
|
+
if (getActiveTaskTransaction()) {
|
|
335
|
+
const current = await readWorkState(target, { packageRoot, taskId, statePath });
|
|
336
|
+
if (current) return current;
|
|
337
|
+
await writeWorkState(target, state, { packageRoot, taskId, statePath });
|
|
338
|
+
return state;
|
|
339
|
+
}
|
|
340
|
+
return withTaskTransaction({
|
|
341
|
+
target,
|
|
342
|
+
taskId: taskId ?? "legacy-work-state",
|
|
343
|
+
lockTaskId: taskId ?? "legacy-work-state",
|
|
344
|
+
operation: "initialize-work-state",
|
|
345
|
+
}, async () => initializeWorkState(target, state, { packageRoot, taskId, statePath }));
|
|
346
|
+
}
|
|
347
|
+
|
|
284
348
|
export async function clearWorkState(target, options = {}) {
|
|
285
349
|
const relPath = options?.statePath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "state") : WORK_STATE_PATH);
|
|
286
350
|
await assertSafePath(target, relPath);
|