@hasna/todos 0.15.51 → 0.16.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 (184) hide show
  1. package/CHANGELOG.md +1884 -0
  2. package/README.md +314 -37
  3. package/dist/ai-tools.d.ts.map +1 -1
  4. package/dist/ai.d.ts.map +1 -1
  5. package/dist/cli/cloud-router.d.ts +99 -51
  6. package/dist/cli/cloud-router.d.ts.map +1 -1
  7. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  8. package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
  9. package/dist/cli/commands/delegate.d.ts.map +1 -1
  10. package/dist/cli/commands/environment-snapshots.d.ts.map +1 -1
  11. package/dist/cli/commands/machines-api.d.ts +4 -0
  12. package/dist/cli/commands/machines-api.d.ts.map +1 -0
  13. package/dist/cli/commands/machines.d.ts.map +1 -1
  14. package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
  15. package/dist/cli/commands/plan-commands.d.ts +3 -0
  16. package/dist/cli/commands/plan-commands.d.ts.map +1 -0
  17. package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
  18. package/dist/cli/commands/pr-group-commands.d.ts.map +1 -1
  19. package/dist/cli/commands/project-commands.d.ts +15 -0
  20. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  21. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  22. package/dist/cli/commands/storage-commands.d.ts.map +1 -1
  23. package/dist/cli/commands/task-list-commands.d.ts +3 -0
  24. package/dist/cli/commands/task-list-commands.d.ts.map +1 -0
  25. package/dist/cli/commands/template-commands.d.ts +3 -0
  26. package/dist/cli/commands/template-commands.d.ts.map +1 -0
  27. package/dist/cli/helpers.d.ts.map +1 -1
  28. package/dist/cli/index.js +15156 -11088
  29. package/dist/cli/machine-api.d.ts +7 -0
  30. package/dist/cli/machine-api.d.ts.map +1 -0
  31. package/dist/cli/plan-read-api.d.ts +6 -0
  32. package/dist/cli/plan-read-api.d.ts.map +1 -0
  33. package/dist/cli/project-api.d.ts +119 -0
  34. package/dist/cli/project-api.d.ts.map +1 -0
  35. package/dist/cli/stage-a.d.ts +34 -4
  36. package/dist/cli/stage-a.d.ts.map +1 -1
  37. package/dist/cli/task-query-api.d.ts +21 -0
  38. package/dist/cli/task-query-api.d.ts.map +1 -0
  39. package/dist/cli/template-api.d.ts +7 -0
  40. package/dist/cli/template-api.d.ts.map +1 -0
  41. package/dist/contracts.js +836 -266
  42. package/dist/db/builtin-templates.d.ts +2 -33
  43. package/dist/db/builtin-templates.d.ts.map +1 -1
  44. package/dist/db/database.d.ts.map +1 -1
  45. package/dist/db/handoffs.d.ts.map +1 -1
  46. package/dist/db/machines.d.ts.map +1 -1
  47. package/dist/db/migrations.d.ts.map +1 -1
  48. package/dist/db/plans.d.ts.map +1 -1
  49. package/dist/db/retrospectives.d.ts.map +1 -1
  50. package/dist/db/schema.d.ts.map +1 -1
  51. package/dist/db/task-lists.d.ts.map +1 -1
  52. package/dist/db/task-runs.d.ts +2 -0
  53. package/dist/db/task-runs.d.ts.map +1 -1
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3863 -1708
  57. package/dist/lib/assignee-validation.d.ts.map +1 -1
  58. package/dist/lib/builtin-template-library.d.ts +32 -0
  59. package/dist/lib/builtin-template-library.d.ts.map +1 -0
  60. package/dist/lib/cli-reference.d.ts.map +1 -1
  61. package/dist/lib/config.d.ts +10 -13
  62. package/dist/lib/config.d.ts.map +1 -1
  63. package/dist/lib/db-backup.d.ts +1 -1
  64. package/dist/lib/db-backup.d.ts.map +1 -1
  65. package/dist/lib/env.d.ts +18 -0
  66. package/dist/lib/env.d.ts.map +1 -0
  67. package/dist/lib/environment-snapshots.d.ts.map +1 -1
  68. package/dist/lib/gatherer.d.ts.map +1 -1
  69. package/dist/lib/headless-boundaries.d.ts +4 -3
  70. package/dist/lib/headless-boundaries.d.ts.map +1 -1
  71. package/dist/lib/local-encryption.d.ts +12 -0
  72. package/dist/lib/local-encryption.d.ts.map +1 -1
  73. package/dist/lib/local-opt-in.d.ts +90 -0
  74. package/dist/lib/local-opt-in.d.ts.map +1 -0
  75. package/dist/lib/paths.d.ts +9 -0
  76. package/dist/lib/paths.d.ts.map +1 -1
  77. package/dist/lib/plan-artifacts.d.ts +1 -0
  78. package/dist/lib/plan-artifacts.d.ts.map +1 -1
  79. package/dist/lib/plan-client-boundary.d.ts +2 -0
  80. package/dist/lib/plan-client-boundary.d.ts.map +1 -0
  81. package/dist/lib/plan-schedule.d.ts +6 -0
  82. package/dist/lib/plan-schedule.d.ts.map +1 -0
  83. package/dist/lib/project-panel.d.ts +6 -0
  84. package/dist/lib/project-panel.d.ts.map +1 -1
  85. package/dist/lib/project-receipt.d.ts +3 -0
  86. package/dist/lib/project-receipt.d.ts.map +1 -0
  87. package/dist/lib/sandbox-profiles.d.ts.map +1 -1
  88. package/dist/lib/shared-plan-artifacts.d.ts +14 -0
  89. package/dist/lib/shared-plan-artifacts.d.ts.map +1 -0
  90. package/dist/lib/sync.d.ts.map +1 -1
  91. package/dist/lib/task-list-client-boundary.d.ts +3 -0
  92. package/dist/lib/task-list-client-boundary.d.ts.map +1 -0
  93. package/dist/lib/task-routing.d.ts.map +1 -1
  94. package/dist/lib/template-client-boundary.d.ts +2 -0
  95. package/dist/lib/template-client-boundary.d.ts.map +1 -0
  96. package/dist/mcp/index.d.ts +1 -0
  97. package/dist/mcp/index.d.ts.map +1 -1
  98. package/dist/mcp/index.js +45195 -40368
  99. package/dist/mcp/plan-api.d.ts +6 -0
  100. package/dist/mcp/plan-api.d.ts.map +1 -0
  101. package/dist/mcp/remote-authority.d.ts +49 -0
  102. package/dist/mcp/remote-authority.d.ts.map +1 -0
  103. package/dist/mcp/task-coordination-api.d.ts +15 -0
  104. package/dist/mcp/task-coordination-api.d.ts.map +1 -0
  105. package/dist/mcp/task-list-api.d.ts +8 -0
  106. package/dist/mcp/task-list-api.d.ts.map +1 -0
  107. package/dist/mcp/token-utils.d.ts.map +1 -1
  108. package/dist/mcp/tools/environment-snapshots.d.ts.map +1 -1
  109. package/dist/mcp/tools/machines.d.ts.map +1 -1
  110. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  111. package/dist/mcp/tools/task-resources.d.ts.map +1 -1
  112. package/dist/mcp.d.ts +2 -2
  113. package/dist/mcp.d.ts.map +1 -1
  114. package/dist/mcp.js +45 -19
  115. package/dist/project-registration.js +2830 -557
  116. package/dist/registry.js +837 -267
  117. package/dist/release-provenance.json +6 -6
  118. package/dist/sdk/client.d.ts +51 -4
  119. package/dist/sdk/client.d.ts.map +1 -1
  120. package/dist/sdk/index.d.ts +2 -0
  121. package/dist/sdk/index.d.ts.map +1 -1
  122. package/dist/sdk/index.js +1209 -1080
  123. package/dist/sdk/resolve.d.ts +91 -0
  124. package/dist/sdk/resolve.d.ts.map +1 -0
  125. package/dist/sdk/v1.generated.d.ts +219 -3
  126. package/dist/sdk/v1.generated.d.ts.map +1 -1
  127. package/dist/server/auth-posture.d.ts +66 -7
  128. package/dist/server/auth-posture.d.ts.map +1 -1
  129. package/dist/server/client-ip.d.ts +39 -0
  130. package/dist/server/client-ip.d.ts.map +1 -0
  131. package/dist/server/cloud.d.ts +2 -1
  132. package/dist/server/cloud.d.ts.map +1 -1
  133. package/dist/server/index.d.ts +1 -1
  134. package/dist/server/index.js +20556 -15533
  135. package/dist/server/openapi.d.ts +1341 -83
  136. package/dist/server/openapi.d.ts.map +1 -1
  137. package/dist/server/port.d.ts +2 -2
  138. package/dist/server/port.d.ts.map +1 -1
  139. package/dist/server/routes.d.ts +3 -6
  140. package/dist/server/routes.d.ts.map +1 -1
  141. package/dist/server/serve.d.ts +2 -6
  142. package/dist/server/serve.d.ts.map +1 -1
  143. package/dist/server/v1.d.ts +3 -1
  144. package/dist/server/v1.d.ts.map +1 -1
  145. package/dist/storage/atomic-project-migration.d.ts +53 -0
  146. package/dist/storage/atomic-project-migration.d.ts.map +1 -0
  147. package/dist/storage/index.d.ts +3 -1
  148. package/dist/storage/index.d.ts.map +1 -1
  149. package/dist/storage/interfaces.d.ts +61 -0
  150. package/dist/storage/interfaces.d.ts.map +1 -1
  151. package/dist/storage/machine-registry.d.ts +36 -0
  152. package/dist/storage/machine-registry.d.ts.map +1 -0
  153. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  154. package/dist/storage/postgres-sync.d.ts +2 -2
  155. package/dist/storage/postgres-sync.d.ts.map +1 -1
  156. package/dist/storage/run-artifact-creation-upload.d.ts +50 -0
  157. package/dist/storage/run-artifact-creation-upload.d.ts.map +1 -0
  158. package/dist/storage/s3-artifact-sync.d.ts +18 -0
  159. package/dist/storage/s3-artifact-sync.d.ts.map +1 -1
  160. package/dist/storage/sqlite-snapshot.d.ts +2 -0
  161. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  162. package/dist/storage.d.ts +2 -2
  163. package/dist/storage.d.ts.map +1 -1
  164. package/dist/storage.js +2695 -322
  165. package/dist/task-manifest/postgres.d.ts +1 -1
  166. package/dist/task-manifest/sqlite.d.ts +1 -1
  167. package/dist/task-manifest.js +244 -73
  168. package/dist/task-subtree-transfer.js +198 -4
  169. package/dist/testing.d.ts +90 -29
  170. package/dist/testing.d.ts.map +1 -1
  171. package/dist/testing.js +34 -10
  172. package/dist/types/index.d.ts +40 -4
  173. package/dist/types/index.d.ts.map +1 -1
  174. package/docs/PLAN_API.md +93 -0
  175. package/docs/TASK_LIST_API.md +58 -0
  176. package/docs/TASK_QUERY_API.md +14 -0
  177. package/docs/TEMPLATE_API.md +18 -0
  178. package/docs/native-storage.md +405 -0
  179. package/package.json +19 -16
  180. package/postinstall.js +52 -1
  181. package/dashboard/dist/assets/index-BNQ4gJua.js +0 -342
  182. package/dashboard/dist/assets/index-DjzvHUWt.css +0 -1
  183. package/dashboard/dist/index.html +0 -13
  184. package/dashboard/dist/logo.jpg +0 -0
package/dist/storage.js CHANGED
@@ -253,7 +253,7 @@ function isBlockingDependencyStatus(status) {
253
253
  function isTerminalStatus(status) {
254
254
  return status === "completed" || status === "failed" || status === "cancelled";
255
255
  }
256
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
256
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError, InputValidationError;
257
257
  var init_types = __esm(() => {
258
258
  TASK_STATUSES = [
259
259
  "pending",
@@ -268,7 +268,7 @@ var init_types = __esm(() => {
268
268
  "high",
269
269
  "critical"
270
270
  ];
271
- PLAN_STATUSES = ["active", "completed", "archived"];
271
+ PLAN_STATUSES = ["active", "completed", "archived", "planning", "cancelled"];
272
272
  VersionConflictError = class VersionConflictError extends Error {
273
273
  taskId;
274
274
  expectedVersion;
@@ -457,6 +457,72 @@ var init_types = __esm(() => {
457
457
  this.name = "DispatchNotFoundError";
458
458
  }
459
459
  };
460
+ InputValidationError = class InputValidationError extends Error {
461
+ static code = "INVALID_INPUT";
462
+ code = InputValidationError.code;
463
+ suggestion;
464
+ constructor(message, suggestion) {
465
+ super(message);
466
+ this.name = "InputValidationError";
467
+ this.suggestion = suggestion;
468
+ }
469
+ };
470
+ });
471
+
472
+ // src/lib/local-opt-in.ts
473
+ import {
474
+ clientTransportEnvKeys,
475
+ credentialOverrideEnvKey,
476
+ credentialPointerEnvKey,
477
+ CREDENTIAL_PROFILE_ENV_KEY
478
+ } from "@hasna/contracts/client";
479
+ function isTodosLocalOptIn(env = process.env) {
480
+ return TODOS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
481
+ }
482
+ function todosAuthorityEnvKeys() {
483
+ const keys = clientTransportEnvKeys("todos");
484
+ return [
485
+ ...keys.apiUrlKeys,
486
+ ...keys.apiKeyKeys,
487
+ credentialOverrideEnvKey("todos"),
488
+ credentialPointerEnvKey("todos"),
489
+ CREDENTIAL_PROFILE_ENV_KEY
490
+ ];
491
+ }
492
+ function hasTodosEnvAuthorityIntent(env = process.env) {
493
+ return todosAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
494
+ }
495
+ function selectsTodosLocalStore(env = process.env) {
496
+ return !hasTodosEnvAuthorityIntent(env) && isTodosLocalOptIn(env);
497
+ }
498
+ function todosResolverEnv(env) {
499
+ const blanks = todosAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
500
+ if (blanks.length === 0)
501
+ return env;
502
+ const next = { ...env };
503
+ for (const key of blanks)
504
+ delete next[key];
505
+ return next;
506
+ }
507
+ function isAmbientTodosEnv(env) {
508
+ if (typeof process !== "undefined" && env === process.env)
509
+ return true;
510
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
511
+ }
512
+ function todosResolverInputs(env, credentials = {}) {
513
+ const normalised = todosResolverEnv(env);
514
+ if (normalised === env)
515
+ return { env: normalised, credentials };
516
+ const keychain = { ...credentials.keychain };
517
+ if (keychain.enabled === undefined && keychain.run === undefined) {
518
+ keychain.enabled = isAmbientTodosEnv(env);
519
+ }
520
+ return { env: normalised, credentials: { ...credentials, keychain } };
521
+ }
522
+ var TODOS_LOCAL_OPT_IN_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
523
+ var init_local_opt_in = __esm(() => {
524
+ TODOS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_TODOS_LOCAL", "TODOS_LOCAL"];
525
+ CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
460
526
  });
461
527
 
462
528
  // src/project-registration/schema.ts
@@ -2028,16 +2094,18 @@ var init_migrations = __esm(() => {
2028
2094
  SET leaf_task_id = COALESCE(leaf_task_id, (
2029
2095
  SELECT attempt.leaf_task_id
2030
2096
  FROM pr_group_attempts AS attempt
2097
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2031
2098
  WHERE attempt.group_id = pr_groups.id
2032
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2099
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2033
2100
  attempt.created_at ASC, attempt.id ASC
2034
2101
  LIMIT 1
2035
2102
  )),
2036
2103
  branch = COALESCE(branch, (
2037
2104
  SELECT attempt.branch
2038
2105
  FROM pr_group_attempts AS attempt
2106
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2039
2107
  WHERE attempt.group_id = pr_groups.id
2040
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2108
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2041
2109
  attempt.created_at ASC, attempt.id ASC
2042
2110
  LIMIT 1
2043
2111
  ));
@@ -2060,16 +2128,18 @@ var init_migrations = __esm(() => {
2060
2128
  SET pr_number = COALESCE(pr_number, (
2061
2129
  SELECT attempt.pr_number
2062
2130
  FROM pr_group_attempts AS attempt
2131
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2063
2132
  WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
2064
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2133
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2065
2134
  attempt.created_at ASC, attempt.id ASC
2066
2135
  LIMIT 1
2067
2136
  )),
2068
2137
  base_sha = COALESCE(base_sha, (
2069
2138
  SELECT attempt.base_sha
2070
2139
  FROM pr_group_attempts AS attempt
2140
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2071
2141
  WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
2072
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2142
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2073
2143
  attempt.created_at ASC, attempt.id ASC
2074
2144
  LIMIT 1
2075
2145
  ));
@@ -2108,7 +2178,7 @@ var init_migrations = __esm(() => {
2108
2178
  ));
2109
2179
 
2110
2180
  INSERT OR IGNORE INTO _migrations (id)
2111
- SELECT 66
2181
+ SELECT 67
2112
2182
  WHERE NOT EXISTS (
2113
2183
  SELECT 1 FROM pr_groups WHERE leaf_task_id IS NULL OR branch IS NULL
2114
2184
  )
@@ -2313,32 +2383,52 @@ var init_migrations = __esm(() => {
2313
2383
  });
2314
2384
 
2315
2385
  // src/db/schema.ts
2386
+ function runPrGroupLineageMigration(db, migration, rebuild) {
2387
+ if (db.inTransaction)
2388
+ throw new Error("PR-group migration requires its own transaction");
2389
+ const priorForeignKeys = db.query("PRAGMA foreign_keys").get().foreign_keys;
2390
+ try {
2391
+ if (rebuild)
2392
+ db.exec("PRAGMA foreign_keys = OFF");
2393
+ db.exec("BEGIN IMMEDIATE");
2394
+ for (const statement of migration.split(/;\s*(?:\n|$)/).map((value) => value.trim()).filter(Boolean)) {
2395
+ if (/^(?:PRAGMA foreign_keys\s*=|BEGIN IMMEDIATE$|COMMIT$)/i.test(statement))
2396
+ continue;
2397
+ db.run(statement);
2398
+ }
2399
+ const completed = db.query("SELECT id FROM _migrations WHERE id = ?").get(rebuild ? 68 : 67);
2400
+ if (!completed)
2401
+ throw new Error("PR-group lineage backfill is incomplete; migration rolled back");
2402
+ if (rebuild && db.query("PRAGMA foreign_key_check").all().length) {
2403
+ throw new Error("PR-group migration failed foreign-key integrity verification");
2404
+ }
2405
+ db.exec("COMMIT");
2406
+ } catch (error) {
2407
+ if (db.inTransaction)
2408
+ db.exec("ROLLBACK");
2409
+ throw error;
2410
+ } finally {
2411
+ db.exec(`PRAGMA foreign_keys = ${priorForeignKeys ? "ON" : "OFF"}`);
2412
+ }
2413
+ }
2316
2414
  function runMigrations(db) {
2317
- let strictMigrationFailure = null;
2318
- const executeMigration = (migration, index) => {
2415
+ let currentLevel = 0;
2416
+ try {
2417
+ currentLevel = db.query("SELECT MAX(id) as max_id FROM _migrations").get()?.max_id ?? 0;
2418
+ } catch {}
2419
+ for (let index = currentLevel;index < MIGRATIONS.length; index++) {
2319
2420
  try {
2320
- db.exec(migration);
2421
+ if (index === 66 || index === 67)
2422
+ runPrGroupLineageMigration(db, MIGRATIONS[index], index === 67);
2423
+ else
2424
+ db.exec(MIGRATIONS[index]);
2321
2425
  } catch (error) {
2322
- try {
2426
+ if (db.inTransaction)
2323
2427
  db.exec("ROLLBACK");
2324
- } catch {}
2325
- if (index + 1 >= 68)
2326
- strictMigrationFailure = error;
2327
- }
2328
- };
2329
- try {
2330
- const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
2331
- const currentLevel = result?.max_id ?? 0;
2332
- for (let i = currentLevel;i < MIGRATIONS.length; i++) {
2333
- executeMigration(MIGRATIONS[i], i);
2334
- }
2335
- } catch {
2336
- for (const [index, migration] of MIGRATIONS.entries()) {
2337
- executeMigration(migration, index);
2428
+ if (index + 1 >= 67)
2429
+ throw error;
2338
2430
  }
2339
2431
  }
2340
- if (strictMigrationFailure)
2341
- throw strictMigrationFailure;
2342
2432
  ensureSchema(db);
2343
2433
  }
2344
2434
  function planSlugBase(value) {
@@ -3650,16 +3740,18 @@ function ensureSchema(db) {
3650
3740
  SET leaf_task_id = COALESCE(leaf_task_id, (
3651
3741
  SELECT attempt.leaf_task_id
3652
3742
  FROM pr_group_attempts AS attempt
3743
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3653
3744
  WHERE attempt.group_id = pr_groups.id
3654
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3745
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3655
3746
  attempt.created_at ASC, attempt.id ASC
3656
3747
  LIMIT 1
3657
3748
  )),
3658
3749
  branch = COALESCE(branch, (
3659
3750
  SELECT attempt.branch
3660
3751
  FROM pr_group_attempts AS attempt
3752
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3661
3753
  WHERE attempt.group_id = pr_groups.id
3662
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3754
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3663
3755
  attempt.created_at ASC, attempt.id ASC
3664
3756
  LIMIT 1
3665
3757
  ));
@@ -3680,16 +3772,18 @@ function ensureSchema(db) {
3680
3772
  SET pr_number = COALESCE(pr_number, (
3681
3773
  SELECT attempt.pr_number
3682
3774
  FROM pr_group_attempts AS attempt
3775
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3683
3776
  WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
3684
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3777
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3685
3778
  attempt.created_at ASC, attempt.id ASC
3686
3779
  LIMIT 1
3687
3780
  )),
3688
3781
  base_sha = COALESCE(base_sha, (
3689
3782
  SELECT attempt.base_sha
3690
3783
  FROM pr_group_attempts AS attempt
3784
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3691
3785
  WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
3692
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3786
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3693
3787
  attempt.created_at ASC, attempt.id ASC
3694
3788
  LIMIT 1
3695
3789
  ));
@@ -3801,6 +3895,28 @@ var init_schema = __esm(() => {
3801
3895
  init_migrations();
3802
3896
  });
3803
3897
 
3898
+ // src/lib/env.ts
3899
+ var alias = (canonical, legacy) => process.env[canonical] ?? process.env[legacy], env;
3900
+ var init_env = __esm(() => {
3901
+ env = {
3902
+ profile: () => alias("HASNA_TODOS_PROFILE", "TODOS_PROFILE"),
3903
+ toolGroups: () => alias("HASNA_TODOS_TOOL_GROUPS", "TODOS_TOOL_GROUPS"),
3904
+ machineId: () => alias("HASNA_TODOS_MACHINE_ID", "TODOS_MACHINE_ID"),
3905
+ machineName: () => alias("HASNA_TODOS_MACHINE_NAME", "TODOS_MACHINE_NAME"),
3906
+ rateLimitMax: () => alias("HASNA_TODOS_RATE_LIMIT_MAX", "TODOS_RATE_LIMIT_MAX"),
3907
+ trustProxy: () => alias("HASNA_TODOS_TRUST_PROXY", "TODOS_TRUST_PROXY"),
3908
+ autoProject: () => alias("HASNA_TODOS_AUTO_PROJECT", "TODOS_AUTO_PROJECT"),
3909
+ aiFormat: () => alias("HASNA_TODOS_AI_FORMAT", "TODOS_AI_FORMAT"),
3910
+ syncAgents: () => alias("HASNA_TODOS_SYNC_AGENTS", "TODOS_SYNC_AGENTS"),
3911
+ taskListId: () => alias("HASNA_TODOS_TASK_LIST_ID", "TODOS_TASK_LIST_ID"),
3912
+ claudeTaskList: () => alias("HASNA_TODOS_CLAUDE_TASK_LIST", "TODOS_CLAUDE_TASK_LIST"),
3913
+ sandboxProfilesPath: () => alias("HASNA_TODOS_SANDBOX_PROFILES_PATH", "TODOS_SANDBOX_PROFILES_PATH"),
3914
+ seatRosterPath: () => alias("HASNA_TODOS_SEAT_ROSTER_PATH", "TODOS_SEAT_ROSTER_PATH"),
3915
+ delegateNoticeChannel: () => alias("HASNA_TODOS_DELEGATE_NOTICE_CHANNEL", "TODOS_DELEGATE_NOTICE_CHANNEL"),
3916
+ delegateNotifyBin: () => alias("HASNA_TODOS_DELEGATE_NOTIFY_BIN", "TODOS_DELEGATE_NOTIFY_BIN")
3917
+ };
3918
+ });
3919
+
3804
3920
  // src/db/machines.ts
3805
3921
  import { existsSync } from "fs";
3806
3922
  import { hostname as osHostname, platform as osPlatform, arch as osArch } from "os";
@@ -3863,7 +3979,7 @@ function extractTopology(machine) {
3863
3979
  }
3864
3980
  function getOrCreateLocalMachine(db) {
3865
3981
  const d = db || getDatabase();
3866
- const name = process.env["TODOS_MACHINE_NAME"] || osHostname();
3982
+ const name = env.machineName() || osHostname();
3867
3983
  const host = osHostname();
3868
3984
  const plat = osPlatform();
3869
3985
  const existing = d.query("SELECT * FROM machines WHERE name = ?").get(name);
@@ -3932,7 +4048,7 @@ function registerMachine(name, opts, db) {
3932
4048
  }
3933
4049
  function updateMachineHeartbeat(idOrName, opts = {}, db) {
3934
4050
  const d = db || getDatabase();
3935
- const key = idOrName || process.env["TODOS_MACHINE_NAME"] || osHostname();
4051
+ const key = idOrName || env.machineName() || osHostname();
3936
4052
  const row = d.query("SELECT * FROM machines WHERE id = ? OR name = ?").get(key, key);
3937
4053
  if (!row) {
3938
4054
  return registerMachine(key, {
@@ -4084,6 +4200,7 @@ function backfillMachineId(db, force = false) {
4084
4200
  var _machineId = null, TABLES_WITH_MACHINE_ID;
4085
4201
  var init_machines = __esm(() => {
4086
4202
  init_database();
4203
+ init_env();
4087
4204
  TABLES_WITH_MACHINE_ID = [
4088
4205
  "projects",
4089
4206
  "tasks",
@@ -4282,108 +4399,100 @@ var init_identity_mapping = __esm(() => {
4282
4399
  });
4283
4400
  });
4284
4401
 
4285
- // node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
4402
+ // src/lib/paths.ts
4403
+ import { existsSync as existsSync2 } from "fs";
4286
4404
  import { homedir } from "os";
4287
- import { join } from "path";
4288
- function assertApp(app) {
4405
+ import { join, resolve as resolve2 } from "path";
4406
+ import { homedir as pathsResolverHomedir } from "os";
4407
+ import { join as pathsResolverJoin } from "path";
4408
+ function pathsResolverAssertApp(app) {
4289
4409
  if (typeof app !== "string" || app.length === 0) {
4290
4410
  throw new TypeError("paths: app must be a non-empty string");
4291
4411
  }
4292
- if (!APP_SLUG_RE.test(app)) {
4412
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
4293
4413
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
4294
4414
  }
4295
4415
  }
4296
- function envOf(options) {
4297
- return options.env ?? process.env;
4298
- }
4299
- function envValue(options, kind) {
4300
- const value = envOf(options)[KIND_ENV[kind]];
4301
- return typeof value === "string" && value.length > 0 ? value : undefined;
4302
- }
4303
- function isMacOS(platform) {
4304
- return platform === "darwin";
4416
+ function pathsResolverAssertKind(kind) {
4417
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
4418
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
4419
+ }
4305
4420
  }
4306
- function baseDir(kind, options) {
4307
- const override = envValue(options, kind);
4308
- if (override)
4421
+ function pathsResolverBaseDir(kind, options) {
4422
+ pathsResolverAssertKind(kind);
4423
+ const env2 = options.env ?? process.env;
4424
+ const override = env2[PATHS_RESOLVER_KIND_ENV[kind]];
4425
+ if (typeof override === "string" && override.length > 0)
4309
4426
  return override;
4310
- const home = options.home ?? homedir();
4427
+ const home = options.home ?? pathsResolverHomedir();
4311
4428
  const platform = options.platform ?? process.platform;
4312
- if (isMacOS(platform)) {
4429
+ if (platform === "darwin") {
4313
4430
  switch (kind) {
4314
4431
  case "config":
4315
4432
  case "data":
4316
- return join(home, "Library", "Application Support", "Hasna");
4433
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
4317
4434
  case "cache":
4318
- return join(home, "Library", "Caches", "Hasna");
4435
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
4319
4436
  case "state":
4320
- return join(home, "Library", "Logs", "Hasna");
4437
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
4321
4438
  }
4322
4439
  }
4323
4440
  switch (kind) {
4324
4441
  case "config":
4325
- return join(home, ".config", "hasna");
4442
+ return pathsResolverJoin(home, ".config", "hasna");
4326
4443
  case "data":
4327
- return join(home, ".local", "share", "hasna");
4444
+ return pathsResolverJoin(home, ".local", "share", "hasna");
4328
4445
  case "state":
4329
- return join(home, ".local", "state", "hasna");
4446
+ return pathsResolverJoin(home, ".local", "state", "hasna");
4330
4447
  case "cache":
4331
- return join(home, ".cache", "hasna");
4448
+ return pathsResolverJoin(home, ".cache", "hasna");
4332
4449
  }
4333
4450
  }
4334
- function resolvePath(kind, options) {
4335
- assertApp(options.app);
4336
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
4337
- return join(baseDir(kind, options), appSegment);
4451
+ function pathsResolverResolve(kind, options) {
4452
+ pathsResolverAssertApp(options.app);
4453
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
4454
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
4338
4455
  }
4339
4456
  function dataDir(options) {
4340
- return resolvePath("data", options);
4457
+ return pathsResolverResolve("data", options);
4341
4458
  }
4342
- var KIND_ENV, APP_SLUG_RE;
4343
- var init_dist = __esm(() => {
4344
- KIND_ENV = {
4345
- config: "HASNA_CONFIG_HOME",
4346
- data: "HASNA_DATA_HOME",
4347
- state: "HASNA_STATE_HOME",
4348
- cache: "HASNA_CACHE_HOME"
4349
- };
4350
- APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4351
- });
4352
-
4353
- // src/lib/paths.ts
4354
- import { existsSync as existsSync2 } from "fs";
4355
- import { homedir as homedir2 } from "os";
4356
- import { join as join2, resolve as resolve2 } from "path";
4357
- function effectiveHome(env = process.env) {
4358
- return env.HOME || env.USERPROFILE || homedir2();
4459
+ function effectiveHome(env2 = process.env) {
4460
+ return env2.HOME || env2.USERPROFILE || homedir();
4359
4461
  }
4360
- function legacyHomeDir(env = process.env) {
4361
- return join2(effectiveHome(env), ".hasna", "todos");
4462
+ function legacyHomeDir(env2 = process.env) {
4463
+ return join(effectiveHome(env2), ".hasna", "todos");
4362
4464
  }
4363
- function resolverHome(env = process.env) {
4364
- return dataDir({ app: "todos", home: effectiveHome(env), env });
4465
+ function resolverHome(env2 = process.env) {
4466
+ return dataDir({ app: "todos", home: effectiveHome(env2), env: env2 });
4365
4467
  }
4366
- function adoptResolverHome(resolved, env = process.env) {
4367
- const dataOverride = env.HASNA_DATA_HOME;
4468
+ function adoptResolverHome(resolved, env2 = process.env) {
4469
+ const dataOverride = env2.HASNA_DATA_HOME;
4368
4470
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
4369
4471
  return true;
4370
- return existsSync2(join2(resolved, "todos.db")) || existsSync2(join2(resolved, "config.json"));
4472
+ return existsSync2(join(resolved, "todos.db")) || existsSync2(join(resolved, "config.json"));
4371
4473
  }
4372
- function getTodosDir(env = process.env) {
4373
- const resolved = resolverHome(env);
4374
- return resolve2(adoptResolverHome(resolved, env) ? resolved : legacyHomeDir(env));
4474
+ function getTodosDir(env2 = process.env) {
4475
+ const resolved = resolverHome(env2);
4476
+ return resolve2(adoptResolverHome(resolved, env2) ? resolved : legacyHomeDir(env2));
4375
4477
  }
4478
+ var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE;
4376
4479
  var init_paths = __esm(() => {
4377
- init_dist();
4480
+ PATHS_RESOLVER_KIND_ENV = {
4481
+ config: "HASNA_CONFIG_HOME",
4482
+ data: "HASNA_DATA_HOME",
4483
+ state: "HASNA_STATE_HOME",
4484
+ cache: "HASNA_CACHE_HOME"
4485
+ };
4486
+ PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4378
4487
  });
4379
4488
 
4380
4489
  // src/lib/sync-utils.ts
4381
4490
  import { existsSync as existsSync3, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4382
4491
  import { createHash } from "crypto";
4383
- import { homedir as homedir3 } from "os";
4384
- import { join as join3 } from "path";
4492
+ import { homedir as homedir2 } from "os";
4493
+ import { join as join2 } from "path";
4385
4494
  function getHomeDir() {
4386
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir3();
4495
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
4387
4496
  }
4388
4497
  function getTodosGlobalDir() {
4389
4498
  return getTodosDir();
@@ -4409,14 +4518,14 @@ function writeJsonFile(path, data) {
4409
4518
  `);
4410
4519
  }
4411
4520
  function readHighWaterMark(dir) {
4412
- const path = join3(dir, ".highwatermark");
4521
+ const path = join2(dir, ".highwatermark");
4413
4522
  if (!existsSync3(path))
4414
4523
  return 1;
4415
4524
  const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
4416
4525
  return isNaN(val) ? 1 : val;
4417
4526
  }
4418
4527
  function writeHighWaterMark(dir, value) {
4419
- writeFileSync(join3(dir, ".highwatermark"), String(value));
4528
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
4420
4529
  }
4421
4530
  function getFileMtimeMs(path) {
4422
4531
  try {
@@ -4576,7 +4685,7 @@ __export(exports_database, {
4576
4685
  });
4577
4686
  import { Database } from "bun:sqlite";
4578
4687
  import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
4579
- import { dirname, join as join4, resolve as resolve3 } from "path";
4688
+ import { dirname, join as join3, resolve as resolve3 } from "path";
4580
4689
  function isInMemoryDb(path) {
4581
4690
  return path === ":memory:" || path.startsWith("file::memory:");
4582
4691
  }
@@ -4585,7 +4694,7 @@ function findNearestProjectDb(startDir) {
4585
4694
  const stopAt = gitRoot ? resolve3(gitRoot) : resolve3(startDir);
4586
4695
  let dir = resolve3(startDir);
4587
4696
  while (true) {
4588
- const candidate = join4(dir, ".hasna", "todos", "todos.db");
4697
+ const candidate = join3(dir, ".hasna", "todos", "todos.db");
4589
4698
  if (existsSync4(candidate))
4590
4699
  return candidate;
4591
4700
  if (dir === stopAt)
@@ -4600,7 +4709,7 @@ function findNearestProjectDb(startDir) {
4600
4709
  function findGitRoot(startDir) {
4601
4710
  let dir = resolve3(startDir);
4602
4711
  while (true) {
4603
- if (existsSync4(join4(dir, ".git")))
4712
+ if (existsSync4(join3(dir, ".git")))
4604
4713
  return dir;
4605
4714
  const parent = dirname(dir);
4606
4715
  if (parent === dir)
@@ -4610,7 +4719,7 @@ function findGitRoot(startDir) {
4610
4719
  return null;
4611
4720
  }
4612
4721
  function getGlobalDbPath() {
4613
- return join4(getTodosGlobalDir(), "todos.db");
4722
+ return join3(getTodosGlobalDir(), "todos.db");
4614
4723
  }
4615
4724
  function hasExplicitProjectArg(args = process.argv.slice(2)) {
4616
4725
  return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
@@ -4648,7 +4757,7 @@ function getDbPath() {
4648
4757
  if (process.env["TODOS_DB_SCOPE"] === "project") {
4649
4758
  const gitRoot = findGitRoot(cwd);
4650
4759
  if (gitRoot && canCreateScopedProjectDb()) {
4651
- return join4(gitRoot, ".hasna", "todos", "todos.db");
4760
+ return join3(gitRoot, ".hasna", "todos", "todos.db");
4652
4761
  }
4653
4762
  }
4654
4763
  return getGlobalDbPath();
@@ -4689,6 +4798,9 @@ function maybeInstallShadowCapture(db) {
4689
4798
  }
4690
4799
  }
4691
4800
  function getDatabase(dbPath) {
4801
+ if (dbPath === undefined && !selectsTodosLocalStore()) {
4802
+ throw new Error("API_DATABASE_FALLBACK_FORBIDDEN: this operation must use the shared Todos API; implicit SQLite access is unavailable");
4803
+ }
4692
4804
  const path = dbPath || getDbPath();
4693
4805
  if (_db && _dbPath === path)
4694
4806
  return _db;
@@ -4828,6 +4940,7 @@ function lowerInClause(column, values, params) {
4828
4940
  }
4829
4941
  var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, ALLOWED_TABLES;
4830
4942
  var init_database = __esm(() => {
4943
+ init_local_opt_in();
4831
4944
  init_schema();
4832
4945
  init_machines();
4833
4946
  init_identity_mapping();
@@ -4838,9 +4951,9 @@ var init_database = __esm(() => {
4838
4951
 
4839
4952
  // src/lib/config.ts
4840
4953
  import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
4841
- import { dirname as dirname2, join as join5 } from "path";
4954
+ import { dirname as dirname2, join as join4 } from "path";
4842
4955
  function getConfigPath() {
4843
- return join5(getTodosGlobalDir(), "config.json");
4956
+ return join4(getTodosGlobalDir(), "config.json");
4844
4957
  }
4845
4958
  function normalizeAgent(agent) {
4846
4959
  return agent.trim().toLowerCase();
@@ -4891,21 +5004,6 @@ function normalizeApiUrl(value) {
4891
5004
  return null;
4892
5005
  return trimmed.replace(/\/+$/, "");
4893
5006
  }
4894
- function getLocalApiConfig(env = process.env) {
4895
- const config = loadConfig();
4896
- const envApiUrl = normalizeApiUrl(env["TODOS_URL"]);
4897
- const configApiUrl = normalizeApiUrl(config.apiUrl);
4898
- const apiUrl = envApiUrl ?? configApiUrl;
4899
- const apiKey = env["TODOS_API_KEY"] || config.apiKey || null;
4900
- return {
4901
- apiUrl,
4902
- apiKey,
4903
- source: {
4904
- apiUrl: envApiUrl ? "TODOS_URL" : configApiUrl ? "config" : "none",
4905
- apiKey: env["TODOS_API_KEY"] ? "TODOS_API_KEY" : config.apiKey ? "config" : "none"
4906
- }
4907
- };
4908
- }
4909
5007
  function getSyncAgentsFromConfig() {
4910
5008
  const config = loadConfig();
4911
5009
  const agents = config.sync_agents;
@@ -5756,11 +5854,11 @@ function writeAllowed(profile, root, writePath) {
5756
5854
  return isPathInside(scopeRoot, target);
5757
5855
  });
5758
5856
  }
5759
- function redactedEnvKeys(profile, env) {
5760
- if (!env)
5857
+ function redactedEnvKeys(profile, env2) {
5858
+ if (!env2)
5761
5859
  return [];
5762
5860
  const patterns = unique2([...DEFAULT_ENV_REDACTIONS, ...profile.env_redactions]).map((item) => item.toUpperCase());
5763
- return Object.keys(env).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5861
+ return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5764
5862
  }
5765
5863
  function checkWorkspacePermission(input = {}) {
5766
5864
  const status = getWorkspaceTrustStatus(input.path || process.cwd());
@@ -5898,18 +5996,18 @@ function profileByName(name, path) {
5898
5996
  const resolved = normalizePath2(path);
5899
5997
  return profiles.find((profile) => isPathInside2(profile.root, resolved)) || defaultProfile2("default", resolved);
5900
5998
  }
5901
- function redactedEnvKeys2(profile, env) {
5902
- if (!env)
5999
+ function redactedEnvKeys2(profile, env2) {
6000
+ if (!env2)
5903
6001
  return [];
5904
6002
  const patterns = unique3([...DEFAULT_ENV_REDACTIONS2, ...profile.env_redactions]).map((item) => item.toUpperCase());
5905
- return Object.keys(env).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
6003
+ return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5906
6004
  }
5907
- function omittedEnvKeys(profile, env) {
5908
- if (!env)
6005
+ function omittedEnvKeys(profile, env2) {
6006
+ if (!env2)
5909
6007
  return [];
5910
6008
  if (profile.env_allowlist.includes("*"))
5911
6009
  return [];
5912
- return Object.keys(env).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
6010
+ return Object.keys(env2).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
5913
6011
  }
5914
6012
  function resolveFromCwd(cwd, path) {
5915
6013
  return normalizePath2(path.startsWith("/") ? path : `${cwd}/${path}`);
@@ -6292,15 +6390,23 @@ var init_event_hooks = __esm(() => {
6292
6390
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
6293
6391
  });
6294
6392
 
6295
- // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
6393
+ // node_modules/.bun/@hasna+events@0.1.17/node_modules/@hasna/events/dist/index.js
6296
6394
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6297
6395
  import { Buffer as Buffer2 } from "buffer";
6396
+ import { existsSync as existsSync22 } from "fs";
6397
+ import { join as join32 } from "path";
6298
6398
  import { existsSync as existsSync6 } from "fs";
6299
- import { homedir as homedir4 } from "os";
6300
- import { join as join6 } from "path";
6399
+ import { homedir as homedir22 } from "os";
6400
+ import { join as join22, resolve as resolve8 } from "path";
6401
+ import { homedir as homedir3 } from "os";
6402
+ import { join as join5 } from "path";
6301
6403
  import { createHmac, timingSafeEqual } from "crypto";
6404
+ import { lookup as dnsLookup } from "dns/promises";
6405
+ import { isIP } from "net";
6302
6406
  import { randomUUID as randomUUID2 } from "crypto";
6303
6407
  import { spawn } from "child_process";
6408
+ import { request as nodeHttpRequest } from "http";
6409
+ import { request as nodeHttpsRequest } from "https";
6304
6410
  import { randomUUID as randomUUID22 } from "crypto";
6305
6411
  function getPathValue(input, path) {
6306
6412
  return path.split(".").reduce((value, part) => {
@@ -6397,8 +6503,93 @@ function channelMatchesEvent(channel, event) {
6397
6503
  return true;
6398
6504
  return channel.filters.some((filter) => eventMatchesFilter(event, filter));
6399
6505
  }
6506
+ function assertApp(app) {
6507
+ if (typeof app !== "string" || app.length === 0) {
6508
+ throw new TypeError("paths: app must be a non-empty string");
6509
+ }
6510
+ if (!APP_SLUG_RE.test(app)) {
6511
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
6512
+ }
6513
+ }
6514
+ function envOf(options) {
6515
+ return options.env ?? process.env;
6516
+ }
6517
+ function envValue(options, kind) {
6518
+ const value = envOf(options)[KIND_ENV[kind]];
6519
+ return typeof value === "string" && value.length > 0 ? value : undefined;
6520
+ }
6521
+ function isMacOS(platform) {
6522
+ return platform === "darwin";
6523
+ }
6524
+ function baseDir(kind, options) {
6525
+ const override = envValue(options, kind);
6526
+ if (override)
6527
+ return override;
6528
+ const home = options.home ?? homedir3();
6529
+ const platform = options.platform ?? process.platform;
6530
+ if (isMacOS(platform)) {
6531
+ switch (kind) {
6532
+ case "config":
6533
+ case "data":
6534
+ return join5(home, "Library", "Application Support", "Hasna");
6535
+ case "cache":
6536
+ return join5(home, "Library", "Caches", "Hasna");
6537
+ case "state":
6538
+ return join5(home, "Library", "Logs", "Hasna");
6539
+ }
6540
+ }
6541
+ switch (kind) {
6542
+ case "config":
6543
+ return join5(home, ".config", "hasna");
6544
+ case "data":
6545
+ return join5(home, ".local", "share", "hasna");
6546
+ case "state":
6547
+ return join5(home, ".local", "state", "hasna");
6548
+ case "cache":
6549
+ return join5(home, ".cache", "hasna");
6550
+ }
6551
+ }
6552
+ function resolvePath(kind, options) {
6553
+ assertApp(options.app);
6554
+ const appSegment = options.internal === true ? join5("internal", options.app) : options.app;
6555
+ return join5(baseDir(kind, options), appSegment);
6556
+ }
6557
+ function dataDir2(options) {
6558
+ return resolvePath("data", options);
6559
+ }
6560
+ function effectiveHome2() {
6561
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir22();
6562
+ }
6563
+ function legacyHomeDir2() {
6564
+ return join22(effectiveHome2(), ".hasna", "events");
6565
+ }
6566
+ function resolverHome2() {
6567
+ return dataDir2({ app: "events", home: effectiveHome2() || undefined });
6568
+ }
6569
+ function adoptResolverHome2(resolved, env2 = process.env) {
6570
+ const dataOverride = env2.HASNA_DATA_HOME;
6571
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
6572
+ return true;
6573
+ return existsSync6(join22(resolved, EVENTS_STORE_SENTINEL_FILE));
6574
+ }
6575
+ function exactEventsHome() {
6576
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
6577
+ if (dir && dir.trim())
6578
+ return dir.trim();
6579
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
6580
+ if (home && home.trim())
6581
+ return home.trim();
6582
+ return;
6583
+ }
6584
+ function getEventsHome() {
6585
+ const exact = exactEventsHome();
6586
+ if (exact)
6587
+ return resolve8(exact);
6588
+ const resolved = resolverHome2();
6589
+ return adoptResolverHome2(resolved) ? resolve8(resolved) : resolve8(legacyHomeDir2());
6590
+ }
6400
6591
  function getEventsDataDir(override) {
6401
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join6(homedir4(), ".hasna", "events");
6592
+ return override || getEventsHome();
6402
6593
  }
6403
6594
 
6404
6595
  class JsonEventsStore {
@@ -6407,12 +6598,12 @@ class JsonEventsStore {
6407
6598
  channelsPath;
6408
6599
  eventsPath;
6409
6600
  deliveriesPath;
6410
- constructor(dataDir2 = getEventsDataDir()) {
6411
- this.dataDir = dataDir2;
6412
- this.runtime = localJsonRuntime(dataDir2);
6413
- this.channelsPath = join6(dataDir2, "channels.json");
6414
- this.eventsPath = join6(dataDir2, "events.json");
6415
- this.deliveriesPath = join6(dataDir2, "deliveries.json");
6601
+ constructor(dataDir22 = getEventsDataDir()) {
6602
+ this.dataDir = dataDir22;
6603
+ this.runtime = localJsonRuntime(dataDir22);
6604
+ this.channelsPath = join32(dataDir22, "channels.json");
6605
+ this.eventsPath = join32(dataDir22, "events.json");
6606
+ this.deliveriesPath = join32(dataDir22, "deliveries.json");
6416
6607
  }
6417
6608
  async init() {
6418
6609
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -6529,7 +6720,7 @@ class JsonEventsStore {
6529
6720
  };
6530
6721
  }
6531
6722
  async ensureArrayFile(path) {
6532
- if (!existsSync6(path)) {
6723
+ if (!existsSync22(path)) {
6533
6724
  await writeFile(path, `[]
6534
6725
  `, { encoding: "utf-8", mode: 384 });
6535
6726
  }
@@ -6559,7 +6750,7 @@ class JsonEventsStore {
6559
6750
  });
6560
6751
  }
6561
6752
  }
6562
- function localJsonRuntime(dataDir2 = getEventsDataDir()) {
6753
+ function localJsonRuntime(dataDir22 = getEventsDataDir()) {
6563
6754
  return {
6564
6755
  mode: "local-files",
6565
6756
  name: "json-events-store",
@@ -6572,7 +6763,7 @@ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
6572
6763
  durable: true,
6573
6764
  idempotency: "best-effort-local",
6574
6765
  replayCursors: true,
6575
- description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6766
+ description: `Local JSON files in ${dataDir22}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6576
6767
  };
6577
6768
  }
6578
6769
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -6643,6 +6834,177 @@ function signPayload(secret, timestamp2, body) {
6643
6834
  const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp2, body)).digest("hex");
6644
6835
  return `sha256=${digest}`;
6645
6836
  }
6837
+ function isPrivateAddress(address) {
6838
+ const normalized = stripZoneId(address);
6839
+ const version = isIP(normalized);
6840
+ if (version === 4) {
6841
+ const integer = ipv4ToInt(normalized);
6842
+ if (integer === undefined)
6843
+ return true;
6844
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
6845
+ }
6846
+ if (version === 6) {
6847
+ const groups = ipv6Groups(normalized);
6848
+ if (!groups)
6849
+ return true;
6850
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
6851
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
6852
+ continue;
6853
+ if (prefix.bits === 96 && groups[5] === 65535) {
6854
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
6855
+ }
6856
+ if (prefix.bits === 16 && groups[0] === 8194) {
6857
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
6858
+ }
6859
+ return true;
6860
+ }
6861
+ return false;
6862
+ }
6863
+ return true;
6864
+ }
6865
+ async function resolveWebhookTarget(url, policy = {}) {
6866
+ const hostname = normalizeHostname(url.hostname);
6867
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
6868
+ if (allowlist.includes(hostname)) {
6869
+ const version2 = isIP(hostname);
6870
+ if (version2 === 4 || version2 === 6) {
6871
+ return { hostname, addresses: [hostname] };
6872
+ }
6873
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
6874
+ let resolved2;
6875
+ try {
6876
+ resolved2 = await lookup2(hostname);
6877
+ } catch {
6878
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
6879
+ }
6880
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
6881
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
6882
+ }
6883
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
6884
+ return { hostname, addresses };
6885
+ }
6886
+ const version = isIP(hostname);
6887
+ if (version === 4 || version === 6) {
6888
+ if (isPrivateAddress(hostname)) {
6889
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
6890
+ }
6891
+ return { hostname, addresses: [hostname] };
6892
+ }
6893
+ const lookup = policy.lookup ?? defaultTargetLookup;
6894
+ let resolved;
6895
+ try {
6896
+ resolved = await lookup(hostname);
6897
+ } catch {
6898
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
6899
+ }
6900
+ if (!Array.isArray(resolved) || resolved.length === 0) {
6901
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
6902
+ }
6903
+ const allowed = [];
6904
+ for (const entry of resolved) {
6905
+ const address = normalizeHostname(entry.address);
6906
+ if (isPrivateAddress(address)) {
6907
+ if (allowlist.includes(address)) {
6908
+ allowed.push(address);
6909
+ continue;
6910
+ }
6911
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
6912
+ }
6913
+ allowed.push(address);
6914
+ }
6915
+ if (allowed.length === 0) {
6916
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
6917
+ }
6918
+ return { hostname, addresses: allowed };
6919
+ }
6920
+ function normalizeMaxRedirects(value) {
6921
+ if (value === undefined)
6922
+ return DEFAULT_MAX_REDIRECTS;
6923
+ if (!Number.isInteger(value) || value < 0)
6924
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
6925
+ return value;
6926
+ }
6927
+ function normalizeHostname(hostname) {
6928
+ const lower = hostname.toLowerCase();
6929
+ if (lower.startsWith("[") && lower.endsWith("]"))
6930
+ return lower.slice(1, -1);
6931
+ return lower;
6932
+ }
6933
+ function stripZoneId(address) {
6934
+ const percent = address.indexOf("%");
6935
+ return percent === -1 ? address : address.slice(0, percent);
6936
+ }
6937
+ function ipv4ToInt(address) {
6938
+ const parts = address.split(".");
6939
+ if (parts.length !== 4)
6940
+ return;
6941
+ let value = 0;
6942
+ for (const part of parts) {
6943
+ if (!/^\d{1,3}$/.test(part))
6944
+ return;
6945
+ const octet = Number(part);
6946
+ if (octet > 255)
6947
+ return;
6948
+ value = value << 8 | octet;
6949
+ }
6950
+ return value >>> 0;
6951
+ }
6952
+ function ipv4IntToString(integer) {
6953
+ return [
6954
+ integer >>> 24 & 255,
6955
+ integer >>> 16 & 255,
6956
+ integer >>> 8 & 255,
6957
+ integer & 255
6958
+ ].join(".");
6959
+ }
6960
+ function ipv6Groups(address) {
6961
+ const raw = stripZoneId(address);
6962
+ const doubleColon = raw.indexOf("::");
6963
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
6964
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
6965
+ const parseGroups = (text) => {
6966
+ if (text === "")
6967
+ return [];
6968
+ const out = [];
6969
+ for (const part of text.split(":")) {
6970
+ if (part.includes(".")) {
6971
+ const v4 = ipv4ToInt(part);
6972
+ if (v4 === undefined)
6973
+ return;
6974
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
6975
+ } else {
6976
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
6977
+ return;
6978
+ out.push(parseInt(part, 16));
6979
+ }
6980
+ }
6981
+ return out;
6982
+ };
6983
+ const head = parseGroups(headText);
6984
+ if (!head)
6985
+ return;
6986
+ const tail = parseGroups(tailText);
6987
+ if (!tail)
6988
+ return;
6989
+ const total = head.length + tail.length;
6990
+ if (doubleColon === -1) {
6991
+ return total === 8 ? head : undefined;
6992
+ }
6993
+ if (total >= 8)
6994
+ return;
6995
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
6996
+ }
6997
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
6998
+ let remaining = prefixBits;
6999
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
7000
+ const take = Math.min(16, remaining);
7001
+ const mask = 65535 << 16 - take & 65535;
7002
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
7003
+ return false;
7004
+ remaining -= take;
7005
+ }
7006
+ return true;
7007
+ }
6646
7008
  function now2() {
6647
7009
  return new Date().toISOString();
6648
7010
  }
@@ -6673,9 +7035,18 @@ function buildWebhookRequest(event, channel, options = {}) {
6673
7035
  }
6674
7036
  return { body, headers };
6675
7037
  }
7038
+ function normalizeWebhookUrl(raw) {
7039
+ const url = new URL(raw);
7040
+ if (url.username !== "" || url.password !== "") {
7041
+ url.username = "";
7042
+ url.password = "";
7043
+ }
7044
+ return url.toString();
7045
+ }
6676
7046
  async function dispatchWebhook(event, channel, options = {}) {
6677
7047
  if (!channel.webhook)
6678
7048
  throw new Error(`Channel ${channel.id} has no webhook config`);
7049
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
6679
7050
  const startedAt = now2();
6680
7051
  let secret = channel.webhook.secret;
6681
7052
  if (channel.webhook.secretRef) {
@@ -6692,10 +7063,14 @@ async function dispatchWebhook(event, channel, options = {}) {
6692
7063
  }
6693
7064
  const timestamp2 = (options.now?.() ?? new Date).toISOString();
6694
7065
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
7066
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
7067
+ if (validateTargets) {
7068
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
7069
+ }
6695
7070
  const controller = new AbortController;
6696
7071
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
6697
7072
  try {
6698
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
7073
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
6699
7074
  method: "POST",
6700
7075
  headers,
6701
7076
  body,
@@ -6723,6 +7098,130 @@ async function dispatchWebhook(event, channel, options = {}) {
6723
7098
  clearTimeout(timeout);
6724
7099
  }
6725
7100
  }
7101
+ function isRedirectStatus(status) {
7102
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
7103
+ }
7104
+ function redirectKeepsBody(status) {
7105
+ return status === 307 || status === 308;
7106
+ }
7107
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
7108
+ const isHttps = target.protocol === "https:";
7109
+ if (!isHttps && target.protocol !== "http:") {
7110
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
7111
+ }
7112
+ const defaultPort = isHttps ? 443 : 80;
7113
+ const port = target.port ? Number(target.port) : defaultPort;
7114
+ const requestOptions = {
7115
+ hostname: target.hostname,
7116
+ port,
7117
+ path: `${target.pathname}${target.search}`,
7118
+ method,
7119
+ headers,
7120
+ ...tls?.ca ? { ca: tls.ca } : {},
7121
+ lookup: (hostname, _options, callback) => {
7122
+ const entries = addresses.map((address) => ({
7123
+ address,
7124
+ family: address.includes(":") ? 6 : 4
7125
+ }));
7126
+ callback(null, entries);
7127
+ }
7128
+ };
7129
+ return new Promise((resolve22, reject) => {
7130
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
7131
+ const onAbort = () => {
7132
+ const error = new Error("The operation was aborted.");
7133
+ error.name = "AbortError";
7134
+ request.destroy(error);
7135
+ };
7136
+ if (signal.aborted)
7137
+ onAbort();
7138
+ else
7139
+ signal.addEventListener("abort", onAbort, { once: true });
7140
+ request.on("error", reject);
7141
+ if (body !== undefined)
7142
+ request.write(body);
7143
+ request.end();
7144
+ function onResponse(response) {
7145
+ const chunks = [];
7146
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
7147
+ response.on("error", reject);
7148
+ response.on("end", () => {
7149
+ const headersRecord = {};
7150
+ for (const [name, value] of Object.entries(response.headers)) {
7151
+ if (typeof value === "string")
7152
+ headersRecord[name] = value;
7153
+ else if (Array.isArray(value))
7154
+ headersRecord[name] = value.join(", ");
7155
+ }
7156
+ resolve22(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
7157
+ });
7158
+ }
7159
+ });
7160
+ }
7161
+ async function dispatchValidatedWebhook(event, channel, input) {
7162
+ const { body, headers, startedAt, options } = input;
7163
+ const webhook = channel.webhook;
7164
+ if (!webhook)
7165
+ throw new Error(`Channel ${channel.id} has no webhook config`);
7166
+ const policy = options.webhookTargetPolicy ?? {};
7167
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
7168
+ const controller = new AbortController;
7169
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
7170
+ try {
7171
+ let target = new URL(normalizeWebhookUrl(webhook.url));
7172
+ let requestHeaders = headers;
7173
+ let method = "POST";
7174
+ let requestBody = body;
7175
+ let redirectsFollowed = 0;
7176
+ for (;; ) {
7177
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
7178
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
7179
+ });
7180
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
7181
+ method,
7182
+ headers: requestHeaders,
7183
+ body: requestBody,
7184
+ signal: controller.signal,
7185
+ redirect: "manual"
7186
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
7187
+ const location = response.headers.get("location");
7188
+ if (isRedirectStatus(response.status) && location) {
7189
+ if (redirectsFollowed >= maxRedirects) {
7190
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
7191
+ }
7192
+ redirectsFollowed += 1;
7193
+ const next = new URL(location, target);
7194
+ target = next;
7195
+ if (!redirectKeepsBody(response.status)) {
7196
+ method = "GET";
7197
+ requestBody = undefined;
7198
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
7199
+ }
7200
+ continue;
7201
+ }
7202
+ const responseBody = truncate(await response.text());
7203
+ return {
7204
+ attempt: 1,
7205
+ status: response.ok ? "success" : "failed",
7206
+ startedAt,
7207
+ completedAt: now2(),
7208
+ responseStatus: response.status,
7209
+ responseBody,
7210
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
7211
+ };
7212
+ }
7213
+ } catch (error) {
7214
+ return {
7215
+ attempt: 1,
7216
+ status: "failed",
7217
+ startedAt,
7218
+ completedAt: now2(),
7219
+ error: error instanceof Error ? error.message : String(error)
7220
+ };
7221
+ } finally {
7222
+ clearTimeout(timeout);
7223
+ }
7224
+ }
6726
7225
  function failedAttempt(startedAt, error) {
6727
7226
  return {
6728
7227
  attempt: 1,
@@ -6737,7 +7236,7 @@ async function dispatchCommand(event, channel) {
6737
7236
  throw new Error(`Channel ${channel.id} has no command config`);
6738
7237
  const startedAt = now2();
6739
7238
  const eventJson = JSON.stringify(event);
6740
- const env = {
7239
+ const env2 = {
6741
7240
  ...process.env,
6742
7241
  ...channel.command.env,
6743
7242
  HASNA_CHANNEL_ID: channel.id,
@@ -6751,10 +7250,10 @@ async function dispatchCommand(event, channel) {
6751
7250
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
6752
7251
  HASNA_EVENT_JSON: eventJson
6753
7252
  };
6754
- return new Promise((resolve8) => {
7253
+ return new Promise((resolve22) => {
6755
7254
  const child = spawn(channel.command.command, channel.command.args ?? [], {
6756
7255
  cwd: channel.command.cwd,
6757
- env,
7256
+ env: env2,
6758
7257
  stdio: ["pipe", "pipe", "pipe"]
6759
7258
  });
6760
7259
  let stdout = "";
@@ -6769,7 +7268,7 @@ async function dispatchCommand(event, channel) {
6769
7268
  });
6770
7269
  child.on("error", (error) => {
6771
7270
  clearTimeout(timeout);
6772
- resolve8({
7271
+ resolve22({
6773
7272
  attempt: 1,
6774
7273
  status: "failed",
6775
7274
  startedAt,
@@ -6782,7 +7281,7 @@ async function dispatchCommand(event, channel) {
6782
7281
  child.on("close", (code, signal) => {
6783
7282
  clearTimeout(timeout);
6784
7283
  const success = code === 0;
6785
- resolve8({
7284
+ resolve22({
6786
7285
  attempt: 1,
6787
7286
  status: success ? "success" : "failed",
6788
7287
  startedAt,
@@ -6918,7 +7417,9 @@ class EventsClient {
6918
7417
  this.transportOptions = {
6919
7418
  fetchImpl: options.fetchImpl,
6920
7419
  secretResolver: options.secretResolver,
6921
- now: options.now
7420
+ now: options.now,
7421
+ tls: options.tls,
7422
+ webhookTargetPolicy: options.webhookTargetPolicy
6922
7423
  };
6923
7424
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
6924
7425
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -7123,9 +7624,51 @@ function normalizeRetryPolicy(policy) {
7123
7624
  multiplier: Math.max(1, policy?.multiplier ?? 2)
7124
7625
  };
7125
7626
  }
7126
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
7127
- var init_dist2 = __esm(() => {
7627
+ var KIND_ENV, APP_SLUG_RE, HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", EVENTS_STORE_SENTINEL_FILE = "events.json", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, DEFAULT_MAX_REDIRECTS = 5, IPV4_PRIVATE_RANGES, IPV6_SPECIAL_PREFIXES, defaultTargetLookup = async (hostname) => {
7628
+ return dnsLookup(hostname, { all: true, verbatim: false });
7629
+ }, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
7630
+ var init_dist = __esm(() => {
7631
+ KIND_ENV = {
7632
+ config: "HASNA_CONFIG_HOME",
7633
+ data: "HASNA_DATA_HOME",
7634
+ state: "HASNA_STATE_HOME",
7635
+ cache: "HASNA_CACHE_HOME"
7636
+ };
7637
+ APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7128
7638
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7639
+ IPV4_PRIVATE_RANGES = [
7640
+ [0, 16777215],
7641
+ [167772160, 184549375],
7642
+ [1681915904, 1686110207],
7643
+ [2130706432, 2147483647],
7644
+ [2851995648, 2852061183],
7645
+ [2886729728, 2887778303],
7646
+ [3221225472, 3221225727],
7647
+ [3221225984, 3221226239],
7648
+ [3227017984, 3227018239],
7649
+ [3232235520, 3232301055],
7650
+ [3323068416, 3323199487],
7651
+ [3325256704, 3325256959],
7652
+ [3405803776, 3405804031],
7653
+ [3758096384, 4294967295]
7654
+ ];
7655
+ IPV6_SPECIAL_PREFIXES = [
7656
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
7657
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
7658
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
7659
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
7660
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
7661
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
7662
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
7663
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
7664
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
7665
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
7666
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
7667
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
7668
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
7669
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
7670
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
7671
+ ];
7129
7672
  EventValidationError = class EventValidationError extends Error {
7130
7673
  eventType;
7131
7674
  issues;
@@ -7149,6 +7692,8 @@ function rowToTaskList(row) {
7149
7692
  };
7150
7693
  }
7151
7694
  function createTaskList(input, db) {
7695
+ if (input.status !== undefined)
7696
+ throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
7152
7697
  const d = db || getDatabase();
7153
7698
  return d.transaction(() => {
7154
7699
  const id = uuid();
@@ -7190,6 +7735,8 @@ function listTaskLists(projectId, db) {
7190
7735
  return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
7191
7736
  }
7192
7737
  function updateTaskList(id, input, db) {
7738
+ if (input.status !== undefined)
7739
+ throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
7193
7740
  const d = db || getDatabase();
7194
7741
  return d.transaction(() => {
7195
7742
  const existing = getTaskList(id, d);
@@ -7547,7 +8094,7 @@ function emitSharedTaskEventQuiet(input) {
7547
8094
  }
7548
8095
  var SOURCE = "todos";
7549
8096
  var init_shared_events = __esm(() => {
7550
- init_dist2();
8097
+ init_dist();
7551
8098
  init_database();
7552
8099
  init_projects();
7553
8100
  init_task_lists();
@@ -11188,7 +11735,13 @@ function resolveCreateSlug(input, projectId, db) {
11188
11735
  }
11189
11736
  return nextPlanSlug(planSlugBase2(input.name), projectId, db);
11190
11737
  }
11738
+ function assertLegacyPlanInput(input) {
11739
+ if (input.start_date !== undefined || input.end_date !== undefined || input.status === "planning" || input.status === "cancelled") {
11740
+ throw new Error("Plan calendar dates and planning/cancelled statuses require the shared Todos API; explicit SQLite storage cannot retain them");
11741
+ }
11742
+ }
11191
11743
  function createPlan(input, db) {
11744
+ assertLegacyPlanInput(input);
11192
11745
  const d = db || getDatabase();
11193
11746
  const id = uuid();
11194
11747
  const timestamp2 = now();
@@ -11269,6 +11822,7 @@ function updatePlanStored(id, input, db) {
11269
11822
  return updated;
11270
11823
  }
11271
11824
  function updatePlan(id, input, db) {
11825
+ assertLegacyPlanInput(input);
11272
11826
  const d = db || getDatabase();
11273
11827
  return d.transaction(() => {
11274
11828
  guardPlanRowsSqlite([id], d);
@@ -11743,27 +12297,27 @@ var init_boards = __esm(() => {
11743
12297
  // src/lib/artifact-store.ts
11744
12298
  import { createHash as createHash3 } from "crypto";
11745
12299
  import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
11746
- import { basename, dirname as dirname4, join as join7, resolve as resolve8 } from "path";
12300
+ import { basename, dirname as dirname4, join as join6, resolve as resolve9 } from "path";
11747
12301
  import { tmpdir as tmpdir2 } from "os";
11748
12302
  function isInMemoryDb2(path) {
11749
12303
  return path === ":memory:" || path.startsWith("file::memory:");
11750
12304
  }
11751
12305
  function artifactStoreRoot() {
11752
12306
  if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
11753
- return resolve8(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
12307
+ return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
11754
12308
  if (process.env["TODOS_ARTIFACTS_DIR"])
11755
- return resolve8(process.env["TODOS_ARTIFACTS_DIR"]);
12309
+ return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
11756
12310
  const dbPath = getDatabasePath();
11757
12311
  if (isInMemoryDb2(dbPath))
11758
- return join7(tmpdir2(), "hasna-todos-artifacts");
11759
- return join7(dirname4(resolve8(dbPath)), "artifacts");
12312
+ return join6(tmpdir2(), "hasna-todos-artifacts");
12313
+ return join6(dirname4(resolve9(dbPath)), "artifacts");
11760
12314
  }
11761
12315
  function artifactStorePath(relativePath) {
11762
12316
  const normalized = relativePath.replace(/\\/g, "/");
11763
12317
  if (normalized.includes("..") || normalized.startsWith("/") || normalized.length === 0) {
11764
12318
  throw new Error("Invalid artifact store path");
11765
12319
  }
11766
- return join7(artifactStoreRoot(), normalized);
12320
+ return join6(artifactStoreRoot(), normalized);
11767
12321
  }
11768
12322
  function sha256(buffer) {
11769
12323
  return createHash3("sha256").update(buffer).digest("hex");
@@ -11803,7 +12357,7 @@ function mediaTypeFor(path, textLike) {
11803
12357
  return "application/octet-stream";
11804
12358
  }
11805
12359
  function storeArtifactContent(input) {
11806
- const sourcePath = resolve8(input.path);
12360
+ const sourcePath = resolve9(input.path);
11807
12361
  if (!existsSync8(sourcePath))
11808
12362
  return null;
11809
12363
  const sourceStat = statSync2(sourcePath);
@@ -11821,7 +12375,7 @@ function storeArtifactContent(input) {
11821
12375
  redactionStatus = "redacted";
11822
12376
  }
11823
12377
  const storedSha = sha256(storedBuffer);
11824
- const relativePath = join7("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
12378
+ const relativePath = join6("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
11825
12379
  const destination = artifactStorePath(relativePath);
11826
12380
  if (!existsSync8(destination)) {
11827
12381
  mkdirSync4(dirname4(destination), { recursive: true });
@@ -11958,19 +12512,19 @@ function importStoredArtifactContent(content) {
11958
12512
  }
11959
12513
  function getArtifactStoreRoot(dbPath) {
11960
12514
  if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
11961
- return resolve8(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
12515
+ return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
11962
12516
  if (process.env["TODOS_ARTIFACTS_DIR"])
11963
- return resolve8(process.env["TODOS_ARTIFACTS_DIR"]);
12517
+ return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
11964
12518
  const path = dbPath ?? getDatabasePath();
11965
12519
  if (isInMemoryDb2(path))
11966
- return join7(tmpdir2(), "hasna-todos-artifacts");
11967
- return join7(dirname4(resolve8(path)), "artifacts");
12520
+ return join6(tmpdir2(), "hasna-todos-artifacts");
12521
+ return join6(dirname4(resolve9(path)), "artifacts");
11968
12522
  }
11969
12523
  function computeContentHash(path) {
11970
- return sha256(readFileSync4(resolve8(path)));
12524
+ return sha256(readFileSync4(resolve9(path)));
11971
12525
  }
11972
12526
  function storeArtifactFile(input) {
11973
- const sourcePath = resolve8(input.sourcePath);
12527
+ const sourcePath = resolve9(input.sourcePath);
11974
12528
  if (!existsSync8(sourcePath)) {
11975
12529
  throw new Error(`Source file not found: ${input.sourcePath}`);
11976
12530
  }
@@ -11984,7 +12538,7 @@ function storeArtifactFile(input) {
11984
12538
  let localPath = sourcePath;
11985
12539
  if (storageMode === "copy") {
11986
12540
  const fileName = input.name && input.name.trim().length > 0 ? basename(input.name) : basename(sourcePath);
11987
- const destination = join7(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
12541
+ const destination = join6(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
11988
12542
  mkdirSync4(dirname4(destination), { recursive: true });
11989
12543
  writeFileSync2(destination, buffer);
11990
12544
  localPath = destination;
@@ -12019,7 +12573,7 @@ function buildArtifactExportManifest(artifacts, dbPath) {
12019
12573
  };
12020
12574
  }
12021
12575
  function writeArtifactExportManifest(manifest, outputPath) {
12022
- const destination = resolve8(outputPath);
12576
+ const destination = resolve9(outputPath);
12023
12577
  mkdirSync4(dirname4(destination), { recursive: true });
12024
12578
  writeFileSync2(destination, `${JSON.stringify(manifest, null, 2)}
12025
12579
  `);
@@ -12707,7 +13261,9 @@ function finishTaskRunTransaction(input, db) {
12707
13261
  const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
12708
13262
  const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
12709
13263
  if (!run) {
12710
- throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
13264
+ if (input.run_id)
13265
+ throw new InputValidationError(`Run not found: ${input.run_id}`, "Pass an existing run id, or --key.");
13266
+ throw new InputValidationError("runs finish requires a run id or --key", "Pass run_id, or key with task_id.");
12711
13267
  }
12712
13268
  if (input.task_id && run.task_id !== input.task_id) {
12713
13269
  throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
@@ -13692,9 +14248,9 @@ function normalizeGeneratedAgentNames(db) {
13692
14248
 
13693
14249
  // src/db/agents.ts
13694
14250
  function getActiveWindowMs() {
13695
- const env = process.env["TODOS_AGENT_TIMEOUT_MS"];
13696
- if (env) {
13697
- const parsed = parseInt(env, 10);
14251
+ const env2 = process.env["TODOS_AGENT_TIMEOUT_MS"];
14252
+ if (env2) {
14253
+ const parsed = parseInt(env2, 10);
13698
14254
  if (!isNaN(parsed) && parsed > 0)
13699
14255
  return parsed;
13700
14256
  }
@@ -14540,19 +15096,184 @@ function rollbackPlanProjectLinkSqlite(input, db) {
14540
15096
  })();
14541
15097
  }
14542
15098
 
14543
- // src/storage/sqlite-snapshot.ts
14544
- init_database();
14545
- init_audit();
14546
- init_plans();
14547
- init_projects();
14548
- init_task_lists();
14549
- init_tasks();
14550
- init_templates();
14551
- init_storage_tombstones();
14552
-
14553
- // src/storage/audit-history-import.ts
14554
- var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
14555
- var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
15099
+ // src/storage/machine-registry.ts
15100
+ import { randomUUID as randomUUID3 } from "crypto";
15101
+ import { isDeepStrictEqual } from "util";
15102
+ class MachineRegistryError extends Error {
15103
+ status;
15104
+ constructor(message, status = 409) {
15105
+ super(message);
15106
+ this.status = status;
15107
+ }
15108
+ }
15109
+ var fields = new Set(["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"]);
15110
+ function validateMachines(value) {
15111
+ if (!Array.isArray(value) || value.length > 1e4)
15112
+ throw new MachineRegistryError("machines must be an array of at most 10000 complete records", 400);
15113
+ for (const row of value) {
15114
+ if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some((key) => !fields.has(key)))
15115
+ throw new MachineRegistryError("Invalid machine record fields", 400);
15116
+ for (const key of ["id", "name", "last_seen_at", "created_at"])
15117
+ if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 1024)
15118
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15119
+ for (const key of ["hostname", "platform", "ssh_address", "archived_at"])
15120
+ if (row[key] !== null && typeof row[key] !== "string")
15121
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15122
+ for (const key of ["last_seen_at", "created_at", "archived_at"])
15123
+ if (row[key] !== null && !Number.isFinite(Date.parse(row[key])))
15124
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15125
+ if (typeof row.is_primary !== "boolean" || !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
15126
+ throw new MachineRegistryError("Invalid machine flags or metadata", 400);
15127
+ if (!isDeepStrictEqual(JSON.parse(JSON.stringify(row.metadata)), row.metadata))
15128
+ throw new MachineRegistryError("Machine metadata must preserve JSON values exactly", 400);
15129
+ if (JSON.stringify(row.metadata).length > 262144)
15130
+ throw new MachineRegistryError("Machine metadata is too large", 400);
15131
+ if (row.is_primary && row.archived_at !== null)
15132
+ throw new MachineRegistryError("An archived machine cannot be primary", 400);
15133
+ }
15134
+ return value;
15135
+ }
15136
+ function planMachineImport(existing, incoming) {
15137
+ const rows = validateMachines(incoming);
15138
+ const byId = new Map(existing.map((row) => [row.id, row]));
15139
+ const byName = new Map(existing.map((row) => [row.name, row.id]));
15140
+ const insert = [];
15141
+ let skipped = 0;
15142
+ for (const row of rows) {
15143
+ const prior = byId.get(row.id);
15144
+ if (prior && !isDeepStrictEqual(prior, row))
15145
+ throw new MachineRegistryError("Machine migration conflicts with an existing identity; reconcile before retrying");
15146
+ if (byName.has(row.name) && byName.get(row.name) !== row.id)
15147
+ throw new MachineRegistryError("Machine name belongs to a different identity; migration was not applied");
15148
+ if (prior)
15149
+ skipped++;
15150
+ else {
15151
+ insert.push(row);
15152
+ byId.set(row.id, row);
15153
+ byName.set(row.name, row.id);
15154
+ }
15155
+ }
15156
+ if ([...byId.values()].filter((row) => row.is_primary).length > 1)
15157
+ throw new MachineRegistryError("Machine migration would create multiple primary machines");
15158
+ return { rows: insert, skipped };
15159
+ }
15160
+ function createPostgresMachineRegistry(client, service, table, ensure) {
15161
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
15162
+ throw new Error("Invalid machine registry table");
15163
+ async function list(connection = client) {
15164
+ await ensure();
15165
+ const result = await connection.query(`SELECT payload FROM ${table} WHERE service=$1 AND object_type='machines' AND deleted_at IS NULL ORDER BY object_id`, [service]);
15166
+ return validateMachines(result.rows.map((row) => typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload));
15167
+ }
15168
+ return { list, async execute(input) {
15169
+ if (!input || typeof input !== "object" || Array.isArray(input) || !["register", "heartbeat", "set-primary", "archive", "unarchive", "delete", "import"].includes(input.action))
15170
+ throw new MachineRegistryError("Invalid machine operation", 400);
15171
+ if (Object.keys(input).some((key) => !["action", "name", "id", "options", "machines"].includes(key)))
15172
+ throw new MachineRegistryError("Unknown machine operation fields", 400);
15173
+ if (input.options !== undefined && (!input.options || typeof input.options !== "object" || Array.isArray(input.options)))
15174
+ throw new MachineRegistryError("Invalid machine options", 400);
15175
+ await ensure();
15176
+ if (!client.transaction)
15177
+ throw new MachineRegistryError("Machine registry requires a transactional server backend", 501);
15178
+ return client.transaction(async (tx) => {
15179
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${service}:machine-registry`]);
15180
+ const current = await list(tx);
15181
+ let changed = [];
15182
+ let skipped = 0;
15183
+ let machine;
15184
+ let deleted;
15185
+ const stamp = new Date().toISOString();
15186
+ if (input.action === "import") {
15187
+ const plan = planMachineImport(current, input.machines);
15188
+ const ids = plan.rows.map((row) => row.id);
15189
+ if (ids.length) {
15190
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id IN (SELECT jsonb_array_elements_text($2::text::jsonb)) AND deleted_at IS NOT NULL`, [service, JSON.stringify(ids)]);
15191
+ if (retired.rows.length)
15192
+ throw new MachineRegistryError("Machine migration contains a retired identity");
15193
+ }
15194
+ changed = plan.rows;
15195
+ skipped = plan.skipped;
15196
+ } else {
15197
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 1024)
15198
+ throw new MachineRegistryError("Machine name or ID is required", 400);
15199
+ const matches = current.filter((row) => row.name === input.name || row.id === input.name);
15200
+ if (matches.length > 1)
15201
+ throw new MachineRegistryError("Machine selector is ambiguous between a name and another identity; use an unambiguous selector");
15202
+ const prior = matches[0];
15203
+ if (input.action === "register" || input.action === "heartbeat") {
15204
+ const opts = input.options ?? {};
15205
+ const allowed = new Set(["hostname", "platform", "ssh_address", "primary", "tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"]);
15206
+ for (const [key, value] of Object.entries(opts)) {
15207
+ if (!allowed.has(key) || (key === "primary" ? typeof value !== "boolean" : typeof value !== "string" || value.length > 4096))
15208
+ throw new MachineRegistryError("Invalid machine registration options", 400);
15209
+ }
15210
+ if (input.id !== undefined && (typeof input.id !== "string" || !input.id.trim() || input.id.length > 1024))
15211
+ throw new MachineRegistryError("Invalid stable machine ID", 400);
15212
+ if (prior && input.id && input.id !== prior.id)
15213
+ throw new MachineRegistryError("Machine registration ID does not match existing identity");
15214
+ if (!prior && input.id && current.some((row) => row.id === input.id))
15215
+ throw new MachineRegistryError("Machine ID already belongs to another name");
15216
+ const metadata = { ...prior?.metadata };
15217
+ for (const key of ["tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"])
15218
+ if (opts[key] !== undefined)
15219
+ metadata[key] = opts[key];
15220
+ if (!prior && input.id) {
15221
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id=$2 AND deleted_at IS NOT NULL`, [service, input.id]);
15222
+ if (retired.rows.length)
15223
+ throw new MachineRegistryError("Machine identity has been retired");
15224
+ }
15225
+ machine = { id: prior?.id ?? input.id ?? randomUUID3(), name: prior?.name ?? input.name, hostname: opts.hostname ?? prior?.hostname ?? null, platform: opts.platform ?? prior?.platform ?? null, ssh_address: opts.ssh_address ?? prior?.ssh_address ?? null, created_at: prior?.created_at ?? stamp, last_seen_at: stamp, archived_at: prior?.archived_at ?? null, is_primary: opts.primary === true || (prior?.is_primary ?? false), metadata };
15226
+ if (machine.archived_at && machine.is_primary)
15227
+ throw new MachineRegistryError("Cannot set archived machine as primary");
15228
+ changed = [machine];
15229
+ if (opts.primary === true)
15230
+ changed.push(...current.filter((row) => row.is_primary && row.id !== machine.id).map((row) => ({ ...row, is_primary: false })));
15231
+ } else {
15232
+ if (!prior)
15233
+ throw new MachineRegistryError("Machine not found", 404);
15234
+ machine = { ...prior };
15235
+ if (input.action === "set-primary") {
15236
+ if (prior.archived_at)
15237
+ throw new MachineRegistryError("Cannot set archived machine as primary");
15238
+ machine.is_primary = true;
15239
+ changed = current.filter((row) => row.is_primary && row.id !== prior.id).map((row) => ({ ...row, is_primary: false }));
15240
+ } else if (input.action === "archive" || input.action === "delete") {
15241
+ if (prior.is_primary)
15242
+ throw new MachineRegistryError("Cannot archive or delete the primary machine");
15243
+ const refs = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type<>'machines' AND deleted_at IS NULL AND payload->>'machine_id'=$2 AND ($3='delete' OR (object_type='tasks' AND payload->>'status' IN ('pending','in_progress'))) LIMIT 1`, [service, prior.id, input.action]);
15244
+ if (refs.rows.length)
15245
+ throw new MachineRegistryError("Machine still has referenced records or active tasks");
15246
+ machine.archived_at = stamp;
15247
+ deleted = input.action === "delete" ? true : undefined;
15248
+ } else if (input.action === "unarchive")
15249
+ machine.archived_at = null;
15250
+ else
15251
+ throw new MachineRegistryError("Unknown machine action", 400);
15252
+ changed.push(machine);
15253
+ }
15254
+ }
15255
+ for (const row of changed) {
15256
+ await tx.query(`INSERT INTO ${table} (service,object_type,object_id,payload,updated_at,deleted_at,version) VALUES ($1,'machines',$2,$3::text::jsonb,$4,$5,1) ON CONFLICT (service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=COALESCE(${table}.version,0)+1`, [service, row.id, JSON.stringify(row), stamp, deleted && row.id === machine?.id ? stamp : null]);
15257
+ }
15258
+ const machines = await list(tx);
15259
+ return { schema_version: 2, machines, ...machine ? { machine } : {}, inserted: changed.filter((row) => !current.some((old) => old.id === row.id)).length, skipped, ...deleted ? { deleted } : {} };
15260
+ });
15261
+ } };
15262
+ }
15263
+
15264
+ // src/storage/sqlite-snapshot.ts
15265
+ init_database();
15266
+ init_audit();
15267
+ init_plans();
15268
+ init_projects();
15269
+ init_task_lists();
15270
+ init_tasks();
15271
+ init_templates();
15272
+ init_storage_tombstones();
15273
+
15274
+ // src/storage/audit-history-import.ts
15275
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
15276
+ var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
14556
15277
  var AUDIT_HISTORY_FIELDS = [
14557
15278
  "id",
14558
15279
  "task_id",
@@ -14753,6 +15474,7 @@ function exportSqliteTodosStorageSnapshot(db) {
14753
15474
  return {
14754
15475
  exportedAt: new Date().toISOString(),
14755
15476
  source: "sqlite",
15477
+ machines: exportSqliteMachines(d),
14756
15478
  tasks: listTasks({ include_archived: true }, d),
14757
15479
  projects: listProjects(d),
14758
15480
  projectMachinePaths: listProjectMachinePaths(d),
@@ -14766,7 +15488,6 @@ function exportSqliteTodosStorageSnapshot(db) {
14766
15488
  };
14767
15489
  }
14768
15490
  function importSqliteTodosStorageSnapshot(snapshot, db) {
14769
- const d = db ?? getDatabase();
14770
15491
  const result = {
14771
15492
  inserted: 0,
14772
15493
  updated: 0,
@@ -14774,6 +15495,22 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14774
15495
  skipped: 0,
14775
15496
  errors: []
14776
15497
  };
15498
+ if (snapshot.taskLists.some((row) => row.status !== undefined)) {
15499
+ result.errors.push("The explicit SQLite storage cannot retain task-list status; use the shared API");
15500
+ return result;
15501
+ }
15502
+ const d = db ?? getDatabase();
15503
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
15504
+ result.errors.push("Machine tombstones require explicit registry lifecycle operations");
15505
+ return result;
15506
+ }
15507
+ try {
15508
+ if (snapshot.machines !== undefined)
15509
+ validateMachines(snapshot.machines);
15510
+ } catch (e) {
15511
+ result.errors.push(e instanceof Error ? e.message : String(e));
15512
+ return result;
15513
+ }
14777
15514
  result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
14778
15515
  if (result.errors.length === 0) {
14779
15516
  const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
@@ -14784,6 +15521,20 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14784
15521
  result.errors.push(...auditImport.errors);
14785
15522
  if (result.errors.length > 0)
14786
15523
  return result;
15524
+ if (snapshot.machines?.length) {
15525
+ try {
15526
+ d.transaction(() => {
15527
+ const plan = planMachineImport(exportSqliteMachines(d), snapshot.machines);
15528
+ for (const row of plan.rows)
15529
+ d.run("INSERT INTO machines (id,name,hostname,platform,last_seen_at,metadata,created_at,ssh_address,is_primary,archived_at) VALUES (?,?,?,?,?,?,?,?,?,?)", [row.id, row.name, row.hostname, row.platform, row.last_seen_at, JSON.stringify(row.metadata), row.created_at, row.ssh_address, Number(row.is_primary), row.archived_at]);
15530
+ result.inserted += plan.rows.length;
15531
+ result.skipped += plan.skipped;
15532
+ })();
15533
+ } catch (error) {
15534
+ result.errors.push(error instanceof Error ? error.message : String(error));
15535
+ return result;
15536
+ }
15537
+ }
14787
15538
  result.skipped += auditImport.identicalReplayCount;
14788
15539
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
14789
15540
  for (const row of rows) {
@@ -15018,6 +15769,17 @@ function clockColumnsForTable(table) {
15018
15769
  return ["created_at"];
15019
15770
  return ["updated_at", "created_at"];
15020
15771
  }
15772
+ function exportSqliteMachines(db) {
15773
+ const columns = ["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"];
15774
+ const observed = db.query("PRAGMA table_info(machines)").all().map((row) => row.name);
15775
+ if (observed.length !== columns.length || observed.some((name) => !columns.includes(name)))
15776
+ throw new Error("Machine SQLite schema differs from the lossless migration contract; preserve the source and upgrade the migrator");
15777
+ return validateMachines(db.query("SELECT * FROM machines ORDER BY id").all().map((row) => {
15778
+ if (row.is_primary !== 0 && row.is_primary !== 1)
15779
+ throw new Error("Machine is_primary must be the raw SQLite integer 0 or 1; migration refused");
15780
+ return { ...row, is_primary: row.is_primary === 1, metadata: JSON.parse(String(row.metadata)) };
15781
+ }));
15782
+ }
15021
15783
 
15022
15784
  // src/storage/local-sqlite.ts
15023
15785
  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;
@@ -15223,9 +15985,1158 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15223
15985
  return adapter;
15224
15986
  }
15225
15987
 
15988
+ // src/lib/builtin-template-library.ts
15989
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
15990
+ import { join as join7 } from "path";
15991
+ var BUILTIN_TEMPLATE_LIBRARY_VERSION = "2026-05-21";
15992
+ var BUILTIN_TEMPLATE_LIBRARY_SOURCE = "bundled-local-template-library";
15993
+ var TEMPLATE_LIBRARY_SCHEMA = "todos.template_library.v1";
15994
+ var BUILTIN_TEMPLATES = [
15995
+ {
15996
+ name: "bug-fix",
15997
+ description: "Reproduce, diagnose, fix, test, and release a defect.",
15998
+ category: "bug-fix",
15999
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16000
+ variables: [
16001
+ { name: "bug", required: true, description: "Bug description" }
16002
+ ],
16003
+ tasks: [
16004
+ {
16005
+ position: 0,
16006
+ title_pattern: "Reproduce: {bug}",
16007
+ priority: "critical",
16008
+ tags: ["bug", "repro"]
16009
+ },
16010
+ {
16011
+ position: 1,
16012
+ title_pattern: "Diagnose root cause of {bug}",
16013
+ priority: "critical",
16014
+ tags: ["bug", "diagnosis"],
16015
+ depends_on_positions: [0]
16016
+ },
16017
+ {
16018
+ position: 2,
16019
+ title_pattern: "Write regression test for {bug}",
16020
+ priority: "high",
16021
+ tags: ["bug", "test"],
16022
+ depends_on_positions: [1]
16023
+ },
16024
+ {
16025
+ position: 3,
16026
+ title_pattern: "Implement fix for {bug}",
16027
+ priority: "critical",
16028
+ tags: ["bug", "implementation"],
16029
+ depends_on_positions: [2]
16030
+ },
16031
+ {
16032
+ position: 4,
16033
+ title_pattern: "Run full verification for {bug}",
16034
+ priority: "high",
16035
+ tags: ["bug", "verification"],
16036
+ depends_on_positions: [3]
16037
+ },
16038
+ {
16039
+ position: 5,
16040
+ title_pattern: "Publish and smoke test fix for {bug}",
16041
+ priority: "high",
16042
+ tags: ["bug", "release"],
16043
+ depends_on_positions: [4]
16044
+ }
16045
+ ]
16046
+ },
16047
+ {
16048
+ name: "feature-implementation",
16049
+ description: "Plan, build, test, document, and release a product feature.",
16050
+ category: "feature",
16051
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16052
+ variables: [
16053
+ { name: "feature", required: true, description: "Feature name" },
16054
+ {
16055
+ name: "scope",
16056
+ required: false,
16057
+ default: "medium",
16058
+ description: "Implementation size or risk"
16059
+ }
16060
+ ],
16061
+ tasks: [
16062
+ {
16063
+ position: 0,
16064
+ title_pattern: "Define acceptance criteria for {feature}",
16065
+ priority: "high",
16066
+ tags: ["feature", "spec"]
16067
+ },
16068
+ {
16069
+ position: 1,
16070
+ title_pattern: "Design {scope} implementation plan for {feature}",
16071
+ priority: "high",
16072
+ tags: ["feature", "design"],
16073
+ depends_on_positions: [0]
16074
+ },
16075
+ {
16076
+ position: 2,
16077
+ title_pattern: "Implement {feature}",
16078
+ priority: "critical",
16079
+ tags: ["feature", "implementation"],
16080
+ depends_on_positions: [1]
16081
+ },
16082
+ {
16083
+ position: 3,
16084
+ title_pattern: "Add tests for {feature}",
16085
+ priority: "high",
16086
+ tags: ["feature", "test"],
16087
+ depends_on_positions: [2]
16088
+ },
16089
+ {
16090
+ position: 4,
16091
+ title_pattern: "Update docs for {feature}",
16092
+ priority: "medium",
16093
+ tags: ["feature", "docs"],
16094
+ depends_on_positions: [2]
16095
+ },
16096
+ {
16097
+ position: 5,
16098
+ title_pattern: "Run release checks for {feature}",
16099
+ priority: "high",
16100
+ tags: ["feature", "verification"],
16101
+ depends_on_positions: [3, 4]
16102
+ }
16103
+ ]
16104
+ },
16105
+ {
16106
+ name: "security-review",
16107
+ description: "Threat model, test, remediate, and report on security posture.",
16108
+ category: "security",
16109
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16110
+ variables: [
16111
+ {
16112
+ name: "target",
16113
+ required: true,
16114
+ description: "System, package, or change under review"
16115
+ }
16116
+ ],
16117
+ tasks: [
16118
+ {
16119
+ position: 0,
16120
+ title_pattern: "Map trust boundaries for {target}",
16121
+ priority: "critical",
16122
+ tags: ["security", "threat-model"]
16123
+ },
16124
+ {
16125
+ position: 1,
16126
+ title_pattern: "Scan {target} for vulnerabilities and secret exposure",
16127
+ priority: "critical",
16128
+ tags: ["security", "scan"],
16129
+ depends_on_positions: [0]
16130
+ },
16131
+ {
16132
+ position: 2,
16133
+ title_pattern: "Review authz, data access, and dependency risks in {target}",
16134
+ priority: "critical",
16135
+ tags: ["security", "review"],
16136
+ depends_on_positions: [0]
16137
+ },
16138
+ {
16139
+ position: 3,
16140
+ title_pattern: "Fix critical security findings in {target}",
16141
+ priority: "critical",
16142
+ tags: ["security", "fix"],
16143
+ depends_on_positions: [1, 2]
16144
+ },
16145
+ {
16146
+ position: 4,
16147
+ title_pattern: "Retest {target} security fixes",
16148
+ priority: "high",
16149
+ tags: ["security", "verification"],
16150
+ depends_on_positions: [3]
16151
+ },
16152
+ {
16153
+ position: 5,
16154
+ title_pattern: "Write local security review report for {target}",
16155
+ priority: "medium",
16156
+ tags: ["security", "report"],
16157
+ depends_on_positions: [4]
16158
+ }
16159
+ ]
16160
+ },
16161
+ {
16162
+ name: "release",
16163
+ description: "Prepare, verify, publish, install, and smoke test a package release.",
16164
+ category: "release",
16165
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16166
+ variables: [
16167
+ { name: "package", required: true, description: "Package name" },
16168
+ {
16169
+ name: "version",
16170
+ required: false,
16171
+ default: "patch",
16172
+ description: "Release version or bump type"
16173
+ }
16174
+ ],
16175
+ tasks: [
16176
+ {
16177
+ position: 0,
16178
+ title_pattern: "Prepare {package} {version} release notes",
16179
+ priority: "medium",
16180
+ tags: ["release", "docs"]
16181
+ },
16182
+ {
16183
+ position: 1,
16184
+ title_pattern: "Run full tests for {package}",
16185
+ priority: "critical",
16186
+ tags: ["release", "test"]
16187
+ },
16188
+ {
16189
+ position: 2,
16190
+ title_pattern: "Run build and release verification for {package}",
16191
+ priority: "critical",
16192
+ tags: ["release", "verification"],
16193
+ depends_on_positions: [1]
16194
+ },
16195
+ {
16196
+ position: 3,
16197
+ title_pattern: "Scan {package} release diff for secrets",
16198
+ priority: "critical",
16199
+ tags: ["release", "security"],
16200
+ depends_on_positions: [2]
16201
+ },
16202
+ {
16203
+ position: 4,
16204
+ title_pattern: "Publish {package} {version}",
16205
+ priority: "high",
16206
+ tags: ["release", "publish"],
16207
+ depends_on_positions: [3]
16208
+ },
16209
+ {
16210
+ position: 5,
16211
+ title_pattern: "Install and smoke test published {package}",
16212
+ priority: "high",
16213
+ tags: ["release", "smoke"],
16214
+ depends_on_positions: [4]
16215
+ }
16216
+ ]
16217
+ },
16218
+ {
16219
+ name: "migration",
16220
+ description: "Plan, test, apply, and verify a schema or data migration.",
16221
+ category: "migration",
16222
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16223
+ variables: [
16224
+ {
16225
+ name: "migration",
16226
+ required: true,
16227
+ description: "Migration name or objective"
16228
+ }
16229
+ ],
16230
+ tasks: [
16231
+ {
16232
+ position: 0,
16233
+ title_pattern: "Design migration plan for {migration}",
16234
+ priority: "critical",
16235
+ tags: ["migration", "plan"]
16236
+ },
16237
+ {
16238
+ position: 1,
16239
+ title_pattern: "Write rollback plan for {migration}",
16240
+ priority: "critical",
16241
+ tags: ["migration", "rollback"],
16242
+ depends_on_positions: [0]
16243
+ },
16244
+ {
16245
+ position: 2,
16246
+ title_pattern: "Implement migration {migration}",
16247
+ priority: "critical",
16248
+ tags: ["migration", "implementation"],
16249
+ depends_on_positions: [0]
16250
+ },
16251
+ {
16252
+ position: 3,
16253
+ title_pattern: "Test migration {migration} on fixture data",
16254
+ priority: "high",
16255
+ tags: ["migration", "test"],
16256
+ depends_on_positions: [2]
16257
+ },
16258
+ {
16259
+ position: 4,
16260
+ title_pattern: "Run migration {migration} verification and drift checks",
16261
+ priority: "high",
16262
+ tags: ["migration", "verification"],
16263
+ depends_on_positions: [1, 3]
16264
+ },
16265
+ {
16266
+ position: 5,
16267
+ title_pattern: "Document migration {migration} evidence",
16268
+ priority: "medium",
16269
+ tags: ["migration", "docs"],
16270
+ depends_on_positions: [4]
16271
+ }
16272
+ ]
16273
+ },
16274
+ {
16275
+ name: "incident",
16276
+ description: "Triage, mitigate, repair, verify, and retrospect an incident.",
16277
+ category: "incident",
16278
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16279
+ variables: [
16280
+ { name: "incident", required: true, description: "Incident summary" }
16281
+ ],
16282
+ tasks: [
16283
+ {
16284
+ position: 0,
16285
+ title_pattern: "Triage incident: {incident}",
16286
+ priority: "critical",
16287
+ tags: ["incident", "triage"]
16288
+ },
16289
+ {
16290
+ position: 1,
16291
+ title_pattern: "Mitigate customer impact for {incident}",
16292
+ priority: "critical",
16293
+ tags: ["incident", "mitigation"],
16294
+ depends_on_positions: [0]
16295
+ },
16296
+ {
16297
+ position: 2,
16298
+ title_pattern: "Diagnose root cause for {incident}",
16299
+ priority: "critical",
16300
+ tags: ["incident", "diagnosis"],
16301
+ depends_on_positions: [0]
16302
+ },
16303
+ {
16304
+ position: 3,
16305
+ title_pattern: "Implement durable repair for {incident}",
16306
+ priority: "critical",
16307
+ tags: ["incident", "repair"],
16308
+ depends_on_positions: [2]
16309
+ },
16310
+ {
16311
+ position: 4,
16312
+ title_pattern: "Verify recovery from {incident}",
16313
+ priority: "high",
16314
+ tags: ["incident", "verification"],
16315
+ depends_on_positions: [1, 3]
16316
+ },
16317
+ {
16318
+ position: 5,
16319
+ title_pattern: "Write retrospective for {incident}",
16320
+ priority: "medium",
16321
+ tags: ["incident", "retro"],
16322
+ depends_on_positions: [4]
16323
+ }
16324
+ ]
16325
+ },
16326
+ {
16327
+ name: "docs-refresh",
16328
+ description: "Audit, update, validate, and publish documentation changes.",
16329
+ category: "docs",
16330
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16331
+ variables: [
16332
+ {
16333
+ name: "area",
16334
+ required: true,
16335
+ description: "Documentation area or product surface"
16336
+ }
16337
+ ],
16338
+ tasks: [
16339
+ {
16340
+ position: 0,
16341
+ title_pattern: "Audit current docs for {area}",
16342
+ priority: "medium",
16343
+ tags: ["docs", "audit"]
16344
+ },
16345
+ {
16346
+ position: 1,
16347
+ title_pattern: "Update examples and commands for {area}",
16348
+ priority: "medium",
16349
+ tags: ["docs", "examples"],
16350
+ depends_on_positions: [0]
16351
+ },
16352
+ {
16353
+ position: 2,
16354
+ title_pattern: "Validate docs snippets for {area}",
16355
+ priority: "high",
16356
+ tags: ["docs", "verification"],
16357
+ depends_on_positions: [1]
16358
+ },
16359
+ {
16360
+ position: 3,
16361
+ title_pattern: "Refresh screenshots or generated artifacts for {area}",
16362
+ priority: "medium",
16363
+ tags: ["docs", "assets"],
16364
+ depends_on_positions: [1]
16365
+ },
16366
+ {
16367
+ position: 4,
16368
+ title_pattern: "Publish docs refresh for {area}",
16369
+ priority: "medium",
16370
+ tags: ["docs", "release"],
16371
+ depends_on_positions: [2, 3]
16372
+ }
16373
+ ]
16374
+ },
16375
+ {
16376
+ name: "qa",
16377
+ description: "Build a focused QA plan, execute checks, file defects, and sign off.",
16378
+ category: "qa",
16379
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16380
+ variables: [
16381
+ {
16382
+ name: "target",
16383
+ required: true,
16384
+ description: "Feature, release, or workflow under QA"
16385
+ }
16386
+ ],
16387
+ tasks: [
16388
+ {
16389
+ position: 0,
16390
+ title_pattern: "Create QA matrix for {target}",
16391
+ priority: "high",
16392
+ tags: ["qa", "plan"]
16393
+ },
16394
+ {
16395
+ position: 1,
16396
+ title_pattern: "Run happy-path QA for {target}",
16397
+ priority: "high",
16398
+ tags: ["qa", "manual"],
16399
+ depends_on_positions: [0]
16400
+ },
16401
+ {
16402
+ position: 2,
16403
+ title_pattern: "Run edge-case QA for {target}",
16404
+ priority: "high",
16405
+ tags: ["qa", "edge-cases"],
16406
+ depends_on_positions: [0]
16407
+ },
16408
+ {
16409
+ position: 3,
16410
+ title_pattern: "File and dedupe QA defects for {target}",
16411
+ priority: "medium",
16412
+ tags: ["qa", "bugs"],
16413
+ depends_on_positions: [1, 2]
16414
+ },
16415
+ {
16416
+ position: 4,
16417
+ title_pattern: "Verify QA fixes for {target}",
16418
+ priority: "high",
16419
+ tags: ["qa", "verification"],
16420
+ depends_on_positions: [3]
16421
+ },
16422
+ {
16423
+ position: 5,
16424
+ title_pattern: "Record QA signoff for {target}",
16425
+ priority: "medium",
16426
+ tags: ["qa", "signoff"],
16427
+ depends_on_positions: [4]
16428
+ }
16429
+ ]
16430
+ },
16431
+ {
16432
+ name: "open-source-project",
16433
+ description: "Full open-source project bootstrap \u2014 scaffold to publish",
16434
+ category: "open-source",
16435
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16436
+ variables: [
16437
+ { name: "name", required: true, description: "Service name" },
16438
+ {
16439
+ name: "org",
16440
+ required: false,
16441
+ default: "hasna",
16442
+ description: "GitHub org"
16443
+ }
16444
+ ],
16445
+ tasks: [
16446
+ {
16447
+ position: 0,
16448
+ title_pattern: "Scaffold {name} package structure",
16449
+ priority: "critical"
16450
+ },
16451
+ {
16452
+ position: 1,
16453
+ title_pattern: "Create {name} SQLite database + migrations",
16454
+ priority: "critical",
16455
+ depends_on_positions: [0]
16456
+ },
16457
+ {
16458
+ position: 2,
16459
+ title_pattern: "Implement {name} CRUD operations",
16460
+ priority: "high",
16461
+ depends_on_positions: [1]
16462
+ },
16463
+ {
16464
+ position: 3,
16465
+ title_pattern: "Build {name} MCP server with standard tools",
16466
+ priority: "high",
16467
+ depends_on_positions: [2]
16468
+ },
16469
+ {
16470
+ position: 4,
16471
+ title_pattern: "Build {name} CLI with Commander.js",
16472
+ priority: "high",
16473
+ depends_on_positions: [2]
16474
+ },
16475
+ {
16476
+ position: 5,
16477
+ title_pattern: "Build {name} REST API",
16478
+ priority: "medium",
16479
+ depends_on_positions: [2]
16480
+ },
16481
+ {
16482
+ position: 6,
16483
+ title_pattern: "Write unit tests for {name}",
16484
+ priority: "high",
16485
+ depends_on_positions: [2, 3, 4]
16486
+ },
16487
+ {
16488
+ position: 7,
16489
+ title_pattern: "Add Apache 2.0 license and README",
16490
+ priority: "medium",
16491
+ depends_on_positions: [0]
16492
+ },
16493
+ {
16494
+ position: 8,
16495
+ title_pattern: "Create GitHub repo {org}/{name}",
16496
+ priority: "medium",
16497
+ depends_on_positions: [7]
16498
+ },
16499
+ {
16500
+ position: 9,
16501
+ title_pattern: "Add local backup and restore workflow for {name}",
16502
+ priority: "medium",
16503
+ depends_on_positions: [1]
16504
+ },
16505
+ {
16506
+ position: 10,
16507
+ title_pattern: "Add agent tools (register_agent, heartbeat, set_focus, list_agents)",
16508
+ priority: "medium",
16509
+ depends_on_positions: [3]
16510
+ },
16511
+ {
16512
+ position: 11,
16513
+ title_pattern: "Publish @hasna/{name} to npm",
16514
+ priority: "high",
16515
+ depends_on_positions: [6, 7, 8]
16516
+ },
16517
+ {
16518
+ position: 12,
16519
+ title_pattern: "Install @hasna/{name} globally and verify",
16520
+ priority: "medium",
16521
+ depends_on_positions: [11]
16522
+ }
16523
+ ]
16524
+ },
16525
+ {
16526
+ name: "release",
16527
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16528
+ category: "ops",
16529
+ description: "Version release workflow \u2014 test, changelog, publish, verify",
16530
+ variables: [
16531
+ {
16532
+ name: "version",
16533
+ required: true,
16534
+ description: "Release version (e.g. 1.2.0)"
16535
+ },
16536
+ {
16537
+ name: "package",
16538
+ required: false,
16539
+ default: "package",
16540
+ description: "Package name"
16541
+ }
16542
+ ],
16543
+ tasks: [
16544
+ {
16545
+ position: 0,
16546
+ title_pattern: "Run full test suite for {package} {version}",
16547
+ priority: "critical"
16548
+ },
16549
+ {
16550
+ position: 1,
16551
+ title_pattern: "Update changelog for {version}",
16552
+ priority: "high",
16553
+ depends_on_positions: [0]
16554
+ },
16555
+ {
16556
+ position: 2,
16557
+ title_pattern: "Bump version to {version}",
16558
+ priority: "high",
16559
+ depends_on_positions: [1]
16560
+ },
16561
+ {
16562
+ position: 3,
16563
+ title_pattern: "Publish {package} {version}",
16564
+ priority: "critical",
16565
+ depends_on_positions: [2]
16566
+ },
16567
+ {
16568
+ position: 4,
16569
+ title_pattern: "Verify {package} {version} install and CLI",
16570
+ priority: "high",
16571
+ depends_on_positions: [3]
16572
+ }
16573
+ ]
16574
+ },
16575
+ {
16576
+ name: "docs-refresh",
16577
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16578
+ category: "workflow",
16579
+ description: "Documentation refresh \u2014 audit, update, verify links",
16580
+ variables: [
16581
+ {
16582
+ name: "scope",
16583
+ required: true,
16584
+ description: "Docs scope (README, API, AGENTS.md)"
16585
+ }
16586
+ ],
16587
+ tasks: [
16588
+ {
16589
+ position: 0,
16590
+ title_pattern: "Audit outdated sections in {scope}",
16591
+ priority: "high"
16592
+ },
16593
+ {
16594
+ position: 1,
16595
+ title_pattern: "Update {scope} for current behavior",
16596
+ priority: "high",
16597
+ depends_on_positions: [0]
16598
+ },
16599
+ {
16600
+ position: 2,
16601
+ title_pattern: "Verify examples in {scope} still work",
16602
+ priority: "medium",
16603
+ depends_on_positions: [1]
16604
+ },
16605
+ {
16606
+ position: 3,
16607
+ title_pattern: "Fix broken links in {scope}",
16608
+ priority: "medium",
16609
+ depends_on_positions: [1]
16610
+ }
16611
+ ]
16612
+ },
16613
+ {
16614
+ name: "migration",
16615
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16616
+ category: "ops",
16617
+ description: "Schema/data migration workflow",
16618
+ variables: [
16619
+ {
16620
+ name: "migration",
16621
+ required: true,
16622
+ description: "Migration name or ID"
16623
+ },
16624
+ {
16625
+ name: "target",
16626
+ required: false,
16627
+ default: "sqlite",
16628
+ description: "Target store"
16629
+ }
16630
+ ],
16631
+ tasks: [
16632
+ {
16633
+ position: 0,
16634
+ title_pattern: "Write forward migration for {migration} ({target})",
16635
+ priority: "critical"
16636
+ },
16637
+ {
16638
+ position: 1,
16639
+ title_pattern: "Add migration tests for {migration}",
16640
+ priority: "high",
16641
+ depends_on_positions: [0]
16642
+ },
16643
+ {
16644
+ position: 2,
16645
+ title_pattern: "Run migration dry-run on copy",
16646
+ priority: "high",
16647
+ depends_on_positions: [1]
16648
+ },
16649
+ {
16650
+ position: 3,
16651
+ title_pattern: "Apply {migration} and verify integrity",
16652
+ priority: "critical",
16653
+ depends_on_positions: [2]
16654
+ },
16655
+ {
16656
+ position: 4,
16657
+ title_pattern: "Document rollback for {migration}",
16658
+ priority: "medium",
16659
+ depends_on_positions: [3]
16660
+ }
16661
+ ]
16662
+ },
16663
+ {
16664
+ name: "incident-response",
16665
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16666
+ category: "ops",
16667
+ description: "Incident triage, mitigation, postmortem",
16668
+ variables: [
16669
+ { name: "incident", required: true, description: "Incident summary" }
16670
+ ],
16671
+ tasks: [
16672
+ {
16673
+ position: 0,
16674
+ title_pattern: "Triage: {incident}",
16675
+ priority: "critical",
16676
+ tags: ["incident"]
16677
+ },
16678
+ {
16679
+ position: 1,
16680
+ title_pattern: "Mitigate impact of {incident}",
16681
+ priority: "critical",
16682
+ depends_on_positions: [0],
16683
+ tags: ["incident"]
16684
+ },
16685
+ {
16686
+ position: 2,
16687
+ title_pattern: "Identify root cause: {incident}",
16688
+ priority: "critical",
16689
+ depends_on_positions: [1]
16690
+ },
16691
+ {
16692
+ position: 3,
16693
+ title_pattern: "Permanent fix for {incident}",
16694
+ priority: "high",
16695
+ depends_on_positions: [2]
16696
+ },
16697
+ {
16698
+ position: 4,
16699
+ title_pattern: "Write postmortem for {incident}",
16700
+ priority: "medium",
16701
+ depends_on_positions: [3]
16702
+ },
16703
+ {
16704
+ position: 5,
16705
+ title_pattern: "Add monitoring/alert for {incident} class",
16706
+ priority: "medium",
16707
+ depends_on_positions: [4]
16708
+ }
16709
+ ]
16710
+ }
16711
+ ];
16712
+ function templateMetadata(template) {
16713
+ return {
16714
+ source: BUILTIN_TEMPLATE_LIBRARY_SOURCE,
16715
+ library_version: template.version,
16716
+ category: template.category,
16717
+ template_file: `${template.name}.json`,
16718
+ local_only: true,
16719
+ marketplace_free: true
16720
+ };
16721
+ }
16722
+ function listBuiltinTemplates() {
16723
+ return BUILTIN_TEMPLATES.map((template) => ({
16724
+ ...template,
16725
+ variables: template.variables.map((variable) => ({ ...variable })),
16726
+ tasks: template.tasks.map((task) => ({
16727
+ ...task,
16728
+ tags: task.tags ? [...task.tags] : undefined
16729
+ }))
16730
+ }));
16731
+ }
16732
+ function getBuiltinTemplate(name) {
16733
+ return listBuiltinTemplates().find((template) => template.name === name) ?? null;
16734
+ }
16735
+ function exportBuiltinTemplate(name) {
16736
+ const template = getBuiltinTemplate(name);
16737
+ if (!template)
16738
+ throw new Error(`Built-in template not found: ${name}`);
16739
+ return {
16740
+ name: template.name,
16741
+ title_pattern: `${template.name}: {${template.variables[0]?.name ?? "name"}}`,
16742
+ description: template.description,
16743
+ priority: "medium",
16744
+ tags: [template.category, "local-template"],
16745
+ variables: template.variables,
16746
+ project_id: null,
16747
+ plan_id: null,
16748
+ metadata: templateMetadata(template),
16749
+ tasks: template.tasks.map((task) => ({
16750
+ position: task.position,
16751
+ title_pattern: task.title_pattern,
16752
+ description: task.description ?? null,
16753
+ priority: task.priority ?? "medium",
16754
+ tags: task.tags ?? [template.category],
16755
+ task_type: task.task_type ?? null,
16756
+ condition: task.condition ?? null,
16757
+ include_template_id: task.include_template_id ?? null,
16758
+ depends_on_positions: task.depends_on_positions ?? task.depends_on ?? [],
16759
+ metadata: task.metadata ?? { category: template.category }
16760
+ }))
16761
+ };
16762
+ }
16763
+ function exportBuiltinTemplateFiles() {
16764
+ return BUILTIN_TEMPLATES.map((template) => ({
16765
+ filename: `${template.name}.json`,
16766
+ template: exportBuiltinTemplate(template.name)
16767
+ }));
16768
+ }
16769
+ function writeBuiltinTemplateFiles(directory) {
16770
+ mkdirSync5(directory, { recursive: true });
16771
+ const files = [];
16772
+ for (const entry of exportBuiltinTemplateFiles()) {
16773
+ const path = join7(directory, entry.filename);
16774
+ writeFileSync3(path, `${JSON.stringify(entry.template, null, 2)}
16775
+ `, "utf-8");
16776
+ files.push(path);
16777
+ }
16778
+ return { directory, written: files.length, files };
16779
+ }
16780
+ function builtinTemplateInputs() {
16781
+ return listBuiltinTemplates().map((bt) => ({
16782
+ name: bt.name,
16783
+ title_pattern: `${bt.name}: {${bt.variables[0]?.name || "name"}}`,
16784
+ description: bt.description,
16785
+ tags: [bt.category, "local-template"],
16786
+ variables: bt.variables,
16787
+ metadata: templateMetadata(bt),
16788
+ tasks: bt.tasks.map((task) => ({
16789
+ title_pattern: task.title_pattern,
16790
+ description: task.description,
16791
+ priority: task.priority,
16792
+ tags: task.tags,
16793
+ task_type: task.task_type,
16794
+ depends_on: task.depends_on_positions || task.depends_on,
16795
+ metadata: task.metadata
16796
+ }))
16797
+ }));
16798
+ }
16799
+
16800
+ // src/lib/plan-schedule.ts
16801
+ function validatePlanSchedule(value) {
16802
+ for (const key of ["start_date", "end_date"]) {
16803
+ const date = value[key];
16804
+ if (date === undefined || date === null)
16805
+ continue;
16806
+ if (typeof date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date)
16807
+ return `${key} must be a valid ISO calendar date (YYYY-MM-DD)`;
16808
+ }
16809
+ if (typeof value.start_date === "string" && typeof value.end_date === "string" && value.start_date > value.end_date)
16810
+ return "end_date must not precede start_date";
16811
+ return null;
16812
+ }
16813
+
16814
+ // src/storage/atomic-project-migration.ts
16815
+ init_types();
16816
+ import { createHash as createHash5 } from "crypto";
16817
+ var families = { projects: "projects", tasks: "tasks", plans: "plans", taskLists: "task_lists", auditHistory: "audit_history" };
16818
+
16819
+ class AtomicMigrationError extends Error {
16820
+ status;
16821
+ record;
16822
+ constructor(status, message, record) {
16823
+ super(message);
16824
+ this.status = status;
16825
+ this.record = record;
16826
+ this.name = "AtomicMigrationError";
16827
+ }
16828
+ }
16829
+ function fail(message, status = 409, record) {
16830
+ throw new AtomicMigrationError(status, message, record && { object_type: record.object_type, object_id: record.object_id });
16831
+ }
16832
+ function plain(value) {
16833
+ return !!value && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
16834
+ }
16835
+ function canonical(value, depth = 0) {
16836
+ if (depth > 32)
16837
+ fail("Migration JSON depth exceeds 32", 400);
16838
+ if (value === null || typeof value === "boolean" || typeof value === "string")
16839
+ return JSON.stringify(value);
16840
+ if (typeof value === "number" && Number.isFinite(value))
16841
+ return JSON.stringify(value);
16842
+ if (Array.isArray(value))
16843
+ return "[" + value.map((v) => canonical(v, depth + 1)).join(",") + "]";
16844
+ if (plain(value))
16845
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonical(value[k], depth + 1)).join(",") + "}";
16846
+ return fail("Migration requires finite JSON values", 400);
16847
+ }
16848
+ function atomicMigrationHash(value) {
16849
+ return createHash5("sha256").update(canonical(value)).digest("hex");
16850
+ }
16851
+ function stamp(value) {
16852
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value)
16853
+ fail("Migration clocks require canonical millisecond UTC timestamps", 400);
16854
+ return value;
16855
+ }
16856
+ function version(value) {
16857
+ if (value == null)
16858
+ return 0;
16859
+ if (!Number.isSafeInteger(value) || Number(value) < 0)
16860
+ fail("Invalid migration version", 400);
16861
+ return value;
16862
+ }
16863
+ function key(row) {
16864
+ return row.object_type + "\x00" + row.object_id;
16865
+ }
16866
+ function normalized(row) {
16867
+ return { ...row, source_machine_id: row.source_machine_id ?? null, updated_at: new Date(row.updated_at).toISOString(), deleted_at: row.deleted_at ? new Date(row.deleted_at).toISOString() : null };
16868
+ }
16869
+ function digest(row) {
16870
+ return atomicMigrationHash(normalized(row));
16871
+ }
16872
+ function compare(a, b) {
16873
+ return Date.parse(String(a.updated_at)) - Date.parse(String(b.updated_at)) || version(a.version) - version(b.version);
16874
+ }
16875
+ function identity(value) {
16876
+ if (typeof value !== "string" || !value.trim() || value.length > 256 || value.includes("\x00"))
16877
+ fail("Invalid migration record identity", 400);
16878
+ return value;
16879
+ }
16880
+ function validate(request) {
16881
+ if (!plain(request) || request.schema_version !== 1 || typeof request.operation_id !== "string" || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.operation_id))
16882
+ fail("Invalid migration operation identity/version", 400);
16883
+ if (Object.keys(request).some((k) => !["schema_version", "operation_id", "expected_authority", "snapshot", "snapshot_hash"].includes(k)) || Buffer.byteLength(canonical(request)) > 2200000)
16884
+ fail("Unsupported or oversized migration envelope", 400);
16885
+ if (!plain(request.expected_authority))
16886
+ fail("Expected migration authority is required", 400);
16887
+ identity(request.expected_authority.tenant_id);
16888
+ identity(request.expected_authority.kid);
16889
+ identity(request.expected_authority.deployment_id);
16890
+ if (!plain(request.snapshot))
16891
+ fail("Migration snapshot must be an object", 400);
16892
+ const encoded = canonical(request.snapshot);
16893
+ if (Buffer.byteLength(encoded) > 2 * 1024 * 1024)
16894
+ fail("Migration snapshot exceeds 2 MiB", 413);
16895
+ if (atomicMigrationHash(request.snapshot) !== request.snapshot_hash)
16896
+ fail("Migration snapshot hash mismatch", 400);
16897
+ const allowed = new Set([...Object.keys(families), "tombstones", "exportedAt", "source"]);
16898
+ for (const [name, value] of Object.entries(request.snapshot))
16899
+ if (!allowed.has(name) && !(Array.isArray(value) && value.length === 0))
16900
+ fail(`Unsupported atomic migration family: ${name}`, 400);
16901
+ const rows = [];
16902
+ for (const [name, type] of Object.entries(families)) {
16903
+ const values = request.snapshot[name] ?? [];
16904
+ if (!Array.isArray(values))
16905
+ fail(`Invalid migration array: ${name}`, 400);
16906
+ for (const value of values) {
16907
+ if (!plain(value))
16908
+ fail("Invalid migration record", 400);
16909
+ const id = identity(value.id);
16910
+ const clock = stamp(value.updated_at ?? value.created_at);
16911
+ if (type === "tasks") {
16912
+ if (typeof value.title !== "string" || !TASK_STATUSES.includes(value.status) || !TASK_PRIORITIES.includes(value.priority))
16913
+ fail("Invalid task migration payload", 400);
16914
+ if (value.locked_by || value.runner_id)
16915
+ fail("Active task leases require separate migration reconciliation", 409);
16916
+ }
16917
+ if (type === "plans" && (typeof value.name !== "string" || !PLAN_STATUSES.includes(value.status)))
16918
+ fail("Invalid plan migration payload", 400);
16919
+ if (type === "projects" && typeof value.name !== "string")
16920
+ fail("Invalid project migration payload", 400);
16921
+ if (type === "audit_history" && (typeof value.task_id !== "string" || typeof value.action !== "string"))
16922
+ fail("Invalid history migration payload", 400);
16923
+ rows.push({ object_type: type, object_id: id, payload: value, updated_at: clock, deleted_at: null, version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
16924
+ }
16925
+ }
16926
+ const tombstones = request.snapshot.tombstones ?? [];
16927
+ if (!Array.isArray(tombstones))
16928
+ fail("Invalid tombstone array", 400);
16929
+ for (const value of tombstones) {
16930
+ if (!plain(value) || value.object_type !== "projects")
16931
+ fail("Atomic migration supports only project tombstones", 400);
16932
+ if (!plain(value.payload))
16933
+ fail("Project tombstone must preserve its original payload", 400);
16934
+ if (stamp(value.updated_at) < stamp(value.deleted_at))
16935
+ fail("Tombstone update clock precedes deletion", 400);
16936
+ const id = identity(value.object_id);
16937
+ if (value.payload.id !== id)
16938
+ fail("Tombstone payload identity mismatch", 400);
16939
+ rows.push({ object_type: "projects", object_id: id, payload: value.payload, updated_at: stamp(value.updated_at), deleted_at: stamp(value.deleted_at), version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
16940
+ }
16941
+ if (!rows.length || rows.length > 1000)
16942
+ fail("Atomic migration requires 1-1000 records", 400);
16943
+ const unique4 = new Map;
16944
+ for (const row of rows) {
16945
+ const previous = unique4.get(key(row));
16946
+ if (previous && digest(previous) !== digest(row))
16947
+ fail("Divergent duplicate migration identity", 409, row);
16948
+ unique4.set(key(row), row);
16949
+ }
16950
+ return [...unique4.values()];
16951
+ }
16952
+ function validateGraph(rows) {
16953
+ const live = [...rows.values()].filter((r) => !r.deleted_at);
16954
+ const get = (type, id) => typeof id === "string" ? rows.get(type + "\x00" + id) : undefined;
16955
+ for (const row of live) {
16956
+ if (row.object_type === "audit_history") {
16957
+ if (!get("tasks", row.payload.task_id))
16958
+ fail("Migration history references an unknown task", 409, row);
16959
+ continue;
16960
+ }
16961
+ if (!["projects", "tasks", "plans", "task_lists"].includes(row.object_type))
16962
+ continue;
16963
+ if (row.object_type === "tasks" && row.payload.plan_id) {
16964
+ const plan = get("plans", row.payload.plan_id);
16965
+ if (plan && !plan.deleted_at && (plan.payload.project_id ?? null) !== (row.payload.project_id ?? null))
16966
+ fail("Migration task project conflicts with its plan", 409, row);
16967
+ }
16968
+ if (["tasks", "plans"].includes(row.object_type) && row.payload.task_list_id != null) {
16969
+ const selector = row.payload.task_list_id;
16970
+ if (typeof selector !== "string" || !selector)
16971
+ fail("Invalid migration task-list selector", 409, row);
16972
+ const candidates = live.filter((candidate) => candidate.object_type === "task_lists" && (candidate.payload.project_id ?? null) === (row.payload.project_id ?? null) && (candidate.object_id === selector || candidate.payload.slug === selector));
16973
+ const project = get("projects", row.payload.project_id);
16974
+ const canonicalDefault = project && !project.deleted_at && project.payload.task_list_id === selector;
16975
+ if (candidates.length > 1 || !candidates.length && !canonicalDefault)
16976
+ fail("Migration task-list reference is missing or ambiguous; explicit reconciliation required", 409, row);
16977
+ }
16978
+ const refs = [];
16979
+ if (row.object_type === "projects")
16980
+ refs.push(["projects", row.payload.parent_id]);
16981
+ else
16982
+ refs.push(["projects", row.payload.project_id]);
16983
+ if (row.object_type === "tasks")
16984
+ refs.push(["tasks", row.payload.parent_id], ["plans", row.payload.plan_id]);
16985
+ for (const [type, id] of refs) {
16986
+ if (id == null)
16987
+ continue;
16988
+ if (typeof id !== "string" || !id || !get(type, id) || get(type, id).deleted_at)
16989
+ fail("Migration reference is missing or deleted", 409, row);
16990
+ }
16991
+ if (row.object_type === "tasks" && row.payload.parent_id) {
16992
+ const parent = get("tasks", row.payload.parent_id);
16993
+ if ((parent.payload.project_id ?? null) !== (row.payload.project_id ?? null))
16994
+ fail("Migration task parent crosses project scope", 409, row);
16995
+ }
16996
+ }
16997
+ for (const type of ["projects", "tasks"])
16998
+ for (const row of live.filter((r) => r.object_type === type)) {
16999
+ const seen = new Set;
17000
+ let cursor = row;
17001
+ while (cursor) {
17002
+ if (seen.has(cursor.object_id))
17003
+ fail("Migration hierarchy contains a cycle", 409, row);
17004
+ seen.add(cursor.object_id);
17005
+ cursor = get(type, cursor.payload.parent_id);
17006
+ }
17007
+ }
17008
+ const projects = live.filter((r) => r.object_type === "projects").map((r) => r.payload);
17009
+ const lists = live.filter((r) => r.object_type === "task_lists").map((r) => r.payload);
17010
+ const errors = validateSnapshotRoutingRecords(projects, lists);
17011
+ if (errors.length)
17012
+ fail("Migration project/list routing conflict");
17013
+ }
17014
+ function createAtomicProjectMigration(options) {
17015
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.table))
17016
+ throw new Error("Unsafe migration table");
17017
+ return { async apply(request) {
17018
+ const incoming = validate(request);
17019
+ if (!options.client.transaction)
17020
+ fail("Atomic migration requires a transaction-capable backend", 501);
17021
+ await options.ensureSchema();
17022
+ return options.client.transaction(async (client) => {
17023
+ const deadline = Date.now() + 20000;
17024
+ await client.query("SET LOCAL lock_timeout = '5s'");
17025
+ await client.query("SET LOCAL statement_timeout = '15s'");
17026
+ await client.query("SET LOCAL synchronous_commit = 'on'");
17027
+ const durability = await client.query("SELECT current_setting('fsync') AS fsync,current_setting('full_page_writes') AS full_page_writes,current_setting('synchronous_commit') AS synchronous_commit");
17028
+ const durable = durability.rows[0];
17029
+ if (durable?.fsync !== "on" || durable.full_page_writes !== "on" || durable.synchronous_commit !== "on")
17030
+ fail("Migration database durability settings are not ready", 503);
17031
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity',0))", [options.service]);
17032
+ await client.query(`LOCK TABLE ${options.table} IN SHARE ROW EXCLUSIVE MODE`);
17033
+ const journalId = atomicMigrationHash({ operation_id: request.operation_id });
17034
+ const journal = await client.query(`SELECT payload->>'request_hash' AS request_hash,payload->'receipt' AS receipt FROM ${options.table} WHERE service=$1 AND object_type='atomic_project_migrations' AND object_id=$2`, [options.service, journalId]);
17035
+ const requestHash = atomicMigrationHash(request);
17036
+ if (journal.rows[0]) {
17037
+ if (journal.rows[0].request_hash !== requestHash)
17038
+ fail("Migration operation identity was already used for another request");
17039
+ return journal.rows[0].receipt;
17040
+ }
17041
+ const bounds = await client.query(`SELECT count(*)::text AS records,COALESCE(sum(octet_length(payload::text)),0)::text AS bytes,COALESCE(max(octet_length(payload::text)),0)::text AS largest FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history')`, [options.service]);
17042
+ const size = bounds.rows[0];
17043
+ if (!size || Number(size.records) > 1e4 || Number(size.bytes) > 16 * 1024 * 1024 || Number(size.largest) > 2 * 1024 * 1024)
17044
+ fail("Atomic migration destination exceeds its bounded row/byte scope", 413);
17045
+ const selected = await client.query(`SELECT object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history') LIMIT 10001 FOR UPDATE`, [options.service]);
17046
+ if (selected.rows.length > 1e4)
17047
+ fail("Atomic migration destination exceeds bounded 10000-record scope", 413);
17048
+ for (const row of selected.rows) {
17049
+ canonical(row.payload);
17050
+ if (Date.now() > deadline)
17051
+ fail("Migration destination validation exceeded time budget", 503);
17052
+ }
17053
+ const original = new Map(selected.rows.map((r) => [key(r), normalized(r)]));
17054
+ const current = new Map(original);
17055
+ const receipts = [];
17056
+ const receipt = (source, before, after, outcome) => receipts.push({ object_type: source.object_type, object_id: source.object_id, outcome, before_hash: before ? digest(before) : null, after_hash: digest(after), source_hash: digest(source) });
17057
+ for (const row of incoming.filter((r) => !r.deleted_at)) {
17058
+ const before = current.get(key(row));
17059
+ if (before) {
17060
+ if (digest(before) === digest(row)) {
17061
+ receipt(row, before, before, "identical");
17062
+ continue;
17063
+ }
17064
+ if (before.object_type === "tasks" && (before.payload.locked_by || before.payload.runner_id))
17065
+ fail("Active destination task lease requires reconciliation", 409, before);
17066
+ if (row.object_type === "audit_history")
17067
+ fail("Immutable history identity conflict", 409, row);
17068
+ const clock = compare(row, before);
17069
+ if (clock === 0)
17070
+ fail("Ambiguous equal-clock migration conflict", 409, row);
17071
+ if (clock < 0) {
17072
+ receipt(row, before, before, "superseded");
17073
+ continue;
17074
+ }
17075
+ if (before.deleted_at)
17076
+ fail("Migration cannot implicitly resurrect a deleted record", 409, row);
17077
+ }
17078
+ current.set(key(row), row);
17079
+ receipt(row, before, row, before ? "updated" : "inserted");
17080
+ }
17081
+ for (const row of incoming.filter((r) => r.deleted_at)) {
17082
+ const before = current.get(key(row));
17083
+ if (before) {
17084
+ if (digest(before) === digest(row)) {
17085
+ receipt(row, before, before, "identical");
17086
+ continue;
17087
+ }
17088
+ const clock = compare(row, before);
17089
+ if (clock === 0)
17090
+ fail("Ambiguous equal-clock lifecycle conflict", 409, row);
17091
+ if (clock < 0) {
17092
+ receipt(row, before, before, "superseded");
17093
+ continue;
17094
+ }
17095
+ }
17096
+ for (const [id, linked] of current) {
17097
+ if (linked.deleted_at)
17098
+ continue;
17099
+ const field = linked.object_type === "projects" ? "parent_id" : "project_id";
17100
+ if (!["projects", "tasks", "plans", "task_lists"].includes(linked.object_type) || linked.payload[field] !== row.object_id)
17101
+ continue;
17102
+ if (compare(linked, row) > 0)
17103
+ fail("A newer linked record conflicts with the historical project deletion", 409, linked);
17104
+ if (linked.object_type === "tasks" && (linked.payload.locked_by || linked.payload.runner_id))
17105
+ fail("Active linked task lease requires reconciliation", 409, linked);
17106
+ const detached = { ...linked, payload: { ...linked.payload, [field]: null, updated_at: row.updated_at }, updated_at: row.updated_at };
17107
+ if (linked.object_type === "tasks") {
17108
+ detached.version = version(linked.version) + 1;
17109
+ detached.payload.version = detached.version;
17110
+ }
17111
+ current.set(id, detached);
17112
+ receipt(linked, linked, detached, "detached");
17113
+ }
17114
+ current.set(key(row), row);
17115
+ receipt(row, before, row, "deleted");
17116
+ }
17117
+ validateGraph(current);
17118
+ for (const [id, row] of current) {
17119
+ if (Date.now() > deadline)
17120
+ fail("Atomic migration exceeded its transaction time budget", 503);
17121
+ const before = original.get(id);
17122
+ if (before && digest(before) === digest(row))
17123
+ continue;
17124
+ await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id) VALUES($1,$2,$3,$4::jsonb,$5::timestamptz,$6::timestamptz,$7,$8) ON CONFLICT(service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=EXCLUDED.version,source_machine_id=EXCLUDED.source_machine_id`, [options.service, row.object_type, row.object_id, row.payload, row.updated_at, row.deleted_at, row.version, row.source_machine_id ?? null]);
17125
+ }
17126
+ const result = { schema_version: 1, operation_id: request.operation_id, snapshot_hash: request.snapshot_hash, authority: request.expected_authority, status: "complete", records: receipts };
17127
+ const changedKeys = new Set(receipts.map((row) => key(row)));
17128
+ const evidence = { request_hash: requestHash, request, receipt: result, before: [...original.values()].filter((row) => changedKeys.has(key(row))) };
17129
+ if (Date.now() > deadline)
17130
+ fail("Atomic migration exceeded its transaction time budget", 503);
17131
+ await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at) VALUES($1,'atomic_project_migrations',$2,$3::jsonb,now())`, [options.service, journalId, evidence]);
17132
+ return result;
17133
+ });
17134
+ } };
17135
+ }
17136
+
15226
17137
  // src/storage/postgres-adapter.ts
15227
17138
  init_types();
15228
- import { randomUUID as randomUUID3 } from "crypto";
17139
+ import { randomUUID as randomUUID4 } from "crypto";
15229
17140
  init_creator_identity();
15230
17141
 
15231
17142
  // src/lib/instant-compare.ts
@@ -15269,8 +17180,8 @@ function sqliteJulianDay(value) {
15269
17180
  const ms = Date.UTC(year, month - 1, day, hour, minute, second);
15270
17181
  return (ms + micros / 1000) / 86400000 + 2440587.5 - offsetMinutes / 1440;
15271
17182
  }
15272
- function changedSinceStampNewer(stamp, since) {
15273
- const stampJd = sqliteJulianDay(stamp);
17183
+ function changedSinceStampNewer(stamp2, since) {
17184
+ const stampJd = sqliteJulianDay(stamp2);
15274
17185
  if (stampJd === null)
15275
17186
  return true;
15276
17187
  const sinceJd = sqliteJulianDay(since);
@@ -15504,6 +17415,10 @@ class PostgresTodosSyncStore {
15504
17415
  }
15505
17416
  }
15506
17417
  async pushSnapshot(snapshot, context = {}) {
17418
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines"))
17419
+ throw new Error("Machine tombstones require explicit registry lifecycle operations");
17420
+ if (snapshot.machines !== undefined)
17421
+ validateMachines(snapshot.machines);
15507
17422
  const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
15508
17423
  if (routingErrors.length > 0) {
15509
17424
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
@@ -15526,6 +17441,14 @@ class PostgresTodosSyncStore {
15526
17441
  throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
15527
17442
  }
15528
17443
  const result = { records: 0, objectTypes: {} };
17444
+ if (snapshot.machines?.length) {
17445
+ if (!this.client.transaction)
17446
+ throw new Error("Machine snapshot migration requires transaction(callback)");
17447
+ const registry = createPostgresMachineRegistry({ query: client.query.bind(client), transaction: (fn) => fn(client) }, this.service, this.tableName, async () => {});
17448
+ const receipt = await registry.execute({ action: "import", machines: snapshot.machines });
17449
+ result.records += receipt.inserted + receipt.skipped;
17450
+ result.objectTypes.machines = receipt.inserted + receipt.skipped;
17451
+ }
15529
17452
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
15530
17453
  for (const entry of snapshotEntries(snapshot)) {
15531
17454
  if (entry.deletedAt === null)
@@ -15689,6 +17612,7 @@ function rowsToSnapshot(rows) {
15689
17612
  tasks: [],
15690
17613
  projects: [],
15691
17614
  projectMachinePaths: [],
17615
+ machines: [],
15692
17616
  plans: [],
15693
17617
  agents: [],
15694
17618
  taskLists: [],
@@ -15700,6 +17624,12 @@ function rowsToSnapshot(rows) {
15700
17624
  for (const row of rows) {
15701
17625
  const payload = payloadRecord(row.payload);
15702
17626
  const deletedAt = stringValue(row.deleted_at);
17627
+ if (row.object_type === "machines") {
17628
+ if (deletedAt)
17629
+ throw new Error("Snapshot contains retired machine identities; use the authoritative registry instead of silently discarding tombstones");
17630
+ snapshot.machines.push(payload);
17631
+ continue;
17632
+ }
15703
17633
  if (deletedAt) {
15704
17634
  snapshot.tombstones ??= [];
15705
17635
  snapshot.tombstones.push({
@@ -15760,12 +17690,12 @@ init_redaction();
15760
17690
  init_task_parent_integrity();
15761
17691
 
15762
17692
  // src/task-manifest/canonical.ts
15763
- import { createHash as createHash5 } from "crypto";
17693
+ import { createHash as createHash6 } from "crypto";
15764
17694
  function canonicalize2(value) {
15765
17695
  if (Array.isArray(value))
15766
17696
  return value.map(canonicalize2);
15767
17697
  if (value !== null && typeof value === "object") {
15768
- return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry2]) => [key, canonicalize2(entry2)]));
17698
+ return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key2, entry2]) => [key2, canonicalize2(entry2)]));
15769
17699
  }
15770
17700
  return value;
15771
17701
  }
@@ -15773,10 +17703,10 @@ function canonicalJson(value) {
15773
17703
  return JSON.stringify(canonicalize2(value));
15774
17704
  }
15775
17705
  function canonicalDigest(value) {
15776
- return createHash5("sha256").update(canonicalJson(value)).digest("hex");
17706
+ return createHash6("sha256").update(canonicalJson(value)).digest("hex");
15777
17707
  }
15778
17708
  function deterministicUuid(namespace, ...parts) {
15779
- const bytes = createHash5("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
17709
+ const bytes = createHash6("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
15780
17710
  bytes[6] = bytes[6] & 15 | 80;
15781
17711
  bytes[8] = bytes[8] & 63 | 128;
15782
17712
  const hex = bytes.toString("hex");
@@ -15836,13 +17766,14 @@ function createPostgresTodosStorageAdapter(options) {
15836
17766
  find: (ref) => findGitRefs(ref, store)
15837
17767
  },
15838
17768
  projects: {
15839
- create: (input, context) => createProject2(input, store, context),
17769
+ create: (input, context) => store.withDependencyGraphTransaction((scoped) => createProject2(input, scoped, context)),
15840
17770
  get: (id) => store.get("projects", id),
15841
17771
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
15842
17772
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
15843
- update: (id, input) => updateProject2(id, input, store),
17773
+ update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateProject2(id, input, scoped)),
15844
17774
  rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
15845
- delete: (id, context) => store.delete("projects", id, context)
17775
+ delete: async (id, context) => (await store.deleteProjectPreserving(id, false, context)).deleted,
17776
+ deletePreserving: (id, force, context, requireCompletedTasks) => store.deleteProjectPreserving(id, force, context, requireCompletedTasks)
15846
17777
  },
15847
17778
  plans: {
15848
17779
  create: (input, context) => createPlan2(input, store, context),
@@ -15851,6 +17782,7 @@ function createPostgresTodosStorageAdapter(options) {
15851
17782
  update: (id, input) => updatePlan2(id, input, store),
15852
17783
  completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
15853
17784
  delete: (id, context) => store.deletePlan(id, context),
17785
+ deletePreserving: (id, force, context) => store.deletePlanPreserving(id, force, context),
15854
17786
  addComment: (input, context) => addPlanComment2(input, store, context),
15855
17787
  getComments: async (planId) => {
15856
17788
  const pages = [];
@@ -15875,7 +17807,7 @@ function createPostgresTodosStorageAdapter(options) {
15875
17807
  apply: (input, context) => store.applyPlanProjectLink(input, context),
15876
17808
  rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
15877
17809
  getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
15878
- getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
17810
+ getReceiptByIdempotencyKey: (key2) => store.getPlanProjectLinkReceiptByIdempotencyKey(key2)
15879
17811
  },
15880
17812
  agents: {
15881
17813
  register: (input, context) => registerAgent2(input, store, context),
@@ -15888,17 +17820,27 @@ function createPostgresTodosStorageAdapter(options) {
15888
17820
  },
15889
17821
  taskLists: {
15890
17822
  create: (input, context) => createTaskList2(input, store, context),
15891
- get: (id) => store.get("task_lists", id),
15892
- getBySlug: async (slug, projectId) => (await store.list("task_lists")).find((list) => list.slug === slug && (projectId === undefined || list.project_id === projectId)) ?? null,
15893
- list: async (projectId) => (await store.list("task_lists")).filter((list) => projectId === undefined || list.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
15894
- update: (id, input) => updateTaskList2(id, input, store),
15895
- delete: (id, context) => store.delete("task_lists", id, context)
17823
+ get: async (id) => {
17824
+ const list = await store.get("task_lists", id);
17825
+ return list ? withTaskListStatus(list) : null;
17826
+ },
17827
+ getBySlug: async (slug, projectId) => {
17828
+ const list = (await store.list("task_lists")).find((list2) => list2.slug === slug && (projectId === undefined || list2.project_id === projectId));
17829
+ return list ? withTaskListStatus(list) : null;
17830
+ },
17831
+ list: async (projectId) => (await store.list("task_lists")).map(withTaskListStatus).filter((list) => projectId === undefined || list.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
17832
+ update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateTaskList2(id, input, scoped)).then(withTaskListStatus),
17833
+ delete: async (id, context) => (await store.deleteTaskListPreserving(id, false, context)).deleted,
17834
+ deletePreserving: (id, force, context) => store.deleteTaskListPreserving(id, force, context)
15896
17835
  },
15897
17836
  templates: {
17837
+ updateWithHistory: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
17838
+ history: (id) => store.withTemplateTransaction((scoped) => readTemplateHistory(id, scoped)),
17839
+ initialize: (context) => store.withTemplateTransaction((scoped) => initializeTemplates(scoped, context)),
15898
17840
  create: (input, context) => createTemplate2(input, store, context),
15899
17841
  get: (id) => store.get("templates", id),
15900
17842
  list: async () => (await store.list("templates")).sort((a, b) => a.name.localeCompare(b.name)),
15901
- update: (id, input) => updateTemplate2(id, input, store),
17843
+ update: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
15902
17844
  delete: (id, context) => deleteTemplate2(id, store, context),
15903
17845
  getWithTasks: async (id) => {
15904
17846
  const template = await store.get("templates", id);
@@ -15932,10 +17874,12 @@ function createPostgresTodosStorageAdapter(options) {
15932
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)),
15933
17875
  getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
15934
17876
  },
17877
+ machines: createPostgresMachineRegistry(options.client, options.service ?? "todos", options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, () => store.ensureSchema()),
17878
+ atomicProjectMigration: createAtomicProjectMigration({ client: options.client, table: options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, service: options.service ?? "todos", ensureSchema: () => store.ensureSchema() }),
15935
17879
  sync: {
15936
17880
  getTasksChangedSince: (since, filters) => getChangedSince(since, filters, store),
15937
17881
  exportSnapshot: () => exportSnapshot(store),
15938
- importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
17882
+ importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context, adapter.machines)
15939
17883
  },
15940
17884
  integrity: {
15941
17885
  report: () => store.integrityReport()
@@ -15952,6 +17896,7 @@ class PostgresJsonRecordStore {
15952
17896
  tableName;
15953
17897
  cursorTableName;
15954
17898
  schemaReady = null;
17899
+ projectIntegrityLocked = false;
15955
17900
  constructor(options) {
15956
17901
  this.options = options;
15957
17902
  this.service = options.service ?? "todos";
@@ -15962,6 +17907,43 @@ class PostgresJsonRecordStore {
15962
17907
  machineId(context) {
15963
17908
  return context?.requestId ?? this.sourceMachineId ?? null;
15964
17909
  }
17910
+ async withDependencyGraphTransaction(fn) {
17911
+ await this.ensureSchema();
17912
+ return this.withTaskParentIntegrityTransaction(async (client) => {
17913
+ const scoped = new PostgresJsonRecordStore({ ...this.options, client });
17914
+ scoped.schemaReady = Promise.resolve();
17915
+ scoped.projectIntegrityLocked = true;
17916
+ return fn(scoped);
17917
+ });
17918
+ }
17919
+ async withTemplateTransaction(fn) {
17920
+ await this.ensureSchema();
17921
+ const transaction = this.options.client.transaction;
17922
+ if (!transaction)
17923
+ throw new Error("TEMPLATE_ATOMICITY_UNAVAILABLE: upgrade the Todos storage backend");
17924
+ return transaction(async (client) => {
17925
+ await client.query("/* todos:template-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':template-integrity', 0))", [this.service]);
17926
+ const scoped = new PostgresJsonRecordStore({ ...this.options, client });
17927
+ scoped.schemaReady = Promise.resolve();
17928
+ return fn(scoped);
17929
+ });
17930
+ }
17931
+ async lockTemplate(id) {
17932
+ await this.options.client.query(`/* todos:template-row-lock */ SELECT object_id FROM ${this.tableName} WHERE service = $1 AND object_type = 'templates' AND object_id = $2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
17933
+ }
17934
+ async persistTemplateRevision(template, previousVersion) {
17935
+ const result = await this.options.client.query(`/* todos:template-version-cas */ UPDATE ${this.tableName}
17936
+ SET payload = $3::jsonb,
17937
+ updated_at = GREATEST(clock_timestamp(), COALESCE(updated_at, 'epoch'::timestamptz) + interval '1 microsecond'),
17938
+ version = $5
17939
+ WHERE service = $1 AND object_type = 'templates' AND object_id = $2
17940
+ AND deleted_at IS NULL AND (payload->>'version')::integer = $4
17941
+ AND COALESCE(version, 1) = $4
17942
+ RETURNING payload`, [this.service, template.id, jsonbParam(template), previousVersion, template.version]);
17943
+ if (result.rows.length !== 1)
17944
+ throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template revision write was refused; no history was committed");
17945
+ return payloadRecord2(result.rows[0].payload);
17946
+ }
15965
17947
  async ensureSchema() {
15966
17948
  if (!this.schemaReady) {
15967
17949
  this.schemaReady = (async () => {
@@ -16148,6 +18130,8 @@ class PostgresJsonRecordStore {
16148
18130
  return "1=0";
16149
18131
  return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
16150
18132
  };
18133
+ if (filter.include_archived === false)
18134
+ conds.push(`(payload->>'archived_at' IS NULL)`);
16151
18135
  if (filter.ids)
16152
18136
  conds.push(inClause("payload->>'id'", filter.ids));
16153
18137
  if (filter.project_id !== undefined)
@@ -16316,6 +18300,32 @@ class PostgresJsonRecordStore {
16316
18300
  throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
16317
18301
  }
16318
18302
  }
18303
+ const projectTypes = new Set(["projects", "tasks", "plans", "task_lists"]);
18304
+ const reference = type === "projects" ? value.parent_id : value.project_id;
18305
+ const planReference = value.plan_id;
18306
+ const needsPlanFence = type === "tasks" && typeof planReference === "string" && planReference.length > 0;
18307
+ const listReference = value.task_list_id;
18308
+ const needsListFence = (type === "tasks" || type === "plans") && typeof listReference === "string" && listReference.length > 0;
18309
+ const needsProjectFence = needsListFence || needsPlanFence || type === "projects" || projectTypes.has(type) && typeof reference === "string" && reference.length > 0;
18310
+ if (needsProjectFence && !this.projectIntegrityLocked) {
18311
+ return this.withDependencyGraphTransaction((scoped) => scoped.upsert(type, value, context));
18312
+ }
18313
+ if (needsListFence)
18314
+ await this.assertTaskListMembership(type, value.id, listReference);
18315
+ if (needsPlanFence) {
18316
+ const plan = await this.options.client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, planReference]);
18317
+ if (!plan.rows.length)
18318
+ throw new PlanNotFoundError(planReference);
18319
+ }
18320
+ if (projectTypes.has(type)) {
18321
+ if (typeof reference === "string" && reference)
18322
+ await this.assertLiveProject(reference);
18323
+ if (type === "projects") {
18324
+ const prior = await this.options.client.query(`SELECT deleted_at FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 FOR UPDATE`, [this.service, value.id]);
18325
+ if (prior.rows[0]?.deleted_at)
18326
+ throw new ProjectNotFoundError(value.id);
18327
+ }
18328
+ }
16319
18329
  await this.ensureSchema();
16320
18330
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
16321
18331
  let result;
@@ -16397,10 +18407,13 @@ class PostgresJsonRecordStore {
16397
18407
  if (planIds.length === 0 && !parentGuard)
16398
18408
  return this.upsert("tasks", value, context);
16399
18409
  await this.ensureSchema();
16400
- if (parentGuard && !queryClient) {
18410
+ if (!queryClient) {
16401
18411
  return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
16402
18412
  }
16403
18413
  const client = queryClient ?? this.options.client;
18414
+ if (value.project_id)
18415
+ await this.assertLiveProject(value.project_id, client);
18416
+ await this.assertTaskListMembership("tasks", value.id, value.task_list_id, client);
16404
18417
  const updatedAt = value.updated_at;
16405
18418
  const targetPlanId = value.plan_id;
16406
18419
  const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
@@ -16535,8 +18548,11 @@ class PostgresJsonRecordStore {
16535
18548
  }
16536
18549
  return payloadRecord2(row.payload);
16537
18550
  }
16538
- async updatePlanWithProjectLinkGuard(value, context = {}) {
18551
+ async updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context = {}) {
18552
+ if (!this.projectIntegrityLocked)
18553
+ return this.withDependencyGraphTransaction((scoped) => scoped.updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context));
16539
18554
  await this.ensureSchema();
18555
+ await this.assertTaskListMembership("plans", value.id, value.task_list_id);
16540
18556
  const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
16541
18557
  SELECT payload FROM ${this.tableName}
16542
18558
  WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
@@ -16555,19 +18571,24 @@ class PostgresJsonRecordStore {
16555
18571
  version = COALESCE(r.version, 0) + 1
16556
18572
  FROM locked_plan
16557
18573
  WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
18574
+ AND locked_plan.payload->>'updated_at' = $6
16558
18575
  RETURNING r.payload
16559
18576
  )
16560
18577
  SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
18578
+ (SELECT payload->>'updated_at' FROM locked_plan) AS current_revision,
16561
18579
  (SELECT payload FROM stored) AS payload`, [
16562
18580
  this.service,
16563
18581
  value.id,
16564
18582
  jsonbParam(value),
16565
18583
  value.updated_at,
16566
- context.requestId ?? this.sourceMachineId ?? null
18584
+ context.requestId ?? this.sourceMachineId ?? null,
18585
+ expectedUpdatedAt
16567
18586
  ]);
16568
18587
  const row = result.rows[0];
16569
- if (!row?.plan_found || !row.payload)
18588
+ if (!row?.plan_found)
16570
18589
  throw new PlanNotFoundError(value.id);
18590
+ if (!row.payload)
18591
+ throw new PlanRevisionConflictError(value.id, expectedUpdatedAt, row.current_revision);
16571
18592
  return payloadRecord2(row.payload);
16572
18593
  }
16573
18594
  async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
@@ -16841,6 +18862,71 @@ class PostgresJsonRecordStore {
16841
18862
  return null;
16842
18863
  return Number(row.counter);
16843
18864
  }
18865
+ async assertTaskListMembership(type, id, target, client = this.options.client) {
18866
+ if (target === undefined || target === null || target === "")
18867
+ return;
18868
+ const prior = await client.query(`SELECT payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type=$2 AND object_id=$3 AND deleted_at IS NULL`, [this.service, type, id]);
18869
+ if (prior.rows[0]?.task_list_id === target)
18870
+ return;
18871
+ const list = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, target]);
18872
+ if (!list.rows.length)
18873
+ throw new TaskListNotFoundError(String(target));
18874
+ }
18875
+ async deleteTaskListPreserving(id, force, context = {}) {
18876
+ await this.ensureSchema();
18877
+ return this.withTaskParentIntegrityTransaction(async (client) => {
18878
+ const receipt = { schema_version: 1, task_list_id: id, deleted: false, detached_task_ids: [], detached_plan_ids: [], detached_tasks: 0, detached_plans: 0 };
18879
+ const target = await client.query(`SELECT object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
18880
+ if (!target.rows.length)
18881
+ return receipt;
18882
+ const related = await client.query(`SELECT object_type,object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2 ORDER BY object_type,object_id FOR UPDATE`, [this.service, id]);
18883
+ if (related.rows.length && !force)
18884
+ throw new ResourceConflictError("TASK_LIST_NOT_EMPTY", "Task list has linked records; force confirms detaching them without deleting content or history");
18885
+ const timestamp2 = new Date([...target.rows, ...related.rows].reduce((latest, row) => Math.max(latest, Date.parse(String(row.updated_at)) + 1), Date.now())).toISOString();
18886
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{task_list_id}','null'::jsonb)||jsonb_build_object('updated_at',$3::text)||CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
18887
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
18888
+ receipt.deleted = true;
18889
+ receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
18890
+ receipt.detached_plan_ids = related.rows.filter((row) => row.object_type === "plans").map((row) => row.object_id);
18891
+ receipt.detached_tasks = receipt.detached_task_ids.length;
18892
+ receipt.detached_plans = receipt.detached_plan_ids.length;
18893
+ return receipt;
18894
+ });
18895
+ }
18896
+ async assertLiveProject(id, client = this.options.client) {
18897
+ const result = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, id]);
18898
+ if (!result.rows.length)
18899
+ throw new ProjectNotFoundError(id);
18900
+ }
18901
+ async deleteProjectPreserving(id, force, context = {}, requireCompletedTasks = false) {
18902
+ await this.ensureSchema();
18903
+ return this.withTaskParentIntegrityTransaction(async (client) => {
18904
+ const project = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
18905
+ const receipt = { schema_version: 1, project_id: id, deleted: false, preserved_tasks: 0, preserved_plans: 0, detached_task_lists: 0, detached_child_projects: 0 };
18906
+ if (!project.rows.length)
18907
+ return receipt;
18908
+ const related = await client.query(`SELECT object_type,object_id,payload->>'status' AS status FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2)) FOR UPDATE`, [this.service, id]);
18909
+ if (requireCompletedTasks && related.rows.some((row) => row.object_type === "tasks" && row.status !== "completed" && row.status !== "cancelled"))
18910
+ throw new ResourceConflictError("PROJECT_INCOMPLETE", "Project has incomplete tasks; deregistration refused");
18911
+ if (related.rows.length && !force)
18912
+ throw new ResourceConflictError("PROJECT_NOT_EMPTY", "Project has linked records; force confirms detaching them without deleting their content");
18913
+ const timestamp2 = new Date().toISOString();
18914
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(jsonb_set(payload,CASE WHEN object_type='projects' THEN '{parent_id}'::text[] ELSE '{project_id}'::text[] END,'null'::jsonb),'{updated_at}',to_jsonb($3::text)) || CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END, updated_at=$3::timestamptz, version=CASE WHEN object_type='tasks' THEN COALESCE(version,0)+1 ELSE version END,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2))`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
18915
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,source_machine_id=$4 WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
18916
+ receipt.deleted = true;
18917
+ for (const row of related.rows) {
18918
+ if (row.object_type === "tasks")
18919
+ receipt.preserved_tasks++;
18920
+ else if (row.object_type === "plans")
18921
+ receipt.preserved_plans++;
18922
+ else if (row.object_type === "task_lists")
18923
+ receipt.detached_task_lists++;
18924
+ else
18925
+ receipt.detached_child_projects++;
18926
+ }
18927
+ return receipt;
18928
+ });
18929
+ }
16844
18930
  async delete(type, id, context = {}) {
16845
18931
  await this.ensureSchema();
16846
18932
  const existing = await this.get(type, id);
@@ -16945,7 +19031,7 @@ class PostgresJsonRecordStore {
16945
19031
  }
16946
19032
  return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
16947
19033
  }
16948
- async currentPlanProjectLinkResult(receipt, action) {
19034
+ async currentPlanProjectLinkResult(receipt, action, previouslyCommitted = false) {
16949
19035
  const [plan, project, tasks] = await Promise.all([
16950
19036
  this.get("plans", receipt.plan_id),
16951
19037
  this.get("projects", receipt.project_id),
@@ -16953,11 +19039,14 @@ class PostgresJsonRecordStore {
16953
19039
  ]);
16954
19040
  const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
16955
19041
  if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
16956
- throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
19042
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", previouslyCommitted ? "The plan-project link was already committed; current state has drifted from its immutable receipt" : "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id, ...previouslyCommitted ? { operation_committed: true, current_state_matches_receipt: false, receipt } : {} });
16957
19043
  }
16958
19044
  return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
16959
19045
  }
16960
19046
  async applyPlanProjectLink(input, context = {}) {
19047
+ if (!this.projectIntegrityLocked) {
19048
+ return this.withDependencyGraphTransaction((scoped) => scoped.applyPlanProjectLink(input, context));
19049
+ }
16961
19050
  await this.ensureSchema();
16962
19051
  const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
16963
19052
  if (existing) {
@@ -16968,7 +19057,7 @@ class PostgresJsonRecordStore {
16968
19057
  if (rolledBack) {
16969
19058
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
16970
19059
  }
16971
- return this.currentPlanProjectLinkResult(existing, "already_linked");
19060
+ return this.currentPlanProjectLinkResult(existing, "already_linked", true);
16972
19061
  }
16973
19062
  const [plan, project, tasks, scopedPlans] = await Promise.all([
16974
19063
  this.get("plans", input.plan_id),
@@ -17110,7 +19199,7 @@ class PostgresJsonRecordStore {
17110
19199
  if (isPostgresUniqueViolation(error)) {
17111
19200
  const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
17112
19201
  if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
17113
- return this.currentPlanProjectLinkResult(raced, "already_linked");
19202
+ return this.currentPlanProjectLinkResult(raced, "already_linked", true);
17114
19203
  }
17115
19204
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
17116
19205
  }
@@ -17133,7 +19222,7 @@ class PostgresJsonRecordStore {
17133
19222
  if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
17134
19223
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
17135
19224
  }
17136
- return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
19225
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked", row.existing_receipt != null);
17137
19226
  }
17138
19227
  async rollbackPlanProjectLink(input, context = {}) {
17139
19228
  await this.ensureSchema();
@@ -17256,6 +19345,28 @@ class PostgresJsonRecordStore {
17256
19345
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
17257
19346
  return accepted;
17258
19347
  }
19348
+ async deletePlanPreserving(id, force, context = {}) {
19349
+ await this.ensureSchema();
19350
+ return this.withTaskParentIntegrityTransaction(async (client) => {
19351
+ const receipt = { schema_version: 1, plan_id: id, deleted: false, detached_task_ids: [], detached_task_list_ids: [], detached_tasks: 0, detached_task_lists: 0 };
19352
+ const target = await client.query(`SELECT object_id,payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
19353
+ if (!target.rows.length)
19354
+ return receipt;
19355
+ const related = await client.query(`SELECT object_type,object_id FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type='tasks' AND payload->>'plan_id'=$2) OR (object_type='task_lists' AND object_id=$3)) ORDER BY object_type,object_id FOR UPDATE`, [this.service, id, target.rows[0].task_list_id]);
19356
+ const notes = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plan_comments' AND payload->>'plan_id'=$2 AND deleted_at IS NULL LIMIT 1`, [this.service, id]);
19357
+ if ((related.rows.length || notes.rows.length) && !force)
19358
+ throw new ResourceConflictError("PLAN_NOT_EMPTY", "Plan has linked records; force confirms detaching them while preserving content and history");
19359
+ const timestamp2 = new Date().toISOString();
19360
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{plan_id}','null'::jsonb) || jsonb_build_object('updated_at',$3::text) || jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1),updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type='tasks' AND payload->>'plan_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
19361
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
19362
+ receipt.deleted = true;
19363
+ receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
19364
+ receipt.detached_task_list_ids = related.rows.filter((row) => row.object_type === "task_lists").map((row) => row.object_id);
19365
+ receipt.detached_tasks = receipt.detached_task_ids.length;
19366
+ receipt.detached_task_lists = receipt.detached_task_list_ids.length;
19367
+ return receipt;
19368
+ });
19369
+ }
17259
19370
  async deletePlan(id, context = {}) {
17260
19371
  await this.ensureSchema();
17261
19372
  const timestamp2 = new Date().toISOString();
@@ -17302,6 +19413,8 @@ class PostgresJsonRecordStore {
17302
19413
  }
17303
19414
  async tombstone(tombstone, context = {}) {
17304
19415
  await this.ensureSchema();
19416
+ if (tombstone.object_type === "atomic_project_migrations")
19417
+ throw new Error("Migration evidence is immutable");
17305
19418
  const deletedAt = stringValue2(tombstone.deleted_at) ?? new Date().toISOString();
17306
19419
  const updatedAt = stringValue2(tombstone.updated_at) ?? deletedAt;
17307
19420
  const existing = await this.clock(tombstone.object_type, tombstone.object_id);
@@ -17359,7 +19472,7 @@ class PostgresJsonRecordStore {
17359
19472
  }
17360
19473
  async function createTask2(input, store, context) {
17361
19474
  const timestamp2 = new Date().toISOString();
17362
- const taskId = randomUUID3();
19475
+ const taskId = randomUUID4();
17363
19476
  await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
17364
19477
  const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
17365
19478
  const requestedProjectId = input.project_id ?? context?.projectId ?? null;
@@ -17581,42 +19694,46 @@ function dependencyId(taskId, dependsOn) {
17581
19694
  return `${taskId}::${dependsOn}`;
17582
19695
  }
17583
19696
  async function addDependency2(taskId, dependsOn, store, context) {
17584
- if (taskId === dependsOn)
17585
- throw new Error("A task cannot depend on itself");
17586
- if (!await store.get("tasks", taskId))
17587
- throw new Error(`Task not found: ${taskId}`);
17588
- if (!await store.get("tasks", dependsOn))
17589
- throw new Error(`Task not found: ${dependsOn}`);
17590
- const edges = await store.list("dependencies");
17591
- const adjacency = new Map;
17592
- for (const edge of edges) {
17593
- if (!adjacency.has(edge.task_id))
17594
- adjacency.set(edge.task_id, []);
17595
- adjacency.get(edge.task_id).push(edge.depends_on);
17596
- }
17597
- const queue = [dependsOn];
17598
- const seen = new Set;
17599
- while (queue.length) {
17600
- const node = queue.shift();
17601
- if (node === taskId)
17602
- throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
17603
- if (seen.has(node))
17604
- continue;
17605
- seen.add(node);
17606
- for (const next of adjacency.get(node) ?? [])
17607
- queue.push(next);
17608
- }
17609
- const timestamp2 = new Date().toISOString();
17610
- const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
17611
- await store.upsert("dependencies", record, context);
17612
- return { task_id: taskId, depends_on: dependsOn };
19697
+ return store.withDependencyGraphTransaction(async (store2) => {
19698
+ if (taskId === dependsOn)
19699
+ throw new Error("A task cannot depend on itself");
19700
+ if (!await store2.get("tasks", taskId))
19701
+ throw new Error(`Task not found: ${taskId}`);
19702
+ if (!await store2.get("tasks", dependsOn))
19703
+ throw new Error(`Task not found: ${dependsOn}`);
19704
+ const edges = await store2.list("dependencies");
19705
+ const adjacency = new Map;
19706
+ for (const edge of edges) {
19707
+ if (!adjacency.has(edge.task_id))
19708
+ adjacency.set(edge.task_id, []);
19709
+ adjacency.get(edge.task_id).push(edge.depends_on);
19710
+ }
19711
+ const queue = [dependsOn];
19712
+ const seen = new Set;
19713
+ while (queue.length) {
19714
+ const node = queue.shift();
19715
+ if (node === taskId)
19716
+ throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
19717
+ if (seen.has(node))
19718
+ continue;
19719
+ seen.add(node);
19720
+ for (const next of adjacency.get(node) ?? [])
19721
+ queue.push(next);
19722
+ }
19723
+ const timestamp2 = new Date().toISOString();
19724
+ const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
19725
+ await store2.upsert("dependencies", record, context);
19726
+ return { task_id: taskId, depends_on: dependsOn };
19727
+ });
17613
19728
  }
17614
19729
  async function removeDependency2(taskId, dependsOn, store) {
17615
- const existing = await store.get("dependencies", dependencyId(taskId, dependsOn));
17616
- if (!existing)
17617
- return false;
17618
- await store.delete("dependencies", dependencyId(taskId, dependsOn));
17619
- return true;
19730
+ return store.withDependencyGraphTransaction(async (store2) => {
19731
+ const existing = await store2.get("dependencies", dependencyId(taskId, dependsOn));
19732
+ if (!existing)
19733
+ return false;
19734
+ await store2.delete("dependencies", dependencyId(taskId, dependsOn));
19735
+ return true;
19736
+ });
17620
19737
  }
17621
19738
  async function listDependencies(taskId, store) {
17622
19739
  const edges = await store.list("dependencies");
@@ -17632,7 +19749,7 @@ async function addVerification(input, store, context) {
17632
19749
  throw new Error(`Task not found: ${input.task_id}`);
17633
19750
  const timestamp2 = new Date().toISOString();
17634
19751
  const verification = {
17635
- id: randomUUID3(),
19752
+ id: randomUUID4(),
17636
19753
  task_id: input.task_id,
17637
19754
  command: input.command,
17638
19755
  status: input.status ?? "unknown",
@@ -17653,7 +19770,7 @@ async function addCommit(input, store, context) {
17653
19770
  throw new Error(`Task not found: ${input.task_id}`);
17654
19771
  const timestamp2 = new Date().toISOString();
17655
19772
  const commit = {
17656
- id: randomUUID3(),
19773
+ id: randomUUID4(),
17657
19774
  task_id: input.task_id,
17658
19775
  sha: input.sha,
17659
19776
  message: input.message ?? null,
@@ -17749,7 +19866,7 @@ async function createProject2(input, store, context) {
17749
19866
  if (!derivedSlug || !taskListId)
17750
19867
  throw new Error("Project name and task-list slug must be non-empty");
17751
19868
  const parentId = input.parent_id ?? null;
17752
- const id = randomUUID3();
19869
+ const id = randomUUID4();
17753
19870
  if (parentId !== null) {
17754
19871
  const parent = await store.get("projects", parentId);
17755
19872
  if (!parent)
@@ -17760,6 +19877,9 @@ async function createProject2(input, store, context) {
17760
19877
  id,
17761
19878
  name: input.name,
17762
19879
  path: input.path,
19880
+ status: input.status ?? "active",
19881
+ short_id: input.short_id ?? null,
19882
+ metadata: input.metadata ?? {},
17763
19883
  description: input.description ?? null,
17764
19884
  task_list_id: taskListId,
17765
19885
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
@@ -17807,6 +19927,9 @@ async function updateProject2(id, input, store) {
17807
19927
  return store.upsert("projects", updated);
17808
19928
  }
17809
19929
  async function createPlan2(input, store, context) {
19930
+ const scheduleError = validatePlanSchedule(input);
19931
+ if (scheduleError)
19932
+ throw new Error(scheduleError);
17810
19933
  const timestamp2 = new Date().toISOString();
17811
19934
  const projectId = input.project_id ?? context?.projectId ?? null;
17812
19935
  const slug = await resolvePostgresPlanSlug({
@@ -17816,7 +19939,7 @@ async function createPlan2(input, store, context) {
17816
19939
  store
17817
19940
  });
17818
19941
  return store.upsert("plans", {
17819
- id: randomUUID3(),
19942
+ id: randomUUID4(),
17820
19943
  slug,
17821
19944
  project_id: projectId,
17822
19945
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -17824,6 +19947,8 @@ async function createPlan2(input, store, context) {
17824
19947
  name: input.name,
17825
19948
  description: input.description ?? null,
17826
19949
  status: input.status ?? "active",
19950
+ ...input.start_date !== undefined ? { start_date: input.start_date } : {},
19951
+ ...input.end_date !== undefined ? { end_date: input.end_date } : {},
17827
19952
  created_at: timestamp2,
17828
19953
  updated_at: timestamp2,
17829
19954
  machine_id: store.machineId(context),
@@ -17832,6 +19957,9 @@ async function createPlan2(input, store, context) {
17832
19957
  }
17833
19958
  async function updatePlan2(id, input, store) {
17834
19959
  const plan = await requireRecord("plans", id, store);
19960
+ const scheduleError = validatePlanSchedule({ ...plan, ...input });
19961
+ if (scheduleError)
19962
+ throw new Error(scheduleError);
17835
19963
  const patch = definedPatch(input);
17836
19964
  if (input.slug !== undefined) {
17837
19965
  patch.slug = await resolvePostgresPlanSlug({
@@ -17846,8 +19974,8 @@ async function updatePlan2(id, input, store) {
17846
19974
  ...plan,
17847
19975
  ...patch,
17848
19976
  project_id: plan.project_id,
17849
- updated_at: new Date().toISOString()
17850
- });
19977
+ updated_at: new Date(Math.max(Date.now(), Date.parse(plan.updated_at) + 1)).toISOString()
19978
+ }, plan.updated_at);
17851
19979
  }
17852
19980
  function matchAgentByName(agents, name, options) {
17853
19981
  const target = normalizeAgentNameInput(name);
@@ -17868,7 +19996,7 @@ async function registerAgent2(input, store, context) {
17868
19996
  }
17869
19997
  const timestamp2 = new Date().toISOString();
17870
19998
  const agent = {
17871
- id: existing?.id ?? randomUUID3().slice(0, 8),
19999
+ id: existing?.id ?? randomUUID4().slice(0, 8),
17872
20000
  name: canonicalName,
17873
20001
  description: input.description ?? existing?.description ?? null,
17874
20002
  role: input.role ?? existing?.role ?? null,
@@ -17935,18 +20063,24 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
17935
20063
  const updated = await store.upsert("agents", { ...agent, session_id: null, last_seen_at: new Date().toISOString() }, context);
17936
20064
  return { agent: updated, released: true };
17937
20065
  }
20066
+ function withTaskListStatus(list) {
20067
+ return { ...list, status: list.status ?? "active" };
20068
+ }
17938
20069
  async function createTaskList2(input, store, context) {
20070
+ if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
20071
+ throw new Error("Invalid task-list status");
17939
20072
  const timestamp2 = new Date().toISOString();
17940
20073
  const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
17941
20074
  if (!slug)
17942
20075
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
17943
20076
  return store.upsert("task_lists", {
17944
- id: randomUUID3(),
20077
+ id: randomUUID4(),
17945
20078
  project_id: input.project_id ?? context?.projectId ?? null,
17946
20079
  slug,
17947
20080
  name: input.name,
17948
20081
  description: input.description ?? null,
17949
20082
  metadata: input.metadata ?? {},
20083
+ status: input.status ?? "active",
17950
20084
  created_at: timestamp2,
17951
20085
  updated_at: timestamp2,
17952
20086
  machine_id: store.machineId(context),
@@ -17954,6 +20088,8 @@ async function createTaskList2(input, store, context) {
17954
20088
  }, context);
17955
20089
  }
17956
20090
  async function updateTaskList2(id, input, store) {
20091
+ if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
20092
+ throw new Error("Invalid task-list status");
17957
20093
  const list = await requireRecord("task_lists", id, store);
17958
20094
  const patch = definedPatch(input);
17959
20095
  const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
@@ -17976,13 +20112,13 @@ async function updateTaskList2(id, input, store) {
17976
20112
  ...list,
17977
20113
  ...patch,
17978
20114
  metadata: input.metadata ?? list.metadata,
17979
- updated_at: new Date().toISOString()
20115
+ updated_at: new Date(Math.max(Date.now(), Date.parse(list.updated_at) + 1)).toISOString()
17980
20116
  });
17981
20117
  }
17982
20118
  async function createTemplate2(input, store, context) {
17983
20119
  const timestamp2 = new Date().toISOString();
17984
20120
  const template = {
17985
- id: randomUUID3(),
20121
+ id: randomUUID4(),
17986
20122
  name: input.name,
17987
20123
  title_pattern: input.title_pattern,
17988
20124
  description: input.description ?? null,
@@ -18003,7 +20139,7 @@ async function createTemplate2(input, store, context) {
18003
20139
  }
18004
20140
  function buildTemplateTasks(templateId, inputs, timestamp2) {
18005
20141
  return inputs.map((input, position) => ({
18006
- id: randomUUID3(),
20142
+ id: randomUUID4(),
18007
20143
  template_id: templateId,
18008
20144
  position,
18009
20145
  title_pattern: input.title_pattern,
@@ -18021,22 +20157,107 @@ function buildTemplateTasks(templateId, inputs, timestamp2) {
18021
20157
  async function deleteTemplate2(id, store, context) {
18022
20158
  return store.deleteTemplateWithTasks(id, context);
18023
20159
  }
18024
- async function updateTemplate2(id, input, store) {
20160
+ async function templateWithTasks(id, store) {
18025
20161
  const template = await store.get("templates", id);
18026
20162
  if (!template)
18027
20163
  return null;
18028
- return store.upsert("templates", {
18029
- ...template,
18030
- ...definedPatch(input),
18031
- tags: input.tags ?? template.tags,
18032
- variables: input.variables ?? template.variables,
18033
- metadata: input.metadata ?? template.metadata,
18034
- version: template.version + 1
20164
+ const tasks = (await store.list("template_tasks")).filter((row) => row.template_id === id).sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
20165
+ return { ...template, tasks };
20166
+ }
20167
+ async function updateTemplate2(id, input, store) {
20168
+ await store.lockTemplate(id);
20169
+ const current = await templateWithTasks(id, store);
20170
+ if (!current)
20171
+ return null;
20172
+ if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version >= 1e4)
20173
+ throw new Error("Template revision is invalid or exceeds the supported bound");
20174
+ if (input.expected_version !== undefined && input.expected_version !== current.version) {
20175
+ throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template changed; fetch its current version before updating");
20176
+ }
20177
+ const {
20178
+ id: _id,
20179
+ version: _version,
20180
+ created_at: _created,
20181
+ machine_id: _machine,
20182
+ synced_at: _synced,
20183
+ ...snapshot
20184
+ } = current;
20185
+ await store.upsert("template_versions", {
20186
+ id: randomUUID4(),
20187
+ template_id: id,
20188
+ version: current.version,
20189
+ snapshot: JSON.stringify(snapshot),
20190
+ created_at: new Date().toISOString()
18035
20191
  });
20192
+ const { tasks, ...template } = current;
20193
+ const { expected_version: _expected, ...patch } = input;
20194
+ const updated = await store.persistTemplateRevision({
20195
+ ...template,
20196
+ ...definedPatch(patch),
20197
+ version: current.version + 1
20198
+ }, current.version);
20199
+ return { ...updated, tasks };
20200
+ }
20201
+ async function readTemplateHistory(id, store) {
20202
+ await store.lockTemplate(id);
20203
+ const current = await store.get("templates", id);
20204
+ if (!current)
20205
+ return null;
20206
+ if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version > 1e4)
20207
+ throw new Error("Template history exceeds the supported version bound");
20208
+ const versions = (await store.list("template_versions")).filter((row) => row.template_id === id).sort((a, b) => b.version - a.version);
20209
+ const seen = new Set;
20210
+ for (const row of versions) {
20211
+ if (!Number.isSafeInteger(row.version) || row.version < 1 || row.version >= current.version || seen.has(row.version))
20212
+ throw new Error("Template history has conflicting versions");
20213
+ seen.add(row.version);
20214
+ }
20215
+ const missing_versions = Array.from({ length: current.version - 1 }, (_, index) => index + 1).filter((version2) => !seen.has(version2));
20216
+ return {
20217
+ current_version: current.version,
20218
+ versions,
20219
+ selection: {
20220
+ schema_version: 1,
20221
+ template_id: id,
20222
+ complete: missing_versions.length === 0,
20223
+ missing_versions
20224
+ }
20225
+ };
20226
+ }
20227
+ async function initializeTemplates(store, context) {
20228
+ const existing = await store.list("templates");
20229
+ const records = [];
20230
+ for (const [definition_index, input] of builtinTemplateInputs().entries()) {
20231
+ const matches = existing.filter((row) => row.name === input.name);
20232
+ if (matches.length) {
20233
+ records.push({
20234
+ definition_index,
20235
+ ids: matches.map((row) => row.id).sort(),
20236
+ name: input.name,
20237
+ status: "skipped"
20238
+ });
20239
+ } else {
20240
+ const template = await createTemplate2(input, store, context);
20241
+ records.push({
20242
+ definition_index,
20243
+ ids: [template.id],
20244
+ name: template.name,
20245
+ status: "created"
20246
+ });
20247
+ }
20248
+ }
20249
+ const names = records.filter((row) => row.status === "created").map((row) => row.name);
20250
+ return {
20251
+ schema_version: 1,
20252
+ created: names.length,
20253
+ skipped: records.length - names.length,
20254
+ names,
20255
+ records
20256
+ };
18036
20257
  }
18037
20258
  async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
18038
20259
  const entry2 = {
18039
- id: randomUUID3(),
20260
+ id: randomUUID4(),
18040
20261
  task_id: taskId,
18041
20262
  action,
18042
20263
  field: field ?? null,
@@ -18050,7 +20271,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
18050
20271
  }
18051
20272
  async function addComment2(input, store, context) {
18052
20273
  const comment = {
18053
- id: randomUUID3(),
20274
+ id: randomUUID4(),
18054
20275
  task_id: input.task_id,
18055
20276
  agent_id: input.agent_id ?? context?.agentId ?? null,
18056
20277
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -18066,7 +20287,7 @@ function redactComment(comment) {
18066
20287
  }
18067
20288
  async function addPlanComment2(input, store, context) {
18068
20289
  const comment = {
18069
- id: randomUUID3(),
20290
+ id: randomUUID4(),
18070
20291
  plan_id: input.plan_id,
18071
20292
  agent_id: input.agent_id ?? context?.agentId ?? null,
18072
20293
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -18084,6 +20305,7 @@ async function exportSnapshot(store) {
18084
20305
  return {
18085
20306
  exportedAt: new Date().toISOString(),
18086
20307
  source: "postgres",
20308
+ machines: await store.list("machines"),
18087
20309
  tasks: await store.list("tasks"),
18088
20310
  projects: await store.list("projects"),
18089
20311
  projectMachinePaths: await store.list("project_machine_paths"),
@@ -18096,8 +20318,27 @@ async function exportSnapshot(store) {
18096
20318
  tombstones: await store.listTombstones()
18097
20319
  };
18098
20320
  }
18099
- async function importSnapshot(snapshot, store, context) {
20321
+ async function importSnapshot(snapshot, store, context, machines) {
18100
20322
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
20323
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "atomic_project_migrations")) {
20324
+ result.errors.push("Migration evidence is immutable");
20325
+ return result;
20326
+ }
20327
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
20328
+ result.errors.push("Machine tombstones require explicit registry lifecycle operations");
20329
+ return result;
20330
+ }
20331
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "projects")) {
20332
+ result.errors.push("Project tombstones require explicit reference-preserving project deletion");
20333
+ return result;
20334
+ }
20335
+ try {
20336
+ if (snapshot.machines !== undefined)
20337
+ validateMachines(snapshot.machines);
20338
+ } catch (e) {
20339
+ result.errors.push(e instanceof Error ? e.message : String(e));
20340
+ return result;
20341
+ }
18101
20342
  result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
18102
20343
  if (result.errors.length > 0)
18103
20344
  return result;
@@ -18108,18 +20349,47 @@ async function importSnapshot(snapshot, store, context) {
18108
20349
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
18109
20350
  if (result.errors.length > 0)
18110
20351
  return result;
20352
+ if (new Set(snapshot.projects.map((project) => project.id)).size !== snapshot.projects.length) {
20353
+ result.errors.push("Snapshot contains duplicate project identities");
20354
+ return result;
20355
+ }
20356
+ const orderedProjects = [];
20357
+ const pendingProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
20358
+ while (pendingProjects.size > 0) {
20359
+ const ready = [...pendingProjects.values()].filter((project) => !project.parent_id || !pendingProjects.has(project.parent_id));
20360
+ if (ready.length === 0) {
20361
+ result.errors.push("Snapshot project hierarchy contains a cycle");
20362
+ return result;
20363
+ }
20364
+ for (const project of ready) {
20365
+ orderedProjects.push(project);
20366
+ pendingProjects.delete(project.id);
20367
+ }
20368
+ }
18111
20369
  const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
18112
20370
  result.errors.push(...auditHistory.errors);
18113
20371
  if (result.errors.length > 0)
18114
20372
  return result;
20373
+ if (snapshot.machines?.length) {
20374
+ try {
20375
+ if (!machines)
20376
+ throw new Error("Machine registry import is unavailable");
20377
+ const receipt = await machines.execute({ action: "import", machines: snapshot.machines });
20378
+ result.inserted += receipt.inserted;
20379
+ result.skipped += receipt.skipped;
20380
+ } catch (error) {
20381
+ result.errors.push(error instanceof Error ? error.message : String(error));
20382
+ return result;
20383
+ }
20384
+ }
18115
20385
  result.skipped += auditHistory.identical;
18116
20386
  const entries = [
18117
- ...snapshot.tasks.map((row) => ["tasks", row]),
18118
- ...snapshot.projects.map((row) => ["projects", row]),
20387
+ ...orderedProjects.map((row) => ["projects", row]),
18119
20388
  ...(snapshot.projectMachinePaths ?? []).map((row) => ["project_machine_paths", row]),
18120
- ...snapshot.plans.map((row) => ["plans", row]),
18121
20389
  ...snapshot.agents.map((row) => ["agents", row]),
18122
20390
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
20391
+ ...snapshot.plans.map((row) => ["plans", row]),
20392
+ ...snapshot.tasks.map((row) => ["tasks", row]),
18123
20393
  ...snapshot.templates.map((row) => ["templates", row]),
18124
20394
  ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
18125
20395
  ];
@@ -18306,7 +20576,7 @@ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
18306
20576
  lastError = error;
18307
20577
  if (!isTransientPostgresError(error) || attempt === attempts)
18308
20578
  throw error;
18309
- await new Promise((resolve9) => setTimeout(resolve9, delayMs * attempt));
20579
+ await new Promise((resolve10) => setTimeout(resolve10, delayMs * attempt));
18310
20580
  }
18311
20581
  }
18312
20582
  throw lastError;
@@ -18373,7 +20643,7 @@ class TodosShadowMirror {
18373
20643
  lastError: this.metrics.lastError
18374
20644
  };
18375
20645
  }
18376
- enqueueUpsert(key, record, context) {
20646
+ enqueueUpsert(key2, record, context) {
18377
20647
  if (!record || typeof record !== "object")
18378
20648
  return;
18379
20649
  const payload = record;
@@ -18382,7 +20652,7 @@ class TodosShadowMirror {
18382
20652
  this.metrics.enqueued += 1;
18383
20653
  this.queue.push({
18384
20654
  kind: "upsert",
18385
- key,
20655
+ key: key2,
18386
20656
  record: payload,
18387
20657
  ...context ? { context } : {},
18388
20658
  enqueuedAt: Date.now(),
@@ -18407,8 +20677,8 @@ class TodosShadowMirror {
18407
20677
  async flush() {
18408
20678
  if (this.idle())
18409
20679
  return;
18410
- await new Promise((resolve9) => {
18411
- this.idleResolvers.push(resolve9);
20680
+ await new Promise((resolve10) => {
20681
+ this.idleResolvers.push(resolve10);
18412
20682
  this.pump();
18413
20683
  });
18414
20684
  }
@@ -18420,8 +20690,8 @@ class TodosShadowMirror {
18420
20690
  return;
18421
20691
  const resolvers = this.idleResolvers;
18422
20692
  this.idleResolvers = [];
18423
- for (const resolve9 of resolvers)
18424
- resolve9();
20693
+ for (const resolve10 of resolvers)
20694
+ resolve10();
18425
20695
  }
18426
20696
  pump() {
18427
20697
  if (this.pumping)
@@ -18759,8 +21029,8 @@ function createTodosCloudQueryClient(url, options = {}) {
18759
21029
  };
18760
21030
  return client;
18761
21031
  }
18762
- function createTodosCloudQueryClientFromEnv(env = process.env, options = {}) {
18763
- const url = getTodosStorageDatabaseUrl(env);
21032
+ function createTodosCloudQueryClientFromEnv(env2 = process.env, options = {}) {
21033
+ const url = getTodosStorageDatabaseUrl(env2);
18764
21034
  if (!url)
18765
21035
  return null;
18766
21036
  return createTodosCloudQueryClient(url, options);
@@ -18900,7 +21170,7 @@ class TodosShadowOutbox {
18900
21170
  const remaining = deadline - Date.now();
18901
21171
  if (remaining <= 0)
18902
21172
  break;
18903
- await new Promise((resolve9) => setTimeout(resolve9, Math.min(200, remaining)));
21173
+ await new Promise((resolve10) => setTimeout(resolve10, Math.min(200, remaining)));
18904
21174
  }
18905
21175
  }
18906
21176
  return this.getStats();
@@ -19078,8 +21348,8 @@ var _capturedDb = null;
19078
21348
  var _outbox = null;
19079
21349
  var _cloud = null;
19080
21350
  var _exitRegistered = false;
19081
- function maybeInstallShadowCapture2(db, env = process.env) {
19082
- if (!isTodosShadowEnabled(env))
21351
+ function maybeInstallShadowCapture2(db, env2 = process.env) {
21352
+ if (!isTodosShadowEnabled(env2))
19083
21353
  return false;
19084
21354
  if (_capturedDb === db)
19085
21355
  return true;
@@ -19087,15 +21357,15 @@ function maybeInstallShadowCapture2(db, env = process.env) {
19087
21357
  _capturedDb = db;
19088
21358
  return true;
19089
21359
  }
19090
- function getRuntimeShadowOutbox(db, env = process.env) {
19091
- if (!isTodosShadowEnabled(env)) {
19092
- throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env)}=1 to enable it`);
21360
+ function getRuntimeShadowOutbox(db, env2 = process.env) {
21361
+ if (!isTodosShadowEnabled(env2)) {
21362
+ throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env2)}=1 to enable it`);
19093
21363
  }
19094
21364
  if (_outbox && _capturedDb === db)
19095
21365
  return _outbox;
19096
- const cloud = createTodosCloudQueryClientFromEnv(env);
21366
+ const cloud = createTodosCloudQueryClientFromEnv(env2);
19097
21367
  if (!cloud) {
19098
- throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env)}`);
21368
+ throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env2)}`);
19099
21369
  }
19100
21370
  _cloud = cloud;
19101
21371
  installShadowOutboxSchema(db);
@@ -19103,28 +21373,28 @@ function getRuntimeShadowOutbox(db, env = process.env) {
19103
21373
  _outbox = new TodosShadowOutbox({ db, postgresClient: cloud });
19104
21374
  return _outbox;
19105
21375
  }
19106
- function startRuntimeShadowDrain(db, env = process.env) {
19107
- if (!isTodosShadowEnabled(env))
21376
+ function startRuntimeShadowDrain(db, env2 = process.env) {
21377
+ if (!isTodosShadowEnabled(env2))
19108
21378
  return;
19109
- maybeInstallShadowCapture2(db, env);
21379
+ maybeInstallShadowCapture2(db, env2);
19110
21380
  try {
19111
- const outbox = getRuntimeShadowOutbox(db, env);
21381
+ const outbox = getRuntimeShadowOutbox(db, env2);
19112
21382
  outbox.startLoop();
19113
- registerShadowExitFlush(db, env);
21383
+ registerShadowExitFlush(db, env2);
19114
21384
  } catch (error) {
19115
21385
  const message = error instanceof Error ? error.message : String(error);
19116
21386
  console.error(`[todos] shadow drain not started: ${message}`);
19117
21387
  }
19118
21388
  }
19119
- function registerShadowExitFlush(db, env = process.env) {
21389
+ function registerShadowExitFlush(db, env2 = process.env) {
19120
21390
  if (_exitRegistered)
19121
21391
  return;
19122
- if (!isTodosShadowEnabled(env))
21392
+ if (!isTodosShadowEnabled(env2))
19123
21393
  return;
19124
21394
  _exitRegistered = true;
19125
21395
  const flush = async () => {
19126
21396
  try {
19127
- const outbox = getRuntimeShadowOutbox(db, env);
21397
+ const outbox = getRuntimeShadowOutbox(db, env2);
19128
21398
  await outbox.flush(5000);
19129
21399
  } catch {} finally {
19130
21400
  await closeRuntimeShadowCloud();
@@ -19282,7 +21552,7 @@ function isCommentRedactionBackfillComplete(result) {
19282
21552
  return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
19283
21553
  }
19284
21554
  // src/storage/s3-artifacts.ts
19285
- import { createHash as createHash6, createHmac as createHmac2 } from "crypto";
21555
+ import { createHash as createHash7, createHmac as createHmac2 } from "crypto";
19286
21556
  function createTodosS3ArtifactStore(options) {
19287
21557
  const requestFetch = options.fetch ?? fetch;
19288
21558
  const now3 = options.now ?? (() => new Date);
@@ -19290,8 +21560,8 @@ function createTodosS3ArtifactStore(options) {
19290
21560
  objectKey: (relativePath) => buildS3ObjectKey(options.config, relativePath),
19291
21561
  objectUrl: (relativePath) => buildS3ObjectUrl(options.config, buildS3ObjectKey(options.config, relativePath)),
19292
21562
  async putObject(input) {
19293
- const key = buildS3ObjectKey(options.config, input.key);
19294
- const url = buildS3ObjectUrl(options.config, key);
21563
+ const key2 = buildS3ObjectKey(options.config, input.key);
21564
+ const url = buildS3ObjectUrl(options.config, key2);
19295
21565
  const body = normalizeBody(input.body);
19296
21566
  const headers = {
19297
21567
  "content-type": input.contentType ?? "application/octet-stream"
@@ -19314,7 +21584,7 @@ function createTodosS3ArtifactStore(options) {
19314
21584
  throw new Error(`S3 put failed with HTTP ${response.status}`);
19315
21585
  return {
19316
21586
  bucket: options.config.bucket,
19317
- key,
21587
+ key: key2,
19318
21588
  url: url.toString(),
19319
21589
  etag: response.headers.get("etag") ?? undefined
19320
21590
  };
@@ -19354,11 +21624,11 @@ function createTodosS3ArtifactStore(options) {
19354
21624
  }
19355
21625
  function buildS3ObjectKey(config, relativePath) {
19356
21626
  const prefix = normalizeS3Prefix(config.prefix);
19357
- const key = normalizeS3Key(relativePath);
19358
- return `${prefix}${key}`;
21627
+ const key2 = normalizeS3Key(relativePath);
21628
+ return `${prefix}${key2}`;
19359
21629
  }
19360
- function buildS3ObjectUrl(config, key) {
19361
- const encodedKey = encodeS3Path(key);
21630
+ function buildS3ObjectUrl(config, key2) {
21631
+ const encodedKey = encodeS3Path(key2);
19362
21632
  if (config.endpoint || config.forcePathStyle) {
19363
21633
  const base = new URL(config.endpoint ?? `https://s3.${config.region ?? "us-east-1"}.amazonaws.com`);
19364
21634
  base.pathname = `${trimTrailingSlash(base.pathname)}/${encodeURIComponent(config.bucket)}/${encodedKey}`;
@@ -19422,25 +21692,25 @@ function normalizeBody(body) {
19422
21692
  throw new Error("S3 body must be a string, Uint8Array, Buffer, or ArrayBuffer");
19423
21693
  }
19424
21694
  function normalizeS3Prefix(value) {
19425
- const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
19426
- if (!normalized || normalized.includes(".."))
21695
+ const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
21696
+ if (!normalized2 || normalized2.includes(".."))
19427
21697
  throw new Error("Invalid S3 object key");
19428
- return normalized.endsWith("/") ? normalized : `${normalized}/`;
21698
+ return normalized2.endsWith("/") ? normalized2 : `${normalized2}/`;
19429
21699
  }
19430
21700
  function normalizeS3Key(value) {
19431
- const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
19432
- if (!normalized || normalized.includes("..") || normalized.endsWith("/"))
21701
+ const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
21702
+ if (!normalized2 || normalized2.includes("..") || normalized2.endsWith("/"))
19433
21703
  throw new Error("Invalid S3 object key");
19434
- return normalized;
21704
+ return normalized2;
19435
21705
  }
19436
- function encodeS3Path(key) {
19437
- return key.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
21706
+ function encodeS3Path(key2) {
21707
+ return key2.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
19438
21708
  }
19439
21709
  function trimTrailingSlash(value) {
19440
21710
  return value.replace(/\/+$/, "");
19441
21711
  }
19442
21712
  function canonicalQuery(url) {
19443
- return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
21713
+ return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key2, value]) => `${encodeURIComponent(key2)}=${encodeURIComponent(value)}`).join("&");
19444
21714
  }
19445
21715
  function normalizeHeaders(headers) {
19446
21716
  const entries = Object.entries(headers).map(([name, value]) => [
@@ -19454,13 +21724,13 @@ function toAmzDate(date) {
19454
21724
  return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
19455
21725
  }
19456
21726
  function sha256Hex(value) {
19457
- return createHash6("sha256").update(value).digest("hex");
21727
+ return createHash7("sha256").update(value).digest("hex");
19458
21728
  }
19459
- function hmac(key, value) {
19460
- return createHmac2("sha256", key).update(value).digest();
21729
+ function hmac(key2, value) {
21730
+ return createHmac2("sha256", key2).update(value).digest();
19461
21731
  }
19462
- function hmacHex(key, value) {
19463
- return createHmac2("sha256", key).update(value).digest("hex");
21732
+ function hmacHex(key2, value) {
21733
+ return createHmac2("sha256", key2).update(value).digest("hex");
19464
21734
  }
19465
21735
  function getSigningKey(secretAccessKey, dateStamp, region, service) {
19466
21736
  const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp);
@@ -19494,7 +21764,7 @@ async function uploadRunArtifactsToS3(options) {
19494
21764
  continue;
19495
21765
  }
19496
21766
  const ref = await options.store.putObject({
19497
- key: content.relative_path,
21767
+ key: remoteArtifactKey(artifact.task_id, content.sha256),
19498
21768
  body: Buffer.from(content.base64, "base64"),
19499
21769
  contentType: mediaType(metadata) ?? "application/octet-stream",
19500
21770
  metadata: {
@@ -19508,7 +21778,8 @@ async function uploadRunArtifactsToS3(options) {
19508
21778
  provider: "s3",
19509
21779
  bucket: ref.bucket,
19510
21780
  key: ref.key,
19511
- relative_path: content.relative_path,
21781
+ relative_path: remoteArtifactKey(artifact.task_id, content.sha256),
21782
+ local_path: content.relative_path,
19512
21783
  url: ref.url,
19513
21784
  sha256: content.sha256,
19514
21785
  size_bytes: content.size_bytes,
@@ -19612,7 +21883,7 @@ async function downloadRunArtifactsFromS3(options) {
19612
21883
  const bytes = Buffer.from(await response.arrayBuffer());
19613
21884
  const report = importStoredArtifactContent({
19614
21885
  artifact_id: artifact.id,
19615
- relative_path: remote.relative_path,
21886
+ relative_path: remote.local_path ?? remote.relative_path,
19616
21887
  sha256: remote.sha256,
19617
21888
  size_bytes: remote.size_bytes,
19618
21889
  base64: bytes.toString("base64")
@@ -19644,6 +21915,12 @@ async function downloadRunArtifactsFromS3(options) {
19644
21915
  function emptyResult() {
19645
21916
  return { uploaded: 0, downloaded: 0, skipped: 0, errors: [], artifacts: [] };
19646
21917
  }
21918
+ function remoteArtifactKey(taskId, sha2562) {
21919
+ if (!/^[0-9a-f]{64}$/.test(sha2562)) {
21920
+ throw new Error("run artifact digest must be a lowercase hex sha-256; refusing to build an object key from it");
21921
+ }
21922
+ return `artifacts/${taskId}/${sha2562}`;
21923
+ }
19647
21924
  function planArtifact(artifact, status, remote) {
19648
21925
  return {
19649
21926
  id: artifact.id,
@@ -19707,10 +21984,106 @@ function mediaType(metadata) {
19707
21984
  const value = store["media_type"];
19708
21985
  return typeof value === "string" ? value : null;
19709
21986
  }
21987
+ // src/storage/run-artifact-creation-upload.ts
21988
+ init_database();
21989
+ init_artifact_store();
21990
+ init_config();
21991
+ async function uploadRunArtifactAtCreation(input) {
21992
+ const db = input.db ?? getDatabase();
21993
+ const now3 = input.now ?? (() => new Date);
21994
+ try {
21995
+ const artifact = getRunArtifactById(db, input.artifactId);
21996
+ if (!artifact)
21997
+ return { uploaded: false, reason: "artifact_not_found" };
21998
+ const metadata = artifact.metadata;
21999
+ const existing = remoteRef(metadata);
22000
+ if (existing)
22001
+ return { uploaded: true, reason: "already_remote", ref: existing };
22002
+ if (!input.store) {
22003
+ const config = loadTodosStorageConfig(input.env ?? process.env);
22004
+ if (!config.objectStorage)
22005
+ return { uploaded: false, reason: "s3_not_configured" };
22006
+ const credentials = creationCredentialsFromEnv(input.env ?? process.env);
22007
+ if (!credentials)
22008
+ return { uploaded: false, reason: "s3_credentials_missing" };
22009
+ input.store = createTodosS3ArtifactStore({ config: config.objectStorage, credentials });
22010
+ }
22011
+ const store = input.store;
22012
+ const content = exportStoredArtifactContent({
22013
+ id: artifact.id,
22014
+ path: artifact.path,
22015
+ size_bytes: artifact.size_bytes,
22016
+ sha256: artifact.sha256,
22017
+ metadata
22018
+ });
22019
+ if (!content)
22020
+ return { uploaded: false, reason: "no_local_content" };
22021
+ const relativePath = remoteArtifactKey(artifact.task_id, content.sha256);
22022
+ const ref = await store.putObject({
22023
+ key: relativePath,
22024
+ body: Buffer.from(content.base64, "base64"),
22025
+ contentType: mediaType2(metadata) ?? "application/octet-stream",
22026
+ metadata: {
22027
+ artifact_id: artifact.id,
22028
+ run_id: artifact.run_id,
22029
+ task_id: artifact.task_id,
22030
+ sha256: content.sha256
22031
+ }
22032
+ });
22033
+ const remote = {
22034
+ provider: "s3",
22035
+ bucket: ref.bucket,
22036
+ key: ref.key,
22037
+ relative_path: relativePath,
22038
+ local_path: content.relative_path,
22039
+ url: ref.url,
22040
+ sha256: content.sha256,
22041
+ size_bytes: content.size_bytes,
22042
+ uploaded_at: now3().toISOString()
22043
+ };
22044
+ updateArtifactMetadata(db, artifact.id, {
22045
+ ...metadata,
22046
+ remote_artifact_store: remote
22047
+ });
22048
+ return { uploaded: true, reason: "uploaded", ref: remote };
22049
+ } catch (error) {
22050
+ return {
22051
+ uploaded: false,
22052
+ reason: "upload_error",
22053
+ error: error instanceof Error ? error.message : String(error)
22054
+ };
22055
+ }
22056
+ }
22057
+ function creationCredentialsFromEnv(env2) {
22058
+ const accessKeyId = readCredentialEnv(env2, "s3AccessKeyId");
22059
+ const secretAccessKey = readCredentialEnv(env2, "s3SecretAccessKey");
22060
+ if (!accessKeyId || !secretAccessKey)
22061
+ return null;
22062
+ const sessionToken = readCredentialEnv(env2, "s3SessionToken");
22063
+ return sessionToken ? { accessKeyId, secretAccessKey, sessionToken } : { accessKeyId, secretAccessKey };
22064
+ }
22065
+ function readCredentialEnv(env2, key2) {
22066
+ const value = env2[TODOS_STORAGE_ENV[key2]] ?? env2[TODOS_STORAGE_FALLBACK_ENV[key2]];
22067
+ const trimmed = value?.trim();
22068
+ return trimmed ? trimmed : undefined;
22069
+ }
22070
+ function getRunArtifactById(db, id) {
22071
+ const row = db.query("SELECT * FROM task_run_artifacts WHERE id = ?").get(id);
22072
+ return row ? { ...row, metadata: parseMetadata2(row.metadata) } : null;
22073
+ }
22074
+ function mediaType2(metadata) {
22075
+ const store = metadata["artifact_store"];
22076
+ if (!store || typeof store !== "object" || Array.isArray(store))
22077
+ return null;
22078
+ const value = store["media_type"];
22079
+ return typeof value === "string" ? value : null;
22080
+ }
19710
22081
  export {
19711
22082
  uploadRunArtifactsToS3,
22083
+ uploadRunArtifactAtCreation,
19712
22084
  startRuntimeShadowDrain,
19713
22085
  signAwsV4Request,
22086
+ remoteArtifactKey,
19714
22087
  registerShadowExitFlush,
19715
22088
  postgresTodosSyncSchemaSql,
19716
22089
  postgresTodosScopedSlugUniqueIndexSql,