@hasna/todos 0.15.7 → 0.15.9

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.
package/dist/mcp/index.js CHANGED
@@ -11646,6 +11646,40 @@ function deleteTaskList(id, db) {
11646
11646
  return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
11647
11647
  })();
11648
11648
  }
11649
+ function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
11650
+ const d = db || getDatabase();
11651
+ return d.transaction(() => {
11652
+ const current = getTaskList(id, d);
11653
+ if (!current) {
11654
+ return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
11655
+ }
11656
+ const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
11657
+ if (changed) {
11658
+ return { status: "changed", task_dependents: 0, plan_dependents: 0 };
11659
+ }
11660
+ const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
11661
+ const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
11662
+ if (taskDependents > 0 || planDependents > 0) {
11663
+ return {
11664
+ status: "has_dependents",
11665
+ task_dependents: taskDependents,
11666
+ plan_dependents: planDependents
11667
+ };
11668
+ }
11669
+ recordStorageTombstone({
11670
+ object_type: "task_lists",
11671
+ object_id: id,
11672
+ payload: current
11673
+ }, d);
11674
+ releaseCanonicalSlugClaims("task_list", id, d);
11675
+ const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
11676
+ return {
11677
+ status: deleted ? "deleted" : "not_found",
11678
+ task_dependents: 0,
11679
+ plan_dependents: 0
11680
+ };
11681
+ })();
11682
+ }
11649
11683
  function ensureTaskList(name, slug, projectId, db) {
11650
11684
  const d = db || getDatabase();
11651
11685
  const existing = getTaskListBySlug(slug, projectId, d);
@@ -20769,12 +20803,16 @@ function protectRemoteClient(client) {
20769
20803
  function remoteAuthorityBase(client) {
20770
20804
  return client.baseUrl.replace(/\/v1\/?$/, "");
20771
20805
  }
20772
- async function requiredRemoteRoute(client, route, request) {
20806
+ async function requiredRemoteRoute(client, route, request, recognized404Codes = []) {
20773
20807
  try {
20774
20808
  return await request();
20775
20809
  } catch (error) {
20776
20810
  const status = error && typeof error === "object" ? error.status : undefined;
20777
20811
  if (status === 404) {
20812
+ const body = error && typeof error === "object" ? error.body : undefined;
20813
+ const code = body && typeof body === "object" && !Array.isArray(body) ? body.code : undefined;
20814
+ if (typeof code === "string" && recognized404Codes.includes(code))
20815
+ throw error;
20778
20816
  throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} does not expose ${route}; ` + "deploy the @hasna/todos /v1 server contract before retrying; local SQLite fallback is disabled", { cause: error });
20779
20817
  }
20780
20818
  throw error;
@@ -35176,7 +35214,7 @@ var package_default;
35176
35214
  var init_package = __esm(() => {
35177
35215
  package_default = {
35178
35216
  name: "@hasna/todos",
35179
- version: "0.15.7",
35217
+ version: "0.15.9",
35180
35218
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35181
35219
  type: "module",
35182
35220
  main: "dist/index.js",
@@ -45071,7 +45109,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45071
45109
  getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
45072
45110
  list: (projectId) => listTaskLists(projectId, database()),
45073
45111
  update: (id, input) => updateTaskList(id, input, database()),
45074
- delete: (id) => deleteTaskList(id, database())
45112
+ delete: (id) => deleteTaskList(id, database()),
45113
+ deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
45075
45114
  },
45076
45115
  templates: {
45077
45116
  create: (input) => createTemplate(input, database()),
@@ -48377,8 +48416,592 @@ var init_postgres2 = __esm(() => {
48377
48416
  });
48378
48417
 
48379
48418
  // src/project-registration/sqlite.ts
48380
- var sqliteTransactionTails, PROJECT_REFERENCE_COLUMNS, TASK_LIST_REFERENCE_COLUMNS;
48419
+ function sameSqliteValue(left, right) {
48420
+ return JSON.stringify(left) === JSON.stringify(right);
48421
+ }
48422
+ function taskListFromRow(row) {
48423
+ return {
48424
+ ...row,
48425
+ metadata: JSON.parse(row.metadata || "{}")
48426
+ };
48427
+ }
48428
+ function selectProject(db, id) {
48429
+ return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
48430
+ }
48431
+ function selectTaskList(db, id) {
48432
+ const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
48433
+ return row ? taskListFromRow(row) : null;
48434
+ }
48435
+ function selectProjectConflict(db, path, taskListSlug) {
48436
+ return db.query(`
48437
+ SELECT * FROM projects
48438
+ WHERE path = ? OR task_list_id = ?
48439
+ ORDER BY created_at ASC, id ASC
48440
+ LIMIT 1
48441
+ `).get(path, taskListSlug);
48442
+ }
48443
+ function selectTaskListConflict(db, projectId, slug) {
48444
+ const row = db.query(`
48445
+ SELECT * FROM task_lists
48446
+ WHERE project_id = ? AND slug = ?
48447
+ LIMIT 1
48448
+ `).get(projectId, slug);
48449
+ return row ? taskListFromRow(row) : null;
48450
+ }
48451
+ function quoteSqliteIdentifier(value) {
48452
+ return `"${value.replaceAll('"', '""')}"`;
48453
+ }
48454
+ function hasSqliteDependents(db, resourceKind, targetId) {
48455
+ const targetTable = resourceKind === "project" ? "projects" : "task_lists";
48456
+ const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
48457
+ const tables = db.query(`
48458
+ SELECT name FROM sqlite_schema
48459
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
48460
+ ORDER BY name
48461
+ `).all();
48462
+ for (const { name: tableName } of tables) {
48463
+ const quotedTable = quoteSqliteIdentifier(tableName);
48464
+ const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
48465
+ const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
48466
+ const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
48467
+ for (const columnName of referenceColumns) {
48468
+ const row = db.query(`
48469
+ SELECT 1 AS found
48470
+ FROM ${quotedTable}
48471
+ WHERE ${quoteSqliteIdentifier(columnName)} = ?
48472
+ LIMIT 1
48473
+ `).get(targetId);
48474
+ if (row)
48475
+ return true;
48476
+ }
48477
+ }
48478
+ return false;
48479
+ }
48480
+ function receiptFromRow2(row) {
48481
+ return {
48482
+ ...row,
48483
+ authority: "todos",
48484
+ created_by_operation: Number(row["created_by_operation"]) === 1
48485
+ };
48486
+ }
48487
+ function bindingFromRow2(row) {
48488
+ return row;
48489
+ }
48490
+
48491
+ class SqliteTodosProjectRegistrationTransaction {
48492
+ db;
48493
+ storage;
48494
+ constructor(db) {
48495
+ this.db = db;
48496
+ this.storage = createLocalSqliteTodosStorageAdapter({ db });
48497
+ }
48498
+ async lockStep(_identity) {}
48499
+ async getReceiptForLookup(identity) {
48500
+ const row = this.db.query(`
48501
+ SELECT * FROM todos_project_registration_receipts
48502
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48503
+ AND operation_id = ? AND step_id = ? AND resource_kind = ?
48504
+ AND direction = ? AND idempotency_key = ? AND target_selector = ?
48505
+ ORDER BY CASE outcome
48506
+ WHEN 'terminal_nonacceptance' THEN 0
48507
+ WHEN 'duplicate_of_accepted' THEN 1
48508
+ ELSE 2
48509
+ END, created_at DESC, receipt_id DESC
48510
+ LIMIT 1
48511
+ `).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction, identity.idempotency_key, identity.target_selector);
48512
+ return row ? receiptFromRow2(row) : null;
48513
+ }
48514
+ async getReceiptById(receiptId) {
48515
+ const row = this.db.query("SELECT * FROM todos_project_registration_receipts WHERE receipt_id = ? LIMIT 1").get(receiptId);
48516
+ return row ? receiptFromRow2(row) : null;
48517
+ }
48518
+ async getAcceptedReceiptForStep(identity) {
48519
+ const row = this.db.query(`
48520
+ SELECT * FROM todos_project_registration_receipts
48521
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48522
+ AND operation_id = ? AND step_id = ? AND resource_kind = ?
48523
+ AND direction = ? AND outcome = 'accepted'
48524
+ ORDER BY created_at ASC, receipt_id ASC
48525
+ LIMIT 1
48526
+ `).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction);
48527
+ return row ? receiptFromRow2(row) : null;
48528
+ }
48529
+ async insertReceipt(receipt) {
48530
+ const result = this.db.query(`
48531
+ INSERT OR IGNORE INTO todos_project_registration_receipts (
48532
+ receipt_id, authority, route, package_version, authority_id, tenant_id,
48533
+ corpus_id, operation_id, step_id, resource_kind, direction,
48534
+ target_selector, idempotency_key, request_digest, precondition_digest,
48535
+ normalized_call_digest, outcome, reason, target_id, result_revision,
48536
+ result_digest, duplicate_of_receipt_id, accepted_receipt_id,
48537
+ created_by_operation, created_at
48538
+ ) VALUES (
48539
+ ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
48540
+ )
48541
+ `).run(receipt.receipt_id, receipt.authority, receipt.route, receipt.package_version, receipt.authority_id, receipt.tenant_id, receipt.corpus_id, receipt.operation_id, receipt.step_id, receipt.resource_kind, receipt.direction, receipt.target_selector, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.normalized_call_digest, receipt.outcome, receipt.reason, receipt.target_id, receipt.result_revision, receipt.result_digest, receipt.duplicate_of_receipt_id, receipt.accepted_receipt_id, receipt.created_by_operation ? 1 : 0, receipt.created_at);
48542
+ return result.changes === 1;
48543
+ }
48544
+ async getBinding(scope, resourceKind, targetSelector) {
48545
+ const row = this.db.query(`
48546
+ SELECT * FROM todos_project_registration_bindings
48547
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48548
+ AND resource_kind = ? AND target_selector = ?
48549
+ LIMIT 1
48550
+ `).get(scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48551
+ return row ? bindingFromRow2(row) : null;
48552
+ }
48553
+ async claimBinding(binding) {
48554
+ const result = this.db.query(`
48555
+ INSERT OR IGNORE INTO todos_project_registration_bindings (
48556
+ authority_id, tenant_id, corpus_id, resource_kind, target_selector,
48557
+ operation_id, step_id, direction, idempotency_key, request_digest,
48558
+ precondition_digest, normalized_call_digest, state, target_id,
48559
+ accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
48560
+ created_at, updated_at
48561
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
48562
+ `).run(binding.authority_id, binding.tenant_id, binding.corpus_id, binding.resource_kind, binding.target_selector, binding.operation_id, binding.step_id, binding.direction, binding.idempotency_key, binding.request_digest, binding.precondition_digest, binding.normalized_call_digest, binding.state, binding.target_id, binding.accepted_receipt_id, binding.result_revision, binding.result_digest, binding.removed_receipt_id, binding.created_at, binding.updated_at);
48563
+ return result.changes === 1;
48564
+ }
48565
+ async setBindingAccepted(scope, resourceKind, targetSelector, update) {
48566
+ const result = this.db.query(`
48567
+ UPDATE todos_project_registration_bindings
48568
+ SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
48569
+ result_revision = ?, result_digest = ?, updated_at = ?
48570
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48571
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
48572
+ `).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48573
+ if (result.changes !== 1) {
48574
+ throw new Error("Todos project registration binding was not pending at acceptance");
48575
+ }
48576
+ }
48577
+ async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
48578
+ this.db.query(`
48579
+ UPDATE todos_project_registration_bindings
48580
+ SET state = 'terminal_nonacceptance', updated_at = ?
48581
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48582
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
48583
+ `).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48584
+ }
48585
+ async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
48586
+ const result = this.db.query(`
48587
+ UPDATE todos_project_registration_bindings
48588
+ SET state = 'removed', removed_receipt_id = ?, updated_at = ?
48589
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48590
+ AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
48591
+ `).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48592
+ if (result.changes !== 1) {
48593
+ throw new Error("Todos project registration binding was not accepted at removal");
48594
+ }
48595
+ }
48596
+ async findProjectConflict(path, taskListSlug) {
48597
+ const row = this.db.query(`
48598
+ SELECT * FROM projects
48599
+ WHERE path = ? OR task_list_id = ?
48600
+ ORDER BY created_at ASC, id ASC
48601
+ LIMIT 1
48602
+ `).get(path, taskListSlug);
48603
+ return row ?? null;
48604
+ }
48605
+ async findTaskListConflict(projectId, slug) {
48606
+ return await this.storage.taskLists.getBySlug(slug, projectId);
48607
+ }
48608
+ async createProject(input) {
48609
+ return await this.storage.projects.create(input);
48610
+ }
48611
+ async createTaskList(input) {
48612
+ return await this.storage.taskLists.create(input);
48613
+ }
48614
+ async getProject(id) {
48615
+ return await this.storage.projects.get(id);
48616
+ }
48617
+ async getTaskList(id) {
48618
+ return await this.storage.taskLists.get(id);
48619
+ }
48620
+ async lockCompensationWrites() {}
48621
+ async hasDependents(resourceKind, targetId) {
48622
+ return hasSqliteDependents(this.db, resourceKind, targetId);
48623
+ }
48624
+ async deleteProject(id) {
48625
+ return await this.storage.projects.delete(id);
48626
+ }
48627
+ async deleteTaskList(id) {
48628
+ return await this.storage.taskLists.delete(id);
48629
+ }
48630
+ }
48631
+
48632
+ class StagedSqliteTodosProjectRegistrationTransaction {
48633
+ db;
48634
+ direct;
48635
+ validators = [];
48636
+ mutations = [];
48637
+ receipts = new Map;
48638
+ bindings = new Map;
48639
+ projects = new Map;
48640
+ taskLists = new Map;
48641
+ constructor(db) {
48642
+ this.db = db;
48643
+ this.direct = new SqliteTodosProjectRegistrationTransaction(db);
48644
+ }
48645
+ commit() {
48646
+ this.db.exec("BEGIN IMMEDIATE");
48647
+ try {
48648
+ for (const validate of this.validators) {
48649
+ if (!validate()) {
48650
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
48651
+ }
48652
+ }
48653
+ for (const mutate of this.mutations)
48654
+ mutate();
48655
+ this.db.exec("COMMIT");
48656
+ } catch (error) {
48657
+ try {
48658
+ this.db.exec("ROLLBACK");
48659
+ } catch {}
48660
+ throw error;
48661
+ }
48662
+ }
48663
+ async lockStep(_identity) {}
48664
+ async getReceiptForLookup(identity) {
48665
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
48666
+ const stored = await this.direct.getReceiptForLookup(identity);
48667
+ if (stored)
48668
+ staged.push(stored);
48669
+ const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
48670
+ return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
48671
+ }
48672
+ async getReceiptById(receiptId) {
48673
+ return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
48674
+ }
48675
+ async getAcceptedReceiptForStep(identity) {
48676
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
48677
+ const stored = await this.direct.getAcceptedReceiptForStep(identity);
48678
+ if (stored)
48679
+ staged.push(stored);
48680
+ return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
48681
+ }
48682
+ async insertReceipt(receipt) {
48683
+ if (this.receipts.has(receipt.receipt_id))
48684
+ return false;
48685
+ if (await this.direct.getReceiptById(receipt.receipt_id))
48686
+ return false;
48687
+ const planned = { ...receipt };
48688
+ this.receipts.set(planned.receipt_id, planned);
48689
+ this.mutations.push(() => {
48690
+ try {
48691
+ const result = this.db.query(`
48692
+ INSERT OR IGNORE INTO todos_project_registration_receipts (
48693
+ receipt_id, authority, route, package_version, authority_id, tenant_id,
48694
+ corpus_id, operation_id, step_id, resource_kind, direction,
48695
+ target_selector, idempotency_key, request_digest, precondition_digest,
48696
+ normalized_call_digest, outcome, reason, target_id, result_revision,
48697
+ result_digest, duplicate_of_receipt_id, accepted_receipt_id,
48698
+ created_by_operation, created_at
48699
+ ) VALUES (
48700
+ ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
48701
+ )
48702
+ `).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
48703
+ if (result.changes !== 1) {
48704
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
48705
+ }
48706
+ } catch (error) {
48707
+ if (error instanceof SqliteRegistrationOptimisticConflict)
48708
+ throw error;
48709
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
48710
+ }
48711
+ });
48712
+ return true;
48713
+ }
48714
+ async getBinding(scope, resourceKind, targetSelector) {
48715
+ const key = this.bindingKey(scope, resourceKind, targetSelector);
48716
+ return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
48717
+ }
48718
+ async claimBinding(binding) {
48719
+ const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
48720
+ if (this.bindings.has(key))
48721
+ return false;
48722
+ if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
48723
+ return false;
48724
+ }
48725
+ const planned = { ...binding };
48726
+ this.bindings.set(key, planned);
48727
+ this.mutations.push(() => {
48728
+ try {
48729
+ const result = this.db.query(`
48730
+ INSERT OR IGNORE INTO todos_project_registration_bindings (
48731
+ authority_id, tenant_id, corpus_id, resource_kind, target_selector,
48732
+ operation_id, step_id, direction, idempotency_key, request_digest,
48733
+ precondition_digest, normalized_call_digest, state, target_id,
48734
+ accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
48735
+ created_at, updated_at
48736
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
48737
+ `).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
48738
+ if (result.changes !== 1) {
48739
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
48740
+ }
48741
+ } catch (error) {
48742
+ if (error instanceof SqliteRegistrationOptimisticConflict)
48743
+ throw error;
48744
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
48745
+ }
48746
+ });
48747
+ return true;
48748
+ }
48749
+ async setBindingAccepted(scope, resourceKind, targetSelector, update) {
48750
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
48751
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
48752
+ ...binding,
48753
+ state: "accepted",
48754
+ target_id: update.target_id,
48755
+ accepted_receipt_id: update.accepted_receipt_id,
48756
+ result_revision: update.result_revision,
48757
+ result_digest: update.result_digest,
48758
+ updated_at: update.updated_at
48759
+ });
48760
+ this.mutations.push(() => {
48761
+ const result = this.db.query(`
48762
+ UPDATE todos_project_registration_bindings
48763
+ SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
48764
+ result_revision = ?, result_digest = ?, updated_at = ?
48765
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48766
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
48767
+ `).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48768
+ if (result.changes !== 1) {
48769
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
48770
+ }
48771
+ });
48772
+ }
48773
+ async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
48774
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
48775
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
48776
+ ...binding,
48777
+ state: "terminal_nonacceptance",
48778
+ updated_at: updatedAt
48779
+ });
48780
+ this.mutations.push(() => {
48781
+ const result = this.db.query(`
48782
+ UPDATE todos_project_registration_bindings
48783
+ SET state = 'terminal_nonacceptance', updated_at = ?
48784
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48785
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
48786
+ `).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48787
+ if (result.changes !== 1) {
48788
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
48789
+ }
48790
+ });
48791
+ }
48792
+ async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
48793
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
48794
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
48795
+ ...binding,
48796
+ state: "removed",
48797
+ removed_receipt_id: removedReceiptId,
48798
+ updated_at: updatedAt
48799
+ });
48800
+ this.mutations.push(() => {
48801
+ const result = this.db.query(`
48802
+ UPDATE todos_project_registration_bindings
48803
+ SET state = 'removed', removed_receipt_id = ?, updated_at = ?
48804
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
48805
+ AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
48806
+ `).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
48807
+ if (result.changes !== 1) {
48808
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
48809
+ }
48810
+ });
48811
+ }
48812
+ async findProjectConflict(path, taskListSlug) {
48813
+ const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
48814
+ if (planned)
48815
+ return planned;
48816
+ const observed = selectProjectConflict(this.db, path, taskListSlug);
48817
+ this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
48818
+ return observed;
48819
+ }
48820
+ async findTaskListConflict(projectId, slug) {
48821
+ const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
48822
+ if (planned)
48823
+ return planned;
48824
+ const observed = selectTaskListConflict(this.db, projectId, slug);
48825
+ this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
48826
+ return observed;
48827
+ }
48828
+ async createProject(input) {
48829
+ const derivedSlug = normalizeSlug(input.name);
48830
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
48831
+ if (!derivedSlug || !taskListId) {
48832
+ throw new Error("Project name and task-list slug must be non-empty");
48833
+ }
48834
+ const project = {
48835
+ id: uuid(),
48836
+ name: input.name,
48837
+ path: input.path,
48838
+ description: input.description || null,
48839
+ task_list_id: taskListId,
48840
+ task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
48841
+ task_counter: 0,
48842
+ created_at: now(),
48843
+ updated_at: now(),
48844
+ machine_id: currentStorageMachineId(this.db)
48845
+ };
48846
+ project.updated_at = project.created_at;
48847
+ this.projects.set(project.id, project);
48848
+ this.mutations.push(() => {
48849
+ try {
48850
+ const result = this.db.run(`INSERT INTO projects (
48851
+ id, name, path, description, task_list_id, task_prefix,
48852
+ task_counter, created_at, updated_at, machine_id
48853
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
48854
+ project.id,
48855
+ project.name,
48856
+ project.path,
48857
+ project.description,
48858
+ project.task_list_id,
48859
+ project.task_prefix,
48860
+ project.created_at,
48861
+ project.updated_at,
48862
+ project.machine_id ?? null
48863
+ ]);
48864
+ if (result.changes < 1) {
48865
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
48866
+ }
48867
+ } catch (error) {
48868
+ if (error instanceof SqliteRegistrationOptimisticConflict)
48869
+ throw error;
48870
+ throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
48871
+ }
48872
+ });
48873
+ return project;
48874
+ }
48875
+ async createTaskList(input) {
48876
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
48877
+ if (!slug)
48878
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
48879
+ const taskList = {
48880
+ id: uuid(),
48881
+ project_id: input.project_id || null,
48882
+ slug,
48883
+ name: input.name,
48884
+ description: input.description || null,
48885
+ metadata: input.metadata ?? {},
48886
+ created_at: now(),
48887
+ updated_at: now(),
48888
+ machine_id: currentStorageMachineId(this.db)
48889
+ };
48890
+ taskList.updated_at = taskList.created_at;
48891
+ this.taskLists.set(taskList.id, taskList);
48892
+ this.mutations.push(() => {
48893
+ try {
48894
+ const result = this.db.run(`INSERT INTO task_lists (
48895
+ id, project_id, slug, name, description, metadata,
48896
+ created_at, updated_at, machine_id
48897
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
48898
+ taskList.id,
48899
+ taskList.project_id,
48900
+ taskList.slug,
48901
+ taskList.name,
48902
+ taskList.description,
48903
+ JSON.stringify(taskList.metadata),
48904
+ taskList.created_at,
48905
+ taskList.updated_at,
48906
+ taskList.machine_id ?? null
48907
+ ]);
48908
+ if (result.changes < 1) {
48909
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
48910
+ }
48911
+ } catch (error) {
48912
+ if (error instanceof SqliteRegistrationOptimisticConflict)
48913
+ throw error;
48914
+ throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
48915
+ }
48916
+ });
48917
+ return taskList;
48918
+ }
48919
+ async getProject(id) {
48920
+ if (this.projects.has(id))
48921
+ return this.projects.get(id) ?? null;
48922
+ const observed = selectProject(this.db, id);
48923
+ this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
48924
+ return observed;
48925
+ }
48926
+ async getTaskList(id) {
48927
+ if (this.taskLists.has(id))
48928
+ return this.taskLists.get(id) ?? null;
48929
+ const observed = selectTaskList(this.db, id);
48930
+ this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
48931
+ return observed;
48932
+ }
48933
+ async lockCompensationWrites() {}
48934
+ async hasDependents(resourceKind, targetId) {
48935
+ const observed = hasSqliteDependents(this.db, resourceKind, targetId);
48936
+ this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
48937
+ return observed;
48938
+ }
48939
+ async deleteProject(id) {
48940
+ const project = await this.getProject(id);
48941
+ if (!project)
48942
+ return false;
48943
+ this.projects.set(id, null);
48944
+ this.mutations.push(() => {
48945
+ recordStorageTombstone({
48946
+ object_type: "projects",
48947
+ object_id: id,
48948
+ payload: project
48949
+ }, this.db);
48950
+ if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
48951
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
48952
+ }
48953
+ });
48954
+ return true;
48955
+ }
48956
+ async deleteTaskList(id) {
48957
+ const taskList = await this.getTaskList(id);
48958
+ if (!taskList)
48959
+ return false;
48960
+ this.taskLists.set(id, null);
48961
+ this.mutations.push(() => {
48962
+ recordStorageTombstone({
48963
+ object_type: "task_lists",
48964
+ object_id: id,
48965
+ payload: taskList
48966
+ }, this.db);
48967
+ if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
48968
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
48969
+ }
48970
+ });
48971
+ return true;
48972
+ }
48973
+ bindingKey(scope, resourceKind, targetSelector) {
48974
+ return JSON.stringify([
48975
+ scope.authority_id,
48976
+ scope.tenant_id,
48977
+ scope.corpus_id,
48978
+ resourceKind,
48979
+ targetSelector
48980
+ ]);
48981
+ }
48982
+ async requireBinding(scope, resourceKind, targetSelector, state) {
48983
+ const binding = await this.getBinding(scope, resourceKind, targetSelector);
48984
+ if (!binding || binding.state !== state) {
48985
+ throw new Error(`Todos project registration binding was not ${state}`);
48986
+ }
48987
+ return binding;
48988
+ }
48989
+ availableProjectPrefix(name) {
48990
+ const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
48991
+ const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
48992
+ let candidate = prefix;
48993
+ let suffix = 1;
48994
+ while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
48995
+ suffix += 1;
48996
+ candidate = `${prefix}${suffix}`;
48997
+ }
48998
+ return candidate;
48999
+ }
49000
+ }
49001
+ var sqliteTransactionTails, PROJECT_REFERENCE_COLUMNS, TASK_LIST_REFERENCE_COLUMNS, SqliteRegistrationOptimisticConflict;
48381
49002
  var init_sqlite = __esm(() => {
49003
+ init_database();
49004
+ init_storage_tombstones();
48382
49005
  init_local_sqlite();
48383
49006
  sqliteTransactionTails = new WeakMap;
48384
49007
  PROJECT_REFERENCE_COLUMNS = new Set([
@@ -48388,6 +49011,12 @@ var init_sqlite = __esm(() => {
48388
49011
  "external_project_id"
48389
49012
  ]);
48390
49013
  TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
49014
+ SqliteRegistrationOptimisticConflict = class SqliteRegistrationOptimisticConflict extends Error {
49015
+ constructor(message, options = {}) {
49016
+ super(message, options);
49017
+ this.name = "SqliteRegistrationOptimisticConflict";
49018
+ }
49019
+ };
48391
49020
  });
48392
49021
 
48393
49022
  // src/project-registration/authority.ts
@@ -50892,6 +51521,9 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
50892
51521
  Task: taskSchema,
50893
51522
  Project: projectSchema,
50894
51523
  TaskList: taskListSchema,
51524
+ ProjectTaskListEnsureReceipt: projectTaskListEnsureReceiptSchema,
51525
+ ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
51526
+ ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
50895
51527
  TaskComment: taskCommentSchema,
50896
51528
  Plan: planSchema,
50897
51529
  Template: templateSchema,
@@ -50969,6 +51601,29 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
50969
51601
  name: { type: "string", minLength: 1 }
50970
51602
  }
50971
51603
  },
51604
+ ProjectTaskListEnsureApplyInput: {
51605
+ type: "object",
51606
+ additionalProperties: false,
51607
+ required: ["expected_project_revision"],
51608
+ properties: {
51609
+ expected_project_revision: { type: "string", minLength: 1 },
51610
+ idempotency_key: {
51611
+ type: "string",
51612
+ minLength: 8,
51613
+ maxLength: 128,
51614
+ pattern: "^[A-Za-z0-9._:-]+$"
51615
+ }
51616
+ }
51617
+ },
51618
+ ProjectTaskListRollbackInput: {
51619
+ type: "object",
51620
+ additionalProperties: false,
51621
+ required: ["receipt_id", "expected_task_list_revision"],
51622
+ properties: {
51623
+ receipt_id: { type: "string", minLength: 1 },
51624
+ expected_task_list_revision: { type: "string", minLength: 1 }
51625
+ }
51626
+ },
50972
51627
  ErrorResponse: {
50973
51628
  type: "object",
50974
51629
  required: ["error"],
@@ -52071,6 +52726,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
52071
52726
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
52072
52727
  }
52073
52728
  },
52729
+ "/v1/projects/{id}/task-list/ensure": {
52730
+ get: {
52731
+ operationId: "planProjectTaskListEnsure",
52732
+ summary: "Plan a non-mutating repair of a project's declared task list",
52733
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
52734
+ responses: {
52735
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
52736
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
52737
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
52738
+ }
52739
+ },
52740
+ post: {
52741
+ operationId: "ensureProjectTaskList",
52742
+ summary: "Idempotently create an existing project's declared task list",
52743
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
52744
+ requestBody: {
52745
+ required: true,
52746
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureApplyInput" } } }
52747
+ },
52748
+ responses: {
52749
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
52750
+ "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
52751
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
52752
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
52753
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
52754
+ }
52755
+ }
52756
+ },
52757
+ "/v1/projects/{id}/task-list/rollback": {
52758
+ post: {
52759
+ operationId: "rollbackProjectTaskListEnsure",
52760
+ summary: "Conditionally remove an unchanged task list created by an accepted ensure receipt",
52761
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
52762
+ requestBody: {
52763
+ required: true,
52764
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackInput" } } }
52765
+ },
52766
+ responses: {
52767
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackResult" } } } },
52768
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
52769
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
52770
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
52771
+ }
52772
+ }
52773
+ },
52074
52774
  "/v1/projects/{id}/rename": {
52075
52775
  post: {
52076
52776
  operationId: "renameProject",
@@ -52352,7 +53052,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
52352
53052
  }
52353
53053
  };
52354
53054
  }
52355
- var taskSchema, projectSchema, taskListSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
53055
+ var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
52356
53056
  var init_openapi = __esm(() => {
52357
53057
  init_package_version();
52358
53058
  init_types();
@@ -52400,6 +53100,80 @@ var init_openapi = __esm(() => {
52400
53100
  updated_at: { type: "string" }
52401
53101
  }
52402
53102
  };
53103
+ projectTaskListEnsureReceiptSchema = {
53104
+ type: "object",
53105
+ additionalProperties: false,
53106
+ required: [
53107
+ "schema_version",
53108
+ "receipt_id",
53109
+ "idempotency_key",
53110
+ "project_id",
53111
+ "task_list_id",
53112
+ "slug",
53113
+ "created_by_operation",
53114
+ "result_revision",
53115
+ "result_digest",
53116
+ "rollback_supported",
53117
+ "created_at"
53118
+ ],
53119
+ properties: {
53120
+ schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
53121
+ receipt_id: { type: "string" },
53122
+ idempotency_key: { type: "string" },
53123
+ project_id: { type: "string" },
53124
+ task_list_id: { type: "string" },
53125
+ slug: { type: "string" },
53126
+ created_by_operation: { type: "boolean" },
53127
+ result_revision: { type: "string" },
53128
+ result_digest: { type: "string" },
53129
+ rollback_supported: { type: "boolean" },
53130
+ created_at: { type: "string", format: "date-time" }
53131
+ }
53132
+ };
53133
+ projectTaskListEnsureResultSchema = {
53134
+ type: "object",
53135
+ additionalProperties: false,
53136
+ required: ["mode", "action", "project", "task_list", "receipt"],
53137
+ properties: {
53138
+ mode: { type: "string", enum: ["plan", "apply"] },
53139
+ action: { type: "string", enum: ["would_create", "created", "already_present"] },
53140
+ project: { $ref: "#/components/schemas/Project" },
53141
+ task_list: {
53142
+ oneOf: [
53143
+ { $ref: "#/components/schemas/TaskList" },
53144
+ { type: "null" }
53145
+ ]
53146
+ },
53147
+ receipt: {
53148
+ oneOf: [
53149
+ { $ref: "#/components/schemas/ProjectTaskListEnsureReceipt" },
53150
+ { type: "null" }
53151
+ ]
53152
+ }
53153
+ }
53154
+ };
53155
+ projectTaskListRollbackResultSchema = {
53156
+ type: "object",
53157
+ additionalProperties: false,
53158
+ required: [
53159
+ "schema_version",
53160
+ "action",
53161
+ "project_id",
53162
+ "task_list_id",
53163
+ "accepted_receipt_id",
53164
+ "rollback_receipt_id",
53165
+ "removed_at"
53166
+ ],
53167
+ properties: {
53168
+ schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
53169
+ action: { type: "string", enum: ["removed"] },
53170
+ project_id: { type: "string" },
53171
+ task_list_id: { type: "string" },
53172
+ accepted_receipt_id: { type: "string" },
53173
+ rollback_receipt_id: { type: "string" },
53174
+ removed_at: { type: "string", format: "date-time" }
53175
+ }
53176
+ };
52403
53177
  taskCommentSchema = {
52404
53178
  type: "object",
52405
53179
  required: ["id", "task_id", "agent_id", "session_id", "content", "type", "progress_pct", "created_at"],
@@ -52633,6 +53407,261 @@ function decodeCommentCursor(value) {
52633
53407
  }
52634
53408
  var MAX_COMMENT_CURSOR_LENGTH = 1024;
52635
53409
 
53410
+ // src/lib/project-task-list-ensure.ts
53411
+ import { createHash as createHash15 } from "crypto";
53412
+ function canonicalJson(value) {
53413
+ if (value === null || typeof value !== "object")
53414
+ return JSON.stringify(value);
53415
+ if (Array.isArray(value))
53416
+ return `[${value.map(canonicalJson).join(",")}]`;
53417
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
53418
+ }
53419
+ function digest(value) {
53420
+ return createHash15("sha256").update(canonicalJson(value)).digest("hex");
53421
+ }
53422
+ function deriveIdempotencyKey(projectId, slug) {
53423
+ return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
53424
+ }
53425
+ function normalizeIdempotencyKey(value, projectId, slug) {
53426
+ const key = value?.trim() || deriveIdempotencyKey(projectId, slug);
53427
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
53428
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
53429
+ }
53430
+ return key;
53431
+ }
53432
+ function receiptId2(projectId, slug, idempotencyKey) {
53433
+ return `ptlr_${digest({ project_id: projectId, slug, idempotency_key: idempotencyKey }).slice(0, 48)}`;
53434
+ }
53435
+ function semanticListDigest(list) {
53436
+ const metadata = { ...list.metadata ?? {} };
53437
+ delete metadata[RECEIPT_METADATA_KEY];
53438
+ return digest({
53439
+ project_id: list.project_id,
53440
+ slug: list.slug,
53441
+ name: list.name,
53442
+ description: list.description,
53443
+ metadata
53444
+ });
53445
+ }
53446
+ function storedMarker(list) {
53447
+ const value = list.metadata?.[RECEIPT_METADATA_KEY];
53448
+ if (!value || typeof value !== "object" || Array.isArray(value))
53449
+ return null;
53450
+ const marker = value;
53451
+ if (marker.schema_version !== PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION || typeof marker.receipt_id !== "string" || typeof marker.idempotency_key !== "string" || typeof marker.project_id !== "string" || typeof marker.slug !== "string" || typeof marker.result_digest !== "string" || typeof marker.created_at !== "string")
53452
+ return null;
53453
+ return marker;
53454
+ }
53455
+ function receiptFor(store, project, list, idempotencyKey) {
53456
+ const marker = storedMarker(list);
53457
+ const owned = marker?.project_id === project.id && marker.slug === list.slug;
53458
+ if (owned && marker.idempotency_key !== idempotencyKey) {
53459
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_CONFLICT", "The operation-owned task list was created under a different idempotency key", {
53460
+ project_id: project.id,
53461
+ task_list_id: list.id,
53462
+ receipt_id: marker.receipt_id
53463
+ });
53464
+ }
53465
+ return {
53466
+ schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
53467
+ receipt_id: owned ? marker.receipt_id : `ptlr_existing_${digest({ project_id: project.id, task_list_id: list.id }).slice(0, 39)}`,
53468
+ idempotency_key: owned ? marker.idempotency_key : idempotencyKey,
53469
+ project_id: project.id,
53470
+ task_list_id: list.id,
53471
+ slug: list.slug,
53472
+ created_by_operation: owned,
53473
+ result_revision: list.updated_at,
53474
+ result_digest: owned ? marker.result_digest : semanticListDigest(list),
53475
+ rollback_supported: Boolean(owned && semanticListDigest(list) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused),
53476
+ created_at: owned ? marker.created_at : list.created_at
53477
+ };
53478
+ }
53479
+ async function exactProjectState(store, projectId) {
53480
+ const project = await store.projects.get(projectId);
53481
+ if (!project) {
53482
+ throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
53483
+ }
53484
+ const slug = project.task_list_id?.trim();
53485
+ if (!slug) {
53486
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_NOT_DECLARED", "Project does not declare a canonical task_list_id slug", { project_id: project.id });
53487
+ }
53488
+ const all = await store.taskLists.list();
53489
+ const scopedMatches = all.filter((list) => list.project_id === project.id && list.slug === slug);
53490
+ if (scopedMatches.length > 1) {
53491
+ throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "More than one task list matches the project's exact id and declared slug", { project_id: project.id, slug, task_list_ids: scopedMatches.map((list) => list.id) });
53492
+ }
53493
+ const globalMatches = all.filter((list) => list.project_id === null && list.slug === slug);
53494
+ if (globalMatches.length > 0 && scopedMatches.length === 0) {
53495
+ throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "A legacy global task list already owns the declared slug; refusing to create a second locator", { project_id: project.id, slug, task_list_ids: globalMatches.map((list) => list.id) });
53496
+ }
53497
+ return { project, scoped: scopedMatches[0] ?? null, globalCollision: globalMatches[0] ?? null };
53498
+ }
53499
+ async function planProjectTaskListEnsure(store, projectId) {
53500
+ const { project, scoped } = await exactProjectState(store, projectId);
53501
+ return {
53502
+ mode: "plan",
53503
+ action: scoped ? "already_present" : "would_create",
53504
+ project,
53505
+ task_list: scoped,
53506
+ receipt: null
53507
+ };
53508
+ }
53509
+ async function applyProjectTaskListEnsure(store, projectId, options) {
53510
+ const state = await exactProjectState(store, projectId);
53511
+ const { project } = state;
53512
+ if (project.updated_at !== options.expected_project_revision) {
53513
+ throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed after the ensure plan; fetch a fresh plan before applying", {
53514
+ project_id: project.id,
53515
+ expected_project_revision: options.expected_project_revision,
53516
+ current_project_revision: project.updated_at
53517
+ });
53518
+ }
53519
+ const slug = project.task_list_id;
53520
+ const idempotencyKey = normalizeIdempotencyKey(options.idempotency_key, project.id, slug);
53521
+ if (state.scoped) {
53522
+ return {
53523
+ mode: "apply",
53524
+ action: "already_present",
53525
+ project,
53526
+ task_list: state.scoped,
53527
+ receipt: receiptFor(store, project, state.scoped, idempotencyKey)
53528
+ };
53529
+ }
53530
+ const marker = {
53531
+ schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
53532
+ receipt_id: receiptId2(project.id, slug, idempotencyKey),
53533
+ idempotency_key: idempotencyKey,
53534
+ project_id: project.id,
53535
+ slug,
53536
+ result_digest: semanticListDigest({
53537
+ project_id: project.id,
53538
+ slug,
53539
+ name: project.name,
53540
+ description: null,
53541
+ metadata: {}
53542
+ }),
53543
+ created_at: new Date().toISOString()
53544
+ };
53545
+ let list;
53546
+ try {
53547
+ list = await store.taskLists.create({
53548
+ name: project.name,
53549
+ slug,
53550
+ project_id: project.id,
53551
+ metadata: { [RECEIPT_METADATA_KEY]: marker }
53552
+ });
53553
+ } catch (error) {
53554
+ if (!(error instanceof ResourceConflictError))
53555
+ throw error;
53556
+ const raced = await exactProjectState(store, projectId);
53557
+ if (!raced.scoped)
53558
+ throw error;
53559
+ if (raced.project.updated_at !== options.expected_project_revision || raced.project.task_list_id !== slug) {
53560
+ throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed while the task list was being created; fetch a fresh plan before retrying", {
53561
+ project_id: raced.project.id,
53562
+ expected_project_revision: options.expected_project_revision,
53563
+ current_project_revision: raced.project.updated_at
53564
+ });
53565
+ }
53566
+ return {
53567
+ mode: "apply",
53568
+ action: "already_present",
53569
+ project: raced.project,
53570
+ task_list: raced.scoped,
53571
+ receipt: receiptFor(store, raced.project, raced.scoped, idempotencyKey)
53572
+ };
53573
+ }
53574
+ const projectReadback = await store.projects.get(project.id);
53575
+ if (!projectReadback || projectReadback.updated_at !== options.expected_project_revision || projectReadback.task_list_id !== slug) {
53576
+ let compensated = false;
53577
+ const unchanged = await store.taskLists.get(list.id);
53578
+ const unchangedMarker = unchanged ? storedMarker(unchanged) : null;
53579
+ if (unchanged && unchangedMarker?.receipt_id === marker.receipt_id && semanticListDigest(unchanged) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused) {
53580
+ const deletion = await store.taskLists.deleteIfUnchangedAndUnused(list.id, {
53581
+ project_id: unchanged.project_id,
53582
+ slug: unchanged.slug,
53583
+ name: unchanged.name,
53584
+ description: unchanged.description,
53585
+ metadata: unchanged.metadata,
53586
+ updated_at: unchanged.updated_at
53587
+ });
53588
+ compensated = deletion.status === "deleted";
53589
+ }
53590
+ throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", compensated ? "Project changed while the task list was being created; the new list was rolled back" : "Project changed while the task list was being created; the new list was retained because safe conditional rollback could not be proven", { project_id: project.id, task_list_id: list.id, compensated });
53591
+ }
53592
+ const readback = await store.taskLists.get(list.id);
53593
+ if (!readback || readback.project_id !== project.id || readback.slug !== slug) {
53594
+ throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "Task-list create did not preserve the exact project id and declared slug", { project_id: project.id, task_list_id: list.id, slug });
53595
+ }
53596
+ return {
53597
+ mode: "apply",
53598
+ action: "created",
53599
+ project: projectReadback,
53600
+ task_list: readback,
53601
+ receipt: receiptFor(store, projectReadback, readback, idempotencyKey)
53602
+ };
53603
+ }
53604
+ async function rollbackProjectTaskListEnsure(store, projectId, options) {
53605
+ const conditionalDelete = store.taskLists.deleteIfUnchangedAndUnused;
53606
+ if (!conditionalDelete) {
53607
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "This storage backend cannot guarantee atomic conditional rollback; refusing to delete", { project_id: projectId, receipt_id: options.receipt_id });
53608
+ }
53609
+ const project = await store.projects.get(projectId);
53610
+ if (!project) {
53611
+ throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`);
53612
+ }
53613
+ const candidates = (await store.taskLists.list(project.id)).filter((list2) => storedMarker(list2)?.receipt_id === options.receipt_id);
53614
+ if (candidates.length !== 1) {
53615
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_RECEIPT_NOT_FOUND", "No exact operation-owned task list matches this rollback receipt", { project_id: project.id, receipt_id: options.receipt_id });
53616
+ }
53617
+ const list = candidates[0];
53618
+ const marker = storedMarker(list);
53619
+ if (marker.project_id !== project.id || marker.slug !== list.slug || list.project_id !== project.id || list.updated_at !== options.expected_task_list_revision || semanticListDigest(list) !== marker.result_digest) {
53620
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "The operation-owned task list drifted; refusing conditional rollback", { project_id: project.id, task_list_id: list.id, receipt_id: options.receipt_id });
53621
+ }
53622
+ const deletion = await conditionalDelete.call(store.taskLists, list.id, {
53623
+ project_id: list.project_id,
53624
+ slug: list.slug,
53625
+ name: list.name,
53626
+ description: list.description,
53627
+ metadata: list.metadata,
53628
+ updated_at: list.updated_at
53629
+ });
53630
+ if (deletion.status === "has_dependents") {
53631
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_HAS_DEPENDENTS", "The operation-owned task list has dependents; refusing conditional rollback", {
53632
+ task_list_id: list.id,
53633
+ task_dependents: deletion.task_dependents,
53634
+ plan_dependents: deletion.plan_dependents
53635
+ });
53636
+ }
53637
+ if (deletion.status !== "deleted" || await store.taskLists.get(list.id)) {
53638
+ throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "Conditional rollback did not remove the exact task list", { task_list_id: list.id });
53639
+ }
53640
+ return {
53641
+ schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
53642
+ action: "removed",
53643
+ project_id: project.id,
53644
+ task_list_id: list.id,
53645
+ accepted_receipt_id: options.receipt_id,
53646
+ rollback_receipt_id: `ptlr_inverse_${digest({ accepted_receipt_id: options.receipt_id }).slice(0, 38)}`,
53647
+ removed_at: new Date().toISOString()
53648
+ };
53649
+ }
53650
+ var PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION = "todos.project-task-list-ensure.v1", RECEIPT_METADATA_KEY = "todos_project_task_list_ensure", ProjectTaskListEnsureError;
53651
+ var init_project_task_list_ensure = __esm(() => {
53652
+ init_types();
53653
+ ProjectTaskListEnsureError = class ProjectTaskListEnsureError extends Error {
53654
+ code;
53655
+ details;
53656
+ constructor(code, message, details = {}) {
53657
+ super(message);
53658
+ this.code = code;
53659
+ this.details = details;
53660
+ this.name = "ProjectTaskListEnsureError";
53661
+ }
53662
+ };
53663
+ });
53664
+
52636
53665
  // src/server/v1.ts
52637
53666
  var exports_v1 = {};
52638
53667
  __export(exports_v1, {
@@ -53420,6 +54449,52 @@ async function handleV1Request(req, url, dependencies = {}) {
53420
54449
  }
53421
54450
  return error(405, `method ${method} not allowed on /v1/projects`);
53422
54451
  }
54452
+ if (action === "task-list" && subId === "ensure") {
54453
+ if (method === "GET") {
54454
+ return json4(await planProjectTaskListEnsure(store, id));
54455
+ }
54456
+ if (method !== "POST") {
54457
+ return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/ensure`);
54458
+ }
54459
+ const body = await readJson3(req);
54460
+ if (!body)
54461
+ return error(400, "invalid JSON body");
54462
+ const unknown = Object.keys(body).find((key) => !["expected_project_revision", "idempotency_key"].includes(key));
54463
+ if (unknown)
54464
+ return error(400, `unknown task-list ensure field: ${unknown}`);
54465
+ if (typeof body.expected_project_revision !== "string" || !body.expected_project_revision.trim()) {
54466
+ return error(400, "expected_project_revision must be a non-empty string from a fresh ensure plan");
54467
+ }
54468
+ if (body.idempotency_key !== undefined && typeof body.idempotency_key !== "string") {
54469
+ return error(400, "idempotency_key must be a string");
54470
+ }
54471
+ const result = await applyProjectTaskListEnsure(store, id, {
54472
+ expected_project_revision: body.expected_project_revision,
54473
+ ...typeof body.idempotency_key === "string" ? { idempotency_key: body.idempotency_key } : {}
54474
+ });
54475
+ return json4(result, result.action === "created" ? 201 : 200);
54476
+ }
54477
+ if (action === "task-list" && subId === "rollback") {
54478
+ if (method !== "POST") {
54479
+ return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/rollback`);
54480
+ }
54481
+ const body = await readJson3(req);
54482
+ if (!body)
54483
+ return error(400, "invalid JSON body");
54484
+ const unknown = Object.keys(body).find((key) => !["receipt_id", "expected_task_list_revision"].includes(key));
54485
+ if (unknown)
54486
+ return error(400, `unknown task-list rollback field: ${unknown}`);
54487
+ if (typeof body.receipt_id !== "string" || !body.receipt_id.trim()) {
54488
+ return error(400, "receipt_id must be a non-empty string");
54489
+ }
54490
+ if (typeof body.expected_task_list_revision !== "string" || !body.expected_task_list_revision.trim()) {
54491
+ return error(400, "expected_task_list_revision must be a non-empty string from the accepted receipt");
54492
+ }
54493
+ return json4(await rollbackProjectTaskListEnsure(store, id, {
54494
+ receipt_id: body.receipt_id,
54495
+ expected_task_list_revision: body.expected_task_list_revision
54496
+ }));
54497
+ }
53423
54498
  if (action === "rename") {
53424
54499
  if (method !== "POST")
53425
54500
  return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
@@ -53758,6 +54833,10 @@ async function handleV1Request(req, url, dependencies = {}) {
53758
54833
  }
53759
54834
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
53760
54835
  } catch (e) {
54836
+ if (e instanceof ProjectTaskListEnsureError) {
54837
+ const status = e.code === "PROJECT_NOT_FOUND" || e.code === "PROJECT_TASK_LIST_RECEIPT_NOT_FOUND" ? 404 : e.code === "PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID" ? 400 : 409;
54838
+ return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
54839
+ }
53761
54840
  if (e instanceof TaskReferenceAmbiguousError) {
53762
54841
  return error(409, e.message, {
53763
54842
  code: TaskReferenceAmbiguousError.code,
@@ -53784,6 +54863,7 @@ var init_v1 = __esm(() => {
53784
54863
  init_pr_groups();
53785
54864
  init_project_registration();
53786
54865
  init_redaction();
54866
+ init_project_task_list_ensure();
53787
54867
  JSON_HEADERS3 = { "Content-Type": "application/json" };
53788
54868
  });
53789
54869