@hasna/todos 0.15.46 → 0.15.49

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 (58) hide show
  1. package/dashboard/dist/assets/index-BNQ4gJua.js +342 -0
  2. package/dashboard/dist/assets/index-DjzvHUWt.css +1 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/components/Dashboard.d.ts +1 -1
  6. package/dist/cli/components/Dashboard.d.ts.map +1 -1
  7. package/dist/cli/components/Header.d.ts +1 -1
  8. package/dist/cli/components/Header.d.ts.map +1 -1
  9. package/dist/cli/components/ProjectList.d.ts +1 -1
  10. package/dist/cli/components/ProjectList.d.ts.map +1 -1
  11. package/dist/cli/components/SearchView.d.ts +1 -1
  12. package/dist/cli/components/SearchView.d.ts.map +1 -1
  13. package/dist/cli/components/TaskDetail.d.ts +1 -1
  14. package/dist/cli/components/TaskDetail.d.ts.map +1 -1
  15. package/dist/cli/components/TaskForm.d.ts +1 -1
  16. package/dist/cli/components/TaskForm.d.ts.map +1 -1
  17. package/dist/cli/components/TaskList.d.ts +1 -1
  18. package/dist/cli/components/TaskList.d.ts.map +1 -1
  19. package/dist/cli/index.js +671 -155
  20. package/dist/contracts.js +396 -82
  21. package/dist/db/task-crud.d.ts +6 -0
  22. package/dist/db/task-crud.d.ts.map +1 -1
  23. package/dist/db/tasks.d.ts +1 -1
  24. package/dist/db/tasks.d.ts.map +1 -1
  25. package/dist/db/webhooks.d.ts.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +561 -143
  29. package/dist/lib/dedupe-projection.d.ts +48 -0
  30. package/dist/lib/dedupe-projection.d.ts.map +1 -0
  31. package/dist/lib/redaction.d.ts.map +1 -1
  32. package/dist/lib/task-dedupe.d.ts +5 -9
  33. package/dist/lib/task-dedupe.d.ts.map +1 -1
  34. package/dist/mcp/index.js +621 -154
  35. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  36. package/dist/mcp.js +6 -4
  37. package/dist/project-registration.js +502 -141
  38. package/dist/registry.js +396 -82
  39. package/dist/release-provenance.json +5 -5
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +1278 -342
  42. package/dist/server/routes.d.ts.map +1 -1
  43. package/dist/server/v1.d.ts.map +1 -1
  44. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  45. package/dist/storage/postgres-errors.d.ts +15 -0
  46. package/dist/storage/postgres-errors.d.ts.map +1 -0
  47. package/dist/storage/postgres-sync.d.ts +7 -0
  48. package/dist/storage/postgres-sync.d.ts.map +1 -1
  49. package/dist/storage/shadow-outbox.d.ts.map +1 -1
  50. package/dist/storage/shadow.d.ts.map +1 -1
  51. package/dist/storage.js +510 -137
  52. package/dist/task-manifest.js +118 -47
  53. package/dist/task-subtree-transfer.js +326 -44
  54. package/dist/types/index.d.ts +2 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/package.json +6 -4
  57. package/dashboard/dist/assets/index-DJm6m6Yy.css +0 -1
  58. package/dashboard/dist/assets/index-DVotjwab.js +0 -346
@@ -615,13 +615,15 @@ function upsertSecretSafetyConfig(input) {
615
615
  saveConfig({ ...config, secret_safety: next });
616
616
  return next;
617
617
  }
618
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
618
+ var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
619
619
  var init_redaction = __esm(() => {
620
620
  init_config();
621
+ XAI_PREFIX = ["x", "ai", "-"].join("");
621
622
  DEFAULT_SECRET_PATTERNS = [
622
623
  { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
623
624
  { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
624
625
  { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
626
+ { name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
625
627
  { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
626
628
  { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
627
629
  { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
@@ -6010,6 +6012,30 @@ class SqliteTodosTaskManifestBackend {
6010
6012
  }
6011
6013
  }
6012
6014
 
6015
+ // src/storage/postgres-sync.ts
6016
+ init_types();
6017
+
6018
+ // src/storage/postgres-errors.ts
6019
+ function isPostgresUniqueViolation(error) {
6020
+ if (typeof error !== "object" || error === null)
6021
+ return false;
6022
+ const candidate = error;
6023
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
6024
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
6025
+ const cause = candidate.cause;
6026
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
6027
+ }
6028
+ return states.some((state) => String(state) === "23505");
6029
+ }
6030
+ function postgresConstraintName(error) {
6031
+ if (typeof error !== "object" || error === null)
6032
+ return "";
6033
+ const candidate = error;
6034
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
6035
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
6036
+ return typeof constraint === "string" ? constraint : "";
6037
+ }
6038
+
6013
6039
  // src/storage/postgres-sync.ts
6014
6040
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
6015
6041
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -6212,53 +6238,98 @@ class PostgresTodosSyncStore {
6212
6238
  if (routingErrors.length > 0) {
6213
6239
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
6214
6240
  }
6215
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
6216
- FROM ${this.tableName}
6217
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
6218
- const existingProjects = [];
6219
- const existingTaskLists = [];
6220
- for (const row of existing.rows) {
6221
- const payload = payloadRecord(row.payload);
6222
- if (row.object_type === "projects")
6223
- existingProjects.push(payload);
6224
- if (row.object_type === "task_lists")
6225
- existingTaskLists.push(payload);
6226
- }
6227
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
6228
- if (destinationErrors.length > 0) {
6229
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
6230
- }
6231
- const result = { records: 0, objectTypes: {} };
6232
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
6233
- for (const entry of snapshotEntries(snapshot)) {
6234
- if (entry.deletedAt === null)
6235
- assertCanonicalScopedSlugEntry(entry);
6236
- await this.client.query(`INSERT INTO ${this.tableName} (
6237
- service, object_type, object_id, payload, updated_at,
6238
- deleted_at, source_machine_id, version
6239
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
6240
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
6241
- payload = EXCLUDED.payload,
6242
- updated_at = EXCLUDED.updated_at,
6243
- deleted_at = EXCLUDED.deleted_at,
6244
- source_machine_id = EXCLUDED.source_machine_id,
6245
- version = EXCLUDED.version
6246
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
6247
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
6248
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
6249
- this.service,
6250
- entry.type,
6251
- entry.id,
6252
- entry.payload,
6253
- entry.updatedAt,
6254
- entry.deletedAt,
6255
- sourceMachineId,
6256
- entry.version
6257
- ]);
6258
- result.records += 1;
6259
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
6241
+ const push = async (client) => {
6242
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
6243
+ FROM ${this.tableName}
6244
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
6245
+ const existingProjects = [];
6246
+ const existingTaskLists = [];
6247
+ for (const row of existing.rows) {
6248
+ const payload = payloadRecord(row.payload);
6249
+ if (row.object_type === "projects")
6250
+ existingProjects.push(payload);
6251
+ if (row.object_type === "task_lists")
6252
+ existingTaskLists.push(payload);
6253
+ }
6254
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
6255
+ if (destinationErrors.length > 0) {
6256
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
6257
+ }
6258
+ const result = { records: 0, objectTypes: {} };
6259
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
6260
+ for (const entry of snapshotEntries(snapshot)) {
6261
+ if (entry.deletedAt === null)
6262
+ assertCanonicalScopedSlugEntry(entry);
6263
+ try {
6264
+ await client.query(`INSERT INTO ${this.tableName} (
6265
+ service, object_type, object_id, payload, updated_at,
6266
+ deleted_at, source_machine_id, version
6267
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
6268
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
6269
+ payload = EXCLUDED.payload,
6270
+ updated_at = EXCLUDED.updated_at,
6271
+ deleted_at = EXCLUDED.deleted_at,
6272
+ source_machine_id = EXCLUDED.source_machine_id,
6273
+ version = EXCLUDED.version
6274
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
6275
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
6276
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
6277
+ this.service,
6278
+ entry.type,
6279
+ entry.id,
6280
+ entry.payload,
6281
+ entry.updatedAt,
6282
+ entry.deletedAt,
6283
+ sourceMachineId,
6284
+ entry.version
6285
+ ]);
6286
+ } catch (error) {
6287
+ await this.classifySyncInsertConflict(error, entry);
6288
+ }
6289
+ result.records += 1;
6290
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
6291
+ }
6292
+ return result;
6293
+ };
6294
+ if (typeof this.client.transaction === "function") {
6295
+ return this.client.transaction((client) => push(client));
6296
+ }
6297
+ return push(this.client);
6298
+ }
6299
+ async classifySyncInsertConflict(error, entry) {
6300
+ if (!isPostgresUniqueViolation(error))
6301
+ throw error;
6302
+ const payload = entry.payload;
6303
+ const taskListSlug = String(payload["slug"] ?? "");
6304
+ const projectSlug = String(payload["task_list_id"] ?? "");
6305
+ const constraintName = postgresConstraintName(error);
6306
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
6307
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
6308
+ }
6309
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
6310
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
6311
+ }
6312
+ if (entry.type === "task_lists") {
6313
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
6314
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
6315
+ SELECT 1 FROM ${this.tableName}
6316
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
6317
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
6318
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
6319
+ if (conflict.rows[0]?.conflict) {
6320
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
6321
+ }
6322
+ } else if (entry.type === "projects") {
6323
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
6324
+ SELECT 1 FROM ${this.tableName}
6325
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
6326
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
6327
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
6328
+ if (conflict.rows[0]?.conflict) {
6329
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
6330
+ }
6260
6331
  }
6261
- return result;
6332
+ throw error;
6262
6333
  }
6263
6334
  async pullSnapshot(options = {}) {
6264
6335
  const params = [this.service];
@@ -39,6 +39,219 @@ var __export = (target, all) => {
39
39
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
40
40
  var __require = import.meta.require;
41
41
 
42
+ // src/types/index.ts
43
+ function isBlockingDependencyStatus(status) {
44
+ return status !== "completed" && status !== "cancelled";
45
+ }
46
+ function isTerminalStatus(status) {
47
+ return status === "completed" || status === "failed" || status === "cancelled";
48
+ }
49
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
50
+ var init_types = __esm(() => {
51
+ TASK_STATUSES = [
52
+ "pending",
53
+ "in_progress",
54
+ "completed",
55
+ "failed",
56
+ "cancelled"
57
+ ];
58
+ TASK_PRIORITIES = [
59
+ "low",
60
+ "medium",
61
+ "high",
62
+ "critical"
63
+ ];
64
+ PLAN_STATUSES = ["active", "completed", "archived"];
65
+ VersionConflictError = class VersionConflictError extends Error {
66
+ taskId;
67
+ expectedVersion;
68
+ actualVersion;
69
+ static code = "VERSION_CONFLICT";
70
+ static suggestion = "Fetch the task with get_task to get the current version before updating.";
71
+ constructor(taskId, expectedVersion, actualVersion) {
72
+ super(`Version conflict for task ${taskId}: expected ${expectedVersion}, got ${actualVersion}`);
73
+ this.taskId = taskId;
74
+ this.expectedVersion = expectedVersion;
75
+ this.actualVersion = actualVersion;
76
+ this.name = "VersionConflictError";
77
+ }
78
+ };
79
+ TaskNotFoundError = class TaskNotFoundError extends Error {
80
+ taskId;
81
+ static code = "TASK_NOT_FOUND";
82
+ static suggestion = "Verify the task ID. Use list_tasks or search_tasks to find the correct ID.";
83
+ constructor(taskId) {
84
+ super(`Task not found: ${taskId}`);
85
+ this.taskId = taskId;
86
+ this.name = "TaskNotFoundError";
87
+ }
88
+ };
89
+ TaskNotStartableError = class TaskNotStartableError extends Error {
90
+ taskId;
91
+ status;
92
+ agentId;
93
+ static code = "TASK_NOT_STARTABLE";
94
+ static suggestion = "Reset the task status to pending before starting it again.";
95
+ constructor(taskId, status, agentId) {
96
+ super(`Task ${taskId} is ${status} and cannot be started by ${agentId}; ` + "reset the task status to pending before starting it again");
97
+ this.taskId = taskId;
98
+ this.status = status;
99
+ this.agentId = agentId;
100
+ this.name = "TaskNotStartableError";
101
+ }
102
+ };
103
+ TaskReferenceAmbiguousError = class TaskReferenceAmbiguousError extends Error {
104
+ reference;
105
+ static code = "TASK_REFERENCE_AMBIGUOUS";
106
+ candidateTaskIds;
107
+ candidateProjectIds;
108
+ constructor(reference, candidates) {
109
+ const candidateTaskIds = [...new Set(candidates.map((candidate) => candidate.task_id))].sort();
110
+ const candidateProjectIds = [...new Set(candidates.map((candidate) => candidate.project_id).filter((projectId) => typeof projectId === "string" && projectId.length > 0))].sort();
111
+ const projectList = candidateProjectIds.length > 0 ? candidateProjectIds.join(", ") : "(unscoped)";
112
+ super(`Task reference is ambiguous: "${reference}". Candidate project IDs: ${projectList}. ` + "Use a full task UUID.");
113
+ this.reference = reference;
114
+ this.name = "TaskReferenceAmbiguousError";
115
+ this.candidateTaskIds = candidateTaskIds;
116
+ this.candidateProjectIds = candidateProjectIds;
117
+ }
118
+ };
119
+ ProjectNotFoundError = class ProjectNotFoundError extends Error {
120
+ projectId;
121
+ static code = "PROJECT_NOT_FOUND";
122
+ static suggestion = "Use list_projects to see available projects.";
123
+ constructor(projectId) {
124
+ super(`Project not found: ${projectId}`);
125
+ this.projectId = projectId;
126
+ this.name = "ProjectNotFoundError";
127
+ }
128
+ };
129
+ ResourceConflictError = class ResourceConflictError extends Error {
130
+ code;
131
+ constructor(code, message) {
132
+ super(message);
133
+ this.code = code;
134
+ this.name = "ResourceConflictError";
135
+ }
136
+ };
137
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
138
+ planId;
139
+ expectedUpdatedAt;
140
+ currentUpdatedAt;
141
+ static code = "PLAN_REVISION_CONFLICT";
142
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
143
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
144
+ this.planId = planId;
145
+ this.expectedUpdatedAt = expectedUpdatedAt;
146
+ this.currentUpdatedAt = currentUpdatedAt;
147
+ this.name = "PlanRevisionConflictError";
148
+ }
149
+ };
150
+ PlanNotFoundError = class PlanNotFoundError extends Error {
151
+ planId;
152
+ static code = "PLAN_NOT_FOUND";
153
+ static suggestion = "Use list_plans to see available plans.";
154
+ constructor(planId) {
155
+ super(`Plan not found: ${planId}`);
156
+ this.planId = planId;
157
+ this.name = "PlanNotFoundError";
158
+ }
159
+ };
160
+ LockError = class LockError extends Error {
161
+ taskId;
162
+ lockedBy;
163
+ static code = "LOCK_ERROR";
164
+ static suggestion = "Wait for the lock to expire (30 min) or contact the lock holder.";
165
+ constructor(taskId, lockedBy) {
166
+ super(`Task ${taskId} is locked by ${lockedBy}`);
167
+ this.taskId = taskId;
168
+ this.lockedBy = lockedBy;
169
+ this.name = "LockError";
170
+ }
171
+ };
172
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
173
+ code;
174
+ details;
175
+ constructor(code, message, details = {}) {
176
+ super(message);
177
+ this.code = code;
178
+ this.details = details;
179
+ this.name = "StaleLockHandoffError";
180
+ }
181
+ };
182
+ AgentNotFoundError = class AgentNotFoundError extends Error {
183
+ agentId;
184
+ static code = "AGENT_NOT_FOUND";
185
+ static suggestion = "Use register_agent to create the agent first, or list_agents to find existing ones.";
186
+ constructor(agentId) {
187
+ super(`Agent not found: ${agentId}`);
188
+ this.agentId = agentId;
189
+ this.name = "AgentNotFoundError";
190
+ }
191
+ };
192
+ IdentityAliasAmbiguousError = class IdentityAliasAmbiguousError extends Error {
193
+ code = "IDENTITY_ALIAS_AMBIGUOUS";
194
+ candidates;
195
+ constructor(subject, candidates = []) {
196
+ super(`Identity alias or source is ambiguous: ${subject}`);
197
+ this.name = "IdentityAliasAmbiguousError";
198
+ this.candidates = [...new Set(candidates)].sort();
199
+ }
200
+ };
201
+ IdentityIdImmutableError = class IdentityIdImmutableError extends Error {
202
+ code = "IDENTITY_ID_IMMUTABLE";
203
+ constructor(agentId) {
204
+ super(`IDENTITY_ID_IMMUTABLE: canonical identity for agent ${agentId} cannot be replaced`);
205
+ this.name = "IdentityIdImmutableError";
206
+ }
207
+ };
208
+ TaskListNotFoundError = class TaskListNotFoundError extends Error {
209
+ taskListId;
210
+ static code = "TASK_LIST_NOT_FOUND";
211
+ static suggestion = "Use list_task_lists to see available lists.";
212
+ constructor(taskListId) {
213
+ super(`Task list not found: ${taskListId}`);
214
+ this.taskListId = taskListId;
215
+ this.name = "TaskListNotFoundError";
216
+ }
217
+ };
218
+ DependencyCycleError = class DependencyCycleError extends Error {
219
+ taskId;
220
+ dependsOn;
221
+ static code = "DEPENDENCY_CYCLE";
222
+ static suggestion = "Check the dependency chain with get_task to avoid circular references.";
223
+ constructor(taskId, dependsOn) {
224
+ super(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
225
+ this.taskId = taskId;
226
+ this.dependsOn = dependsOn;
227
+ this.name = "DependencyCycleError";
228
+ }
229
+ };
230
+ CompletionGuardError = class CompletionGuardError extends Error {
231
+ reason;
232
+ retryAfterSeconds;
233
+ static code = "COMPLETION_BLOCKED";
234
+ static suggestion = "Wait for the cooldown period, then retry.";
235
+ constructor(reason, retryAfterSeconds) {
236
+ super(reason);
237
+ this.reason = reason;
238
+ this.retryAfterSeconds = retryAfterSeconds;
239
+ this.name = "CompletionGuardError";
240
+ }
241
+ };
242
+ DISPATCH_STATUSES = ["pending", "sent", "failed", "cancelled"];
243
+ DispatchNotFoundError = class DispatchNotFoundError extends Error {
244
+ dispatchId;
245
+ static code = "DISPATCH_NOT_FOUND";
246
+ static suggestion = "Check the dispatch ID with list_dispatches.";
247
+ constructor(dispatchId) {
248
+ super(`Dispatch not found: ${dispatchId}`);
249
+ this.dispatchId = dispatchId;
250
+ this.name = "DispatchNotFoundError";
251
+ }
252
+ };
253
+ });
254
+
42
255
  // src/lib/slugs.ts
43
256
  function normalizeSlug(value) {
44
257
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -165,6 +378,30 @@ function deriveTodosTaskSubtreeTransferIdempotencyKey(input) {
165
378
  }).slice(0, 48)}`;
166
379
  }
167
380
 
381
+ // src/storage/postgres-sync.ts
382
+ init_types();
383
+
384
+ // src/storage/postgres-errors.ts
385
+ function isPostgresUniqueViolation(error) {
386
+ if (typeof error !== "object" || error === null)
387
+ return false;
388
+ const candidate = error;
389
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
390
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
391
+ const cause = candidate.cause;
392
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
393
+ }
394
+ return states.some((state) => String(state) === "23505");
395
+ }
396
+ function postgresConstraintName(error) {
397
+ if (typeof error !== "object" || error === null)
398
+ return "";
399
+ const candidate = error;
400
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
401
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
402
+ return typeof constraint === "string" ? constraint : "";
403
+ }
404
+
168
405
  // src/storage/postgres-sync.ts
169
406
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
170
407
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -367,53 +604,98 @@ class PostgresTodosSyncStore {
367
604
  if (routingErrors.length > 0) {
368
605
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
369
606
  }
370
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
371
- FROM ${this.tableName}
372
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
373
- const existingProjects = [];
374
- const existingTaskLists = [];
375
- for (const row of existing.rows) {
376
- const payload = payloadRecord(row.payload);
377
- if (row.object_type === "projects")
378
- existingProjects.push(payload);
379
- if (row.object_type === "task_lists")
380
- existingTaskLists.push(payload);
607
+ const push = async (client) => {
608
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
609
+ FROM ${this.tableName}
610
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
611
+ const existingProjects = [];
612
+ const existingTaskLists = [];
613
+ for (const row of existing.rows) {
614
+ const payload = payloadRecord(row.payload);
615
+ if (row.object_type === "projects")
616
+ existingProjects.push(payload);
617
+ if (row.object_type === "task_lists")
618
+ existingTaskLists.push(payload);
619
+ }
620
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
621
+ if (destinationErrors.length > 0) {
622
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
623
+ }
624
+ const result = { records: 0, objectTypes: {} };
625
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
626
+ for (const entry of snapshotEntries(snapshot)) {
627
+ if (entry.deletedAt === null)
628
+ assertCanonicalScopedSlugEntry(entry);
629
+ try {
630
+ await client.query(`INSERT INTO ${this.tableName} (
631
+ service, object_type, object_id, payload, updated_at,
632
+ deleted_at, source_machine_id, version
633
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
634
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
635
+ payload = EXCLUDED.payload,
636
+ updated_at = EXCLUDED.updated_at,
637
+ deleted_at = EXCLUDED.deleted_at,
638
+ source_machine_id = EXCLUDED.source_machine_id,
639
+ version = EXCLUDED.version
640
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
641
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
642
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
643
+ this.service,
644
+ entry.type,
645
+ entry.id,
646
+ entry.payload,
647
+ entry.updatedAt,
648
+ entry.deletedAt,
649
+ sourceMachineId,
650
+ entry.version
651
+ ]);
652
+ } catch (error) {
653
+ await this.classifySyncInsertConflict(error, entry);
654
+ }
655
+ result.records += 1;
656
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
657
+ }
658
+ return result;
659
+ };
660
+ if (typeof this.client.transaction === "function") {
661
+ return this.client.transaction((client) => push(client));
662
+ }
663
+ return push(this.client);
664
+ }
665
+ async classifySyncInsertConflict(error, entry) {
666
+ if (!isPostgresUniqueViolation(error))
667
+ throw error;
668
+ const payload = entry.payload;
669
+ const taskListSlug = String(payload["slug"] ?? "");
670
+ const projectSlug = String(payload["task_list_id"] ?? "");
671
+ const constraintName = postgresConstraintName(error);
672
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
673
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
381
674
  }
382
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
383
- if (destinationErrors.length > 0) {
384
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
675
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
676
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
385
677
  }
386
- const result = { records: 0, objectTypes: {} };
387
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
388
- for (const entry of snapshotEntries(snapshot)) {
389
- if (entry.deletedAt === null)
390
- assertCanonicalScopedSlugEntry(entry);
391
- await this.client.query(`INSERT INTO ${this.tableName} (
392
- service, object_type, object_id, payload, updated_at,
393
- deleted_at, source_machine_id, version
394
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
395
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
396
- payload = EXCLUDED.payload,
397
- updated_at = EXCLUDED.updated_at,
398
- deleted_at = EXCLUDED.deleted_at,
399
- source_machine_id = EXCLUDED.source_machine_id,
400
- version = EXCLUDED.version
401
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
402
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
403
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
404
- this.service,
405
- entry.type,
406
- entry.id,
407
- entry.payload,
408
- entry.updatedAt,
409
- entry.deletedAt,
410
- sourceMachineId,
411
- entry.version
412
- ]);
413
- result.records += 1;
414
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
678
+ if (entry.type === "task_lists") {
679
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
680
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
681
+ SELECT 1 FROM ${this.tableName}
682
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
683
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
684
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
685
+ if (conflict.rows[0]?.conflict) {
686
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
687
+ }
688
+ } else if (entry.type === "projects") {
689
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
690
+ SELECT 1 FROM ${this.tableName}
691
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
692
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
693
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
694
+ if (conflict.rows[0]?.conflict) {
695
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
696
+ }
415
697
  }
416
- return result;
698
+ throw error;
417
699
  }
418
700
  async pullSnapshot(options = {}) {
419
701
  const params = [this.service];
@@ -1219,8 +1219,8 @@ export declare class ProjectNotFoundError extends Error {
1219
1219
  constructor(projectId: string);
1220
1220
  }
1221
1221
  export declare class ResourceConflictError extends Error {
1222
- readonly code: "PROJECT_SLUG_CONFLICT" | "TASK_LIST_SLUG_CONFLICT" | "PLAN_SLUG_CONFLICT" | "PLAN_PROJECT_LINK_CONFLICT" | "TASK_PARENT_CYCLE" | "PROJECT_PARENT_CYCLE";
1223
- constructor(code: "PROJECT_SLUG_CONFLICT" | "TASK_LIST_SLUG_CONFLICT" | "PLAN_SLUG_CONFLICT" | "PLAN_PROJECT_LINK_CONFLICT" | "TASK_PARENT_CYCLE" | "PROJECT_PARENT_CYCLE", message: string);
1222
+ readonly code: "PROJECT_SLUG_CONFLICT" | "TASK_LIST_SLUG_CONFLICT" | "PLAN_SLUG_CONFLICT" | "PLAN_PROJECT_LINK_CONFLICT" | "TASK_PARENT_CYCLE" | "PROJECT_PARENT_CYCLE" | "SNAPSHOT_DESTINATION_CONFLICT";
1223
+ constructor(code: "PROJECT_SLUG_CONFLICT" | "TASK_LIST_SLUG_CONFLICT" | "PLAN_SLUG_CONFLICT" | "PLAN_PROJECT_LINK_CONFLICT" | "TASK_PARENT_CYCLE" | "PROJECT_PARENT_CYCLE" | "SNAPSHOT_DESTINATION_CONFLICT", message: string);
1224
1224
  }
1225
1225
  export declare class PlanRevisionConflictError extends Error {
1226
1226
  readonly planId: string;