@hasna/todos 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +59 -22
  3. package/dist/cli/cloud-router.d.ts +63 -21
  4. package/dist/cli/cloud-router.d.ts.map +1 -1
  5. package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
  6. package/dist/cli/commands/template-commands.d.ts.map +1 -1
  7. package/dist/cli/index.js +2591 -503
  8. package/dist/cli/stage-a.d.ts +2 -12
  9. package/dist/cli/stage-a.d.ts.map +1 -1
  10. package/dist/cli/template-remote.d.ts +90 -0
  11. package/dist/cli/template-remote.d.ts.map +1 -0
  12. package/dist/cli-mcp-parity.d.ts.map +1 -1
  13. package/dist/contracts.js +77 -30
  14. package/dist/db/agents.d.ts +8 -0
  15. package/dist/db/agents.d.ts.map +1 -1
  16. package/dist/db/database.d.ts +6 -0
  17. package/dist/db/database.d.ts.map +1 -1
  18. package/dist/db/identity-mapping.d.ts +25 -0
  19. package/dist/db/identity-mapping.d.ts.map +1 -1
  20. package/dist/db/task-crud.d.ts.map +1 -1
  21. package/dist/index.js +496 -58
  22. package/dist/lib/feature-manifest.d.ts.map +1 -1
  23. package/dist/lib/local-opt-in.d.ts +14 -0
  24. package/dist/lib/local-opt-in.d.ts.map +1 -1
  25. package/dist/lib/paths.d.ts +11 -1
  26. package/dist/lib/paths.d.ts.map +1 -1
  27. package/dist/mcp/index.d.ts +52 -0
  28. package/dist/mcp/index.d.ts.map +1 -1
  29. package/dist/mcp/index.js +15162 -30695
  30. package/dist/mcp/tools/agents.d.ts.map +1 -1
  31. package/dist/mcp/tools/task-adv-tools.d.ts.map +1 -1
  32. package/dist/mcp/tools/task-auto-tools.d.ts.map +1 -1
  33. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  34. package/dist/mcp/tools/task-meta-tools.d.ts.map +1 -1
  35. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  36. package/dist/mcp/tools/task-resources.d.ts.map +1 -1
  37. package/dist/mcp/tools/templates.d.ts.map +1 -1
  38. package/dist/mcp.js +3 -3
  39. package/dist/project-registration.js +466 -35
  40. package/dist/registry.js +77 -30
  41. package/dist/release-provenance.json +5 -5
  42. package/dist/sdk/client.d.ts +3 -1
  43. package/dist/sdk/client.d.ts.map +1 -1
  44. package/dist/sdk/index.js +48 -12
  45. package/dist/sdk/resolve.d.ts +23 -13
  46. package/dist/sdk/resolve.d.ts.map +1 -1
  47. package/dist/sdk/types.d.ts +17 -2
  48. package/dist/sdk/types.d.ts.map +1 -1
  49. package/dist/sdk/v1.generated.d.ts +115 -0
  50. package/dist/sdk/v1.generated.d.ts.map +1 -1
  51. package/dist/server/index.js +3972 -916
  52. package/dist/server/openapi.d.ts +837 -50
  53. package/dist/server/openapi.d.ts.map +1 -1
  54. package/dist/server/rate-limit-config.d.ts +6 -0
  55. package/dist/server/rate-limit-config.d.ts.map +1 -0
  56. package/dist/server/serve.d.ts.map +1 -1
  57. package/dist/server/v1.d.ts.map +1 -1
  58. package/dist/storage/index.d.ts +1 -1
  59. package/dist/storage/index.d.ts.map +1 -1
  60. package/dist/storage/interfaces.d.ts +74 -3
  61. package/dist/storage/interfaces.d.ts.map +1 -1
  62. package/dist/storage/local-sqlite.d.ts.map +1 -1
  63. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  64. package/dist/storage.js +464 -33
  65. package/dist/task-manifest.js +8 -2
  66. package/dist/types/index.d.ts +5 -2
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/package.json +2 -2
  69. package/dist/mcp/tools/code-tools.d.ts +0 -15
  70. package/dist/mcp/tools/code-tools.d.ts.map +0 -1
package/dist/storage.js CHANGED
@@ -4324,20 +4324,17 @@ function ensureAgentIdentitySchema(db) {
4324
4324
  SELECT RAISE(ABORT, 'IDENTITY_SOURCE_LINEAGE_IMMUTABLE');
4325
4325
  END;
4326
4326
 
4327
+ -- Drop first so an older definition is replaced rather than silently
4328
+ -- retained (the upgrade path). AGENT_IDENTITY_HISTORY_TRIGGER_DDL then
4329
+ -- re-creates them with CREATE TRIGGER IF NOT EXISTS. The two statements
4330
+ -- do different jobs and both are kept: the DROP is what refreshes a
4331
+ -- stale definition, and the IF NOT EXISTS is what keeps the CREATE safe
4332
+ -- on its own \u2014 so a caller that reaches it without a preceding DROP, in
4333
+ -- its own connection or transaction, gets a no-op instead of
4334
+ -- "trigger ... already exists".
4327
4335
  DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_immutable;
4328
- CREATE TRIGGER trg_agent_identity_mapping_history_immutable
4329
- BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
4330
- ON agent_identity_source_mappings
4331
- BEGIN
4332
- SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4333
- END;
4334
-
4335
4336
  DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_append_only;
4336
- CREATE TRIGGER trg_agent_identity_mapping_history_append_only
4337
- BEFORE DELETE ON agent_identity_source_mappings
4338
- BEGIN
4339
- SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4340
- END;
4337
+ ${AGENT_IDENTITY_HISTORY_TRIGGER_DDL}
4341
4338
  `);
4342
4339
  db.run("INSERT OR IGNORE INTO _migrations (id) VALUES (?)", [IDENTITY_MIGRATION_ID]);
4343
4340
  });
@@ -4377,7 +4374,20 @@ function storeAgentAlias(agentId, label, aliasKind, status, db) {
4377
4374
  function recordAgentAlias(agentId, label, db) {
4378
4375
  return storeAgentAlias(agentId, label, "historical", "active", db);
4379
4376
  }
4380
- var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65;
4377
+ var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65, AGENT_IDENTITY_HISTORY_TRIGGER_DDL = `
4378
+ CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_immutable
4379
+ BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
4380
+ ON agent_identity_source_mappings
4381
+ BEGIN
4382
+ SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4383
+ END;
4384
+
4385
+ CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_append_only
4386
+ BEFORE DELETE ON agent_identity_source_mappings
4387
+ BEGIN
4388
+ SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4389
+ END;
4390
+ `;
4381
4391
  var init_identity_mapping = __esm(() => {
4382
4392
  init_types();
4383
4393
  init_types();
@@ -4402,7 +4412,7 @@ var init_identity_mapping = __esm(() => {
4402
4412
  // src/lib/paths.ts
4403
4413
  import { existsSync as existsSync2 } from "fs";
4404
4414
  import { homedir } from "os";
4405
- import { join, resolve as resolve2 } from "path";
4415
+ import { isAbsolute, join, resolve as resolve2 } from "path";
4406
4416
  import { homedir as pathsResolverHomedir } from "os";
4407
4417
  import { join as pathsResolverJoin } from "path";
4408
4418
  function pathsResolverAssertApp(app) {
@@ -4459,8 +4469,14 @@ function dataDir(options) {
4459
4469
  function effectiveHome(env2 = process.env) {
4460
4470
  return env2.HOME || env2.USERPROFILE || homedir();
4461
4471
  }
4472
+ function hasnaHomeRoot(env2 = process.env) {
4473
+ const override = env2["HASNA_HOME"]?.trim();
4474
+ if (override && isAbsolute(override))
4475
+ return override;
4476
+ return join(effectiveHome(env2), ".hasna");
4477
+ }
4462
4478
  function legacyHomeDir(env2 = process.env) {
4463
- return join(effectiveHome(env2), ".hasna", "todos");
4479
+ return join(hasnaHomeRoot(env2), "todos");
4464
4480
  }
4465
4481
  function resolverHome(env2 = process.env) {
4466
4482
  return dataDir({ app: "todos", home: effectiveHome(env2), env: env2 });
@@ -4672,15 +4688,18 @@ __export(exports_database, {
4672
4688
  resolvePartialId: () => resolvePartialId,
4673
4689
  resolveAssignedToAliases: () => resolveAssignedToAliases,
4674
4690
  resetDatabase: () => resetDatabase,
4691
+ refuseLocalStore: () => refuseLocalStore,
4675
4692
  now: () => now,
4676
4693
  lowerInClause: () => lowerInClause,
4677
4694
  lockExpiryCutoff: () => lockExpiryCutoff,
4678
4695
  isLockExpired: () => isLockExpired,
4696
+ isLocalStoreRefused: () => isLocalStoreRefused,
4679
4697
  getDatabasePath: () => getDatabasePath,
4680
4698
  getDatabase: () => getDatabase,
4681
4699
  closeDatabase: () => closeDatabase,
4682
4700
  clearExpiredLocks: () => clearExpiredLocks,
4683
4701
  assignedToAliasSet: () => assignedToAliasSet,
4702
+ allowLocalStore: () => allowLocalStore,
4684
4703
  LOCK_EXPIRY_MINUTES: () => LOCK_EXPIRY_MINUTES
4685
4704
  });
4686
4705
  import { Database } from "bun:sqlite";
@@ -4797,7 +4816,18 @@ function maybeInstallShadowCapture(db) {
4797
4816
  console.error(`[todos] shadow capture install failed: ${error instanceof Error ? error.message : String(error)}`);
4798
4817
  }
4799
4818
  }
4819
+ function refuseLocalStore(message) {
4820
+ localStoreRefusal = message;
4821
+ }
4822
+ function allowLocalStore() {
4823
+ localStoreRefusal = null;
4824
+ }
4825
+ function isLocalStoreRefused() {
4826
+ return localStoreRefusal !== null;
4827
+ }
4800
4828
  function getDatabase(dbPath) {
4829
+ if (localStoreRefusal !== null)
4830
+ throw new Error(localStoreRefusal);
4801
4831
  if (dbPath === undefined && !selectsTodosLocalStore()) {
4802
4832
  throw new Error("API_DATABASE_FALLBACK_FORBIDDEN: this operation must use the shared Todos API; implicit SQLite access is unavailable");
4803
4833
  }
@@ -4938,7 +4968,7 @@ function lowerInClause(column, values, params) {
4938
4968
  params.push(...values.map((v) => v.toLowerCase()));
4939
4969
  return `LOWER(${column}) IN (${values.map(() => "?").join(",")})`;
4940
4970
  }
4941
- var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, ALLOWED_TABLES;
4971
+ var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, localStoreRefusal = null, ALLOWED_TABLES;
4942
4972
  var init_database = __esm(() => {
4943
4973
  init_local_opt_in();
4944
4974
  init_schema();
@@ -10679,7 +10709,9 @@ function listTasks(filter = {}, db) {
10679
10709
  params.push(decoded.p, decoded.p, decoded.c, decoded.p, decoded.c, decoded.i);
10680
10710
  } catch {}
10681
10711
  }
10682
- if (!filter.include_archived) {
10712
+ if (filter.archived_only) {
10713
+ conditions.push("archived_at IS NOT NULL");
10714
+ } else if (!filter.include_archived) {
10683
10715
  conditions.push("archived_at IS NULL");
10684
10716
  }
10685
10717
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -10692,7 +10724,8 @@ function listTasks(filter = {}, db) {
10692
10724
  params.push(filter.offset);
10693
10725
  }
10694
10726
  }
10695
- const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC, id ASC${limitClause}`).all(...params);
10727
+ const orderBy = filter.archived_only ? "archived_at DESC, id DESC" : `${PRIORITY_RANK}, created_at DESC, id ASC`;
10728
+ const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${orderBy}${limitClause}`).all(...params);
10696
10729
  return rows.map(rowToTask2);
10697
10730
  }
10698
10731
  function getTaskByFingerprint(fingerprint, db) {
@@ -10835,7 +10868,9 @@ function countTasks(filter = {}, db) {
10835
10868
  }
10836
10869
  }
10837
10870
  addMetadataConditions(filter.metadata, conditions, params);
10838
- if (!filter.include_archived) {
10871
+ if (filter.archived_only) {
10872
+ conditions.push("archived_at IS NOT NULL");
10873
+ } else if (!filter.include_archived) {
10839
10874
  conditions.push("archived_at IS NULL");
10840
10875
  }
10841
10876
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -10972,6 +11007,10 @@ function updateTaskStored(id, input, db) {
10972
11007
  sets.push("completed_at = ?");
10973
11008
  params.push(input.completed_at);
10974
11009
  }
11010
+ if (input.archived_at !== undefined) {
11011
+ sets.push("archived_at = ?");
11012
+ params.push(input.archived_at);
11013
+ }
10975
11014
  if (input.confidence !== undefined) {
10976
11015
  sets.push("confidence = ?");
10977
11016
  params.push(input.confidence);
@@ -11035,6 +11074,8 @@ function updateTaskStored(id, input, db) {
11035
11074
  logTaskChange(id, "update", "parent_id", task.parent_id, input.parent_id, agentId, d);
11036
11075
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
11037
11076
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
11077
+ if (input.archived_at !== undefined && input.archived_at !== task.archived_at)
11078
+ logTaskChange(id, "update", "archived_at", task.archived_at, input.archived_at, agentId, d);
11038
11079
  if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
11039
11080
  logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
11040
11081
  if (input.approved_by !== undefined)
@@ -13891,6 +13932,7 @@ function searchTasks(options, projectId, taskListId, db) {
13891
13932
 
13892
13933
  // src/storage/local-sqlite.ts
13893
13934
  init_tasks();
13935
+ init_task_graph();
13894
13936
  init_projects();
13895
13937
  init_plans();
13896
13938
 
@@ -14454,6 +14496,19 @@ function listAgents(opts, db) {
14454
14496
  }
14455
14497
  return d.query("SELECT * FROM agents WHERE status = 'active' ORDER BY name").all().map(rowToAgent);
14456
14498
  }
14499
+ function listAgentsPage(options, db) {
14500
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 500) {
14501
+ throw new Error("Agent page limit must be an integer from 1 to 500");
14502
+ }
14503
+ if (!Number.isSafeInteger(options.offset) || options.offset < 0) {
14504
+ throw new Error("Agent page offset must be a non-negative integer");
14505
+ }
14506
+ const d = db || getDatabase();
14507
+ const where = options.include_archived ? "" : "WHERE status = 'active'";
14508
+ const total = d.query(`SELECT COUNT(*) AS total FROM agents ${where}`).get().total;
14509
+ const agents = d.query(`SELECT * FROM agents ${where} ORDER BY LOWER(name), id LIMIT ? OFFSET ?`).all(options.limit, options.offset).map(rowToAgent);
14510
+ return { agents, total };
14511
+ }
14457
14512
  function updateAgentActivity(id, db) {
14458
14513
  const d = db || getDatabase();
14459
14514
  d.run("UPDATE agents SET last_seen_at = ? WHERE id = ?", [now(), id]);
@@ -15782,6 +15837,98 @@ function exportSqliteMachines(db) {
15782
15837
  }
15783
15838
 
15784
15839
  // src/storage/local-sqlite.ts
15840
+ function bulkCreateAtomicSqlite(db, inputs) {
15841
+ return db.transaction(() => {
15842
+ const tempIds = new Map;
15843
+ const created = [];
15844
+ const dependencies = [];
15845
+ for (const input of inputs) {
15846
+ const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
15847
+ const task = createTask(taskInput, db);
15848
+ if (temp_id)
15849
+ tempIds.set(temp_id, task.id);
15850
+ created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
15851
+ }
15852
+ for (let index = 0;index < inputs.length; index++) {
15853
+ const input = inputs[index];
15854
+ const taskId = created[index].id;
15855
+ const seenDependencies = new Set;
15856
+ for (const reference of input.depends_on ?? []) {
15857
+ const dependencyId = tempIds.get(reference) ?? resolveTaskRefLocal(db, reference)?.id;
15858
+ if (!dependencyId)
15859
+ throw new TaskNotFoundError(reference);
15860
+ if (seenDependencies.has(dependencyId)) {
15861
+ throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
15862
+ }
15863
+ seenDependencies.add(dependencyId);
15864
+ addDependency(taskId, dependencyId, db);
15865
+ dependencies.push({ task_id: taskId, depends_on: dependencyId });
15866
+ }
15867
+ }
15868
+ return { schema_version: 1, atomic: true, created, dependencies };
15869
+ })();
15870
+ }
15871
+ function deleteTaskHierarchySqlite(db, rootId) {
15872
+ const rows = db.query(`WITH RECURSIVE tree(id, depth) AS (
15873
+ SELECT id, 0 FROM tasks WHERE id = ?
15874
+ UNION ALL
15875
+ SELECT child.id, tree.depth + 1 FROM tasks child JOIN tree ON child.parent_id = tree.id
15876
+ ) SELECT id FROM tree ORDER BY depth DESC, id`).all(rootId);
15877
+ if (rows.length === 0)
15878
+ throw new TaskNotFoundError(rootId);
15879
+ for (const row of rows) {
15880
+ if (!deleteTask(row.id, db))
15881
+ throw new Error(`Atomic hierarchy delete lost task ${row.id}`);
15882
+ }
15883
+ }
15884
+ function bulkDeleteAtomicSqlite(db, ids, force) {
15885
+ return db.transaction(() => {
15886
+ const resolved = ids.map((reference) => ({ reference, task: resolveTaskRefLocal(db, reference) }));
15887
+ const seen = new Set;
15888
+ for (const item of resolved) {
15889
+ if (!item.task)
15890
+ continue;
15891
+ if (seen.has(item.task.id)) {
15892
+ throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
15893
+ }
15894
+ seen.add(item.task.id);
15895
+ }
15896
+ const childState = new Map;
15897
+ for (const item of resolved) {
15898
+ if (!item.task)
15899
+ continue;
15900
+ childState.set(item.task.id, Boolean(db.query("SELECT id FROM tasks WHERE parent_id = ? LIMIT 1").get(item.task.id)));
15901
+ }
15902
+ const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
15903
+ const plannedIds = new Set(planned.map((item) => item.task.id));
15904
+ const roots = planned.filter((item) => {
15905
+ if (!force)
15906
+ return true;
15907
+ let parentId = item.task.parent_id;
15908
+ while (parentId) {
15909
+ if (plannedIds.has(parentId))
15910
+ return false;
15911
+ parentId = getTask(parentId, db)?.parent_id ?? null;
15912
+ }
15913
+ return true;
15914
+ });
15915
+ for (const item of roots)
15916
+ deleteTaskHierarchySqlite(db, item.task.id);
15917
+ return {
15918
+ schema_version: 1,
15919
+ atomic: true,
15920
+ force,
15921
+ results: resolved.map((item) => {
15922
+ if (!item.task)
15923
+ return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
15924
+ if (!force && childState.get(item.task.id)) {
15925
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
15926
+ }
15927
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
15928
+ })
15929
+ };
15930
+ })();
15931
+ }
15785
15932
  var TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15786
15933
  function resolveTaskRefLocal(db, ref) {
15787
15934
  const raw = ref.trim().toLowerCase();
@@ -15878,6 +16025,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15878
16025
  },
15879
16026
  handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
15880
16027
  delete: (id) => deleteTask(id, database()),
16028
+ bulkCreateAtomic: (inputs, _context) => bulkCreateAtomicSqlite(database(), inputs),
16029
+ bulkDeleteAtomic: (ids, force, _context) => bulkDeleteAtomicSqlite(database(), ids, force),
15881
16030
  start: (id, agentId) => startTask(id, agentId, database()),
15882
16031
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
15883
16032
  fail: (id, agentId, reason, options2) => failTask(id, agentId, reason, options2, database()),
@@ -15886,6 +16035,30 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15886
16035
  getActiveWork: (filters) => getActiveWork(filters, database()),
15887
16036
  getChangedSince: (since, filters) => getTasksChangedSince(since, filters, database())
15888
16037
  },
16038
+ dependencies: {
16039
+ add: (taskId, dependsOn) => {
16040
+ addDependency(taskId, dependsOn, database());
16041
+ return { task_id: taskId, depends_on: dependsOn };
16042
+ },
16043
+ remove: (taskId, dependsOn) => removeDependency(taskId, dependsOn, database()),
16044
+ list: (taskId) => {
16045
+ const dependencies = getTaskDependencies(taskId, database());
16046
+ const blocks = getTaskDependents(taskId, database());
16047
+ return { dependencies, blocks, blocked_by: blocks };
16048
+ },
16049
+ listPage: ({ limit, offset }) => {
16050
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
16051
+ throw new Error("SQLite dependency page limit must be an integer from 1 to 500");
16052
+ }
16053
+ if (!Number.isSafeInteger(offset) || offset < 0) {
16054
+ throw new Error("SQLite dependency page offset must be a non-negative integer");
16055
+ }
16056
+ const totalRow = database().query("SELECT COUNT(*) AS total FROM task_dependencies").get();
16057
+ const dependencies = database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on LIMIT ? OFFSET ?").all(limit, offset);
16058
+ return { dependencies, total: totalRow.total };
16059
+ },
16060
+ listAll: () => database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on").all()
16061
+ },
15889
16062
  projects: {
15890
16063
  create: (input) => createProject(input, database()),
15891
16064
  get: (id) => getProject(id, database()),
@@ -15929,6 +16102,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15929
16102
  get: (id) => getAgent(id, database()),
15930
16103
  getByName: (name) => getAgentByName(name, database()),
15931
16104
  list: (options2) => listAgents(options2, database()),
16105
+ listPage: (options2) => listAgentsPage(options2, database()),
15932
16106
  update: (id, input) => updateAgent(id, input, database())
15933
16107
  },
15934
16108
  taskLists: {
@@ -15967,6 +16141,29 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15967
16141
  return comments;
15968
16142
  },
15969
16143
  getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
16144
+ getTaskHistoryPage: (taskId, options2) => {
16145
+ if (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 500) {
16146
+ throw new Error("Task history limit must be an integer from 1 to 500");
16147
+ }
16148
+ if (!Number.isSafeInteger(options2.offset) || options2.offset < 0) {
16149
+ throw new Error("Task history offset must be a non-negative integer");
16150
+ }
16151
+ const conditions = ["task_id = ?"];
16152
+ const values = [taskId];
16153
+ if (options2.since) {
16154
+ conditions.push("created_at >= ?");
16155
+ values.push(options2.since);
16156
+ }
16157
+ if (options2.until) {
16158
+ conditions.push("created_at <= ?");
16159
+ values.push(options2.until);
16160
+ }
16161
+ const where = conditions.join(" AND ");
16162
+ const total = database().query(`SELECT COUNT(*) AS total FROM task_history WHERE ${where}`).get(...values).total;
16163
+ const direction = options2.order === "asc" ? "ASC" : "DESC";
16164
+ const history = database().query(`SELECT * FROM task_history WHERE ${where} ORDER BY created_at ${direction}, id ${direction} LIMIT ? OFFSET ?`).all(...values, options2.limit, options2.offset);
16165
+ return { history, total };
16166
+ },
15970
16167
  getRecentActivity: (limit) => getRecentActivity(limit, database())
15971
16168
  },
15972
16169
  sync: {
@@ -17733,6 +17930,8 @@ function createPostgresTodosStorageAdapter(options) {
17733
17930
  count: (filter = {}) => store.countTasks(filter),
17734
17931
  update: (id, input, context) => updateTask2(id, input, store, context),
17735
17932
  delete: (id, context) => store.deleteTaskHierarchy(id, context),
17933
+ bulkCreateAtomic: (inputs, context) => bulkCreateTasksAtomic(inputs, store, context),
17934
+ bulkDeleteAtomic: (ids, force, context) => bulkDeleteTasksAtomic(ids, force, store, context),
17736
17935
  start: (id, agentId) => startTask2(id, agentId, store),
17737
17936
  complete: (id, agentId, options2) => completeTask2(id, agentId, options2, store),
17738
17937
  fail: (id, agentId, reason, options2) => failTask2(id, agentId, reason, options2, store),
@@ -17749,6 +17948,7 @@ function createPostgresTodosStorageAdapter(options) {
17749
17948
  add: (taskId, dependsOn, context) => addDependency2(taskId, dependsOn, store, context),
17750
17949
  remove: (taskId, dependsOn) => removeDependency2(taskId, dependsOn, store),
17751
17950
  list: (taskId) => listDependencies(taskId, store),
17951
+ listPage: (page) => store.listDependencyPage(page),
17752
17952
  listAll: () => store.list("dependencies")
17753
17953
  },
17754
17954
  verifications: {
@@ -17814,6 +18014,7 @@ function createPostgresTodosStorageAdapter(options) {
17814
18014
  get: (id) => store.get("agents", id),
17815
18015
  getByName: async (name) => matchAgentByName(await store.list("agents"), name),
17816
18016
  list: async (options2) => (await store.list("agents")).filter((agent) => options2?.include_archived || agent.status !== "archived").sort((a, b) => a.name.localeCompare(b.name)),
18017
+ listPage: (options2) => store.listAgentPage(options2),
17817
18018
  update: (id, input) => updateAgent2(id, input, store),
17818
18019
  heartbeat: (idOrName, context) => heartbeatAgent(idOrName, store, context),
17819
18020
  release: (idOrName, sessionId, context) => releaseAgent2(idOrName, sessionId, store, context)
@@ -17871,8 +18072,9 @@ function createPostgresTodosStorageAdapter(options) {
17871
18072
  getCommentsPage: async (taskId, options2) => {
17872
18073
  return (await store.listComments(taskId, options2)).map(redactComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
17873
18074
  },
17874
- getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
17875
- getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
18075
+ getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)),
18076
+ getTaskHistoryPage: (taskId, options2) => store.listTaskHistoryPage(taskId, options2),
18077
+ getRecentActivity: (limit = 20) => store.listRecentActivity(limit)
17876
18078
  },
17877
18079
  machines: createPostgresMachineRegistry(options.client, options.service ?? "todos", options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, () => store.ensureSchema()),
17878
18080
  atomicProjectMigration: createAtomicProjectMigration({ client: options.client, table: options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, service: options.service ?? "todos", ensureSchema: () => store.ensureSchema() }),
@@ -17909,6 +18111,8 @@ class PostgresJsonRecordStore {
17909
18111
  }
17910
18112
  async withDependencyGraphTransaction(fn) {
17911
18113
  await this.ensureSchema();
18114
+ if (this.projectIntegrityLocked)
18115
+ return fn(this);
17912
18116
  return this.withTaskParentIntegrityTransaction(async (client) => {
17913
18117
  const scoped = new PostgresJsonRecordStore({ ...this.options, client });
17914
18118
  scoped.schemaReady = Promise.resolve();
@@ -18055,6 +18259,128 @@ class PostgresJsonRecordStore {
18055
18259
  async list(type) {
18056
18260
  return (await this.listRecords(type)).map((record) => record.payload);
18057
18261
  }
18262
+ async listAgentPage(options) {
18263
+ const { limit, offset } = options;
18264
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18265
+ throw new Error("Postgres agent page limit must be an integer from 1 to 500");
18266
+ }
18267
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18268
+ throw new Error("Postgres agent page offset must be a non-negative integer");
18269
+ }
18270
+ await this.ensureSchema();
18271
+ const result = await this.options.client.query(`/* todos:list-agent-page */ WITH filtered AS (
18272
+ SELECT object_id, payload
18273
+ FROM ${this.tableName}
18274
+ WHERE service = $1 AND object_type = 'agents' AND deleted_at IS NULL
18275
+ AND ($2::boolean OR COALESCE(payload->>'status', 'active') <> 'archived')
18276
+ ), page AS (
18277
+ SELECT object_id, payload FROM filtered
18278
+ ORDER BY LOWER(payload->>'name'), object_id
18279
+ LIMIT $3 OFFSET $4
18280
+ )
18281
+ SELECT (SELECT count(*) FROM filtered) AS total,
18282
+ COALESCE((SELECT jsonb_agg(payload ORDER BY LOWER(payload->>'name'), object_id) FROM page), '[]'::jsonb) AS agents`, [this.service, options.include_archived === true, limit, offset]);
18283
+ const row = result.rows[0];
18284
+ const total = Number(row?.total);
18285
+ if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.agents)) {
18286
+ throw new Error("Postgres agent page returned invalid count or agents");
18287
+ }
18288
+ if (row.agents.length > limit || row.agents.length > 0 && offset + row.agents.length > total) {
18289
+ throw new Error("Postgres agent page exceeded its requested bounds");
18290
+ }
18291
+ return { agents: row.agents.map((value) => payloadRecord2(value)), total };
18292
+ }
18293
+ async listRecentActivity(limit) {
18294
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
18295
+ throw new Error("Postgres recent activity limit must be an integer from 1 to 10000");
18296
+ }
18297
+ await this.ensureSchema();
18298
+ const result = await this.options.client.query(`/* todos:list-recent-activity */ SELECT payload
18299
+ FROM ${this.tableName}
18300
+ WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
18301
+ ORDER BY payload->>'created_at' DESC, object_id DESC
18302
+ LIMIT $2`, [this.service, limit]);
18303
+ return result.rows.map((row) => payloadRecord2(row.payload));
18304
+ }
18305
+ async listTaskHistoryPage(taskId, options) {
18306
+ const { limit, offset } = options;
18307
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18308
+ throw new Error("Postgres task history limit must be an integer from 1 to 500");
18309
+ }
18310
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18311
+ throw new Error("Postgres task history offset must be a non-negative integer");
18312
+ }
18313
+ await this.ensureSchema();
18314
+ const direction = options.order === "asc" ? "ASC" : "DESC";
18315
+ const result = await this.options.client.query(`/* todos:list-task-history-page */ WITH filtered AS (
18316
+ SELECT object_id, payload, payload->>'created_at' AS created_at
18317
+ FROM ${this.tableName}
18318
+ WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
18319
+ AND payload->>'task_id' = $2
18320
+ AND ($3::text IS NULL OR payload->>'created_at' >= $3)
18321
+ AND ($4::text IS NULL OR payload->>'created_at' <= $4)
18322
+ ), page AS (
18323
+ SELECT object_id, payload, created_at FROM filtered
18324
+ ORDER BY created_at ${direction}, object_id ${direction}
18325
+ LIMIT $5 OFFSET $6
18326
+ )
18327
+ SELECT (SELECT count(*) FROM filtered) AS total,
18328
+ COALESCE((SELECT jsonb_agg(payload ORDER BY created_at ${direction}, object_id ${direction}) FROM page), '[]'::jsonb) AS history`, [this.service, taskId, options.since ?? null, options.until ?? null, limit, offset]);
18329
+ const row = result.rows[0];
18330
+ const total = Number(row?.total);
18331
+ if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.history)) {
18332
+ throw new Error("Postgres task history page returned invalid count or history");
18333
+ }
18334
+ if (row.history.length > limit || row.history.length > 0 && offset + row.history.length > total) {
18335
+ throw new Error("Postgres task history page exceeded its requested bounds");
18336
+ }
18337
+ return { history: row.history.map((value) => payloadRecord2(value)), total };
18338
+ }
18339
+ async listDependencyPage(options) {
18340
+ const { limit, offset } = options;
18341
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18342
+ throw new Error("Postgres dependency page limit must be an integer from 1 to 500");
18343
+ }
18344
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18345
+ throw new Error("Postgres dependency page offset must be a non-negative integer");
18346
+ }
18347
+ await this.ensureSchema();
18348
+ const result = await this.options.client.query(`/* todos:list-dependencies-page */ WITH page AS (
18349
+ SELECT payload, updated_at, object_id
18350
+ FROM ${this.tableName}
18351
+ WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL
18352
+ ORDER BY updated_at ASC, object_id ASC
18353
+ LIMIT $2 OFFSET $3
18354
+ )
18355
+ SELECT
18356
+ (SELECT COUNT(*)::text FROM ${this.tableName}
18357
+ WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL) AS total,
18358
+ COALESCE(
18359
+ jsonb_agg(page.payload ORDER BY page.updated_at ASC, page.object_id ASC)
18360
+ FILTER (WHERE page.payload IS NOT NULL),
18361
+ '[]'::jsonb
18362
+ ) AS dependencies
18363
+ FROM page`, [this.service, limit, offset]);
18364
+ const row = result.rows[0];
18365
+ const totalText = typeof row?.total === "string" ? row.total : typeof row?.total === "number" && Number.isSafeInteger(row.total) ? String(row.total) : null;
18366
+ if (totalText === null || !/^\d+$/.test(totalText)) {
18367
+ throw new Error("Postgres dependency page returned an invalid total");
18368
+ }
18369
+ const total = Number(totalText);
18370
+ if (!Number.isSafeInteger(total)) {
18371
+ throw new Error("Postgres dependency page total exceeds the safe integer range");
18372
+ }
18373
+ if (!Array.isArray(row?.dependencies)) {
18374
+ throw new Error("Postgres dependency page returned an invalid dependencies array");
18375
+ }
18376
+ if (row.dependencies.length > limit || offset + row.dependencies.length > total) {
18377
+ throw new Error("Postgres dependency page returned rows outside the requested bound");
18378
+ }
18379
+ return {
18380
+ dependencies: row.dependencies.map((value) => payloadRecord2(value)),
18381
+ total
18382
+ };
18383
+ }
18058
18384
  async listComments(taskId, options = {}) {
18059
18385
  await this.ensureSchema();
18060
18386
  const limit = options.limit ?? 100;
@@ -18130,7 +18456,9 @@ class PostgresJsonRecordStore {
18130
18456
  return "1=0";
18131
18457
  return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
18132
18458
  };
18133
- if (filter.include_archived === false)
18459
+ if (filter.archived_only === true)
18460
+ conds.push(`(payload->>'archived_at' IS NOT NULL)`);
18461
+ else if (filter.include_archived === false)
18134
18462
  conds.push(`(payload->>'archived_at' IS NULL)`);
18135
18463
  if (filter.ids)
18136
18464
  conds.push(inClause("payload->>'id'", filter.ids));
@@ -18187,7 +18515,7 @@ class PostgresJsonRecordStore {
18187
18515
  async listTasks(filter) {
18188
18516
  await this.ensureSchema();
18189
18517
  const { where, params, queryRef } = await this.buildTaskFilterSql(filter);
18190
- const orderBy = queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
18518
+ const orderBy = filter.archived_only ? `ORDER BY payload->>'archived_at' DESC, object_id DESC` : queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
18191
18519
  let sql = `/* todos:list-tasks */ SELECT payload FROM ${this.tableName} WHERE ${where} ${orderBy}`;
18192
18520
  if (filter.limit !== undefined) {
18193
18521
  params.push(filter.limit);
@@ -18407,6 +18735,8 @@ class PostgresJsonRecordStore {
18407
18735
  if (planIds.length === 0 && !parentGuard)
18408
18736
  return this.upsert("tasks", value, context);
18409
18737
  await this.ensureSchema();
18738
+ if (!queryClient && this.projectIntegrityLocked)
18739
+ queryClient = this.options.client;
18410
18740
  if (!queryClient) {
18411
18741
  return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
18412
18742
  }
@@ -18944,9 +19274,11 @@ class PostgresJsonRecordStore {
18944
19274
  }
18945
19275
  async deleteTaskHierarchy(id, context = {}) {
18946
19276
  await this.ensureSchema();
18947
- return this.withTaskParentIntegrityTransaction(async (client) => {
18948
- const timestamp2 = new Date().toISOString();
18949
- const result = await client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
19277
+ if (!this.projectIntegrityLocked) {
19278
+ return this.withDependencyGraphTransaction((scoped) => scoped.deleteTaskHierarchy(id, context));
19279
+ }
19280
+ const timestamp2 = new Date().toISOString();
19281
+ const result = await this.options.client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
18950
19282
  task_tree(object_id, path, cycle) AS (
18951
19283
  SELECT task.object_id, ARRAY[task.object_id], false
18952
19284
  FROM ${this.tableName} AS task
@@ -19008,13 +19340,12 @@ class PostgresJsonRecordStore {
19008
19340
  SELECT EXISTS (SELECT 1 FROM task_tree WHERE object_id = $2) AS found,
19009
19341
  (SELECT count(*) FROM tombstoned) AS deleted_count,
19010
19342
  (SELECT count(*) FROM tombstoned_related) AS related_deleted_count`, [
19011
- this.service,
19012
- id,
19013
- timestamp2,
19014
- context.requestId ?? this.sourceMachineId ?? null
19015
- ]);
19016
- return Boolean(result.rows[0]?.found);
19017
- });
19343
+ this.service,
19344
+ id,
19345
+ timestamp2,
19346
+ context.requestId ?? this.sourceMachineId ?? null
19347
+ ]);
19348
+ return Boolean(result.rows[0]?.found);
19018
19349
  }
19019
19350
  async getPlanProjectLinkReceipt(receiptId) {
19020
19351
  const value = await this.get("plan_project_link_receipts", receiptId);
@@ -19470,6 +19801,102 @@ class PostgresJsonRecordStore {
19470
19801
  updated_at = EXCLUDED.updated_at`, [this.service, name, value]);
19471
19802
  }
19472
19803
  }
19804
+ async function bulkCreateTasksAtomic(inputs, store, context) {
19805
+ return store.withDependencyGraphTransaction(async (scoped) => {
19806
+ const tempIds = new Map;
19807
+ const created = [];
19808
+ const dependencies = [];
19809
+ for (const input of inputs) {
19810
+ const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
19811
+ const task = await createTask2(taskInput, scoped, context);
19812
+ if (temp_id)
19813
+ tempIds.set(temp_id, task.id);
19814
+ created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
19815
+ }
19816
+ for (let index = 0;index < inputs.length; index++) {
19817
+ const input = inputs[index];
19818
+ const taskId = created[index].id;
19819
+ const seenDependencies = new Set;
19820
+ for (const reference of input.depends_on ?? []) {
19821
+ const exact = tempIds.get(reference) ?? (await scoped.get("tasks", reference))?.id;
19822
+ const dependencyId = exact ?? (await scoped.resolveTaskRef(reference))?.id;
19823
+ if (!dependencyId)
19824
+ throw new TaskNotFoundError(reference);
19825
+ if (seenDependencies.has(dependencyId)) {
19826
+ throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
19827
+ }
19828
+ seenDependencies.add(dependencyId);
19829
+ dependencies.push(await addDependency2(taskId, dependencyId, scoped, context));
19830
+ }
19831
+ }
19832
+ return { schema_version: 1, atomic: true, created, dependencies };
19833
+ });
19834
+ }
19835
+ async function bulkDeleteTasksAtomic(ids, force, store, context) {
19836
+ return store.withDependencyGraphTransaction(async (scoped) => {
19837
+ const resolved = [];
19838
+ for (const reference of ids) {
19839
+ resolved.push({
19840
+ reference,
19841
+ task: await scoped.get("tasks", reference) ?? await scoped.resolveTaskRef(reference)
19842
+ });
19843
+ }
19844
+ const seen = new Set;
19845
+ for (const item of resolved) {
19846
+ if (!item.task)
19847
+ continue;
19848
+ if (seen.has(item.task.id)) {
19849
+ throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
19850
+ }
19851
+ seen.add(item.task.id);
19852
+ }
19853
+ const childState = new Map;
19854
+ for (const item of resolved) {
19855
+ if (!item.task)
19856
+ continue;
19857
+ const children = await scoped.listTasks({ parent_id: item.task.id, include_subtasks: true, limit: 1 });
19858
+ childState.set(item.task.id, children.length > 0);
19859
+ }
19860
+ const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
19861
+ const plannedIds = new Set(planned.map((item) => item.task.id));
19862
+ const roots = [];
19863
+ for (const item of planned) {
19864
+ if (!force) {
19865
+ roots.push(item);
19866
+ continue;
19867
+ }
19868
+ let parentId = item.task.parent_id;
19869
+ let covered = false;
19870
+ while (parentId) {
19871
+ if (plannedIds.has(parentId)) {
19872
+ covered = true;
19873
+ break;
19874
+ }
19875
+ parentId = (await scoped.get("tasks", parentId))?.parent_id ?? null;
19876
+ }
19877
+ if (!covered)
19878
+ roots.push(item);
19879
+ }
19880
+ for (const item of roots) {
19881
+ if (!await scoped.deleteTaskHierarchy(item.task.id, context)) {
19882
+ throw new Error(`Atomic bulk delete lost task ${item.task.id}`);
19883
+ }
19884
+ }
19885
+ return {
19886
+ schema_version: 1,
19887
+ atomic: true,
19888
+ force,
19889
+ results: resolved.map((item) => {
19890
+ if (!item.task)
19891
+ return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
19892
+ if (!force && childState.get(item.task.id)) {
19893
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
19894
+ }
19895
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
19896
+ })
19897
+ };
19898
+ });
19899
+ }
19473
19900
  async function createTask2(input, store, context) {
19474
19901
  const timestamp2 = new Date().toISOString();
19475
19902
  const taskId = randomUUID4();
@@ -19560,12 +19987,16 @@ async function updateTask2(id, input, store, context) {
19560
19987
  }
19561
19988
  }
19562
19989
  }
19990
+ const patchTouchesPlacement = input.project_id !== undefined || input.task_list_id !== undefined;
19991
+ const effectiveProjectIdForShortId = input.project_id !== undefined ? input.project_id : existing.project_id;
19992
+ const backfilledShortId = existing.short_id === null && patchTouchesPlacement && effectiveProjectIdForShortId ? await nextTaskShortId2(effectiveProjectIdForShortId, store, context) : null;
19563
19993
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
19564
19994
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
19565
19995
  const task = {
19566
19996
  ...existing,
19567
19997
  ...definedPatch(input),
19568
19998
  ...terminalNow ? { locked_by: null, locked_at: null } : {},
19999
+ short_id: backfilledShortId ?? existing.short_id,
19569
20000
  version: existing.version + 1,
19570
20001
  updated_at: new Date().toISOString(),
19571
20002
  tags: input.tags ?? existing.tags,