@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.
Files changed (47) hide show
  1. package/AGENT_COMPATIBILITY.md +4 -0
  2. package/DOCS_INDEX.md +7 -0
  3. package/EXECUTION_STATE.md +8 -0
  4. package/LOOP_ENGINEERING.md +1 -1
  5. package/LOOP_SYSTEM_DESIGN.md +10 -0
  6. package/PROTOCOL_INTEGRATION.md +30 -0
  7. package/README.md +48 -0
  8. package/THREAT_MODEL.md +3 -1
  9. package/docs/ARTIFACT_REFERENCE.md +11 -0
  10. package/docs/CLI_REFERENCE.md +81 -3
  11. package/docs/CROSS_HARNESS_CONTINUITY.md +11 -0
  12. package/docs/DOCUMENTATION_GUIDE.md +17 -0
  13. package/docs/GETTING_STARTED.md +9 -0
  14. package/docs/RECIPES.md +5 -1
  15. package/docs/TROUBLESHOOTING.md +133 -40
  16. package/package.json +4 -1
  17. package/schemas/execution.schema.json +11 -1
  18. package/schemas/work-state.schema.json +1 -0
  19. package/src/cli.js +21 -2
  20. package/src/commands/doctor.js +22 -0
  21. package/src/commands/migrate-protocol.js +18 -0
  22. package/src/commands/protocol-info.js +16 -0
  23. package/src/commands/run-check.js +2 -0
  24. package/src/commands/task-create.js +3 -2
  25. package/src/commands/task-lock-status.js +29 -0
  26. package/src/commands/task-show.js +3 -3
  27. package/src/commands/task-unlock.js +8 -6
  28. package/src/core/artifacts.js +17 -4
  29. package/src/core/cli-command-definitions.js +44 -0
  30. package/src/core/completion-artifacts.js +15 -3
  31. package/src/core/completion.js +15 -3
  32. package/src/core/diagnosis.js +15 -11
  33. package/src/core/error-codes.js +18 -0
  34. package/src/core/events.js +109 -8
  35. package/src/core/execution.js +73 -9
  36. package/src/core/filesystem.js +20 -2
  37. package/src/core/phase.js +8 -2
  38. package/src/core/protocol-info.js +41 -0
  39. package/src/core/protocol-migration.js +59 -0
  40. package/src/core/reconcile-closure.js +22 -4
  41. package/src/core/resumability.js +8 -6
  42. package/src/core/task-command.js +3 -3
  43. package/src/core/task-lock.js +40 -4
  44. package/src/core/task-migration.js +24 -1
  45. package/src/core/task-paths.js +3 -1
  46. package/src/core/transaction.js +259 -0
  47. 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, writeWorkState } from "./work-state.js";
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 writeWorkState(target, next, { packageRoot, taskId, statePath });
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, writeWorkState } from "./work-state.js";
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 writeWorkState(target, {
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
- }, { packageRoot, taskId });
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { currentRepositoryFingerprint } from "./repository.js";
2
- import { createWorkState, readWorkState, writeWorkState } from "./work-state.js";
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
- await writeWorkState(target, state, { packageRoot, taskId, statePath });
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
- const next = { ...candidate, lastUpdated: new Date().toISOString() };
61
- await writeWorkState(target, next, { packageRoot, taskId, statePath });
62
- return next;
59
+ return mutateWorkState(target, {
60
+ expectedRevision: state.revision ?? 0,
61
+ packageRoot,
62
+ taskId,
63
+ statePath,
64
+ }, () => ({ ...candidate, lastUpdated: new Date().toISOString() }));
63
65
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveTaskContext } from "./task-context.js";
2
- import { withTaskLock } from "./task-lock.js";
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 withTaskLock(target, taskContext.taskId, operation, async () => {
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
 
@@ -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
- acquiredAt: new Date().toISOString(),
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
- acquiredAt: new Date().toISOString(),
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.`,
@@ -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
- return taskArtifactPath(taskId, "lock");
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
+ }