@hasna/todos 0.15.18 → 0.15.20

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/dist/cli/cloud-router.d.ts +20 -1
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +1306 -122
  7. package/dist/cli/stage-a.d.ts +21 -10
  8. package/dist/cli/stage-a.d.ts.map +1 -1
  9. package/dist/contracts.js +283 -16
  10. package/dist/db/audit.d.ts +8 -0
  11. package/dist/db/audit.d.ts.map +1 -1
  12. package/dist/db/plans.d.ts +4 -0
  13. package/dist/db/plans.d.ts.map +1 -1
  14. package/dist/db/task-lifecycle.d.ts +7 -1
  15. package/dist/db/task-lifecycle.d.ts.map +1 -1
  16. package/dist/db/tasks.d.ts +1 -1
  17. package/dist/db/tasks.d.ts.map +1 -1
  18. package/dist/index.js +599 -26
  19. package/dist/lib/cli-help.d.ts +3 -2
  20. package/dist/lib/cli-help.d.ts.map +1 -1
  21. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  22. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  23. package/dist/mcp/index.js +954 -46
  24. package/dist/mcp.js +3 -1
  25. package/dist/project-registration.js +4250 -3684
  26. package/dist/registry.js +283 -16
  27. package/dist/release-provenance.json +5 -5
  28. package/dist/sdk/index.js +7 -0
  29. package/dist/sdk/v1.generated.d.ts +40 -1
  30. package/dist/sdk/v1.generated.d.ts.map +1 -1
  31. package/dist/server/index.js +1287 -379
  32. package/dist/server/openapi.d.ts +232 -0
  33. package/dist/server/openapi.d.ts.map +1 -1
  34. package/dist/server/v1.d.ts.map +1 -1
  35. package/dist/storage/audit-history-import.d.ts +14 -0
  36. package/dist/storage/audit-history-import.d.ts.map +1 -0
  37. package/dist/storage/interfaces.d.ts +22 -1
  38. package/dist/storage/interfaces.d.ts.map +1 -1
  39. package/dist/storage/local-sqlite.d.ts.map +1 -1
  40. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  41. package/dist/storage/shadow.d.ts.map +1 -1
  42. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  43. package/dist/storage.js +596 -25
  44. package/dist/task-manifest.js +24 -1
  45. package/dist/types/index.d.ts +50 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/package.json +3 -1
package/dist/storage.js CHANGED
@@ -276,7 +276,7 @@ function isBlockingDependencyStatus(status) {
276
276
  function isTerminalStatus(status) {
277
277
  return status === "completed" || status === "failed" || status === "cancelled";
278
278
  }
279
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
279
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
280
280
  var init_types = __esm(() => {
281
281
  TASK_STATUSES = [
282
282
  "pending",
@@ -364,6 +364,19 @@ var init_types = __esm(() => {
364
364
  this.name = "ResourceConflictError";
365
365
  }
366
366
  };
367
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
368
+ planId;
369
+ expectedUpdatedAt;
370
+ currentUpdatedAt;
371
+ static code = "PLAN_REVISION_CONFLICT";
372
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
373
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
374
+ this.planId = planId;
375
+ this.expectedUpdatedAt = expectedUpdatedAt;
376
+ this.currentUpdatedAt = currentUpdatedAt;
377
+ this.name = "PlanRevisionConflictError";
378
+ }
379
+ };
367
380
  PlanNotFoundError = class PlanNotFoundError extends Error {
368
381
  planId;
369
382
  static code = "PLAN_NOT_FOUND";
@@ -386,6 +399,16 @@ var init_types = __esm(() => {
386
399
  this.name = "LockError";
387
400
  }
388
401
  };
402
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
403
+ code;
404
+ details;
405
+ constructor(code, message, details = {}) {
406
+ super(message);
407
+ this.code = code;
408
+ this.details = details;
409
+ this.name = "StaleLockHandoffError";
410
+ }
411
+ };
389
412
  AgentNotFoundError = class AgentNotFoundError extends Error {
390
413
  agentId;
391
414
  static code = "AGENT_NOT_FOUND";
@@ -7490,28 +7513,55 @@ var init_activity_audit = __esm(() => {
7490
7513
  function sanitizeHistoryValue(value, context) {
7491
7514
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
7492
7515
  }
7493
- function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
7516
+ function insertTaskHistory(entry, db) {
7494
7517
  const d = db || getDatabase();
7495
- const id = uuid();
7496
- const timestamp2 = now();
7497
- const machineId = currentStorageMachineId(d);
7498
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
7499
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
7518
+ const safeEntry = {
7519
+ ...entry,
7520
+ field: entry.field || null,
7521
+ old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
7522
+ new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
7523
+ agent_id: entry.agent_id || null,
7524
+ machine_id: entry.machine_id ?? currentStorageMachineId(d)
7525
+ };
7500
7526
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
7501
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
7527
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
7528
+ safeEntry.id,
7529
+ safeEntry.task_id,
7530
+ safeEntry.action,
7531
+ safeEntry.field,
7532
+ safeEntry.old_value,
7533
+ safeEntry.new_value,
7534
+ safeEntry.agent_id,
7535
+ safeEntry.created_at,
7536
+ safeEntry.machine_id ?? null
7537
+ ]);
7502
7538
  try {
7503
7539
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
7504
7540
  logActivity2({
7505
7541
  entity_type: "task",
7506
- entity_id: taskId,
7507
- action,
7508
- field,
7509
- old_value: safeOldValue,
7510
- new_value: safeNewValue,
7511
- actor_id: agentId ?? undefined
7542
+ entity_id: safeEntry.task_id,
7543
+ action: safeEntry.action,
7544
+ field: safeEntry.field ?? undefined,
7545
+ old_value: safeEntry.old_value,
7546
+ new_value: safeEntry.new_value,
7547
+ actor_id: safeEntry.agent_id ?? undefined
7512
7548
  }, d);
7513
7549
  } catch {}
7514
- return { id, task_id: taskId, action, field: field || null, old_value: safeOldValue, new_value: safeNewValue, agent_id: agentId || null, created_at: timestamp2, machine_id: machineId };
7550
+ return safeEntry;
7551
+ }
7552
+ function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
7553
+ const d = db || getDatabase();
7554
+ return insertTaskHistory({
7555
+ id: uuid(),
7556
+ task_id: taskId,
7557
+ action,
7558
+ field: field || null,
7559
+ old_value: oldValue ?? null,
7560
+ new_value: newValue ?? null,
7561
+ agent_id: agentId || null,
7562
+ created_at: now(),
7563
+ machine_id: currentStorageMachineId(d)
7564
+ }, d);
7515
7565
  }
7516
7566
  function getTaskHistory(taskId, db) {
7517
7567
  const d = db || getDatabase();
@@ -8534,6 +8584,145 @@ var init_task_graph = __esm(() => {
8534
8584
  init_task_crud();
8535
8585
  });
8536
8586
 
8587
+ // src/lib/stale-lock-handoff.ts
8588
+ function normalizeExactTaskId(value) {
8589
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
8590
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_TASK_ID", "stale-lock handoff requires one exact full task UUID", { task_id: typeof value === "string" ? value : null });
8591
+ }
8592
+ return value.trim().toLowerCase();
8593
+ }
8594
+ function requireNonEmptyString(value, field) {
8595
+ if (typeof value !== "string" || !value.trim()) {
8596
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
8597
+ }
8598
+ const trimmed = value.trim();
8599
+ if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
8600
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
8601
+ }
8602
+ return trimmed;
8603
+ }
8604
+ function requireCanonicalLockVersion(value) {
8605
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
8606
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must be the exact canonical locked_at timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)", { field: "expected_lock_version" });
8607
+ }
8608
+ const parsed = Date.parse(value);
8609
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
8610
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
8611
+ }
8612
+ return value;
8613
+ }
8614
+ function requireStaleThreshold(value) {
8615
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
8616
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
8617
+ }
8618
+ return Number(value);
8619
+ }
8620
+ function prepareStaleLockHandoff(input, options = {}) {
8621
+ const taskId = normalizeExactTaskId(input.task_id);
8622
+ const actor = requireNonEmptyString(input.actor, "actor");
8623
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
8624
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
8625
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
8626
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
8627
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
8628
+ if (!reason) {
8629
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
8630
+ }
8631
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
8632
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
8633
+ }
8634
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
8635
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
8636
+ }
8637
+ const operationTimestamp = options.now ?? new Date().toISOString();
8638
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
8639
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
8640
+ }
8641
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
8642
+ return {
8643
+ task_id: taskId,
8644
+ actor,
8645
+ expected_holder: expectedHolder,
8646
+ expected_lock_version: expectedLockVersion,
8647
+ stale_after_seconds: staleAfterSeconds,
8648
+ new_holder: newHolder,
8649
+ reason,
8650
+ operation_timestamp: operationTimestamp,
8651
+ stale_cutoff: staleCutoff,
8652
+ receipt_id: options.receiptId ?? crypto.randomUUID()
8653
+ };
8654
+ }
8655
+ function buildStaleLockHandoffReceipt(input) {
8656
+ return {
8657
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
8658
+ receipt_id: input.receipt_id,
8659
+ task_id: input.task_id,
8660
+ actor: input.actor,
8661
+ previous_holder: input.expected_holder,
8662
+ previous_lock_version: input.expected_lock_version,
8663
+ new_holder: input.new_holder,
8664
+ new_lock_version: input.operation_timestamp,
8665
+ stale_after_seconds: input.stale_after_seconds,
8666
+ stale_cutoff: input.stale_cutoff,
8667
+ reason: input.reason,
8668
+ created_at: input.operation_timestamp
8669
+ };
8670
+ }
8671
+ function staleLockHandoffHistory(receipt, machineId) {
8672
+ return {
8673
+ id: receipt.receipt_id,
8674
+ task_id: receipt.task_id,
8675
+ action: STALE_LOCK_HANDOFF_ACTION,
8676
+ field: STALE_LOCK_HANDOFF_FIELD,
8677
+ old_value: JSON.stringify({
8678
+ holder: receipt.previous_holder,
8679
+ lock_version: receipt.previous_lock_version
8680
+ }),
8681
+ new_value: JSON.stringify(receipt),
8682
+ agent_id: receipt.actor,
8683
+ created_at: receipt.created_at,
8684
+ machine_id: machineId
8685
+ };
8686
+ }
8687
+ function throwStaleLockHandoffConflict(task, input) {
8688
+ if (!task.locked_by || !task.locked_at) {
8689
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_LOCKED", `Task ${input.task_id} does not have a complete lock to hand off`, { task_id: input.task_id });
8690
+ }
8691
+ if (task.locked_at !== input.expected_lock_version) {
8692
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
8693
+ task_id: input.task_id,
8694
+ expected_lock_version: input.expected_lock_version,
8695
+ current_lock_version: task.locked_at
8696
+ });
8697
+ }
8698
+ if (task.locked_by !== input.expected_holder) {
8699
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
8700
+ task_id: input.task_id,
8701
+ expected_holder: input.expected_holder,
8702
+ current_holder: task.locked_by
8703
+ });
8704
+ }
8705
+ if (isTerminalStatus(task.status)) {
8706
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_TERMINAL", `Task ${input.task_id} is ${task.status} and cannot transfer a lock`, { task_id: input.task_id, status: task.status });
8707
+ }
8708
+ if (task.locked_at >= input.stale_cutoff) {
8709
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
8710
+ task_id: input.task_id,
8711
+ current_lock_version: task.locked_at,
8712
+ stale_cutoff: input.stale_cutoff
8713
+ });
8714
+ }
8715
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
8716
+ }
8717
+ var STALE_LOCK_HANDOFF_SCHEMA_VERSION = "todos.stale-lock-handoff.v1", STALE_LOCK_HANDOFF_ACTION = "stale_lock_handoff", STALE_LOCK_HANDOFF_FIELD = "lock", EXACT_TASK_UUID_RE, CANONICAL_LOCK_VERSION_RE, MAX_REASON_LENGTH = 4096;
8718
+ var init_stale_lock_handoff = __esm(() => {
8719
+ init_types();
8720
+ init_creator_identity();
8721
+ init_prewrite_secrets();
8722
+ EXACT_TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8723
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
8724
+ });
8725
+
8537
8726
  // src/db/task-lifecycle.ts
8538
8727
  var exports_task_lifecycle = {};
8539
8728
  __export(exports_task_lifecycle, {
@@ -8542,6 +8731,7 @@ __export(exports_task_lifecycle, {
8542
8731
  startTask: () => startTask,
8543
8732
  spawnNextRecurrence: () => spawnNextRecurrence,
8544
8733
  lockTask: () => lockTask,
8734
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
8545
8735
  getTasksChangedSince: () => getTasksChangedSince,
8546
8736
  getTaskLockStatus: () => getTaskLockStatus,
8547
8737
  getStaleTasks: () => getStaleTasks,
@@ -8810,6 +9000,38 @@ function unlockTask(id, agentId, db) {
8810
9000
  WHERE id = ?`, [timestamp2, id]);
8811
9001
  return true;
8812
9002
  }
9003
+ function handoffStaleTaskLock(input, db) {
9004
+ const d = db || getDatabase();
9005
+ const prepared = prepareStaleLockHandoff(input);
9006
+ const receipt = buildStaleLockHandoffReceipt(prepared);
9007
+ const history = staleLockHandoffHistory(receipt, null);
9008
+ const transfer = d.transaction(() => {
9009
+ const result = d.run(`UPDATE tasks
9010
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
9011
+ WHERE id = ?
9012
+ AND locked_by = ?
9013
+ AND locked_at = ?
9014
+ AND julianday(locked_at) < julianday(?)
9015
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
9016
+ prepared.new_holder,
9017
+ prepared.operation_timestamp,
9018
+ prepared.operation_timestamp,
9019
+ prepared.task_id,
9020
+ prepared.expected_holder,
9021
+ prepared.expected_lock_version,
9022
+ prepared.stale_cutoff
9023
+ ]);
9024
+ if (result.changes === 0) {
9025
+ const current = getTask(prepared.task_id, d);
9026
+ if (!current)
9027
+ throw new TaskNotFoundError(prepared.task_id);
9028
+ throwStaleLockHandoffConflict(current, prepared);
9029
+ }
9030
+ insertTaskHistory(history, d);
9031
+ });
9032
+ transfer();
9033
+ return receipt;
9034
+ }
8813
9035
  function getTaskLockStatus(id, db) {
8814
9036
  const d = db || getDatabase();
8815
9037
  const task = getTask(id, d);
@@ -9110,6 +9332,7 @@ var init_task_lifecycle = __esm(() => {
9110
9332
  init_task_crud();
9111
9333
  init_task_graph();
9112
9334
  init_prewrite_secrets();
9335
+ init_stale_lock_handoff();
9113
9336
  });
9114
9337
 
9115
9338
  // src/db/task-crud.ts
@@ -10521,6 +10744,48 @@ function updatePlan(id, input, db) {
10521
10744
  return updatePlanStored(id, input, d);
10522
10745
  })();
10523
10746
  }
10747
+ function nextPlanCompletionTimestamp(expectedUpdatedAt) {
10748
+ const expected = Date.parse(expectedUpdatedAt);
10749
+ const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
10750
+ return new Date(Math.max(Date.now(), minimum)).toISOString();
10751
+ }
10752
+ function completePlanAtRevision(id, expectedUpdatedAt, db) {
10753
+ const d = db || getDatabase();
10754
+ return d.transaction(() => {
10755
+ guardPlanRowsSqlite([id], d);
10756
+ const plan = getPlan(id, d);
10757
+ if (!plan)
10758
+ throw new PlanNotFoundError(id);
10759
+ if (plan.updated_at !== expectedUpdatedAt) {
10760
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
10761
+ }
10762
+ if (plan.status === "completed")
10763
+ return { plan, applied: false };
10764
+ const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
10765
+ const result = d.run(`UPDATE plans
10766
+ SET status = 'completed', updated_at = ?
10767
+ WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
10768
+ if (result.changes !== 1) {
10769
+ const current = getPlan(id, d);
10770
+ if (!current)
10771
+ throw new PlanNotFoundError(id);
10772
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
10773
+ }
10774
+ const completed = getPlan(id, d);
10775
+ emitLocalEventHooksQuiet({
10776
+ type: "plan.updated",
10777
+ payload: {
10778
+ id,
10779
+ old_status: plan.status,
10780
+ new_status: completed.status,
10781
+ name: completed.name,
10782
+ project_id: completed.project_id
10783
+ },
10784
+ databasePath: databasePathFromDatabase(d)
10785
+ });
10786
+ return { plan: completed, applied: true };
10787
+ })();
10788
+ }
10524
10789
  function deletePlan(id, db) {
10525
10790
  const d = db || getDatabase();
10526
10791
  const plan = getPlan(id, d);
@@ -13705,6 +13970,36 @@ init_task_lists();
13705
13970
  init_tasks();
13706
13971
  init_templates();
13707
13972
  init_storage_tombstones();
13973
+
13974
+ // src/storage/audit-history-import.ts
13975
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
13976
+ var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
13977
+ var AUDIT_HISTORY_FIELDS = [
13978
+ "id",
13979
+ "task_id",
13980
+ "action",
13981
+ "field",
13982
+ "old_value",
13983
+ "new_value",
13984
+ "agent_id",
13985
+ "created_at",
13986
+ "machine_id"
13987
+ ];
13988
+ function auditHistoryRowsAreFieldIdentical(left, right) {
13989
+ return AUDIT_HISTORY_FIELDS.every((field) => {
13990
+ const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
13991
+ const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
13992
+ return leftValue === rightValue;
13993
+ });
13994
+ }
13995
+ function divergentAuditHistoryReplayError(id) {
13996
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
13997
+ }
13998
+ function forbiddenAuditHistoryTombstoneError(id) {
13999
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
14000
+ }
14001
+
14002
+ // src/storage/sqlite-snapshot.ts
13708
14003
  var PROJECT_COLUMNS = [
13709
14004
  "id",
13710
14005
  "name",
@@ -13905,9 +14200,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
13905
14200
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
13906
14201
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
13907
14202
  }
14203
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
14204
+ result.errors.push(...auditImport.errors);
13908
14205
  if (result.errors.length > 0)
13909
14206
  return result;
13910
- const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
14207
+ result.skipped += auditImport.identicalReplayCount;
14208
+ const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
13911
14209
  for (const row of rows) {
13912
14210
  try {
13913
14211
  const record = asRecord(row);
@@ -13916,7 +14214,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
13916
14214
  result.skipped += 1;
13917
14215
  continue;
13918
14216
  }
13919
- const state = upsertById(d, table, columns, record, updateClockColumn);
14217
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
13920
14218
  if (state === "inserted")
13921
14219
  result.inserted += 1;
13922
14220
  else if (state === "updated")
@@ -13933,19 +14231,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
13933
14231
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
13934
14232
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
13935
14233
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
13936
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
14234
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
13937
14235
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
13938
14236
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
13939
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", (row, changed) => {
14237
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", true, (row, changed) => {
13940
14238
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
13941
14239
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
13942
14240
  }
13943
14241
  });
13944
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
14242
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
13945
14243
  applyTombstones(d, snapshot.tombstones ?? [], result);
13946
14244
  return result;
13947
14245
  }
13948
- function upsertById(db, table, columns, row, updateClockColumn) {
14246
+ function preflightAuditHistoryImport(db, rows, tombstones) {
14247
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
14248
+ const rowsToInsert = [];
14249
+ const seen = new Map;
14250
+ let identicalReplayCount = 0;
14251
+ for (const rawRow of rows) {
14252
+ try {
14253
+ const row = asRecord(rawRow);
14254
+ if (typeof row.id !== "string" || !row.id) {
14255
+ throw new Error("task_history row is missing id");
14256
+ }
14257
+ const prior = seen.get(row.id);
14258
+ if (prior) {
14259
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
14260
+ identicalReplayCount += 1;
14261
+ else
14262
+ errors.push(divergentAuditHistoryReplayError(row.id));
14263
+ continue;
14264
+ }
14265
+ seen.set(row.id, row);
14266
+ const existing = getAuditHistoryById(db, row.id);
14267
+ if (!existing) {
14268
+ rowsToInsert.push(row);
14269
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
14270
+ identicalReplayCount += 1;
14271
+ } else {
14272
+ errors.push(divergentAuditHistoryReplayError(row.id));
14273
+ }
14274
+ } catch (error) {
14275
+ errors.push(error instanceof Error ? error.message : String(error));
14276
+ }
14277
+ }
14278
+ return { rowsToInsert, identicalReplayCount, errors };
14279
+ }
14280
+ function insertAuditHistoryRows(db, rows, result) {
14281
+ for (const rawRow of rows) {
14282
+ try {
14283
+ const row = asRecord(rawRow);
14284
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
14285
+ if (!presentColumns.includes("id"))
14286
+ presentColumns.unshift("id");
14287
+ const placeholders = presentColumns.map(() => "?").join(", ");
14288
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
14289
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
14290
+ if (changes > 0) {
14291
+ result.inserted += 1;
14292
+ continue;
14293
+ }
14294
+ const existing = getAuditHistoryById(db, String(row["id"]));
14295
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
14296
+ result.skipped += 1;
14297
+ } else {
14298
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
14299
+ }
14300
+ } catch (error) {
14301
+ result.errors.push(error instanceof Error ? error.message : String(error));
14302
+ }
14303
+ }
14304
+ }
14305
+ function getAuditHistoryById(db, id) {
14306
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
14307
+ }
14308
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
13949
14309
  const id = row["id"];
13950
14310
  if (typeof id !== "string" || !id)
13951
14311
  throw new Error(`${table} row is missing id`);
@@ -13957,7 +14317,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
13957
14317
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
13958
14318
  const updateColumns = presentColumns.filter((column) => column !== "id");
13959
14319
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
13960
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
14320
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
13961
14321
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
13962
14322
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
13963
14323
  const changes = db.run(sql, values).changes;
@@ -14042,7 +14402,7 @@ function tableForTombstone(objectType) {
14042
14402
  return "task_templates";
14043
14403
  if (objectType === "template_tasks")
14044
14404
  return "template_tasks";
14045
- return "task_history";
14405
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
14046
14406
  }
14047
14407
  function listRows(db, table, columns) {
14048
14408
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -14174,6 +14534,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14174
14534
  unlockTask(id, agentId, database());
14175
14535
  return true;
14176
14536
  },
14537
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
14177
14538
  delete: (id) => deleteTask(id, database()),
14178
14539
  start: (id, agentId) => startTask(id, agentId, database()),
14179
14540
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -14197,6 +14558,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14197
14558
  get: (id) => getPlan(id, database()),
14198
14559
  list: (projectId) => listPlans(projectId, database()),
14199
14560
  update: (id, input) => updatePlan(id, input, database()),
14561
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
14200
14562
  delete: (id) => deletePlan(id, database())
14201
14563
  },
14202
14564
  planProjectLinks: {
@@ -14270,6 +14632,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14270
14632
  init_types();
14271
14633
  import { randomUUID as randomUUID3 } from "crypto";
14272
14634
  init_creator_identity();
14635
+ init_stale_lock_handoff();
14273
14636
 
14274
14637
  // src/storage/postgres-sync.ts
14275
14638
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
@@ -14705,6 +15068,7 @@ function createPostgresTodosStorageAdapter(options) {
14705
15068
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
14706
15069
  lock: (id, agentId) => lockTask2(id, agentId, store),
14707
15070
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
15071
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
14708
15072
  getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
14709
15073
  },
14710
15074
  dependencies: {
@@ -14741,6 +15105,7 @@ function createPostgresTodosStorageAdapter(options) {
14741
15105
  get: (id) => store.get("plans", id),
14742
15106
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
14743
15107
  update: (id, input) => updatePlan2(id, input, store),
15108
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
14744
15109
  delete: (id, context) => store.deletePlan(id, context)
14745
15110
  },
14746
15111
  planProjectLinks: {
@@ -14850,6 +15215,91 @@ class PostgresJsonRecordStore {
14850
15215
  LIMIT 1`, [this.service, type, id]);
14851
15216
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
14852
15217
  }
15218
+ async handoffStaleLock(input, context = {}) {
15219
+ const prepared = prepareStaleLockHandoff(input);
15220
+ const receipt = buildStaleLockHandoffReceipt(prepared);
15221
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
15222
+ await this.ensureSchema();
15223
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
15224
+ target AS MATERIALIZED (
15225
+ SELECT payload
15226
+ FROM ${this.tableName}
15227
+ WHERE service = $1
15228
+ AND object_type = 'tasks'
15229
+ AND object_id = $2
15230
+ AND deleted_at IS NULL
15231
+ FOR UPDATE
15232
+ ),
15233
+ updated AS (
15234
+ UPDATE ${this.tableName} AS task_record
15235
+ SET payload = jsonb_set(
15236
+ jsonb_set(
15237
+ jsonb_set(
15238
+ jsonb_set(
15239
+ task_record.payload,
15240
+ '{locked_by}',
15241
+ to_jsonb($6::text),
15242
+ true
15243
+ ),
15244
+ '{locked_at}',
15245
+ to_jsonb($7::text),
15246
+ true
15247
+ ),
15248
+ '{updated_at}',
15249
+ to_jsonb($7::text),
15250
+ true
15251
+ ),
15252
+ '{version}',
15253
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
15254
+ true
15255
+ ),
15256
+ updated_at = $7::timestamptz,
15257
+ source_machine_id = $10,
15258
+ version = COALESCE(task_record.version, 0) + 1
15259
+ FROM target
15260
+ WHERE task_record.service = $1
15261
+ AND task_record.object_type = 'tasks'
15262
+ AND task_record.object_id = $2
15263
+ AND task_record.deleted_at IS NULL
15264
+ AND target.payload->>'locked_by' = $3
15265
+ AND target.payload->>'locked_at' = $4
15266
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
15267
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
15268
+ RETURNING task_record.payload
15269
+ ),
15270
+ audit AS (
15271
+ INSERT INTO ${this.tableName} (
15272
+ service, object_type, object_id, payload, updated_at,
15273
+ deleted_at, source_machine_id, version
15274
+ )
15275
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
15276
+ NULL, $10, NULL
15277
+ FROM updated
15278
+ RETURNING payload
15279
+ )
15280
+ SELECT
15281
+ (SELECT payload FROM target) AS current_payload,
15282
+ (SELECT payload FROM updated) AS updated_payload,
15283
+ (SELECT payload FROM audit) AS audit_payload`, [
15284
+ this.service,
15285
+ prepared.task_id,
15286
+ prepared.expected_holder,
15287
+ prepared.expected_lock_version,
15288
+ prepared.stale_cutoff,
15289
+ prepared.new_holder,
15290
+ prepared.operation_timestamp,
15291
+ receipt.receipt_id,
15292
+ jsonbParam(history),
15293
+ this.machineId(context)
15294
+ ]);
15295
+ const row = result.rows[0];
15296
+ if (!row?.current_payload)
15297
+ throw new TaskNotFoundError(prepared.task_id);
15298
+ if (!row.updated_payload || !row.audit_payload) {
15299
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
15300
+ }
15301
+ return receipt;
15302
+ }
14853
15303
  async list(type) {
14854
15304
  return (await this.listRecords(type)).map((record) => record.payload);
14855
15305
  }
@@ -15119,6 +15569,28 @@ class PostgresJsonRecordStore {
15119
15569
  }
15120
15570
  return value;
15121
15571
  }
15572
+ async insertImmutableAuditHistory(value, context = {}) {
15573
+ await this.ensureSchema();
15574
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
15575
+ service, object_type, object_id, payload, updated_at,
15576
+ deleted_at, source_machine_id, version
15577
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
15578
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
15579
+ RETURNING object_id`, [
15580
+ this.service,
15581
+ "audit_history",
15582
+ value.id,
15583
+ jsonbParam(value),
15584
+ value.created_at,
15585
+ context.requestId ?? this.sourceMachineId ?? null
15586
+ ]);
15587
+ if (inserted.rows.length > 0)
15588
+ return "inserted";
15589
+ const existing = await this.get("audit_history", value.id);
15590
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
15591
+ return "identical";
15592
+ throw new Error(divergentAuditHistoryReplayError(value.id));
15593
+ }
15122
15594
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
15123
15595
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
15124
15596
  if (planIds.length === 0)
@@ -15228,6 +15700,54 @@ class PostgresJsonRecordStore {
15228
15700
  throw new PlanNotFoundError(value.id);
15229
15701
  return payloadRecord2(row.payload);
15230
15702
  }
15703
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
15704
+ await this.ensureSchema();
15705
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
15706
+ SELECT date_trunc(
15707
+ 'milliseconds',
15708
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
15709
+ ) AS completed_at
15710
+ ), stored AS (
15711
+ UPDATE ${this.tableName} AS record SET
15712
+ payload = record.payload || jsonb_build_object(
15713
+ 'status', 'completed',
15714
+ 'updated_at', to_char(
15715
+ next_clock.completed_at AT TIME ZONE 'UTC',
15716
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
15717
+ )
15718
+ ),
15719
+ updated_at = next_clock.completed_at,
15720
+ deleted_at = NULL,
15721
+ source_machine_id = COALESCE($4, record.source_machine_id),
15722
+ version = COALESCE(record.version, 0) + 1
15723
+ FROM next_clock
15724
+ WHERE record.service = $1
15725
+ AND record.object_type = 'plans'
15726
+ AND record.object_id = $2
15727
+ AND record.deleted_at IS NULL
15728
+ AND record.payload->>'updated_at' = $3::text
15729
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
15730
+ RETURNING record.payload
15731
+ )
15732
+ SELECT payload FROM stored`, [
15733
+ this.service,
15734
+ id,
15735
+ expectedUpdatedAt,
15736
+ context.requestId ?? this.sourceMachineId ?? null
15737
+ ]);
15738
+ const payload = result.rows[0]?.payload;
15739
+ if (payload)
15740
+ return { plan: payloadRecord2(payload), applied: true };
15741
+ const current = await this.get("plans", id);
15742
+ if (!current)
15743
+ throw new PlanNotFoundError(id);
15744
+ if (current.updated_at !== expectedUpdatedAt) {
15745
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
15746
+ }
15747
+ if (current.status === "completed")
15748
+ return { plan: current, applied: false };
15749
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
15750
+ }
15231
15751
  async createTemplateWithTasks(template, tasks, context = {}) {
15232
15752
  await this.ensureSchema();
15233
15753
  const records = [
@@ -16562,6 +17082,11 @@ async function importSnapshot(snapshot, store, context) {
16562
17082
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
16563
17083
  if (result.errors.length > 0)
16564
17084
  return result;
17085
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
17086
+ result.errors.push(...auditHistory.errors);
17087
+ if (result.errors.length > 0)
17088
+ return result;
17089
+ result.skipped += auditHistory.identical;
16565
17090
  const entries = [
16566
17091
  ...snapshot.tasks.map((row) => ["tasks", row]),
16567
17092
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -16570,9 +17095,20 @@ async function importSnapshot(snapshot, store, context) {
16570
17095
  ...snapshot.agents.map((row) => ["agents", row]),
16571
17096
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
16572
17097
  ...snapshot.templates.map((row) => ["templates", row]),
16573
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
16574
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
17098
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
16575
17099
  ];
17100
+ for (const row of auditHistory.rowsToInsert) {
17101
+ try {
17102
+ const outcome = await store.insertImmutableAuditHistory(row, context);
17103
+ if (outcome === "inserted")
17104
+ result.inserted += 1;
17105
+ else
17106
+ result.skipped += 1;
17107
+ } catch (error) {
17108
+ result.errors.push(error instanceof Error ? error.message : String(error));
17109
+ return result;
17110
+ }
17111
+ }
16576
17112
  for (const [type, row] of entries) {
16577
17113
  try {
16578
17114
  const existing = await store.get(type, row.id);
@@ -16606,6 +17142,32 @@ async function importSnapshot(snapshot, store, context) {
16606
17142
  }
16607
17143
  return result;
16608
17144
  }
17145
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
17146
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
17147
+ const rowsToInsert = [];
17148
+ const seen = new Map;
17149
+ let identical = 0;
17150
+ for (const row of rows) {
17151
+ const prior = seen.get(row.id);
17152
+ if (prior) {
17153
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
17154
+ identical += 1;
17155
+ else
17156
+ errors.push(divergentAuditHistoryReplayError(row.id));
17157
+ continue;
17158
+ }
17159
+ seen.set(row.id, row);
17160
+ const existing = await store.get("audit_history", row.id);
17161
+ if (!existing) {
17162
+ rowsToInsert.push(row);
17163
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
17164
+ identical += 1;
17165
+ } else {
17166
+ errors.push(divergentAuditHistoryReplayError(row.id));
17167
+ }
17168
+ }
17169
+ return { rowsToInsert, identical, errors };
17170
+ }
16609
17171
  async function requireRecord(type, id, store) {
16610
17172
  const record = await store.get(type, id);
16611
17173
  if (!record)
@@ -17006,6 +17568,15 @@ function createShadowTodosStorageAdapter(options) {
17006
17568
  mirror.enqueueUpsert("plans", plan, context);
17007
17569
  return plan;
17008
17570
  },
17571
+ async completeAtRevision(id, expectedUpdatedAt, context) {
17572
+ if (typeof local.plans.completeAtRevision !== "function") {
17573
+ throw new Error("Atomic plan completion is not supported by the local shadow adapter");
17574
+ }
17575
+ const completed = await local.plans.completeAtRevision(id, expectedUpdatedAt, context);
17576
+ if (completed.applied)
17577
+ mirror.enqueueUpsert("plans", completed.plan, context);
17578
+ return completed;
17579
+ },
17009
17580
  async delete(id, context) {
17010
17581
  const deleted = await local.plans.delete(id, context);
17011
17582
  if (deleted)