@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/mcp/index.js CHANGED
@@ -47,7 +47,7 @@ function isBlockingDependencyStatus(status) {
47
47
  function isTerminalStatus(status) {
48
48
  return status === "completed" || status === "failed" || status === "cancelled";
49
49
  }
50
- var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
50
+ var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
51
51
  var init_types = __esm(() => {
52
52
  TASK_STATUSES = [
53
53
  "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";
@@ -12170,28 +12180,55 @@ var init_activity_audit = __esm(() => {
12170
12180
  function sanitizeHistoryValue(value, context) {
12171
12181
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
12172
12182
  }
12173
- function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
12183
+ function insertTaskHistory(entry, db) {
12174
12184
  const d = db || getDatabase();
12175
- const id = uuid();
12176
- const timestamp2 = now();
12177
- const machineId = currentStorageMachineId(d);
12178
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
12179
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
12185
+ const safeEntry = {
12186
+ ...entry,
12187
+ field: entry.field || null,
12188
+ old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
12189
+ new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
12190
+ agent_id: entry.agent_id || null,
12191
+ machine_id: entry.machine_id ?? currentStorageMachineId(d)
12192
+ };
12180
12193
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
12181
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
12194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
12195
+ safeEntry.id,
12196
+ safeEntry.task_id,
12197
+ safeEntry.action,
12198
+ safeEntry.field,
12199
+ safeEntry.old_value,
12200
+ safeEntry.new_value,
12201
+ safeEntry.agent_id,
12202
+ safeEntry.created_at,
12203
+ safeEntry.machine_id ?? null
12204
+ ]);
12182
12205
  try {
12183
12206
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
12184
12207
  logActivity2({
12185
12208
  entity_type: "task",
12186
- entity_id: taskId,
12187
- action,
12188
- field,
12189
- old_value: safeOldValue,
12190
- new_value: safeNewValue,
12191
- actor_id: agentId ?? undefined
12209
+ entity_id: safeEntry.task_id,
12210
+ action: safeEntry.action,
12211
+ field: safeEntry.field ?? undefined,
12212
+ old_value: safeEntry.old_value,
12213
+ new_value: safeEntry.new_value,
12214
+ actor_id: safeEntry.agent_id ?? undefined
12192
12215
  }, d);
12193
12216
  } catch {}
12194
- 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 };
12217
+ return safeEntry;
12218
+ }
12219
+ function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
12220
+ const d = db || getDatabase();
12221
+ return insertTaskHistory({
12222
+ id: uuid(),
12223
+ task_id: taskId,
12224
+ action,
12225
+ field: field || null,
12226
+ old_value: oldValue ?? null,
12227
+ new_value: newValue ?? null,
12228
+ agent_id: agentId || null,
12229
+ created_at: now(),
12230
+ machine_id: currentStorageMachineId(d)
12231
+ }, d);
12195
12232
  }
12196
12233
  function getTaskHistory(taskId, db) {
12197
12234
  const d = db || getDatabase();
@@ -13194,6 +13231,145 @@ var init_task_graph = __esm(() => {
13194
13231
  init_task_crud();
13195
13232
  });
13196
13233
 
13234
+ // src/lib/stale-lock-handoff.ts
13235
+ function normalizeExactTaskId(value) {
13236
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
13237
+ 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 });
13238
+ }
13239
+ return value.trim().toLowerCase();
13240
+ }
13241
+ function requireNonEmptyString(value, field) {
13242
+ if (typeof value !== "string" || !value.trim()) {
13243
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
13244
+ }
13245
+ const trimmed = value.trim();
13246
+ if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
13247
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
13248
+ }
13249
+ return trimmed;
13250
+ }
13251
+ function requireCanonicalLockVersion(value) {
13252
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
13253
+ 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" });
13254
+ }
13255
+ const parsed = Date.parse(value);
13256
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
13257
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
13258
+ }
13259
+ return value;
13260
+ }
13261
+ function requireStaleThreshold(value) {
13262
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
13263
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
13264
+ }
13265
+ return Number(value);
13266
+ }
13267
+ function prepareStaleLockHandoff(input, options = {}) {
13268
+ const taskId = normalizeExactTaskId(input.task_id);
13269
+ const actor = requireNonEmptyString(input.actor, "actor");
13270
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
13271
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
13272
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
13273
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
13274
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
13275
+ if (!reason) {
13276
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
13277
+ }
13278
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
13279
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
13280
+ }
13281
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
13282
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
13283
+ }
13284
+ const operationTimestamp = options.now ?? new Date().toISOString();
13285
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
13286
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
13287
+ }
13288
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
13289
+ return {
13290
+ task_id: taskId,
13291
+ actor,
13292
+ expected_holder: expectedHolder,
13293
+ expected_lock_version: expectedLockVersion,
13294
+ stale_after_seconds: staleAfterSeconds,
13295
+ new_holder: newHolder,
13296
+ reason,
13297
+ operation_timestamp: operationTimestamp,
13298
+ stale_cutoff: staleCutoff,
13299
+ receipt_id: options.receiptId ?? crypto.randomUUID()
13300
+ };
13301
+ }
13302
+ function buildStaleLockHandoffReceipt(input) {
13303
+ return {
13304
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
13305
+ receipt_id: input.receipt_id,
13306
+ task_id: input.task_id,
13307
+ actor: input.actor,
13308
+ previous_holder: input.expected_holder,
13309
+ previous_lock_version: input.expected_lock_version,
13310
+ new_holder: input.new_holder,
13311
+ new_lock_version: input.operation_timestamp,
13312
+ stale_after_seconds: input.stale_after_seconds,
13313
+ stale_cutoff: input.stale_cutoff,
13314
+ reason: input.reason,
13315
+ created_at: input.operation_timestamp
13316
+ };
13317
+ }
13318
+ function staleLockHandoffHistory(receipt, machineId) {
13319
+ return {
13320
+ id: receipt.receipt_id,
13321
+ task_id: receipt.task_id,
13322
+ action: STALE_LOCK_HANDOFF_ACTION,
13323
+ field: STALE_LOCK_HANDOFF_FIELD,
13324
+ old_value: JSON.stringify({
13325
+ holder: receipt.previous_holder,
13326
+ lock_version: receipt.previous_lock_version
13327
+ }),
13328
+ new_value: JSON.stringify(receipt),
13329
+ agent_id: receipt.actor,
13330
+ created_at: receipt.created_at,
13331
+ machine_id: machineId
13332
+ };
13333
+ }
13334
+ function throwStaleLockHandoffConflict(task, input) {
13335
+ if (!task.locked_by || !task.locked_at) {
13336
+ 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 });
13337
+ }
13338
+ if (task.locked_at !== input.expected_lock_version) {
13339
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
13340
+ task_id: input.task_id,
13341
+ expected_lock_version: input.expected_lock_version,
13342
+ current_lock_version: task.locked_at
13343
+ });
13344
+ }
13345
+ if (task.locked_by !== input.expected_holder) {
13346
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
13347
+ task_id: input.task_id,
13348
+ expected_holder: input.expected_holder,
13349
+ current_holder: task.locked_by
13350
+ });
13351
+ }
13352
+ if (isTerminalStatus(task.status)) {
13353
+ 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 });
13354
+ }
13355
+ if (task.locked_at >= input.stale_cutoff) {
13356
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
13357
+ task_id: input.task_id,
13358
+ current_lock_version: task.locked_at,
13359
+ stale_cutoff: input.stale_cutoff
13360
+ });
13361
+ }
13362
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
13363
+ }
13364
+ 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;
13365
+ var init_stale_lock_handoff = __esm(() => {
13366
+ init_types();
13367
+ init_creator_identity();
13368
+ init_prewrite_secrets();
13369
+ 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;
13370
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13371
+ });
13372
+
13197
13373
  // src/db/task-lifecycle.ts
13198
13374
  var exports_task_lifecycle = {};
13199
13375
  __export(exports_task_lifecycle, {
@@ -13202,6 +13378,7 @@ __export(exports_task_lifecycle, {
13202
13378
  startTask: () => startTask,
13203
13379
  spawnNextRecurrence: () => spawnNextRecurrence,
13204
13380
  lockTask: () => lockTask,
13381
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
13205
13382
  getTasksChangedSince: () => getTasksChangedSince,
13206
13383
  getTaskLockStatus: () => getTaskLockStatus,
13207
13384
  getStaleTasks: () => getStaleTasks,
@@ -13470,6 +13647,38 @@ function unlockTask(id, agentId, db) {
13470
13647
  WHERE id = ?`, [timestamp2, id]);
13471
13648
  return true;
13472
13649
  }
13650
+ function handoffStaleTaskLock(input, db) {
13651
+ const d = db || getDatabase();
13652
+ const prepared = prepareStaleLockHandoff(input);
13653
+ const receipt = buildStaleLockHandoffReceipt(prepared);
13654
+ const history = staleLockHandoffHistory(receipt, null);
13655
+ const transfer = d.transaction(() => {
13656
+ const result = d.run(`UPDATE tasks
13657
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
13658
+ WHERE id = ?
13659
+ AND locked_by = ?
13660
+ AND locked_at = ?
13661
+ AND julianday(locked_at) < julianday(?)
13662
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
13663
+ prepared.new_holder,
13664
+ prepared.operation_timestamp,
13665
+ prepared.operation_timestamp,
13666
+ prepared.task_id,
13667
+ prepared.expected_holder,
13668
+ prepared.expected_lock_version,
13669
+ prepared.stale_cutoff
13670
+ ]);
13671
+ if (result.changes === 0) {
13672
+ const current = getTask(prepared.task_id, d);
13673
+ if (!current)
13674
+ throw new TaskNotFoundError(prepared.task_id);
13675
+ throwStaleLockHandoffConflict(current, prepared);
13676
+ }
13677
+ insertTaskHistory(history, d);
13678
+ });
13679
+ transfer();
13680
+ return receipt;
13681
+ }
13473
13682
  function getTaskLockStatus(id, db) {
13474
13683
  const d = db || getDatabase();
13475
13684
  const task = getTask(id, d);
@@ -13770,6 +13979,7 @@ var init_task_lifecycle = __esm(() => {
13770
13979
  init_task_crud();
13771
13980
  init_task_graph();
13772
13981
  init_prewrite_secrets();
13982
+ init_stale_lock_handoff();
13773
13983
  });
13774
13984
 
13775
13985
  // src/db/task-crud.ts
@@ -17342,6 +17552,7 @@ __export(exports_tasks, {
17342
17552
  insertTaskTags: () => insertTaskTags,
17343
17553
  importTaskBoardBundle: () => importTaskBoardBundle,
17344
17554
  importCalendarIcs: () => importCalendarIcs,
17555
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
17345
17556
  getTimeReport: () => getTimeReport,
17346
17557
  getTimeLogs: () => getTimeLogs,
17347
17558
  getTasksChangedSince: () => getTasksChangedSince,
@@ -20799,6 +21010,7 @@ var init_http_client = __esm(() => {
20799
21010
  // src/cli/cloud-router.ts
20800
21011
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
20801
21012
  import { normalizeStorageMode } from "@hasna/contracts/mode";
21013
+ import { randomUUID as randomUUID3 } from "crypto";
20802
21014
  import { resolve as resolvePath } from "path";
20803
21015
  function cleanMode(value) {
20804
21016
  const normalized = value?.trim().toLowerCase();
@@ -21201,7 +21413,10 @@ async function cloudGetTask(client, id) {
21201
21413
  }
21202
21414
  async function cloudCreateTask(client, input) {
21203
21415
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
21204
- const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
21416
+ const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.transport.post("/tasks", input, {
21417
+ idempotencyKey: randomUUID3(),
21418
+ retry: false
21419
+ }), ["PARENT_TASK_NOT_FOUND"]));
21205
21420
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
21206
21421
  throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} returned a task create ` + "response without a stored task id; no success row or local SQLite fallback is permitted");
21207
21422
  }
@@ -21212,7 +21427,7 @@ async function cloudCreateTask(client, input) {
21212
21427
  return persisted;
21213
21428
  }
21214
21429
  async function cloudUpdateTask(client, id, patch) {
21215
- return unwrapTask(await client.update("tasks", id, patch));
21430
+ return unwrapTask(await client.transport.patch(`/tasks/${encodeURIComponent(id)}`, patch));
21216
21431
  }
21217
21432
  async function cloudDeleteTask(client, id) {
21218
21433
  try {
@@ -35537,7 +35752,7 @@ var package_default;
35537
35752
  var init_package = __esm(() => {
35538
35753
  package_default = {
35539
35754
  name: "@hasna/todos",
35540
- version: "0.15.19",
35755
+ version: "0.15.20",
35541
35756
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35542
35757
  type: "module",
35543
35758
  main: "dist/index.js",
@@ -35608,6 +35823,8 @@ var init_package = __esm(() => {
35608
35823
  "dev:mcp": "bun run src/mcp/index.ts",
35609
35824
  "dev:serve": "bun run src/server/index.ts",
35610
35825
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
35826
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
35827
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
35611
35828
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
35612
35829
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
35613
35830
  },
@@ -45098,6 +45315,58 @@ var init_plan_project_links = __esm(() => {
45098
45315
  init_tasks();
45099
45316
  });
45100
45317
 
45318
+ // src/storage/audit-history-import.ts
45319
+ function auditHistoryRowsAreFieldIdentical(left, right) {
45320
+ return AUDIT_HISTORY_FIELDS.every((field) => {
45321
+ const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
45322
+ const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
45323
+ return leftValue === rightValue;
45324
+ });
45325
+ }
45326
+ function divergentAuditHistoryReplayError(id) {
45327
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
45328
+ }
45329
+ function forbiddenAuditHistoryTombstoneError(id) {
45330
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
45331
+ }
45332
+ function parseAuditHistoryImportFailure(message) {
45333
+ const divergentPrefix = `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row `;
45334
+ const divergentSuffix = " differs from stored row";
45335
+ if (message.startsWith(divergentPrefix) && message.endsWith(divergentSuffix)) {
45336
+ return {
45337
+ code: AUDIT_HISTORY_DIVERGENT_REPLAY,
45338
+ auditHistoryId: message.slice(divergentPrefix.length, -divergentSuffix.length),
45339
+ conflict: true,
45340
+ status: 409
45341
+ };
45342
+ }
45343
+ const tombstonePrefix = `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone `;
45344
+ const tombstoneSuffix = " is not allowed";
45345
+ if (message.startsWith(tombstonePrefix) && message.endsWith(tombstoneSuffix)) {
45346
+ return {
45347
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
45348
+ auditHistoryId: message.slice(tombstonePrefix.length, -tombstoneSuffix.length),
45349
+ conflict: false,
45350
+ status: 400
45351
+ };
45352
+ }
45353
+ return null;
45354
+ }
45355
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY", AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN", AUDIT_HISTORY_FIELDS;
45356
+ var init_audit_history_import = __esm(() => {
45357
+ AUDIT_HISTORY_FIELDS = [
45358
+ "id",
45359
+ "task_id",
45360
+ "action",
45361
+ "field",
45362
+ "old_value",
45363
+ "new_value",
45364
+ "agent_id",
45365
+ "created_at",
45366
+ "machine_id"
45367
+ ];
45368
+ });
45369
+
45101
45370
  // src/storage/sqlite-snapshot.ts
45102
45371
  function exportSqliteTodosStorageSnapshot(db) {
45103
45372
  const d = db ?? getDatabase();
@@ -45131,8 +45400,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45131
45400
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
45132
45401
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
45133
45402
  }
45403
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
45404
+ result.errors.push(...auditImport.errors);
45134
45405
  if (result.errors.length > 0)
45135
45406
  return result;
45407
+ result.skipped += auditImport.identicalReplayCount;
45136
45408
  const applyRows = (objectType2, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
45137
45409
  for (const row of rows) {
45138
45410
  try {
@@ -45167,10 +45439,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45167
45439
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
45168
45440
  }
45169
45441
  });
45170
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
45442
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
45171
45443
  applyTombstones(d, snapshot.tombstones ?? [], result);
45172
45444
  return result;
45173
45445
  }
45446
+ function preflightAuditHistoryImport(db, rows, tombstones) {
45447
+ const errors2 = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
45448
+ const rowsToInsert = [];
45449
+ const seen = new Map;
45450
+ let identicalReplayCount = 0;
45451
+ for (const rawRow of rows) {
45452
+ try {
45453
+ const row = asRecord2(rawRow);
45454
+ if (typeof row.id !== "string" || !row.id) {
45455
+ throw new Error("task_history row is missing id");
45456
+ }
45457
+ const prior = seen.get(row.id);
45458
+ if (prior) {
45459
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
45460
+ identicalReplayCount += 1;
45461
+ else
45462
+ errors2.push(divergentAuditHistoryReplayError(row.id));
45463
+ continue;
45464
+ }
45465
+ seen.set(row.id, row);
45466
+ const existing = getAuditHistoryById(db, row.id);
45467
+ if (!existing) {
45468
+ rowsToInsert.push(row);
45469
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
45470
+ identicalReplayCount += 1;
45471
+ } else {
45472
+ errors2.push(divergentAuditHistoryReplayError(row.id));
45473
+ }
45474
+ } catch (error) {
45475
+ errors2.push(error instanceof Error ? error.message : String(error));
45476
+ }
45477
+ }
45478
+ return { rowsToInsert, identicalReplayCount, errors: errors2 };
45479
+ }
45480
+ function insertAuditHistoryRows(db, rows, result) {
45481
+ for (const rawRow of rows) {
45482
+ try {
45483
+ const row = asRecord2(rawRow);
45484
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
45485
+ if (!presentColumns.includes("id"))
45486
+ presentColumns.unshift("id");
45487
+ const placeholders2 = presentColumns.map(() => "?").join(", ");
45488
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
45489
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders2})`, values).changes;
45490
+ if (changes > 0) {
45491
+ result.inserted += 1;
45492
+ continue;
45493
+ }
45494
+ const existing = getAuditHistoryById(db, String(row["id"]));
45495
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
45496
+ result.skipped += 1;
45497
+ } else {
45498
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
45499
+ }
45500
+ } catch (error) {
45501
+ result.errors.push(error instanceof Error ? error.message : String(error));
45502
+ }
45503
+ }
45504
+ }
45505
+ function getAuditHistoryById(db, id) {
45506
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
45507
+ }
45174
45508
  function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
45175
45509
  const id = row["id"];
45176
45510
  if (typeof id !== "string" || !id)
@@ -45268,7 +45602,7 @@ function tableForTombstone(objectType2) {
45268
45602
  return "task_templates";
45269
45603
  if (objectType2 === "template_tasks")
45270
45604
  return "template_tasks";
45271
- return "task_history";
45605
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType2)}`);
45272
45606
  }
45273
45607
  function listRows(db, table, columns) {
45274
45608
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -45315,6 +45649,7 @@ var init_sqlite_snapshot = __esm(() => {
45315
45649
  init_tasks();
45316
45650
  init_templates();
45317
45651
  init_storage_tombstones();
45652
+ init_audit_history_import();
45318
45653
  PROJECT_COLUMNS = [
45319
45654
  "id",
45320
45655
  "name",
@@ -45579,6 +45914,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45579
45914
  unlockTask(id, agentId, database());
45580
45915
  return true;
45581
45916
  },
45917
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
45582
45918
  delete: (id) => deleteTask(id, database()),
45583
45919
  start: (id, agentId) => startTask(id, agentId, database()),
45584
45920
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -46498,7 +46834,7 @@ var init_api_keys = __esm(() => {
46498
46834
  });
46499
46835
 
46500
46836
  // src/storage/postgres-adapter.ts
46501
- import { randomUUID as randomUUID3 } from "crypto";
46837
+ import { randomUUID as randomUUID4 } from "crypto";
46502
46838
  function createPostgresTodosStorageAdapter(options) {
46503
46839
  const store = new PostgresJsonRecordStore(options);
46504
46840
  const adapter = {
@@ -46527,6 +46863,7 @@ function createPostgresTodosStorageAdapter(options) {
46527
46863
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
46528
46864
  lock: (id, agentId) => lockTask2(id, agentId, store),
46529
46865
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
46866
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
46530
46867
  getByFingerprint: (fingerprint3) => store.getTaskByFingerprint(fingerprint3)
46531
46868
  },
46532
46869
  dependencies: {
@@ -46673,6 +47010,91 @@ class PostgresJsonRecordStore {
46673
47010
  LIMIT 1`, [this.service, type, id]);
46674
47011
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
46675
47012
  }
47013
+ async handoffStaleLock(input, context = {}) {
47014
+ const prepared = prepareStaleLockHandoff(input);
47015
+ const receipt = buildStaleLockHandoffReceipt(prepared);
47016
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
47017
+ await this.ensureSchema();
47018
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
47019
+ target AS MATERIALIZED (
47020
+ SELECT payload
47021
+ FROM ${this.tableName}
47022
+ WHERE service = $1
47023
+ AND object_type = 'tasks'
47024
+ AND object_id = $2
47025
+ AND deleted_at IS NULL
47026
+ FOR UPDATE
47027
+ ),
47028
+ updated AS (
47029
+ UPDATE ${this.tableName} AS task_record
47030
+ SET payload = jsonb_set(
47031
+ jsonb_set(
47032
+ jsonb_set(
47033
+ jsonb_set(
47034
+ task_record.payload,
47035
+ '{locked_by}',
47036
+ to_jsonb($6::text),
47037
+ true
47038
+ ),
47039
+ '{locked_at}',
47040
+ to_jsonb($7::text),
47041
+ true
47042
+ ),
47043
+ '{updated_at}',
47044
+ to_jsonb($7::text),
47045
+ true
47046
+ ),
47047
+ '{version}',
47048
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
47049
+ true
47050
+ ),
47051
+ updated_at = $7::timestamptz,
47052
+ source_machine_id = $10,
47053
+ version = COALESCE(task_record.version, 0) + 1
47054
+ FROM target
47055
+ WHERE task_record.service = $1
47056
+ AND task_record.object_type = 'tasks'
47057
+ AND task_record.object_id = $2
47058
+ AND task_record.deleted_at IS NULL
47059
+ AND target.payload->>'locked_by' = $3
47060
+ AND target.payload->>'locked_at' = $4
47061
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
47062
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
47063
+ RETURNING task_record.payload
47064
+ ),
47065
+ audit AS (
47066
+ INSERT INTO ${this.tableName} (
47067
+ service, object_type, object_id, payload, updated_at,
47068
+ deleted_at, source_machine_id, version
47069
+ )
47070
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
47071
+ NULL, $10, NULL
47072
+ FROM updated
47073
+ RETURNING payload
47074
+ )
47075
+ SELECT
47076
+ (SELECT payload FROM target) AS current_payload,
47077
+ (SELECT payload FROM updated) AS updated_payload,
47078
+ (SELECT payload FROM audit) AS audit_payload`, [
47079
+ this.service,
47080
+ prepared.task_id,
47081
+ prepared.expected_holder,
47082
+ prepared.expected_lock_version,
47083
+ prepared.stale_cutoff,
47084
+ prepared.new_holder,
47085
+ prepared.operation_timestamp,
47086
+ receipt.receipt_id,
47087
+ jsonbParam(history),
47088
+ this.machineId(context)
47089
+ ]);
47090
+ const row = result.rows[0];
47091
+ if (!row?.current_payload)
47092
+ throw new TaskNotFoundError(prepared.task_id);
47093
+ if (!row.updated_payload || !row.audit_payload) {
47094
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
47095
+ }
47096
+ return receipt;
47097
+ }
46676
47098
  async list(type) {
46677
47099
  return (await this.listRecords(type)).map((record) => record.payload);
46678
47100
  }
@@ -46942,6 +47364,28 @@ class PostgresJsonRecordStore {
46942
47364
  }
46943
47365
  return value;
46944
47366
  }
47367
+ async insertImmutableAuditHistory(value, context = {}) {
47368
+ await this.ensureSchema();
47369
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
47370
+ service, object_type, object_id, payload, updated_at,
47371
+ deleted_at, source_machine_id, version
47372
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
47373
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
47374
+ RETURNING object_id`, [
47375
+ this.service,
47376
+ "audit_history",
47377
+ value.id,
47378
+ jsonbParam(value),
47379
+ value.created_at,
47380
+ context.requestId ?? this.sourceMachineId ?? null
47381
+ ]);
47382
+ if (inserted.rows.length > 0)
47383
+ return "inserted";
47384
+ const existing = await this.get("audit_history", value.id);
47385
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
47386
+ return "identical";
47387
+ throw new Error(divergentAuditHistoryReplayError(value.id));
47388
+ }
46945
47389
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
46946
47390
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
46947
47391
  if (planIds.length === 0)
@@ -47768,7 +48212,7 @@ async function createTask3(input, store, context) {
47768
48212
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
47769
48213
  const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
47770
48214
  const task2 = {
47771
- id: randomUUID3(),
48215
+ id: randomUUID4(),
47772
48216
  short_id: shortId,
47773
48217
  project_id: effectiveProjectId,
47774
48218
  parent_id: input.parent_id ?? null,
@@ -48014,7 +48458,7 @@ async function addVerification(input, store, context) {
48014
48458
  throw new Error(`Task not found: ${input.task_id}`);
48015
48459
  const timestamp4 = new Date().toISOString();
48016
48460
  const verification = {
48017
- id: randomUUID3(),
48461
+ id: randomUUID4(),
48018
48462
  task_id: input.task_id,
48019
48463
  command: input.command,
48020
48464
  status: input.status ?? "unknown",
@@ -48035,7 +48479,7 @@ async function addCommit(input, store, context) {
48035
48479
  throw new Error(`Task not found: ${input.task_id}`);
48036
48480
  const timestamp4 = new Date().toISOString();
48037
48481
  const commit = {
48038
- id: randomUUID3(),
48482
+ id: randomUUID4(),
48039
48483
  task_id: input.task_id,
48040
48484
  sha: input.sha,
48041
48485
  message: input.message ?? null,
@@ -48058,7 +48502,7 @@ async function addGitRef(input, store, context) {
48058
48502
  throw new Error(`Task not found: ${input.task_id}`);
48059
48503
  const timestamp4 = new Date().toISOString();
48060
48504
  const gitRef = {
48061
- id: randomUUID3(),
48505
+ id: randomUUID4(),
48062
48506
  task_id: input.task_id,
48063
48507
  ref_type: input.ref_type,
48064
48508
  name: input.name,
@@ -48128,7 +48572,7 @@ async function createProject2(input, store, context) {
48128
48572
  if (!derivedSlug || !taskListId)
48129
48573
  throw new Error("Project name and task-list slug must be non-empty");
48130
48574
  const project = {
48131
- id: randomUUID3(),
48575
+ id: randomUUID4(),
48132
48576
  name: input.name,
48133
48577
  path: input.path,
48134
48578
  description: input.description ?? null,
@@ -48160,7 +48604,7 @@ async function createPlan2(input, store, context) {
48160
48604
  store
48161
48605
  });
48162
48606
  return store.upsert("plans", {
48163
- id: randomUUID3(),
48607
+ id: randomUUID4(),
48164
48608
  slug,
48165
48609
  project_id: projectId,
48166
48610
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -48212,7 +48656,7 @@ async function registerAgent2(input, store, context) {
48212
48656
  }
48213
48657
  const timestamp4 = new Date().toISOString();
48214
48658
  const agent = {
48215
- id: existing?.id ?? randomUUID3().slice(0, 8),
48659
+ id: existing?.id ?? randomUUID4().slice(0, 8),
48216
48660
  name: canonicalName,
48217
48661
  description: input.description ?? existing?.description ?? null,
48218
48662
  role: input.role ?? existing?.role ?? null,
@@ -48285,7 +48729,7 @@ async function createTaskList2(input, store, context) {
48285
48729
  if (!slug)
48286
48730
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
48287
48731
  return store.upsert("task_lists", {
48288
- id: randomUUID3(),
48732
+ id: randomUUID4(),
48289
48733
  project_id: input.project_id ?? context?.projectId ?? null,
48290
48734
  slug,
48291
48735
  name: input.name,
@@ -48320,7 +48764,7 @@ async function updateTaskList2(id, input, store) {
48320
48764
  async function createTemplate2(input, store, context) {
48321
48765
  const timestamp4 = new Date().toISOString();
48322
48766
  const template = {
48323
- id: randomUUID3(),
48767
+ id: randomUUID4(),
48324
48768
  name: input.name,
48325
48769
  title_pattern: input.title_pattern,
48326
48770
  description: input.description ?? null,
@@ -48341,7 +48785,7 @@ async function createTemplate2(input, store, context) {
48341
48785
  }
48342
48786
  function buildTemplateTasks(templateId, inputs, timestamp4) {
48343
48787
  return inputs.map((input, position) => ({
48344
- id: randomUUID3(),
48788
+ id: randomUUID4(),
48345
48789
  template_id: templateId,
48346
48790
  position,
48347
48791
  title_pattern: input.title_pattern,
@@ -48374,7 +48818,7 @@ async function updateTemplate2(id, input, store) {
48374
48818
  }
48375
48819
  async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
48376
48820
  const entry2 = {
48377
- id: randomUUID3(),
48821
+ id: randomUUID4(),
48378
48822
  task_id: taskId,
48379
48823
  action,
48380
48824
  field: field ?? null,
@@ -48388,7 +48832,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
48388
48832
  }
48389
48833
  async function addComment2(input, store, context) {
48390
48834
  const comment = {
48391
- id: randomUUID3(),
48835
+ id: randomUUID4(),
48392
48836
  task_id: input.task_id,
48393
48837
  agent_id: input.agent_id ?? context?.agentId ?? null,
48394
48838
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -48430,6 +48874,11 @@ async function importSnapshot(snapshot, store, context) {
48430
48874
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
48431
48875
  if (result.errors.length > 0)
48432
48876
  return result;
48877
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
48878
+ result.errors.push(...auditHistory.errors);
48879
+ if (result.errors.length > 0)
48880
+ return result;
48881
+ result.skipped += auditHistory.identical;
48433
48882
  const entries = [
48434
48883
  ...snapshot.tasks.map((row) => ["tasks", row]),
48435
48884
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -48438,9 +48887,20 @@ async function importSnapshot(snapshot, store, context) {
48438
48887
  ...snapshot.agents.map((row) => ["agents", row]),
48439
48888
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
48440
48889
  ...snapshot.templates.map((row) => ["templates", row]),
48441
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
48442
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
48890
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
48443
48891
  ];
48892
+ for (const row of auditHistory.rowsToInsert) {
48893
+ try {
48894
+ const outcome = await store.insertImmutableAuditHistory(row, context);
48895
+ if (outcome === "inserted")
48896
+ result.inserted += 1;
48897
+ else
48898
+ result.skipped += 1;
48899
+ } catch (error) {
48900
+ result.errors.push(error instanceof Error ? error.message : String(error));
48901
+ return result;
48902
+ }
48903
+ }
48444
48904
  for (const [type, row] of entries) {
48445
48905
  try {
48446
48906
  const existing = await store.get(type, row.id);
@@ -48474,6 +48934,32 @@ async function importSnapshot(snapshot, store, context) {
48474
48934
  }
48475
48935
  return result;
48476
48936
  }
48937
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
48938
+ const errors2 = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
48939
+ const rowsToInsert = [];
48940
+ const seen = new Map;
48941
+ let identical = 0;
48942
+ for (const row of rows) {
48943
+ const prior = seen.get(row.id);
48944
+ if (prior) {
48945
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
48946
+ identical += 1;
48947
+ else
48948
+ errors2.push(divergentAuditHistoryReplayError(row.id));
48949
+ continue;
48950
+ }
48951
+ seen.set(row.id, row);
48952
+ const existing = await store.get("audit_history", row.id);
48953
+ if (!existing) {
48954
+ rowsToInsert.push(row);
48955
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
48956
+ identical += 1;
48957
+ } else {
48958
+ errors2.push(divergentAuditHistoryReplayError(row.id));
48959
+ }
48960
+ }
48961
+ return { rowsToInsert, identical, errors: errors2 };
48962
+ }
48477
48963
  async function requireRecord(type, id, store) {
48478
48964
  const record = await store.get(type, id);
48479
48965
  if (!record)
@@ -48580,9 +49066,11 @@ var init_postgres_adapter = __esm(() => {
48580
49066
  init_types();
48581
49067
  init_creator_identity();
48582
49068
  init_plan_project_link_contract();
49069
+ init_stale_lock_handoff();
48583
49070
  init_postgres_sync();
48584
49071
  init_integrity();
48585
49072
  init_redaction();
49073
+ init_audit_history_import();
48586
49074
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
48587
49075
  });
48588
49076
 
@@ -53927,6 +54415,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
53927
54415
  ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
53928
54416
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
53929
54417
  TaskComment: taskCommentSchema,
54418
+ StaleLockHandoffInput: staleLockHandoffInputSchema,
54419
+ StaleLockHandoffReceipt: staleLockHandoffReceiptSchema,
53930
54420
  TaskGitRef: taskGitRefSchema,
53931
54421
  Plan: planSchema,
53932
54422
  PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
@@ -55134,6 +55624,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55134
55624
  }
55135
55625
  }
55136
55626
  },
55627
+ "/v1/tasks/{id}/stale-lock-handoff": {
55628
+ post: {
55629
+ operationId: "handoffStaleTaskLock",
55630
+ summary: "Atomically transfer one exact stale task lock",
55631
+ description: "Compares one full task UUID, current holder, and exact locked_at version, verifies the lock is strictly older than the supplied threshold, then transfers it directly and writes an immutable task-history receipt in the same backend transaction.",
55632
+ parameters: [
55633
+ {
55634
+ name: "id",
55635
+ in: "path",
55636
+ required: true,
55637
+ schema: { type: "string", format: "uuid" },
55638
+ description: "Exact full task UUID. Short ids and prefixes are rejected."
55639
+ }
55640
+ ],
55641
+ requestBody: {
55642
+ required: true,
55643
+ content: {
55644
+ "application/json": {
55645
+ schema: { $ref: "#/components/schemas/StaleLockHandoffInput" }
55646
+ }
55647
+ }
55648
+ },
55649
+ responses: {
55650
+ "200": {
55651
+ content: {
55652
+ "application/json": {
55653
+ schema: {
55654
+ type: "object",
55655
+ additionalProperties: false,
55656
+ required: ["receipt"],
55657
+ properties: {
55658
+ receipt: { $ref: "#/components/schemas/StaleLockHandoffReceipt" }
55659
+ }
55660
+ }
55661
+ }
55662
+ }
55663
+ },
55664
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55665
+ "403": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55666
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55667
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55668
+ "501": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
55669
+ }
55670
+ }
55671
+ },
55137
55672
  "/v1/tasks/{id}/refs": {
55138
55673
  get: {
55139
55674
  operationId: "listTaskGitRefs",
@@ -55730,7 +56265,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55730
56265
  }
55731
56266
  };
55732
56267
  }
55733
- var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
56268
+ var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, staleLockHandoffInputSchema, staleLockHandoffReceiptSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
55734
56269
  var init_openapi = __esm(() => {
55735
56270
  init_package_version();
55736
56271
  init_types();
@@ -55749,6 +56284,8 @@ var init_openapi = __esm(() => {
55749
56284
  reason: { type: "string", nullable: true },
55750
56285
  tags: { type: "array", items: { type: "string" } },
55751
56286
  version: { type: "number" },
56287
+ locked_by: { type: "string", nullable: true },
56288
+ locked_at: { type: "string", format: "date-time", nullable: true },
55752
56289
  created_at: { type: "string" },
55753
56290
  updated_at: { type: "string" }
55754
56291
  }
@@ -55913,6 +56450,69 @@ var init_openapi = __esm(() => {
55913
56450
  created_at: { type: "string", format: "date-time" }
55914
56451
  }
55915
56452
  };
56453
+ staleLockHandoffInputSchema = {
56454
+ type: "object",
56455
+ additionalProperties: false,
56456
+ required: [
56457
+ "expected_holder",
56458
+ "expected_lock_version",
56459
+ "stale_after_seconds",
56460
+ "new_holder",
56461
+ "reason"
56462
+ ],
56463
+ properties: {
56464
+ expected_holder: { type: "string", minLength: 1 },
56465
+ expected_lock_version: {
56466
+ type: "string",
56467
+ format: "date-time",
56468
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
56469
+ description: "Exact authoritative locked_at token read from the task; no default or normalization is applied."
56470
+ },
56471
+ stale_after_seconds: {
56472
+ type: "integer",
56473
+ minimum: 1,
56474
+ description: "Lock age threshold supplied by the caller. The lock must be strictly older at the CAS instant."
56475
+ },
56476
+ new_holder: {
56477
+ type: "string",
56478
+ minLength: 1,
56479
+ description: "Must match the agent bound to the authenticated API key."
56480
+ },
56481
+ reason: { type: "string", minLength: 1, maxLength: 4096 }
56482
+ }
56483
+ };
56484
+ staleLockHandoffReceiptSchema = {
56485
+ type: "object",
56486
+ additionalProperties: false,
56487
+ required: [
56488
+ "schema_version",
56489
+ "receipt_id",
56490
+ "task_id",
56491
+ "actor",
56492
+ "previous_holder",
56493
+ "previous_lock_version",
56494
+ "new_holder",
56495
+ "new_lock_version",
56496
+ "stale_after_seconds",
56497
+ "stale_cutoff",
56498
+ "reason",
56499
+ "created_at"
56500
+ ],
56501
+ properties: {
56502
+ schema_version: { type: "string", enum: ["todos.stale-lock-handoff.v1"] },
56503
+ receipt_id: { type: "string", format: "uuid" },
56504
+ task_id: { type: "string", format: "uuid" },
56505
+ actor: { type: "string" },
56506
+ previous_holder: { type: "string" },
56507
+ previous_lock_version: { type: "string", format: "date-time" },
56508
+ new_holder: { type: "string" },
56509
+ new_lock_version: { type: "string", format: "date-time" },
56510
+ stale_after_seconds: { type: "integer", minimum: 1 },
56511
+ stale_cutoff: { type: "string", format: "date-time" },
56512
+ reason: { type: "string" },
56513
+ created_at: { type: "string", format: "date-time" }
56514
+ }
56515
+ };
55916
56516
  taskGitRefSchema = {
55917
56517
  type: "object",
55918
56518
  additionalProperties: false,
@@ -57154,6 +57754,45 @@ async function handleV1Request(req, url, dependencies = {}) {
57154
57754
  return error(405, `method ${method} not allowed on /v1/tasks`);
57155
57755
  }
57156
57756
  if (action) {
57757
+ if (action === "stale-lock-handoff") {
57758
+ if (method !== "POST") {
57759
+ return error(405, "method must be POST on /v1/tasks/:id/stale-lock-handoff");
57760
+ }
57761
+ const exactId = normalizeExactTaskId(id);
57762
+ if (!principal.agent) {
57763
+ return error(403, "stale-lock handoff requires an authenticated agent-bound key", {
57764
+ code: "STALE_LOCK_HANDOFF_ACTOR_MISMATCH"
57765
+ });
57766
+ }
57767
+ if (typeof store.tasks.handoffStaleLock !== "function") {
57768
+ return error(501, "stale-lock handoff is not supported by this storage backend");
57769
+ }
57770
+ const body3 = await readJson3(req) ?? {};
57771
+ const allowed = new Set([
57772
+ "expected_holder",
57773
+ "expected_lock_version",
57774
+ "stale_after_seconds",
57775
+ "new_holder",
57776
+ "reason"
57777
+ ]);
57778
+ const unknown = Object.keys(body3).find((key2) => !allowed.has(key2));
57779
+ if (unknown) {
57780
+ return error(400, `unknown stale-lock handoff field: ${unknown}`, {
57781
+ code: "STALE_LOCK_HANDOFF_INVALID_INPUT",
57782
+ field: unknown
57783
+ });
57784
+ }
57785
+ const receipt = await store.tasks.handoffStaleLock({
57786
+ task_id: exactId,
57787
+ actor: principal.agent,
57788
+ expected_holder: body3.expected_holder,
57789
+ expected_lock_version: body3.expected_lock_version,
57790
+ stale_after_seconds: body3.stale_after_seconds,
57791
+ new_holder: body3.new_holder,
57792
+ reason: body3.reason
57793
+ }, contextFromPrincipal(principal));
57794
+ return json5({ receipt });
57795
+ }
57157
57796
  if (action === "comments") {
57158
57797
  if (method === "GET") {
57159
57798
  if (!await store.tasks.get(id))
@@ -57942,7 +58581,24 @@ async function handleV1Request(req, url, dependencies = {}) {
57942
58581
  if (received === 0) {
57943
58582
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
57944
58583
  }
58584
+ const forbiddenAuditTombstone = (snapshot.tombstones ?? []).find((tombstone) => tombstone.object_type === "audit_history");
58585
+ if (forbiddenAuditTombstone) {
58586
+ return error(400, forbiddenAuditHistoryTombstoneError(forbiddenAuditTombstone.object_id), {
58587
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
58588
+ conflict: false,
58589
+ audit_history_id: forbiddenAuditTombstone.object_id
58590
+ });
58591
+ }
57945
58592
  const result = await store.sync.importSnapshot(snapshot, contextFromPrincipal(principal));
58593
+ const auditFailureMessage = result.errors.find((message) => parseAuditHistoryImportFailure(message) !== null);
58594
+ if (auditFailureMessage) {
58595
+ const failure = parseAuditHistoryImportFailure(auditFailureMessage);
58596
+ return error(failure.status, auditFailureMessage, {
58597
+ code: failure.code,
58598
+ conflict: failure.conflict,
58599
+ audit_history_id: failure.auditHistoryId
58600
+ });
58601
+ }
57946
58602
  return json5({ result, received });
57947
58603
  }
57948
58604
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
@@ -57965,6 +58621,14 @@ async function handleV1Request(req, url, dependencies = {}) {
57965
58621
  if (e instanceof TaskNotFoundError) {
57966
58622
  return error(404, e.message, { code: TaskNotFoundError.code });
57967
58623
  }
58624
+ if (e instanceof StaleLockHandoffError) {
58625
+ const status2 = e.code === "STALE_LOCK_HANDOFF_INVALID_TASK_ID" || e.code === "STALE_LOCK_HANDOFF_INVALID_INPUT" ? 400 : e.code === "STALE_LOCK_HANDOFF_ACTOR_MISMATCH" ? 403 : 409;
58626
+ return error(status2, e.message, {
58627
+ code: e.code,
58628
+ conflict: status2 === 409,
58629
+ ...e.details
58630
+ });
58631
+ }
57968
58632
  if (e instanceof PlanNotFoundError) {
57969
58633
  return error(404, e.message, { code: PlanNotFoundError.code });
57970
58634
  }
@@ -57999,6 +58663,8 @@ var init_v1 = __esm(() => {
57999
58663
  init_redaction();
58000
58664
  init_project_task_list_ensure();
58001
58665
  init_plan_project_link();
58666
+ init_stale_lock_handoff();
58667
+ init_audit_history_import();
58002
58668
  JSON_HEADERS4 = { "Content-Type": "application/json" };
58003
58669
  RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
58004
58670
  });