@hasna/todos 0.15.19 → 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 (43) 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/query-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  5. package/dist/cli/index.js +851 -47
  6. package/dist/cli/stage-a.d.ts +21 -10
  7. package/dist/cli/stage-a.d.ts.map +1 -1
  8. package/dist/contracts.js +228 -16
  9. package/dist/db/audit.d.ts +8 -0
  10. package/dist/db/audit.d.ts.map +1 -1
  11. package/dist/db/task-lifecycle.d.ts +7 -1
  12. package/dist/db/task-lifecycle.d.ts.map +1 -1
  13. package/dist/db/tasks.d.ts +1 -1
  14. package/dist/db/tasks.d.ts.map +1 -1
  15. package/dist/index.js +479 -20
  16. package/dist/lib/cli-help.d.ts +3 -2
  17. package/dist/lib/cli-help.d.ts.map +1 -1
  18. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  19. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  20. package/dist/mcp/index.js +702 -36
  21. package/dist/mcp.js +3 -1
  22. package/dist/project-registration.js +1147 -686
  23. package/dist/registry.js +228 -16
  24. package/dist/release-provenance.json +5 -5
  25. package/dist/sdk/index.js +7 -0
  26. package/dist/sdk/v1.generated.d.ts +27 -0
  27. package/dist/sdk/v1.generated.d.ts.map +1 -1
  28. package/dist/server/index.js +1035 -369
  29. package/dist/server/openapi.d.ts +182 -0
  30. package/dist/server/openapi.d.ts.map +1 -1
  31. package/dist/server/v1.d.ts.map +1 -1
  32. package/dist/storage/audit-history-import.d.ts +14 -0
  33. package/dist/storage/audit-history-import.d.ts.map +1 -0
  34. package/dist/storage/interfaces.d.ts +12 -1
  35. package/dist/storage/interfaces.d.ts.map +1 -1
  36. package/dist/storage/local-sqlite.d.ts.map +1 -1
  37. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  38. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  39. package/dist/storage.js +476 -19
  40. package/dist/task-manifest.js +11 -1
  41. package/dist/types/index.d.ts +43 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. 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, PlanRevisionConflictError, 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",
@@ -399,6 +399,16 @@ var init_types = __esm(() => {
399
399
  this.name = "LockError";
400
400
  }
401
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
+ };
402
412
  AgentNotFoundError = class AgentNotFoundError extends Error {
403
413
  agentId;
404
414
  static code = "AGENT_NOT_FOUND";
@@ -7503,28 +7513,55 @@ var init_activity_audit = __esm(() => {
7503
7513
  function sanitizeHistoryValue(value, context) {
7504
7514
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
7505
7515
  }
7506
- function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
7516
+ function insertTaskHistory(entry, db) {
7507
7517
  const d = db || getDatabase();
7508
- const id = uuid();
7509
- const timestamp2 = now();
7510
- const machineId = currentStorageMachineId(d);
7511
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
7512
- 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
+ };
7513
7526
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
7514
- 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
+ ]);
7515
7538
  try {
7516
7539
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
7517
7540
  logActivity2({
7518
7541
  entity_type: "task",
7519
- entity_id: taskId,
7520
- action,
7521
- field,
7522
- old_value: safeOldValue,
7523
- new_value: safeNewValue,
7524
- 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
7525
7548
  }, d);
7526
7549
  } catch {}
7527
- 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);
7528
7565
  }
7529
7566
  function getTaskHistory(taskId, db) {
7530
7567
  const d = db || getDatabase();
@@ -8547,6 +8584,145 @@ var init_task_graph = __esm(() => {
8547
8584
  init_task_crud();
8548
8585
  });
8549
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
+
8550
8726
  // src/db/task-lifecycle.ts
8551
8727
  var exports_task_lifecycle = {};
8552
8728
  __export(exports_task_lifecycle, {
@@ -8555,6 +8731,7 @@ __export(exports_task_lifecycle, {
8555
8731
  startTask: () => startTask,
8556
8732
  spawnNextRecurrence: () => spawnNextRecurrence,
8557
8733
  lockTask: () => lockTask,
8734
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
8558
8735
  getTasksChangedSince: () => getTasksChangedSince,
8559
8736
  getTaskLockStatus: () => getTaskLockStatus,
8560
8737
  getStaleTasks: () => getStaleTasks,
@@ -8823,6 +9000,38 @@ function unlockTask(id, agentId, db) {
8823
9000
  WHERE id = ?`, [timestamp2, id]);
8824
9001
  return true;
8825
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
+ }
8826
9035
  function getTaskLockStatus(id, db) {
8827
9036
  const d = db || getDatabase();
8828
9037
  const task = getTask(id, d);
@@ -9123,6 +9332,7 @@ var init_task_lifecycle = __esm(() => {
9123
9332
  init_task_crud();
9124
9333
  init_task_graph();
9125
9334
  init_prewrite_secrets();
9335
+ init_stale_lock_handoff();
9126
9336
  });
9127
9337
 
9128
9338
  // src/db/task-crud.ts
@@ -13760,6 +13970,36 @@ init_task_lists();
13760
13970
  init_tasks();
13761
13971
  init_templates();
13762
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
13763
14003
  var PROJECT_COLUMNS = [
13764
14004
  "id",
13765
14005
  "name",
@@ -13960,8 +14200,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
13960
14200
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
13961
14201
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
13962
14202
  }
14203
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
14204
+ result.errors.push(...auditImport.errors);
13963
14205
  if (result.errors.length > 0)
13964
14206
  return result;
14207
+ result.skipped += auditImport.identicalReplayCount;
13965
14208
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
13966
14209
  for (const row of rows) {
13967
14210
  try {
@@ -13996,10 +14239,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
13996
14239
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
13997
14240
  }
13998
14241
  });
13999
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
14242
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
14000
14243
  applyTombstones(d, snapshot.tombstones ?? [], result);
14001
14244
  return result;
14002
14245
  }
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
+ }
14003
14308
  function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
14004
14309
  const id = row["id"];
14005
14310
  if (typeof id !== "string" || !id)
@@ -14097,7 +14402,7 @@ function tableForTombstone(objectType) {
14097
14402
  return "task_templates";
14098
14403
  if (objectType === "template_tasks")
14099
14404
  return "template_tasks";
14100
- return "task_history";
14405
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
14101
14406
  }
14102
14407
  function listRows(db, table, columns) {
14103
14408
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -14229,6 +14534,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14229
14534
  unlockTask(id, agentId, database());
14230
14535
  return true;
14231
14536
  },
14537
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
14232
14538
  delete: (id) => deleteTask(id, database()),
14233
14539
  start: (id, agentId) => startTask(id, agentId, database()),
14234
14540
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -14326,6 +14632,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14326
14632
  init_types();
14327
14633
  import { randomUUID as randomUUID3 } from "crypto";
14328
14634
  init_creator_identity();
14635
+ init_stale_lock_handoff();
14329
14636
 
14330
14637
  // src/storage/postgres-sync.ts
14331
14638
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
@@ -14761,6 +15068,7 @@ function createPostgresTodosStorageAdapter(options) {
14761
15068
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
14762
15069
  lock: (id, agentId) => lockTask2(id, agentId, store),
14763
15070
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
15071
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
14764
15072
  getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
14765
15073
  },
14766
15074
  dependencies: {
@@ -14907,6 +15215,91 @@ class PostgresJsonRecordStore {
14907
15215
  LIMIT 1`, [this.service, type, id]);
14908
15216
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
14909
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
+ }
14910
15303
  async list(type) {
14911
15304
  return (await this.listRecords(type)).map((record) => record.payload);
14912
15305
  }
@@ -15176,6 +15569,28 @@ class PostgresJsonRecordStore {
15176
15569
  }
15177
15570
  return value;
15178
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
+ }
15179
15594
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
15180
15595
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
15181
15596
  if (planIds.length === 0)
@@ -16667,6 +17082,11 @@ async function importSnapshot(snapshot, store, context) {
16667
17082
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
16668
17083
  if (result.errors.length > 0)
16669
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;
16670
17090
  const entries = [
16671
17091
  ...snapshot.tasks.map((row) => ["tasks", row]),
16672
17092
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -16675,9 +17095,20 @@ async function importSnapshot(snapshot, store, context) {
16675
17095
  ...snapshot.agents.map((row) => ["agents", row]),
16676
17096
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
16677
17097
  ...snapshot.templates.map((row) => ["templates", row]),
16678
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
16679
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
17098
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
16680
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
+ }
16681
17112
  for (const [type, row] of entries) {
16682
17113
  try {
16683
17114
  const existing = await store.get(type, row.id);
@@ -16711,6 +17142,32 @@ async function importSnapshot(snapshot, store, context) {
16711
17142
  }
16712
17143
  return result;
16713
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
+ }
16714
17171
  async function requireRecord(type, id, store) {
16715
17172
  const record = await store.get(type, id);
16716
17173
  if (!record)
@@ -46,7 +46,7 @@ function isBlockingDependencyStatus(status) {
46
46
  function isTerminalStatus(status) {
47
47
  return status === "completed" || status === "failed" || status === "cancelled";
48
48
  }
49
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
49
+ 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;
50
50
  var init_types = __esm(() => {
51
51
  TASK_STATUSES = [
52
52
  "pending",
@@ -169,6 +169,16 @@ var init_types = __esm(() => {
169
169
  this.name = "LockError";
170
170
  }
171
171
  };
172
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
173
+ code;
174
+ details;
175
+ constructor(code, message, details = {}) {
176
+ super(message);
177
+ this.code = code;
178
+ this.details = details;
179
+ this.name = "StaleLockHandoffError";
180
+ }
181
+ };
172
182
  AgentNotFoundError = class AgentNotFoundError extends Error {
173
183
  agentId;
174
184
  static code = "AGENT_NOT_FOUND";