@cassiomc1/forgeloop 1.2.3 → 1.3.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/AGENT_COMPATIBILITY.md +4 -0
- package/DOCS_INDEX.md +7 -0
- package/EXECUTION_STATE.md +8 -0
- package/LOOP_ENGINEERING.md +1 -1
- package/LOOP_SYSTEM_DESIGN.md +10 -0
- package/PROTOCOL_INTEGRATION.md +30 -0
- package/README.md +48 -0
- package/THREAT_MODEL.md +3 -1
- package/docs/ARTIFACT_REFERENCE.md +11 -0
- package/docs/CLI_REFERENCE.md +81 -3
- package/docs/CROSS_HARNESS_CONTINUITY.md +11 -0
- package/docs/DOCUMENTATION_GUIDE.md +17 -0
- package/docs/GETTING_STARTED.md +9 -0
- package/docs/RECIPES.md +5 -1
- package/docs/TROUBLESHOOTING.md +133 -40
- package/package.json +4 -1
- package/schemas/execution.schema.json +11 -1
- package/schemas/work-state.schema.json +1 -0
- package/src/cli.js +21 -2
- package/src/commands/doctor.js +22 -0
- package/src/commands/migrate-protocol.js +18 -0
- package/src/commands/protocol-info.js +16 -0
- package/src/commands/run-check.js +2 -0
- package/src/commands/task-create.js +3 -2
- package/src/commands/task-lock-status.js +29 -0
- package/src/commands/task-show.js +3 -3
- package/src/commands/task-unlock.js +8 -6
- package/src/core/artifacts.js +17 -4
- package/src/core/cli-command-definitions.js +44 -0
- package/src/core/completion-artifacts.js +15 -3
- package/src/core/completion.js +15 -3
- package/src/core/diagnosis.js +15 -11
- package/src/core/error-codes.js +18 -0
- package/src/core/events.js +109 -8
- package/src/core/execution.js +73 -9
- package/src/core/filesystem.js +20 -2
- package/src/core/phase.js +8 -2
- package/src/core/protocol-info.js +41 -0
- package/src/core/protocol-migration.js +59 -0
- package/src/core/reconcile-closure.js +22 -4
- package/src/core/resumability.js +8 -6
- package/src/core/task-command.js +3 -3
- package/src/core/task-lock.js +40 -4
- package/src/core/task-migration.js +24 -1
- package/src/core/task-paths.js +3 -1
- package/src/core/transaction.js +259 -0
- package/src/core/work-state.js +70 -6
package/src/core/phase.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./events.js";
|
|
10
10
|
import { readPersistedRoute } from "./route-artifact.js";
|
|
11
11
|
import { assertWorkPhase, isValidTransition } from "./protocol.js";
|
|
12
|
-
import { readWorkState,
|
|
12
|
+
import { readWorkState, mutateWorkState } from "./work-state.js";
|
|
13
13
|
import { evaluateCompletion } from "./completion.js";
|
|
14
14
|
import { evaluatePreflight } from "./preflight.js";
|
|
15
15
|
import { requiredEvidenceForTarget } from "./completion-artifacts.js";
|
|
@@ -304,6 +304,7 @@ export async function advanceWorkState(target, toPhase, options = {}) {
|
|
|
304
304
|
next.verificationCycle = reenteringVerification ? (state.verificationCycle ?? 1) + 1 : (state.verificationCycle ?? 1);
|
|
305
305
|
}
|
|
306
306
|
if (reenteringVerification) delete next.lastCompletionAttempt;
|
|
307
|
+
next.revision = (state.revision ?? 0) + 1;
|
|
307
308
|
let nextReceipt = null;
|
|
308
309
|
try {
|
|
309
310
|
const receipt = await readJsonArtifact(target, receiptRel, "execution-receipt", packageRoot);
|
|
@@ -361,7 +362,12 @@ export async function advanceWorkState(target, toPhase, options = {}) {
|
|
|
361
362
|
} catch (error) {
|
|
362
363
|
if (error.code !== "ARTIFACT_MISSING") throw error;
|
|
363
364
|
}
|
|
364
|
-
await
|
|
365
|
+
await mutateWorkState(target, {
|
|
366
|
+
expectedRevision: state.revision ?? 0,
|
|
367
|
+
packageRoot,
|
|
368
|
+
taskId,
|
|
369
|
+
statePath,
|
|
370
|
+
}, () => next);
|
|
365
371
|
if (nextReceipt) {
|
|
366
372
|
await writeJsonArtifact(target, receiptRel, nextReceipt, "execution-receipt", packageRoot);
|
|
367
373
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CLI_COMMAND_DEFINITIONS } from "./cli-command-definitions.js";
|
|
2
|
+
import { ARTIFACT_REGISTRY } from "./artifact-registry.js";
|
|
3
|
+
import { PUBLIC_ERROR_REGISTRY } from "./error-codes.js";
|
|
4
|
+
import { GUIDE_REGISTRY } from "./guide-registry.js";
|
|
5
|
+
import { PROTOCOL_VERSION, WORK_PHASES, WORK_TRANSITIONS } from "./protocol.js";
|
|
6
|
+
|
|
7
|
+
export const SCHEMA_COMPATIBILITY_POLICY = Object.freeze({
|
|
8
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
read: "Readers reject unknown protocol or schema versions; compatibility changes require a new published version.",
|
|
11
|
+
write: "Writers emit only the current schema and protocol versions.",
|
|
12
|
+
migration: "Use migrate-protocol --to <version> --dry-run to plan an explicit supported migration. Legacy singleton artifacts are migrated with a receipt-backed task-migrate action.",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function publicSchemaVersions() {
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
[...new Set(Object.values(ARTIFACT_REGISTRY)
|
|
18
|
+
.filter((artifact) => artifact.isPublic && artifact.isPersisted)
|
|
19
|
+
.map((artifact) => artifact.schema))]
|
|
20
|
+
.sort()
|
|
21
|
+
.map((schema) => [schema, [SCHEMA_COMPATIBILITY_POLICY.schemaVersion]]),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function protocolInfo({ packageVersion = null } = {}) {
|
|
26
|
+
const errors = Object.values(PUBLIC_ERROR_REGISTRY);
|
|
27
|
+
const schemaVersions = publicSchemaVersions();
|
|
28
|
+
return {
|
|
29
|
+
packageVersion,
|
|
30
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
31
|
+
readsProtocol: [PROTOCOL_VERSION],
|
|
32
|
+
writesProtocol: [PROTOCOL_VERSION],
|
|
33
|
+
readsSchemaVersions: schemaVersions,
|
|
34
|
+
writesSchemaVersions: schemaVersions,
|
|
35
|
+
compatibility: SCHEMA_COMPATIBILITY_POLICY,
|
|
36
|
+
lifecycle: { phases: WORK_PHASES, transitions: WORK_TRANSITIONS },
|
|
37
|
+
guides: Object.values(GUIDE_REGISTRY),
|
|
38
|
+
commands: Object.values(CLI_COMMAND_DEFINITIONS).map(({ name, category, mutation, description }) => ({ name, category, mutation, description })),
|
|
39
|
+
errors,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED } from "./error-codes.js";
|
|
2
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
3
|
+
import { detectLegacySingletonLayout, migrateLegacyLayout } from "./task-migration.js";
|
|
4
|
+
import { getPackageRoot } from "./templates.js";
|
|
5
|
+
|
|
6
|
+
function assertSupportedTarget(to) {
|
|
7
|
+
if (to === undefined || to === null || to === "") {
|
|
8
|
+
const error = new Error("migrate-protocol requires --to <protocolVersion>");
|
|
9
|
+
error.code = E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (String(to) !== String(PROTOCOL_VERSION)) {
|
|
14
|
+
const error = new Error(
|
|
15
|
+
`Protocol version ${to} is not supported by this ForgeLoop release; supported target: ${PROTOCOL_VERSION}.`,
|
|
16
|
+
);
|
|
17
|
+
error.code = E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Migrates only state that has an explicitly supported, receipt-backed path.
|
|
24
|
+
* Future protocol versions must add a dedicated migration before becoming an
|
|
25
|
+
* accepted target here; accepting an unknown target would risk silent rewrite.
|
|
26
|
+
*/
|
|
27
|
+
export async function migrateProtocol(
|
|
28
|
+
target,
|
|
29
|
+
{ to, dryRun = false, packageRoot = getPackageRoot() } = {},
|
|
30
|
+
) {
|
|
31
|
+
assertSupportedTarget(to);
|
|
32
|
+
const legacy = await detectLegacySingletonLayout(target);
|
|
33
|
+
|
|
34
|
+
if (!legacy.hasLegacy) {
|
|
35
|
+
return {
|
|
36
|
+
migrated: false,
|
|
37
|
+
dryRun,
|
|
38
|
+
fromProtocol: PROTOCOL_VERSION,
|
|
39
|
+
toProtocol: PROTOCOL_VERSION,
|
|
40
|
+
status: "ALREADY_COMPATIBLE",
|
|
41
|
+
actions: [],
|
|
42
|
+
message: `Target already uses supported protocol ${PROTOCOL_VERSION}; no migration is required.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await migrateLegacyLayout(target, { dryRun, packageRoot });
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
fromProtocol: PROTOCOL_VERSION,
|
|
50
|
+
toProtocol: PROTOCOL_VERSION,
|
|
51
|
+
status: dryRun ? "PLANNED_LEGACY_LAYOUT_MIGRATION" : "MIGRATED_LEGACY_LAYOUT",
|
|
52
|
+
actions: [{
|
|
53
|
+
kind: "LEGACY_LAYOUT_MIGRATION",
|
|
54
|
+
command: "task-migrate",
|
|
55
|
+
receipt: ".forgeloop/task-state/<taskKey>/migration-receipt.json",
|
|
56
|
+
artifacts: legacy.legacyFiles.map((item) => item.path),
|
|
57
|
+
}],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readContract } from "./contract.js";
|
|
2
|
+
import { canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
2
3
|
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
3
4
|
import { runCommandExecution } from "./execution.js";
|
|
5
|
+
import { createReceipt } from "./receipt.js";
|
|
4
6
|
import { currentRepositoryFingerprint } from "./repository.js";
|
|
5
7
|
import { taskArtifactPath } from "./task-paths.js";
|
|
6
|
-
import { classifyLoadedWorkState, readWorkState,
|
|
8
|
+
import { classifyLoadedWorkState, readWorkState, mutateWorkState } from "./work-state.js";
|
|
7
9
|
|
|
8
10
|
export const RECONCILE_EVENT = "CHECKPOINT_RECONCILED";
|
|
9
11
|
|
|
@@ -63,6 +65,7 @@ export async function runReconcileClosure({
|
|
|
63
65
|
const stateRel = taskArtifactPath(taskId, "state");
|
|
64
66
|
const contractRel = taskArtifactPath(taskId, "contract");
|
|
65
67
|
const eventsRel = taskArtifactPath(taskId, "events");
|
|
68
|
+
const receiptRel = taskArtifactPath(taskId, "receipt");
|
|
66
69
|
|
|
67
70
|
const state = await readWorkState(target, { packageRoot, taskId });
|
|
68
71
|
if (!state) {
|
|
@@ -154,11 +157,26 @@ export async function runReconcileClosure({
|
|
|
154
157
|
},
|
|
155
158
|
}, packageRoot, { taskId });
|
|
156
159
|
|
|
157
|
-
await
|
|
160
|
+
const nextState = await mutateWorkState(target, {
|
|
161
|
+
expectedRevision: state.revision ?? 0,
|
|
162
|
+
packageRoot,
|
|
163
|
+
taskId,
|
|
164
|
+
}, () => ({
|
|
158
165
|
...state,
|
|
159
166
|
repositoryFingerprint: repository,
|
|
160
167
|
lastUpdated: new Date().toISOString(),
|
|
161
|
-
}
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const receipt = await readJsonArtifact(target, receiptRel, "execution-receipt", packageRoot);
|
|
172
|
+
const reboundReceipt = await createReceipt({
|
|
173
|
+
...receipt.value,
|
|
174
|
+
stateFingerprint: canonicalFingerprint(nextState),
|
|
175
|
+
}, packageRoot, { target, taskId, authorityContext, runtimeContext });
|
|
176
|
+
await writeJsonArtifact(target, receiptRel, reboundReceipt, "execution-receipt", packageRoot);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code !== "ARTIFACT_MISSING") throw error;
|
|
179
|
+
}
|
|
162
180
|
|
|
163
181
|
return {
|
|
164
182
|
taskId,
|
|
@@ -172,4 +190,4 @@ export async function runReconcileClosure({
|
|
|
172
190
|
executionPath: execution.path,
|
|
173
191
|
event: RECONCILE_EVENT,
|
|
174
192
|
};
|
|
175
|
-
}
|
|
193
|
+
}
|
package/src/core/resumability.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { currentRepositoryFingerprint } from "./repository.js";
|
|
2
|
-
import { createWorkState, readWorkState,
|
|
2
|
+
import { createWorkState, initializeWorkState, readWorkState, mutateWorkState } from "./work-state.js";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_PENDING_STEPS = ["planning", "implementation", "verification"];
|
|
5
5
|
|
|
@@ -22,8 +22,7 @@ export async function ensureResumableState({ target, packageRoot, contract, rout
|
|
|
22
22
|
blockers: [],
|
|
23
23
|
verificationEvidence: [],
|
|
24
24
|
});
|
|
25
|
-
|
|
26
|
-
return state;
|
|
25
|
+
return initializeWorkState(target, state, { packageRoot, taskId, statePath });
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
export async function synchronizePreflightState({
|
|
@@ -57,7 +56,10 @@ export async function synchronizePreflightState({
|
|
|
57
56
|
if (JSON.stringify(withoutTimestamp(candidate)) === JSON.stringify(withoutTimestamp(state))) {
|
|
58
57
|
return state;
|
|
59
58
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
return mutateWorkState(target, {
|
|
60
|
+
expectedRevision: state.revision ?? 0,
|
|
61
|
+
packageRoot,
|
|
62
|
+
taskId,
|
|
63
|
+
statePath,
|
|
64
|
+
}, () => ({ ...candidate, lastUpdated: new Date().toISOString() }));
|
|
63
65
|
}
|
package/src/core/task-command.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveTaskContext } from "./task-context.js";
|
|
2
|
-
import {
|
|
2
|
+
import { withTaskTransaction } from "./transaction.js";
|
|
3
3
|
|
|
4
4
|
export async function withResolvedTask(
|
|
5
5
|
target,
|
|
@@ -32,8 +32,8 @@ export async function withTaskMutation(
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
if (taskContext) {
|
|
35
|
-
return
|
|
36
|
-
return callback(taskContext);
|
|
35
|
+
return withTaskTransaction({ target, taskId: taskContext.taskId, operation, packageRoot: options.packageRoot, recordCommitEvent: true }, async (transaction) => {
|
|
36
|
+
return callback({ ...taskContext, transaction });
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
package/src/core/task-lock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
5
6
|
import { taskLockPath } from "./task-paths.js";
|
|
6
7
|
import { E_TASK_LOCKED } from "./error-codes.js";
|
|
@@ -22,6 +23,25 @@ export async function readLockInfo(target, taskId) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
export function classifyLockStaleness(lock, now = Date.now()) {
|
|
27
|
+
if (!lock || lock.corrupted) return { status: "UNKNOWN", stale: false };
|
|
28
|
+
const heartbeat = Date.parse(lock.heartbeatAt ?? lock.acquiredAt);
|
|
29
|
+
const leaseMs = Number.isInteger(lock.leaseMs) && lock.leaseMs > 0 ? lock.leaseMs : 300000;
|
|
30
|
+
if (!Number.isFinite(heartbeat)) return { status: "UNKNOWN", stale: false };
|
|
31
|
+
return now > heartbeat + leaseMs
|
|
32
|
+
? { status: "STALE", stale: true, expiresAt: new Date(heartbeat + leaseMs).toISOString() }
|
|
33
|
+
: { status: "LIVE", stale: false, expiresAt: new Date(heartbeat + leaseMs).toISOString() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* PID values can be reused. Pairing the PID with the process start epoch gives
|
|
38
|
+
* a portable, serializable ownership token without a daemon or platform-only
|
|
39
|
+
* process inspector. Consumers must still treat remote owners as unknown.
|
|
40
|
+
*/
|
|
41
|
+
export function currentProcessStartToken(now = Date.now(), uptimeSeconds = process.uptime()) {
|
|
42
|
+
return `${process.pid}:${Math.max(0, Math.floor(now - (uptimeSeconds * 1000)))}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
export const CLAIMS_LOCK_REL_PATH = ".forgeloop/.claims.lock";
|
|
26
46
|
|
|
27
47
|
export async function readProjectClaimsLockInfo(target) {
|
|
@@ -46,12 +66,18 @@ export async function acquireProjectClaimsLock(target, operation = "claim-reserv
|
|
|
46
66
|
|
|
47
67
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
48
68
|
|
|
69
|
+
const acquiredAt = new Date().toISOString();
|
|
49
70
|
const lockData = {
|
|
50
71
|
lockId: randomUUID(),
|
|
51
72
|
scope: "claims-reservation",
|
|
52
73
|
operation,
|
|
53
74
|
pid: process.pid,
|
|
54
|
-
|
|
75
|
+
hostname: os.hostname(),
|
|
76
|
+
processStartToken: currentProcessStartToken(),
|
|
77
|
+
ownerInstanceId: randomUUID(),
|
|
78
|
+
acquiredAt,
|
|
79
|
+
heartbeatAt: acquiredAt,
|
|
80
|
+
leaseMs: 300000,
|
|
55
81
|
};
|
|
56
82
|
|
|
57
83
|
let fileHandle;
|
|
@@ -123,12 +149,18 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
|
|
|
123
149
|
|
|
124
150
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
125
151
|
|
|
152
|
+
const acquiredAt = new Date().toISOString();
|
|
126
153
|
const lockData = {
|
|
127
154
|
lockId: randomUUID(),
|
|
128
155
|
taskId,
|
|
129
156
|
operation,
|
|
130
157
|
pid: process.pid,
|
|
131
|
-
|
|
158
|
+
hostname: os.hostname(),
|
|
159
|
+
processStartToken: currentProcessStartToken(),
|
|
160
|
+
ownerInstanceId: randomUUID(),
|
|
161
|
+
acquiredAt,
|
|
162
|
+
heartbeatAt: acquiredAt,
|
|
163
|
+
leaseMs: 300000,
|
|
132
164
|
};
|
|
133
165
|
|
|
134
166
|
let fileHandle;
|
|
@@ -179,7 +211,7 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
|
|
|
179
211
|
}
|
|
180
212
|
}
|
|
181
213
|
|
|
182
|
-
export async function forceUnlockTask(target, taskId) {
|
|
214
|
+
export async function forceUnlockTask(target, taskId, { staleOnly = false } = {}) {
|
|
183
215
|
const relativePath = taskLockPath(taskId);
|
|
184
216
|
await assertSafePath(target, relativePath);
|
|
185
217
|
const fullPath = ensureWithin(target, relativePath);
|
|
@@ -189,8 +221,12 @@ export async function forceUnlockTask(target, taskId) {
|
|
|
189
221
|
}
|
|
190
222
|
|
|
191
223
|
const existing = await readLockInfo(target, taskId);
|
|
224
|
+
const classification = classifyLockStaleness(existing);
|
|
225
|
+
if (staleOnly && !classification.stale) {
|
|
226
|
+
return { unlocked: false, previousLock: existing, classification };
|
|
227
|
+
}
|
|
192
228
|
await unlink(fullPath);
|
|
193
|
-
return { unlocked: true, previousLock: existing };
|
|
229
|
+
return { unlocked: true, previousLock: existing, classification };
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
export async function withTaskLock(target, taskId, operationOrCallback, callback) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cp, mkdir, rename, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { ensureWithin, fileExists } from "./filesystem.js";
|
|
3
|
+
import { ensureWithin, fileExists, writeFileAtomic } from "./filesystem.js";
|
|
4
4
|
import { getPackageRoot } from "./templates.js";
|
|
5
5
|
import {
|
|
6
6
|
LEGACY_TASK_ARTIFACT_PATHS,
|
|
@@ -308,6 +308,23 @@ export async function migrateLegacyLayout(
|
|
|
308
308
|
assertMigrationSnapshotsEqual(sourceSnapshot, finalSnapshot, "published namespace");
|
|
309
309
|
await readTaskDescriptor(target, canonicalTaskId, packageRoot);
|
|
310
310
|
|
|
311
|
+
const migrationReceipt = {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
protocolVersion: 1,
|
|
314
|
+
kind: "LEGACY_TASK_MIGRATION",
|
|
315
|
+
taskId: canonicalTaskId,
|
|
316
|
+
taskKey,
|
|
317
|
+
migratedAt: new Date().toISOString(),
|
|
318
|
+
source: sourceSnapshot,
|
|
319
|
+
destination: finalSnapshot,
|
|
320
|
+
cleanupStatus: "PENDING",
|
|
321
|
+
};
|
|
322
|
+
await writeFileAtomic(
|
|
323
|
+
ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
|
|
324
|
+
`${JSON.stringify(migrationReceipt, null, 2)}\n`,
|
|
325
|
+
);
|
|
326
|
+
migratedArtifacts.push("migration-receipt.json");
|
|
327
|
+
|
|
311
328
|
// Test hook for cleanup failure testing
|
|
312
329
|
if (typeof beforeLegacyCleanupForTest === "function") {
|
|
313
330
|
await beforeLegacyCleanupForTest({ finalDirAbs, finalDirRel, target });
|
|
@@ -323,6 +340,12 @@ export async function migrateLegacyLayout(
|
|
|
323
340
|
await removeLegacyArtifact(target, item.path);
|
|
324
341
|
}
|
|
325
342
|
}
|
|
343
|
+
migrationReceipt.cleanupStatus = "COMPLETED";
|
|
344
|
+
migrationReceipt.cleanedAt = new Date().toISOString();
|
|
345
|
+
await writeFileAtomic(
|
|
346
|
+
ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
|
|
347
|
+
`${JSON.stringify(migrationReceipt, null, 2)}\n`,
|
|
348
|
+
);
|
|
326
349
|
} catch (cleanupErr) {
|
|
327
350
|
const error = new Error(
|
|
328
351
|
`Migration published successfully to ${finalDirRel}, but legacy cleanup failed: ${cleanupErr.message}. Manual cleanup required.`,
|
package/src/core/task-paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertTaskId, taskStorageKey } from "./task-identity.js";
|
|
2
2
|
|
|
3
3
|
export const TASK_STATE_ROOT = ".forgeloop/task-state";
|
|
4
|
+
export const TASK_LOCK_ROOT = ".forgeloop/locks";
|
|
4
5
|
export const SESSIONS_ROOT = ".forgeloop/sessions";
|
|
5
6
|
|
|
6
7
|
export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
@@ -74,7 +75,8 @@ export function taskExecutionPath(taskId, executionId) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export function taskLockPath(taskId) {
|
|
77
|
-
|
|
78
|
+
assertTaskId(taskId);
|
|
79
|
+
return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export function sessionArtifactPath(sessionId) {
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}`);
|
|
122
|
+
if (await fileExists(staged)) return readFile(staged, "utf8");
|
|
123
|
+
const appendStaged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
124
|
+
const appendText = await fileExists(appendStaged) ? await readFile(appendStaged, "utf8") : "";
|
|
125
|
+
const destination = ensureWithin(target, relativePath);
|
|
126
|
+
if (!(await fileExists(destination))) return appendText || null;
|
|
127
|
+
return `${await readFile(destination, "utf8")}${appendText}`;
|
|
128
|
+
},
|
|
129
|
+
async stageText(relativePath, text) {
|
|
130
|
+
await assertSafePath(target, relativePath);
|
|
131
|
+
if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind === "APPEND")) {
|
|
132
|
+
throw new Error(`cannot replace append-staged path: ${relativePath}`);
|
|
133
|
+
}
|
|
134
|
+
const staged = `${stageRoot}/${relativePath}`;
|
|
135
|
+
await assertSafePath(target, staged);
|
|
136
|
+
await writeFileAtomic(ensureWithin(target, staged), text);
|
|
137
|
+
if (!manifest.writes.some((entry) => writePath(entry) === relativePath)) {
|
|
138
|
+
manifest.writes.push({ path: relativePath, hadPrevious: false, published: false });
|
|
139
|
+
}
|
|
140
|
+
await writeManifest(target, manifestPath, manifest);
|
|
141
|
+
},
|
|
142
|
+
async appendText(relativePath, text) {
|
|
143
|
+
await assertSafePath(target, relativePath);
|
|
144
|
+
if (typeof text !== "string" || text.length === 0) throw new Error("transaction append text must be a non-empty string");
|
|
145
|
+
if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind !== "APPEND")) {
|
|
146
|
+
throw new Error(`cannot append to replace-staged path: ${relativePath}`);
|
|
147
|
+
}
|
|
148
|
+
const destination = ensureWithin(target, relativePath);
|
|
149
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
150
|
+
const existing = await fileExists(staged) ? await readFile(staged, "utf8") : "";
|
|
151
|
+
await writeFileAtomic(staged, `${existing}${text}`);
|
|
152
|
+
let entry = manifest.writes.find((candidate) => writePath(candidate) === relativePath);
|
|
153
|
+
if (!entry) {
|
|
154
|
+
entry = {
|
|
155
|
+
path: relativePath,
|
|
156
|
+
kind: "APPEND",
|
|
157
|
+
originalSize: (await fileExists(destination)) ? (await stat(destination)).size : 0,
|
|
158
|
+
appendStarted: false,
|
|
159
|
+
published: false,
|
|
160
|
+
};
|
|
161
|
+
manifest.writes.push(entry);
|
|
162
|
+
}
|
|
163
|
+
await writeManifest(target, manifestPath, manifest);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
const result = await transactionContext.run(tx, async () => {
|
|
168
|
+
const callbackResult = await callback(tx);
|
|
169
|
+
if (recordCommitEvent) {
|
|
170
|
+
// Import lazily to keep the transaction/event dependency directional
|
|
171
|
+
// at module initialization. The event is staged while this task's
|
|
172
|
+
// transaction context is still active and is published last below.
|
|
173
|
+
const { appendProtocolEvent } = await import("./events.js");
|
|
174
|
+
await appendProtocolEvent(target, {
|
|
175
|
+
taskId,
|
|
176
|
+
event: "TRANSACTION_COMMITTED",
|
|
177
|
+
details: { transactionId, operation },
|
|
178
|
+
}, packageRoot, { taskId });
|
|
179
|
+
}
|
|
180
|
+
return callbackResult;
|
|
181
|
+
});
|
|
182
|
+
manifest.status = "COMMITTING";
|
|
183
|
+
await writeManifest(target, manifestPath, manifest);
|
|
184
|
+
// The ledger is the commit witness. Publishing it last means that a
|
|
185
|
+
// visible TRANSACTION_COMMITTED event cannot precede a required staged
|
|
186
|
+
// artifact in the same transaction.
|
|
187
|
+
const writes = [...manifest.writes].sort((left, right) => {
|
|
188
|
+
const leftIsLedger = writePath(left).endsWith("events.ndjson");
|
|
189
|
+
const rightIsLedger = writePath(right).endsWith("events.ndjson");
|
|
190
|
+
return Number(leftIsLedger) - Number(rightIsLedger);
|
|
191
|
+
});
|
|
192
|
+
for (const entry of writes) {
|
|
193
|
+
const relativePath = writePath(entry);
|
|
194
|
+
const staged = ensureWithin(target, `${stageRoot}/${relativePath}`);
|
|
195
|
+
const destination = ensureWithin(target, relativePath);
|
|
196
|
+
if (entry.kind === "APPEND") {
|
|
197
|
+
const appendStaged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
|
|
198
|
+
const appendText = await readFile(appendStaged, "utf8");
|
|
199
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
200
|
+
entry.appendStarted = true;
|
|
201
|
+
await writeManifest(target, manifestPath, manifest);
|
|
202
|
+
const handle = await open(destination, "a", 0o644);
|
|
203
|
+
try {
|
|
204
|
+
await handle.writeFile(appendText);
|
|
205
|
+
await handle.sync();
|
|
206
|
+
} finally {
|
|
207
|
+
await handle.close();
|
|
208
|
+
}
|
|
209
|
+
entry.published = true;
|
|
210
|
+
await writeManifest(target, manifestPath, manifest);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const backup = ensureWithin(target, `${root}/backup/${relativePath}`);
|
|
214
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
215
|
+
entry.hadPrevious = await fileExists(destination);
|
|
216
|
+
if (entry.hadPrevious) {
|
|
217
|
+
entry.backupPending = true;
|
|
218
|
+
await writeManifest(target, manifestPath, manifest);
|
|
219
|
+
await mkdir(path.dirname(backup), { recursive: true });
|
|
220
|
+
await rename(destination, backup);
|
|
221
|
+
entry.backupCreated = true;
|
|
222
|
+
}
|
|
223
|
+
await writeManifest(target, manifestPath, manifest);
|
|
224
|
+
await rename(staged, destination);
|
|
225
|
+
entry.published = true;
|
|
226
|
+
await writeManifest(target, manifestPath, manifest);
|
|
227
|
+
}
|
|
228
|
+
manifest.status = "COMMITTED";
|
|
229
|
+
manifest.committedAt = new Date().toISOString();
|
|
230
|
+
await writeManifest(target, manifestPath, manifest);
|
|
231
|
+
return result;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (manifest.status === "COMMITTING") {
|
|
234
|
+
try {
|
|
235
|
+
await rollbackPublishedWrites(target, root, manifest);
|
|
236
|
+
manifest.status = "ROLLED_BACK";
|
|
237
|
+
manifest.recoveredAt = new Date().toISOString();
|
|
238
|
+
} catch (rollbackError) {
|
|
239
|
+
manifest.status = "ABANDONED";
|
|
240
|
+
manifest.rollbackError = { message: rollbackError.message };
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
manifest.status = "ABANDONED";
|
|
244
|
+
}
|
|
245
|
+
manifest.failedAt = new Date().toISOString();
|
|
246
|
+
manifest.error = { message: error.message };
|
|
247
|
+
await writeManifest(target, manifestPath, manifest);
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
while (true) {
|
|
252
|
+
try {
|
|
253
|
+
return await runWithLock();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error.code !== E_TASK_LOCKED || Date.now() - started >= LOCK_WAIT_TIMEOUT_MS) throw error;
|
|
256
|
+
await delay(20);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|