@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/index.js CHANGED
@@ -3662,7 +3662,7 @@ function isBlockingDependencyStatus(status) {
3662
3662
  function isTerminalStatus(status) {
3663
3663
  return status === "completed" || status === "failed" || status === "cancelled";
3664
3664
  }
3665
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
3665
+ 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;
3666
3666
  var init_types = __esm(() => {
3667
3667
  TASK_STATUSES = [
3668
3668
  "pending",
@@ -3750,6 +3750,19 @@ var init_types = __esm(() => {
3750
3750
  this.name = "ResourceConflictError";
3751
3751
  }
3752
3752
  };
3753
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
3754
+ planId;
3755
+ expectedUpdatedAt;
3756
+ currentUpdatedAt;
3757
+ static code = "PLAN_REVISION_CONFLICT";
3758
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
3759
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
3760
+ this.planId = planId;
3761
+ this.expectedUpdatedAt = expectedUpdatedAt;
3762
+ this.currentUpdatedAt = currentUpdatedAt;
3763
+ this.name = "PlanRevisionConflictError";
3764
+ }
3765
+ };
3753
3766
  PlanNotFoundError = class PlanNotFoundError extends Error {
3754
3767
  planId;
3755
3768
  static code = "PLAN_NOT_FOUND";
@@ -3772,6 +3785,16 @@ var init_types = __esm(() => {
3772
3785
  this.name = "LockError";
3773
3786
  }
3774
3787
  };
3788
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
3789
+ code;
3790
+ details;
3791
+ constructor(code, message, details = {}) {
3792
+ super(message);
3793
+ this.code = code;
3794
+ this.details = details;
3795
+ this.name = "StaleLockHandoffError";
3796
+ }
3797
+ };
3775
3798
  AgentNotFoundError = class AgentNotFoundError extends Error {
3776
3799
  agentId;
3777
3800
  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, field2, 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, field2 || 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: field2,
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: field2 || 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, field2, oldValue, newValue, agentId, db) {
7553
+ const d = db || getDatabase();
7554
+ return insertTaskHistory({
7555
+ id: uuid(),
7556
+ task_id: taskId,
7557
+ action,
7558
+ field: field2 || 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, field2) {
8595
+ if (typeof value !== "string" || !value.trim()) {
8596
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field2} must be a non-empty string`, { field: field2 });
8597
+ }
8598
+ const trimmed = value.trim();
8599
+ if (field2 === "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: field2, 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);
@@ -12493,7 +12758,7 @@ var init_dispatches = __esm(() => {
12493
12758
  // package.json
12494
12759
  var package_default = {
12495
12760
  name: "@hasna/todos",
12496
- version: "0.15.18",
12761
+ version: "0.15.20",
12497
12762
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12498
12763
  type: "module",
12499
12764
  main: "dist/index.js",
@@ -12564,6 +12829,8 @@ var package_default = {
12564
12829
  "dev:mcp": "bun run src/mcp/index.ts",
12565
12830
  "dev:serve": "bun run src/server/index.ts",
12566
12831
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
12832
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
12833
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
12567
12834
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
12568
12835
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
12569
12836
  },
@@ -25564,6 +25831,36 @@ init_task_lists();
25564
25831
  init_tasks();
25565
25832
  init_templates();
25566
25833
  init_storage_tombstones();
25834
+
25835
+ // src/storage/audit-history-import.ts
25836
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
25837
+ var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
25838
+ var AUDIT_HISTORY_FIELDS = [
25839
+ "id",
25840
+ "task_id",
25841
+ "action",
25842
+ "field",
25843
+ "old_value",
25844
+ "new_value",
25845
+ "agent_id",
25846
+ "created_at",
25847
+ "machine_id"
25848
+ ];
25849
+ function auditHistoryRowsAreFieldIdentical(left, right) {
25850
+ return AUDIT_HISTORY_FIELDS.every((field2) => {
25851
+ const leftValue = field2 === "machine_id" ? left[field2] ?? null : left[field2];
25852
+ const rightValue = field2 === "machine_id" ? right[field2] ?? null : right[field2];
25853
+ return leftValue === rightValue;
25854
+ });
25855
+ }
25856
+ function divergentAuditHistoryReplayError(id) {
25857
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
25858
+ }
25859
+ function forbiddenAuditHistoryTombstoneError(id) {
25860
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
25861
+ }
25862
+
25863
+ // src/storage/sqlite-snapshot.ts
25567
25864
  var PROJECT_COLUMNS = [
25568
25865
  "id",
25569
25866
  "name",
@@ -25764,9 +26061,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25764
26061
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
25765
26062
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
25766
26063
  }
26064
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
26065
+ result.errors.push(...auditImport.errors);
25767
26066
  if (result.errors.length > 0)
25768
26067
  return result;
25769
- const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
26068
+ result.skipped += auditImport.identicalReplayCount;
26069
+ const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
25770
26070
  for (const row of rows) {
25771
26071
  try {
25772
26072
  const record = asRecord2(row);
@@ -25775,7 +26075,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25775
26075
  result.skipped += 1;
25776
26076
  continue;
25777
26077
  }
25778
- const state = upsertById(d, table, columns, record, updateClockColumn);
26078
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
25779
26079
  if (state === "inserted")
25780
26080
  result.inserted += 1;
25781
26081
  else if (state === "updated")
@@ -25792,19 +26092,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25792
26092
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
25793
26093
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
25794
26094
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
25795
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
26095
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
25796
26096
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
25797
26097
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
25798
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", (row, changed) => {
26098
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", true, (row, changed) => {
25799
26099
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
25800
26100
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
25801
26101
  }
25802
26102
  });
25803
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
26103
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
25804
26104
  applyTombstones(d, snapshot.tombstones ?? [], result);
25805
26105
  return result;
25806
26106
  }
25807
- function upsertById(db, table, columns, row, updateClockColumn) {
26107
+ function preflightAuditHistoryImport(db, rows, tombstones) {
26108
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
26109
+ const rowsToInsert = [];
26110
+ const seen = new Map;
26111
+ let identicalReplayCount = 0;
26112
+ for (const rawRow of rows) {
26113
+ try {
26114
+ const row = asRecord2(rawRow);
26115
+ if (typeof row.id !== "string" || !row.id) {
26116
+ throw new Error("task_history row is missing id");
26117
+ }
26118
+ const prior = seen.get(row.id);
26119
+ if (prior) {
26120
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
26121
+ identicalReplayCount += 1;
26122
+ else
26123
+ errors.push(divergentAuditHistoryReplayError(row.id));
26124
+ continue;
26125
+ }
26126
+ seen.set(row.id, row);
26127
+ const existing = getAuditHistoryById(db, row.id);
26128
+ if (!existing) {
26129
+ rowsToInsert.push(row);
26130
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
26131
+ identicalReplayCount += 1;
26132
+ } else {
26133
+ errors.push(divergentAuditHistoryReplayError(row.id));
26134
+ }
26135
+ } catch (error) {
26136
+ errors.push(error instanceof Error ? error.message : String(error));
26137
+ }
26138
+ }
26139
+ return { rowsToInsert, identicalReplayCount, errors };
26140
+ }
26141
+ function insertAuditHistoryRows(db, rows, result) {
26142
+ for (const rawRow of rows) {
26143
+ try {
26144
+ const row = asRecord2(rawRow);
26145
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
26146
+ if (!presentColumns.includes("id"))
26147
+ presentColumns.unshift("id");
26148
+ const placeholders = presentColumns.map(() => "?").join(", ");
26149
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
26150
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
26151
+ if (changes > 0) {
26152
+ result.inserted += 1;
26153
+ continue;
26154
+ }
26155
+ const existing = getAuditHistoryById(db, String(row["id"]));
26156
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
26157
+ result.skipped += 1;
26158
+ } else {
26159
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
26160
+ }
26161
+ } catch (error) {
26162
+ result.errors.push(error instanceof Error ? error.message : String(error));
26163
+ }
26164
+ }
26165
+ }
26166
+ function getAuditHistoryById(db, id) {
26167
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
26168
+ }
26169
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
25808
26170
  const id = row["id"];
25809
26171
  if (typeof id !== "string" || !id)
25810
26172
  throw new Error(`${table} row is missing id`);
@@ -25816,7 +26178,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
25816
26178
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
25817
26179
  const updateColumns = presentColumns.filter((column) => column !== "id");
25818
26180
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
25819
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
26181
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
25820
26182
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
25821
26183
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
25822
26184
  const changes = db.run(sql, values).changes;
@@ -25901,7 +26263,7 @@ function tableForTombstone(objectType) {
25901
26263
  return "task_templates";
25902
26264
  if (objectType === "template_tasks")
25903
26265
  return "template_tasks";
25904
- return "task_history";
26266
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
25905
26267
  }
25906
26268
  function listRows(db, table, columns) {
25907
26269
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -26033,6 +26395,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
26033
26395
  unlockTask(id, agentId, database());
26034
26396
  return true;
26035
26397
  },
26398
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
26036
26399
  delete: (id) => deleteTask(id, database()),
26037
26400
  start: (id, agentId) => startTask(id, agentId, database()),
26038
26401
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -26056,6 +26419,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
26056
26419
  get: (id) => getPlan(id, database()),
26057
26420
  list: (projectId) => listPlans(projectId, database()),
26058
26421
  update: (id, input) => updatePlan(id, input, database()),
26422
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
26059
26423
  delete: (id) => deletePlan(id, database())
26060
26424
  },
26061
26425
  planProjectLinks: {
@@ -26129,6 +26493,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
26129
26493
  init_types();
26130
26494
  import { randomUUID as randomUUID3 } from "crypto";
26131
26495
  init_creator_identity();
26496
+ init_stale_lock_handoff();
26132
26497
 
26133
26498
  // src/storage/postgres-sync.ts
26134
26499
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
@@ -26564,6 +26929,7 @@ function createPostgresTodosStorageAdapter(options) {
26564
26929
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
26565
26930
  lock: (id, agentId) => lockTask2(id, agentId, store),
26566
26931
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
26932
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
26567
26933
  getByFingerprint: (fingerprint2) => store.getTaskByFingerprint(fingerprint2)
26568
26934
  },
26569
26935
  dependencies: {
@@ -26600,6 +26966,7 @@ function createPostgresTodosStorageAdapter(options) {
26600
26966
  get: (id) => store.get("plans", id),
26601
26967
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
26602
26968
  update: (id, input) => updatePlan2(id, input, store),
26969
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
26603
26970
  delete: (id, context) => store.deletePlan(id, context)
26604
26971
  },
26605
26972
  planProjectLinks: {
@@ -26709,6 +27076,91 @@ class PostgresJsonRecordStore {
26709
27076
  LIMIT 1`, [this.service, type, id]);
26710
27077
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
26711
27078
  }
27079
+ async handoffStaleLock(input, context = {}) {
27080
+ const prepared = prepareStaleLockHandoff(input);
27081
+ const receipt = buildStaleLockHandoffReceipt(prepared);
27082
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
27083
+ await this.ensureSchema();
27084
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
27085
+ target AS MATERIALIZED (
27086
+ SELECT payload
27087
+ FROM ${this.tableName}
27088
+ WHERE service = $1
27089
+ AND object_type = 'tasks'
27090
+ AND object_id = $2
27091
+ AND deleted_at IS NULL
27092
+ FOR UPDATE
27093
+ ),
27094
+ updated AS (
27095
+ UPDATE ${this.tableName} AS task_record
27096
+ SET payload = jsonb_set(
27097
+ jsonb_set(
27098
+ jsonb_set(
27099
+ jsonb_set(
27100
+ task_record.payload,
27101
+ '{locked_by}',
27102
+ to_jsonb($6::text),
27103
+ true
27104
+ ),
27105
+ '{locked_at}',
27106
+ to_jsonb($7::text),
27107
+ true
27108
+ ),
27109
+ '{updated_at}',
27110
+ to_jsonb($7::text),
27111
+ true
27112
+ ),
27113
+ '{version}',
27114
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
27115
+ true
27116
+ ),
27117
+ updated_at = $7::timestamptz,
27118
+ source_machine_id = $10,
27119
+ version = COALESCE(task_record.version, 0) + 1
27120
+ FROM target
27121
+ WHERE task_record.service = $1
27122
+ AND task_record.object_type = 'tasks'
27123
+ AND task_record.object_id = $2
27124
+ AND task_record.deleted_at IS NULL
27125
+ AND target.payload->>'locked_by' = $3
27126
+ AND target.payload->>'locked_at' = $4
27127
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
27128
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
27129
+ RETURNING task_record.payload
27130
+ ),
27131
+ audit AS (
27132
+ INSERT INTO ${this.tableName} (
27133
+ service, object_type, object_id, payload, updated_at,
27134
+ deleted_at, source_machine_id, version
27135
+ )
27136
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
27137
+ NULL, $10, NULL
27138
+ FROM updated
27139
+ RETURNING payload
27140
+ )
27141
+ SELECT
27142
+ (SELECT payload FROM target) AS current_payload,
27143
+ (SELECT payload FROM updated) AS updated_payload,
27144
+ (SELECT payload FROM audit) AS audit_payload`, [
27145
+ this.service,
27146
+ prepared.task_id,
27147
+ prepared.expected_holder,
27148
+ prepared.expected_lock_version,
27149
+ prepared.stale_cutoff,
27150
+ prepared.new_holder,
27151
+ prepared.operation_timestamp,
27152
+ receipt.receipt_id,
27153
+ jsonbParam(history),
27154
+ this.machineId(context)
27155
+ ]);
27156
+ const row = result.rows[0];
27157
+ if (!row?.current_payload)
27158
+ throw new TaskNotFoundError(prepared.task_id);
27159
+ if (!row.updated_payload || !row.audit_payload) {
27160
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
27161
+ }
27162
+ return receipt;
27163
+ }
26712
27164
  async list(type) {
26713
27165
  return (await this.listRecords(type)).map((record) => record.payload);
26714
27166
  }
@@ -26978,6 +27430,28 @@ class PostgresJsonRecordStore {
26978
27430
  }
26979
27431
  return value;
26980
27432
  }
27433
+ async insertImmutableAuditHistory(value, context = {}) {
27434
+ await this.ensureSchema();
27435
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
27436
+ service, object_type, object_id, payload, updated_at,
27437
+ deleted_at, source_machine_id, version
27438
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
27439
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
27440
+ RETURNING object_id`, [
27441
+ this.service,
27442
+ "audit_history",
27443
+ value.id,
27444
+ jsonbParam(value),
27445
+ value.created_at,
27446
+ context.requestId ?? this.sourceMachineId ?? null
27447
+ ]);
27448
+ if (inserted.rows.length > 0)
27449
+ return "inserted";
27450
+ const existing = await this.get("audit_history", value.id);
27451
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
27452
+ return "identical";
27453
+ throw new Error(divergentAuditHistoryReplayError(value.id));
27454
+ }
26981
27455
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
26982
27456
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
26983
27457
  if (planIds.length === 0)
@@ -27087,6 +27561,54 @@ class PostgresJsonRecordStore {
27087
27561
  throw new PlanNotFoundError(value.id);
27088
27562
  return payloadRecord2(row.payload);
27089
27563
  }
27564
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
27565
+ await this.ensureSchema();
27566
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
27567
+ SELECT date_trunc(
27568
+ 'milliseconds',
27569
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
27570
+ ) AS completed_at
27571
+ ), stored AS (
27572
+ UPDATE ${this.tableName} AS record SET
27573
+ payload = record.payload || jsonb_build_object(
27574
+ 'status', 'completed',
27575
+ 'updated_at', to_char(
27576
+ next_clock.completed_at AT TIME ZONE 'UTC',
27577
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
27578
+ )
27579
+ ),
27580
+ updated_at = next_clock.completed_at,
27581
+ deleted_at = NULL,
27582
+ source_machine_id = COALESCE($4, record.source_machine_id),
27583
+ version = COALESCE(record.version, 0) + 1
27584
+ FROM next_clock
27585
+ WHERE record.service = $1
27586
+ AND record.object_type = 'plans'
27587
+ AND record.object_id = $2
27588
+ AND record.deleted_at IS NULL
27589
+ AND record.payload->>'updated_at' = $3::text
27590
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
27591
+ RETURNING record.payload
27592
+ )
27593
+ SELECT payload FROM stored`, [
27594
+ this.service,
27595
+ id,
27596
+ expectedUpdatedAt,
27597
+ context.requestId ?? this.sourceMachineId ?? null
27598
+ ]);
27599
+ const payload = result.rows[0]?.payload;
27600
+ if (payload)
27601
+ return { plan: payloadRecord2(payload), applied: true };
27602
+ const current = await this.get("plans", id);
27603
+ if (!current)
27604
+ throw new PlanNotFoundError(id);
27605
+ if (current.updated_at !== expectedUpdatedAt) {
27606
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
27607
+ }
27608
+ if (current.status === "completed")
27609
+ return { plan: current, applied: false };
27610
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
27611
+ }
27090
27612
  async createTemplateWithTasks(template, tasks, context = {}) {
27091
27613
  await this.ensureSchema();
27092
27614
  const records = [
@@ -28421,6 +28943,11 @@ async function importSnapshot(snapshot, store, context) {
28421
28943
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
28422
28944
  if (result.errors.length > 0)
28423
28945
  return result;
28946
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
28947
+ result.errors.push(...auditHistory.errors);
28948
+ if (result.errors.length > 0)
28949
+ return result;
28950
+ result.skipped += auditHistory.identical;
28424
28951
  const entries = [
28425
28952
  ...snapshot.tasks.map((row) => ["tasks", row]),
28426
28953
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -28429,9 +28956,20 @@ async function importSnapshot(snapshot, store, context) {
28429
28956
  ...snapshot.agents.map((row) => ["agents", row]),
28430
28957
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
28431
28958
  ...snapshot.templates.map((row) => ["templates", row]),
28432
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
28433
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
28959
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
28434
28960
  ];
28961
+ for (const row of auditHistory.rowsToInsert) {
28962
+ try {
28963
+ const outcome = await store.insertImmutableAuditHistory(row, context);
28964
+ if (outcome === "inserted")
28965
+ result.inserted += 1;
28966
+ else
28967
+ result.skipped += 1;
28968
+ } catch (error) {
28969
+ result.errors.push(error instanceof Error ? error.message : String(error));
28970
+ return result;
28971
+ }
28972
+ }
28435
28973
  for (const [type, row] of entries) {
28436
28974
  try {
28437
28975
  const existing = await store.get(type, row.id);
@@ -28465,6 +29003,32 @@ async function importSnapshot(snapshot, store, context) {
28465
29003
  }
28466
29004
  return result;
28467
29005
  }
29006
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
29007
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
29008
+ const rowsToInsert = [];
29009
+ const seen = new Map;
29010
+ let identical = 0;
29011
+ for (const row of rows) {
29012
+ const prior = seen.get(row.id);
29013
+ if (prior) {
29014
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
29015
+ identical += 1;
29016
+ else
29017
+ errors.push(divergentAuditHistoryReplayError(row.id));
29018
+ continue;
29019
+ }
29020
+ seen.set(row.id, row);
29021
+ const existing = await store.get("audit_history", row.id);
29022
+ if (!existing) {
29023
+ rowsToInsert.push(row);
29024
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
29025
+ identical += 1;
29026
+ } else {
29027
+ errors.push(divergentAuditHistoryReplayError(row.id));
29028
+ }
29029
+ }
29030
+ return { rowsToInsert, identical, errors };
29031
+ }
28468
29032
  async function requireRecord(type, id, store) {
28469
29033
  const record = await store.get(type, id);
28470
29034
  if (!record)
@@ -28865,6 +29429,15 @@ function createShadowTodosStorageAdapter(options) {
28865
29429
  mirror.enqueueUpsert("plans", plan, context);
28866
29430
  return plan;
28867
29431
  },
29432
+ async completeAtRevision(id, expectedUpdatedAt, context) {
29433
+ if (typeof local.plans.completeAtRevision !== "function") {
29434
+ throw new Error("Atomic plan completion is not supported by the local shadow adapter");
29435
+ }
29436
+ const completed = await local.plans.completeAtRevision(id, expectedUpdatedAt, context);
29437
+ if (completed.applied)
29438
+ mirror.enqueueUpsert("plans", completed.plan, context);
29439
+ return completed;
29440
+ },
28868
29441
  async delete(id, context) {
28869
29442
  const deleted = await local.plans.delete(id, context);
28870
29443
  if (deleted)