@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
package/dist/mcp/index.js CHANGED
@@ -9582,13 +9582,15 @@ function upsertSecretSafetyConfig(input) {
9582
9582
  saveConfig({ ...config, secret_safety: next });
9583
9583
  return next;
9584
9584
  }
9585
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
9585
+ var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
9586
9586
  var init_redaction = __esm(() => {
9587
9587
  init_config2();
9588
+ XAI_PREFIX = ["x", "ai", "-"].join("");
9588
9589
  DEFAULT_SECRET_PATTERNS = [
9589
9590
  { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
9590
9591
  { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
9591
9592
  { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
9593
+ { name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
9592
9594
  { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
9593
9595
  { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
9594
9596
  { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
@@ -11084,8 +11086,9 @@ var init_event_hooks = __esm(() => {
11084
11086
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
11085
11087
  });
11086
11088
 
11087
- // node_modules/.bun/@hasna+events@0.1.11/node_modules/@hasna/events/dist/index.js
11089
+ // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
11088
11090
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
11091
+ import { Buffer as Buffer2 } from "buffer";
11089
11092
  import { existsSync as existsSync5 } from "fs";
11090
11093
  import { homedir as homedir2 } from "os";
11091
11094
  import { join as join4 } from "path";
@@ -11194,11 +11197,13 @@ function getEventsDataDir(override) {
11194
11197
 
11195
11198
  class JsonEventsStore {
11196
11199
  dataDir;
11200
+ runtime;
11197
11201
  channelsPath;
11198
11202
  eventsPath;
11199
11203
  deliveriesPath;
11200
11204
  constructor(dataDir = getEventsDataDir()) {
11201
11205
  this.dataDir = dataDir;
11206
+ this.runtime = localJsonRuntime(dataDir);
11202
11207
  this.channelsPath = join4(dataDir, "channels.json");
11203
11208
  this.eventsPath = join4(dataDir, "events.json");
11204
11209
  this.deliveriesPath = join4(dataDir, "deliveries.json");
@@ -11246,13 +11251,58 @@ class JsonEventsStore {
11246
11251
  await this.writeJson(this.eventsPath, events);
11247
11252
  return event;
11248
11253
  }
11249
- async listEvents() {
11254
+ async appendEventOnce(event, options = {}) {
11250
11255
  await this.init();
11251
- return this.readJson(this.eventsPath, []);
11256
+ const events = await this.readJson(this.eventsPath, []);
11257
+ const dedupe = options.dedupe !== false;
11258
+ if (dedupe) {
11259
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
11260
+ if (existing) {
11261
+ return {
11262
+ event: existing,
11263
+ stored: false,
11264
+ deduped: true,
11265
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
11266
+ };
11267
+ }
11268
+ }
11269
+ events.push(event);
11270
+ await this.writeJson(this.eventsPath, events);
11271
+ return {
11272
+ event,
11273
+ stored: true,
11274
+ deduped: false,
11275
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
11276
+ };
11277
+ }
11278
+ async listEvents(options = {}) {
11279
+ await this.init();
11280
+ const events = await this.readJson(this.eventsPath, []);
11281
+ return queryEvents(events, options);
11282
+ }
11283
+ async listEventsPage(options = {}) {
11284
+ await this.init();
11285
+ const events = await this.readJson(this.eventsPath, []);
11286
+ const queried = queryEvents(events, {
11287
+ eventId: options.eventId,
11288
+ source: options.source,
11289
+ type: options.type
11290
+ });
11291
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
11292
+ const limit = normalizeEventPageLimit(options.limit);
11293
+ const pageEvents = queried.slice(offset, offset + limit);
11294
+ const nextOffset = offset + pageEvents.length;
11295
+ const hasMore = nextOffset < queried.length;
11296
+ return {
11297
+ events: pageEvents,
11298
+ cursor: options.cursor,
11299
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
11300
+ hasMore
11301
+ };
11252
11302
  }
11253
11303
  async findEventByIdentity(identity) {
11254
11304
  const events = await this.listEvents();
11255
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
11305
+ return findEventByIdentity(events, identity);
11256
11306
  }
11257
11307
  async appendDelivery(result) {
11258
11308
  await this.init();
@@ -11303,6 +11353,83 @@ class JsonEventsStore {
11303
11353
  });
11304
11354
  }
11305
11355
  }
11356
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
11357
+ return {
11358
+ mode: "local-files",
11359
+ name: "json-events-store",
11360
+ remote: false,
11361
+ localFiles: true,
11362
+ localSqlite: false,
11363
+ postgres: false,
11364
+ s3: false,
11365
+ aws: false,
11366
+ durable: true,
11367
+ idempotency: "best-effort-local",
11368
+ replayCursors: true,
11369
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
11370
+ };
11371
+ }
11372
+ function encodeLocalJsonEventCursor(offset, options = {}) {
11373
+ if (!Number.isInteger(offset) || offset < 0)
11374
+ throw new Error(`Invalid event cursor offset: ${offset}`);
11375
+ const payload = {
11376
+ offset,
11377
+ eventId: options.eventId,
11378
+ source: options.source,
11379
+ type: options.type
11380
+ };
11381
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
11382
+ }
11383
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
11384
+ if (!cursor)
11385
+ return 0;
11386
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
11387
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
11388
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
11389
+ let payload;
11390
+ try {
11391
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
11392
+ } catch {
11393
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
11394
+ }
11395
+ const offset = payload.offset;
11396
+ if (!Number.isInteger(offset) || offset < 0)
11397
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
11398
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
11399
+ assertCursorFilter("source", payload.source, options.source);
11400
+ assertCursorFilter("type", payload.type, options.type);
11401
+ return offset;
11402
+ }
11403
+ function normalizeEventPageLimit(limit) {
11404
+ if (limit === undefined)
11405
+ return DEFAULT_EVENT_PAGE_LIMIT;
11406
+ if (!Number.isInteger(limit) || limit < 1)
11407
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
11408
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
11409
+ }
11410
+ function queryEvents(events, options) {
11411
+ let rows = events;
11412
+ if (options.eventId)
11413
+ rows = rows.filter((event) => event.id === options.eventId);
11414
+ if (options.source)
11415
+ rows = rows.filter((event) => event.source === options.source);
11416
+ if (options.type)
11417
+ rows = rows.filter((event) => event.type === options.type);
11418
+ if (options.cursor) {
11419
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
11420
+ rows = rows.slice(offset);
11421
+ }
11422
+ if (options.limit !== undefined)
11423
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
11424
+ return rows;
11425
+ }
11426
+ function assertCursorFilter(name, cursorValue, optionValue) {
11427
+ if (cursorValue !== optionValue)
11428
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
11429
+ }
11430
+ function findEventByIdentity(events, identity) {
11431
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
11432
+ }
11306
11433
  function buildSignatureBase(timestamp2, body) {
11307
11434
  return `${timestamp2}.${body}`;
11308
11435
  }
@@ -11316,21 +11443,27 @@ function now2() {
11316
11443
  function truncate(value, max = 4096) {
11317
11444
  return value.length > max ? `${value.slice(0, max)}...` : value;
11318
11445
  }
11319
- function buildWebhookRequest(event, channel) {
11446
+ function buildWebhookRequest(event, channel, options = {}) {
11320
11447
  if (!channel.webhook)
11321
11448
  throw new Error(`Channel ${channel.id} has no webhook config`);
11449
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
11450
+ if (/^x-hasna-/i.test(name)) {
11451
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
11452
+ }
11453
+ }
11322
11454
  const body = JSON.stringify(event);
11323
- const timestamp2 = event.time;
11455
+ const timestamp2 = options.timestamp ?? new Date().toISOString();
11324
11456
  const headers = {
11325
11457
  "Content-Type": "application/json",
11326
11458
  "User-Agent": "@hasna/events",
11327
11459
  "X-Hasna-Event-Id": event.id,
11328
11460
  "X-Hasna-Event-Type": event.type,
11329
- "X-Hasna-Timestamp": timestamp2,
11330
- ...channel.webhook.headers
11461
+ ...channel.webhook.headers,
11462
+ "X-Hasna-Timestamp": timestamp2
11331
11463
  };
11332
- if (channel.webhook.secret) {
11333
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp2, body);
11464
+ const secret = options.secret ?? channel.webhook.secret;
11465
+ if (secret) {
11466
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
11334
11467
  }
11335
11468
  return { body, headers };
11336
11469
  }
@@ -11338,7 +11471,21 @@ async function dispatchWebhook(event, channel, options = {}) {
11338
11471
  if (!channel.webhook)
11339
11472
  throw new Error(`Channel ${channel.id} has no webhook config`);
11340
11473
  const startedAt = now2();
11341
- const { body, headers } = buildWebhookRequest(event, channel);
11474
+ let secret = channel.webhook.secret;
11475
+ if (channel.webhook.secretRef) {
11476
+ if (!options.secretResolver) {
11477
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
11478
+ }
11479
+ try {
11480
+ secret = await options.secretResolver(channel.webhook.secretRef);
11481
+ } catch {
11482
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
11483
+ }
11484
+ if (!secret)
11485
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
11486
+ }
11487
+ const timestamp2 = (options.now?.() ?? new Date).toISOString();
11488
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
11342
11489
  const controller = new AbortController;
11343
11490
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
11344
11491
  try {
@@ -11370,6 +11517,15 @@ async function dispatchWebhook(event, channel, options = {}) {
11370
11517
  clearTimeout(timeout);
11371
11518
  }
11372
11519
  }
11520
+ function failedAttempt(startedAt, error) {
11521
+ return {
11522
+ attempt: 1,
11523
+ status: "failed",
11524
+ startedAt,
11525
+ completedAt: now2(),
11526
+ error
11527
+ };
11528
+ }
11373
11529
  async function dispatchCommand(event, channel) {
11374
11530
  if (!channel.command)
11375
11531
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -11458,6 +11614,76 @@ function createDeliveryResult(event, channel, attempts) {
11458
11614
  completedAt: attempts.at(-1)?.completedAt ?? now2()
11459
11615
  };
11460
11616
  }
11617
+
11618
+ class EventTypeCatalog {
11619
+ definitions = new Map;
11620
+ register(definition) {
11621
+ this.definitions.set(definition.type, definition);
11622
+ return this;
11623
+ }
11624
+ unregister(type) {
11625
+ return this.definitions.delete(type);
11626
+ }
11627
+ has(type) {
11628
+ return this.definitions.has(type);
11629
+ }
11630
+ get(type) {
11631
+ return this.definitions.get(type);
11632
+ }
11633
+ list() {
11634
+ return [...this.definitions.values()];
11635
+ }
11636
+ validateEvent(event) {
11637
+ const definition = this.definitions.get(event.type);
11638
+ if (!definition)
11639
+ return { ok: true };
11640
+ return definition.validate(event.data, event);
11641
+ }
11642
+ assertEventValid(event) {
11643
+ const result = this.validateEvent(event);
11644
+ if (!result.ok) {
11645
+ throw new EventValidationError(event.type, result.issues);
11646
+ }
11647
+ }
11648
+ }
11649
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
11650
+ if (paths.length === 0)
11651
+ return event;
11652
+ const copy = structuredClone(event);
11653
+ for (const path of paths) {
11654
+ setPath(copy, path, replacement);
11655
+ }
11656
+ return copy;
11657
+ }
11658
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
11659
+ return redactValue2(event, replacement);
11660
+ }
11661
+ function shouldRedactKey(key) {
11662
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
11663
+ }
11664
+ function redactValue2(value, replacement) {
11665
+ if (Array.isArray(value))
11666
+ return value.map((item) => redactValue2(item, replacement));
11667
+ if (!value || typeof value !== "object")
11668
+ return value;
11669
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
11670
+ key,
11671
+ shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
11672
+ ]));
11673
+ }
11674
+ function setPath(input, path, replacement) {
11675
+ const parts = path.split(".");
11676
+ let cursor = input;
11677
+ for (const part of parts.slice(0, -1)) {
11678
+ const next = cursor[part];
11679
+ if (!next || typeof next !== "object")
11680
+ return;
11681
+ cursor = next;
11682
+ }
11683
+ const last = parts.at(-1);
11684
+ if (last && last in cursor)
11685
+ cursor[last] = replacement;
11686
+ }
11461
11687
  function createEvent(input) {
11462
11688
  return {
11463
11689
  id: input.id ?? randomUUID22(),
@@ -11478,10 +11704,18 @@ class EventsClient {
11478
11704
  store;
11479
11705
  redactors;
11480
11706
  transportOptions;
11707
+ catalog;
11708
+ validateCatalogTypes;
11481
11709
  constructor(options = {}) {
11482
11710
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
11483
11711
  this.redactors = options.redactors ?? [];
11484
- this.transportOptions = { fetchImpl: options.fetchImpl };
11712
+ this.transportOptions = {
11713
+ fetchImpl: options.fetchImpl,
11714
+ secretResolver: options.secretResolver,
11715
+ now: options.now
11716
+ };
11717
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
11718
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
11485
11719
  }
11486
11720
  async addChannel(input) {
11487
11721
  const timestamp2 = new Date().toISOString();
@@ -11499,18 +11733,40 @@ class EventsClient {
11499
11733
  }
11500
11734
  async emit(input, options = {}) {
11501
11735
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
11502
- if (options.dedupe !== false) {
11503
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
11504
- if (existing) {
11505
- return { event: existing, deliveries: [], deduped: true };
11506
- }
11507
- }
11508
- await this.store.appendEvent(event);
11509
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
11510
- return { event, deliveries, deduped: false };
11511
- }
11512
- async listEvents() {
11513
- return this.store.listEvents();
11736
+ if (options.validate ?? this.validateCatalogTypes) {
11737
+ this.catalog.assertEventValid(event);
11738
+ }
11739
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
11740
+ if (append.deduped) {
11741
+ return { event: append.event, deliveries: [], deduped: true };
11742
+ }
11743
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
11744
+ return { event: append.event, deliveries, deduped: false };
11745
+ }
11746
+ async listEvents(options = {}) {
11747
+ if (Object.keys(options).length === 0)
11748
+ return this.store.listEvents();
11749
+ return queryClientEvents(await this.store.listEvents(), options);
11750
+ }
11751
+ async listEventsPage(options = {}) {
11752
+ if (this.store.listEventsPage)
11753
+ return this.store.listEventsPage(options);
11754
+ const events = queryClientEvents(await this.store.listEvents(), {
11755
+ eventId: options.eventId,
11756
+ source: options.source,
11757
+ type: options.type
11758
+ });
11759
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
11760
+ const limit = normalizeEventPageLimit(options.limit);
11761
+ const pageEvents = events.slice(offset, offset + limit);
11762
+ const nextOffset = offset + pageEvents.length;
11763
+ const hasMore = nextOffset < events.length;
11764
+ return {
11765
+ events: pageEvents,
11766
+ cursor: options.cursor,
11767
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
11768
+ hasMore
11769
+ };
11514
11770
  }
11515
11771
  async listDeliveries() {
11516
11772
  return this.store.listDeliveries();
@@ -11578,22 +11834,37 @@ class EventsClient {
11578
11834
  return result;
11579
11835
  }
11580
11836
  async replay(options = {}) {
11581
- const events = (await this.store.listEvents()).filter((event) => {
11582
- if (options.eventId && event.id !== options.eventId)
11583
- return false;
11584
- if (options.source && event.source !== options.source)
11585
- return false;
11586
- if (options.type && event.type !== options.type)
11587
- return false;
11588
- return true;
11589
- });
11837
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
11590
11838
  if (options.dryRun)
11591
- return { events, deliveries: [] };
11839
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
11592
11840
  const deliveries = [];
11593
- for (const event of events) {
11841
+ for (const event of page.events) {
11594
11842
  deliveries.push(...await this.deliver(event));
11595
11843
  }
11596
- return { events, deliveries };
11844
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
11845
+ }
11846
+ async appendEvent(event, options) {
11847
+ if (this.store.appendEventOnce) {
11848
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
11849
+ }
11850
+ if (options.dedupe) {
11851
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
11852
+ if (existing) {
11853
+ return {
11854
+ event: existing,
11855
+ stored: false,
11856
+ deduped: true,
11857
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
11858
+ };
11859
+ }
11860
+ }
11861
+ const stored = await this.store.appendEvent(event);
11862
+ return {
11863
+ event: stored,
11864
+ stored: true,
11865
+ deduped: false,
11866
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
11867
+ };
11597
11868
  }
11598
11869
  async applyRedaction(event, channel) {
11599
11870
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -11620,43 +11891,19 @@ class EventsClient {
11620
11891
  return createDeliveryResult(event, channel, attempts);
11621
11892
  }
11622
11893
  }
11623
- function redactPaths(event, paths, replacement = "[REDACTED]") {
11624
- if (paths.length === 0)
11625
- return event;
11626
- const copy = structuredClone(event);
11627
- for (const path of paths) {
11628
- setPath(copy, path, replacement);
11629
- }
11630
- return copy;
11631
- }
11632
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
11633
- return redactValue2(event, replacement);
11634
- }
11635
- function shouldRedactKey(key) {
11636
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
11637
- }
11638
- function redactValue2(value, replacement) {
11639
- if (Array.isArray(value))
11640
- return value.map((item) => redactValue2(item, replacement));
11641
- if (!value || typeof value !== "object")
11642
- return value;
11643
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
11644
- key,
11645
- shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
11646
- ]));
11647
- }
11648
- function setPath(input, path, replacement) {
11649
- const parts = path.split(".");
11650
- let cursor = input;
11651
- for (const part of parts.slice(0, -1)) {
11652
- const next = cursor[part];
11653
- if (!next || typeof next !== "object")
11654
- return;
11655
- cursor = next;
11656
- }
11657
- const last = parts.at(-1);
11658
- if (last && last in cursor)
11659
- cursor[last] = replacement;
11894
+ function queryClientEvents(events, options) {
11895
+ let rows = events;
11896
+ if (options.eventId)
11897
+ rows = rows.filter((event) => event.id === options.eventId);
11898
+ if (options.source)
11899
+ rows = rows.filter((event) => event.source === options.source);
11900
+ if (options.type)
11901
+ rows = rows.filter((event) => event.type === options.type);
11902
+ if (options.cursor)
11903
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
11904
+ if (options.limit !== undefined)
11905
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
11906
+ return rows;
11660
11907
  }
11661
11908
  function normalizeTime(value) {
11662
11909
  if (!value)
@@ -11670,9 +11917,22 @@ function normalizeRetryPolicy(policy) {
11670
11917
  multiplier: Math.max(1, policy?.multiplier ?? 2)
11671
11918
  };
11672
11919
  }
11673
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
11920
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
11674
11921
  var init_dist = __esm(() => {
11675
11922
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
11923
+ EventValidationError = class EventValidationError extends Error {
11924
+ eventType;
11925
+ issues;
11926
+ constructor(eventType, issues) {
11927
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
11928
+ super(`Event validation failed for type "${eventType}": ${detail}`);
11929
+ this.name = "EventValidationError";
11930
+ this.eventType = eventType;
11931
+ this.issues = issues;
11932
+ }
11933
+ };
11934
+ defaultEventTypeCatalog = new EventTypeCatalog;
11935
+ APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
11676
11936
  });
11677
11937
 
11678
11938
  // src/db/task-lists.ts
@@ -12511,8 +12771,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
12511
12771
  const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
12512
12772
  headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
12513
12773
  }
12514
- const resp = await fetch(wh.url, { method: "POST", headers, body });
12774
+ const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
12515
12775
  const respText = await resp.text().catch(() => "");
12776
+ if (resp.status >= 300 && resp.status < 400) {
12777
+ logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
12778
+ return;
12779
+ }
12516
12780
  logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
12517
12781
  if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
12518
12782
  const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -14309,6 +14573,12 @@ function listTasks(filter = {}, db) {
14309
14573
  const d = db || getDatabase();
14310
14574
  const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
14311
14575
  clearExpiredLocks2(d);
14576
+ if (filter.limit !== undefined) {
14577
+ const limit = filter.limit;
14578
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
14579
+ throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
14580
+ }
14581
+ }
14312
14582
  const conditions = [];
14313
14583
  const params = [];
14314
14584
  if (filter.project_id) {
@@ -14842,6 +15112,7 @@ function deleteTask(id, db) {
14842
15112
  }
14843
15113
  return result.changes > 0;
14844
15114
  }
15115
+ var MAX_TASK_LIST_LIMIT = 1e5;
14845
15116
  var init_task_crud = __esm(() => {
14846
15117
  init_types();
14847
15118
  init_database();
@@ -17756,7 +18027,8 @@ __export(exports_tasks, {
17756
18027
  buildTaskBoardSnapshot: () => buildTaskBoardSnapshot,
17757
18028
  archiveTasks: () => archiveTasks,
17758
18029
  archiveCompletedTasks: () => archiveCompletedTasks,
17759
- addDependency: () => addDependency
18030
+ addDependency: () => addDependency,
18031
+ MAX_TASK_LIST_LIMIT: () => MAX_TASK_LIST_LIMIT
17760
18032
  });
17761
18033
  var init_tasks = __esm(() => {
17762
18034
  init_task_crud();
@@ -22179,8 +22451,8 @@ function registerTaskCrudTools(server, ctx) {
22179
22451
  tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (AND logic)"),
22180
22452
  created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
22181
22453
  created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
22182
- limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
22183
- offset: exports_external.number().optional().describe("Pagination offset"),
22454
+ limit: exports_external.number().int().min(1).optional().describe("Max results (default: 50, max 500)"),
22455
+ offset: exports_external.number().int().min(0).optional().describe("Pagination offset"),
22184
22456
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Exact top-level metadata filters")
22185
22457
  }, async (params) => {
22186
22458
  try {
@@ -25530,6 +25802,48 @@ var init_task_relationships = __esm(() => {
25530
25802
  ];
25531
25803
  });
25532
25804
 
25805
+ // src/lib/dedupe-projection.ts
25806
+ function projectTasksForDedupe(tasks) {
25807
+ return tasks.map((task) => {
25808
+ const metadata = {};
25809
+ for (const key of DEDUPE_SOURCE_KEY_ALLOWLIST) {
25810
+ if (key in task.metadata) {
25811
+ metadata[key] = redactValue(task.metadata[key]);
25812
+ }
25813
+ }
25814
+ return {
25815
+ id: task.id,
25816
+ short_id: task.short_id,
25817
+ title: redactEvidenceText(task.title),
25818
+ description: task.description === null || task.description === undefined ? null : redactEvidenceText(task.description),
25819
+ status: task.status,
25820
+ created_at: task.created_at,
25821
+ updated_at: task.updated_at,
25822
+ project_id: task.project_id,
25823
+ task_list_id: task.task_list_id,
25824
+ assigned_to: task.assigned_to,
25825
+ priority: task.priority,
25826
+ metadata
25827
+ };
25828
+ });
25829
+ }
25830
+ var DEDUPE_SOURCE_KEY_ALLOWLIST;
25831
+ var init_dedupe_projection = __esm(() => {
25832
+ init_redaction();
25833
+ DEDUPE_SOURCE_KEY_ALLOWLIST = [
25834
+ "github_url",
25835
+ "github_issue_url",
25836
+ "github_pr_url",
25837
+ "source_url",
25838
+ "url",
25839
+ "external_url",
25840
+ "issue_url",
25841
+ "github_owner",
25842
+ "github_repo",
25843
+ "github_number"
25844
+ ];
25845
+ });
25846
+
25533
25847
  // src/lib/task-dedupe.ts
25534
25848
  function asObject(value) {
25535
25849
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -25694,10 +26008,10 @@ function olderFirst(left, right) {
25694
26008
  function findDuplicateTasks(options = {}, db) {
25695
26009
  const d = db || getDatabase();
25696
26010
  const threshold = options.threshold ?? DEFAULT_THRESHOLD;
25697
- const fingerprints = listTasks({
26011
+ const fingerprints = projectTasksForDedupe(listTasks({
25698
26012
  include_archived: Boolean(options.include_archived),
25699
26013
  limit: options.limit ?? 1000
25700
- }, d).map(fingerprint);
26014
+ }, d)).map(fingerprint);
25701
26015
  const candidates = [];
25702
26016
  for (let i = 0;i < fingerprints.length; i++) {
25703
26017
  for (let j = i + 1;j < fingerprints.length; j++) {
@@ -25932,6 +26246,7 @@ var init_task_dedupe = __esm(() => {
25932
26246
  init_database();
25933
26247
  init_task_relationships();
25934
26248
  init_tasks();
26249
+ init_dedupe_projection();
25935
26250
  STOP_WORDS = new Set([
25936
26251
  "a",
25937
26252
  "an",
@@ -36102,7 +36417,7 @@ var package_default;
36102
36417
  var init_package = __esm(() => {
36103
36418
  package_default = {
36104
36419
  name: "@hasna/todos",
36105
- version: "0.15.46",
36420
+ version: "0.15.49",
36106
36421
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
36107
36422
  type: "module",
36108
36423
  main: "dist/index.js",
@@ -36166,6 +36481,7 @@ var init_package = __esm(() => {
36166
36481
  ],
36167
36482
  scripts: {
36168
36483
  build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
36484
+ "build:js": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
36169
36485
  "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
36170
36486
  migrate: "bun run src/server/index.ts migrate",
36171
36487
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
@@ -36220,18 +36536,19 @@ var init_package = __esm(() => {
36220
36536
  author: "Andrei Hasna <andrei@hasna.com>",
36221
36537
  license: "Apache-2.0",
36222
36538
  dependencies: {
36223
- "@hasna/contracts": "0.13.4",
36539
+ "@hasna/contracts": "0.14.0",
36224
36540
  "@hasna/events": "^0.1.11",
36225
36541
  "@modelcontextprotocol/sdk": "^1.12.1",
36226
36542
  chalk: "^5.4.1",
36227
36543
  commander: "^13.1.0",
36228
36544
  ink: "^5.2.0",
36229
36545
  react: "^18.3.1",
36230
- zod: "^3.24.2"
36546
+ zod: "3.25.76"
36231
36547
  },
36232
36548
  overrides: {
36233
36549
  ajv: "8.20.0",
36234
- "fast-uri": "3.1.2"
36550
+ "fast-uri": "3.1.2",
36551
+ zod: "3.25.76"
36235
36552
  },
36236
36553
  devDependencies: {
36237
36554
  "@types/bun": "^1.2.4",
@@ -46475,6 +46792,27 @@ var init_local_sqlite = __esm(() => {
46475
46792
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46476
46793
  });
46477
46794
 
46795
+ // src/storage/postgres-errors.ts
46796
+ function isPostgresUniqueViolation(error) {
46797
+ if (typeof error !== "object" || error === null)
46798
+ return false;
46799
+ const candidate = error;
46800
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
46801
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
46802
+ const cause = candidate.cause;
46803
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
46804
+ }
46805
+ return states.some((state) => String(state) === "23505");
46806
+ }
46807
+ function postgresConstraintName(error) {
46808
+ if (typeof error !== "object" || error === null)
46809
+ return "";
46810
+ const candidate = error;
46811
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
46812
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
46813
+ return typeof constraint === "string" ? constraint : "";
46814
+ }
46815
+
46478
46816
  // src/storage/postgres-sync.ts
46479
46817
  function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
46480
46818
  assertSafeIdentifier(tableName);
@@ -46669,53 +47007,98 @@ class PostgresTodosSyncStore {
46669
47007
  if (routingErrors.length > 0) {
46670
47008
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
46671
47009
  }
46672
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
46673
- FROM ${this.tableName}
46674
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
46675
- const existingProjects = [];
46676
- const existingTaskLists = [];
46677
- for (const row of existing.rows) {
46678
- const payload = payloadRecord(row.payload);
46679
- if (row.object_type === "projects")
46680
- existingProjects.push(payload);
46681
- if (row.object_type === "task_lists")
46682
- existingTaskLists.push(payload);
46683
- }
46684
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
46685
- if (destinationErrors.length > 0) {
46686
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
46687
- }
46688
- const result = { records: 0, objectTypes: {} };
46689
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
46690
- for (const entry of snapshotEntries(snapshot)) {
46691
- if (entry.deletedAt === null)
46692
- assertCanonicalScopedSlugEntry(entry);
46693
- await this.client.query(`INSERT INTO ${this.tableName} (
46694
- service, object_type, object_id, payload, updated_at,
46695
- deleted_at, source_machine_id, version
46696
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
46697
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
46698
- payload = EXCLUDED.payload,
46699
- updated_at = EXCLUDED.updated_at,
46700
- deleted_at = EXCLUDED.deleted_at,
46701
- source_machine_id = EXCLUDED.source_machine_id,
46702
- version = EXCLUDED.version
46703
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
46704
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
46705
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
46706
- this.service,
46707
- entry.type,
46708
- entry.id,
46709
- entry.payload,
46710
- entry.updatedAt,
46711
- entry.deletedAt,
46712
- sourceMachineId,
46713
- entry.version
46714
- ]);
46715
- result.records += 1;
46716
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
47010
+ const push = async (client) => {
47011
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
47012
+ FROM ${this.tableName}
47013
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
47014
+ const existingProjects = [];
47015
+ const existingTaskLists = [];
47016
+ for (const row of existing.rows) {
47017
+ const payload = payloadRecord(row.payload);
47018
+ if (row.object_type === "projects")
47019
+ existingProjects.push(payload);
47020
+ if (row.object_type === "task_lists")
47021
+ existingTaskLists.push(payload);
47022
+ }
47023
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
47024
+ if (destinationErrors.length > 0) {
47025
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
47026
+ }
47027
+ const result = { records: 0, objectTypes: {} };
47028
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
47029
+ for (const entry of snapshotEntries(snapshot)) {
47030
+ if (entry.deletedAt === null)
47031
+ assertCanonicalScopedSlugEntry(entry);
47032
+ try {
47033
+ await client.query(`INSERT INTO ${this.tableName} (
47034
+ service, object_type, object_id, payload, updated_at,
47035
+ deleted_at, source_machine_id, version
47036
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
47037
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
47038
+ payload = EXCLUDED.payload,
47039
+ updated_at = EXCLUDED.updated_at,
47040
+ deleted_at = EXCLUDED.deleted_at,
47041
+ source_machine_id = EXCLUDED.source_machine_id,
47042
+ version = EXCLUDED.version
47043
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
47044
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
47045
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
47046
+ this.service,
47047
+ entry.type,
47048
+ entry.id,
47049
+ entry.payload,
47050
+ entry.updatedAt,
47051
+ entry.deletedAt,
47052
+ sourceMachineId,
47053
+ entry.version
47054
+ ]);
47055
+ } catch (error) {
47056
+ await this.classifySyncInsertConflict(error, entry);
47057
+ }
47058
+ result.records += 1;
47059
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
47060
+ }
47061
+ return result;
47062
+ };
47063
+ if (typeof this.client.transaction === "function") {
47064
+ return this.client.transaction((client) => push(client));
46717
47065
  }
46718
- return result;
47066
+ return push(this.client);
47067
+ }
47068
+ async classifySyncInsertConflict(error, entry) {
47069
+ if (!isPostgresUniqueViolation(error))
47070
+ throw error;
47071
+ const payload = entry.payload;
47072
+ const taskListSlug = String(payload["slug"] ?? "");
47073
+ const projectSlug = String(payload["task_list_id"] ?? "");
47074
+ const constraintName = postgresConstraintName(error);
47075
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
47076
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
47077
+ }
47078
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
47079
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
47080
+ }
47081
+ if (entry.type === "task_lists") {
47082
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
47083
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
47084
+ SELECT 1 FROM ${this.tableName}
47085
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
47086
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
47087
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
47088
+ if (conflict.rows[0]?.conflict) {
47089
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
47090
+ }
47091
+ } else if (entry.type === "projects") {
47092
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
47093
+ SELECT 1 FROM ${this.tableName}
47094
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
47095
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
47096
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
47097
+ if (conflict.rows[0]?.conflict) {
47098
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
47099
+ }
47100
+ }
47101
+ throw error;
46719
47102
  }
46720
47103
  async pullSnapshot(options = {}) {
46721
47104
  const params = [this.service];
@@ -46872,6 +47255,7 @@ function assertSafeIdentifier(value) {
46872
47255
  }
46873
47256
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
46874
47257
  var init_postgres_sync = __esm(() => {
47258
+ init_types();
46875
47259
  PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
46876
47260
  conflicts;
46877
47261
  constructor(conflicts) {
@@ -47017,6 +47401,12 @@ class TodosShadowOutbox {
47017
47401
  this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
47018
47402
  } catch (error) {
47019
47403
  const message = error instanceof Error ? error.message : String(error);
47404
+ if (error instanceof ResourceConflictError) {
47405
+ this.metrics.lastError = message;
47406
+ this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
47407
+ this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
47408
+ return;
47409
+ }
47020
47410
  this.metrics.retries += 1;
47021
47411
  this.metrics.lastError = message;
47022
47412
  const attempts = row.attempts + 1;
@@ -47139,6 +47529,7 @@ function emptySnapshot() {
47139
47529
  }
47140
47530
  var MAX_BACKOFF_MS;
47141
47531
  var init_shadow_outbox = __esm(() => {
47532
+ init_types();
47142
47533
  init_local_sqlite();
47143
47534
  init_postgres_sync();
47144
47535
  init_shadow_outbox_schema();
@@ -47490,9 +47881,11 @@ class PostgresJsonRecordStore {
47490
47881
  async ensureSchema() {
47491
47882
  if (!this.schemaReady) {
47492
47883
  this.schemaReady = (async () => {
47493
- for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
47494
- await this.options.client.query(sql);
47495
- }
47884
+ await retryOnTransientPostgresError(async () => {
47885
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
47886
+ await this.options.client.query(sql);
47887
+ }
47888
+ });
47496
47889
  })().catch((error) => {
47497
47890
  this.schemaReady = null;
47498
47891
  throw error;
@@ -47906,13 +48299,14 @@ class PostgresJsonRecordStore {
47906
48299
  throw new Error(divergentAuditHistoryReplayError(value.id));
47907
48300
  }
47908
48301
  async withTaskParentIntegrityTransaction(fn) {
47909
- if (typeof this.options.client.transaction !== "function") {
48302
+ const transaction = this.options.client.transaction;
48303
+ if (typeof transaction !== "function") {
47910
48304
  throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
47911
48305
  }
47912
- return this.options.client.transaction(async (client) => {
48306
+ return retryOnTransientPostgresError(() => transaction(async (client) => {
47913
48307
  await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
47914
48308
  return fn(client);
47915
- });
48309
+ }));
47916
48310
  }
47917
48311
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
47918
48312
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
@@ -49795,7 +50189,7 @@ function compareClock(left, right) {
49795
50189
  function numberValue3(value) {
49796
50190
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
49797
50191
  }
49798
- function isPostgresUniqueViolation(error) {
50192
+ function isTransientPostgresError(error) {
49799
50193
  if (typeof error !== "object" || error === null)
49800
50194
  return false;
49801
50195
  const candidate = error;
@@ -49804,17 +50198,27 @@ function isPostgresUniqueViolation(error) {
49804
50198
  const cause = candidate.cause;
49805
50199
  states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
49806
50200
  }
49807
- return states.some((state) => String(state) === "23505");
50201
+ if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
50202
+ return true;
50203
+ }
50204
+ const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
50205
+ return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
49808
50206
  }
49809
- function postgresConstraintName(error) {
49810
- if (typeof error !== "object" || error === null)
49811
- return "";
49812
- const candidate = error;
49813
- const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
49814
- const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
49815
- return typeof constraint === "string" ? constraint : "";
50207
+ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
50208
+ let lastError;
50209
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
50210
+ try {
50211
+ return await fn();
50212
+ } catch (error) {
50213
+ lastError = error;
50214
+ if (!isTransientPostgresError(error) || attempt === attempts)
50215
+ throw error;
50216
+ await new Promise((resolve16) => setTimeout(resolve16, delayMs * attempt));
50217
+ }
50218
+ }
50219
+ throw lastError;
49816
50220
  }
49817
- var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC", TASK_ORDER_BY;
50221
+ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC", TASK_ORDER_BY, TRANSIENT_POSTGRES_SQLSTATES, TRANSIENT_POSTGRES_MESSAGE_MARKERS;
49818
50222
  var init_postgres_adapter = __esm(() => {
49819
50223
  init_types();
49820
50224
  init_creator_identity();
@@ -49828,6 +50232,12 @@ var init_postgres_adapter = __esm(() => {
49828
50232
  init_audit_history_import();
49829
50233
  init_canonical();
49830
50234
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
50235
+ TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
50236
+ TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
50237
+ "canceling statement due to lock timeout",
50238
+ "deadlock detected",
50239
+ "could not serialize access"
50240
+ ];
49831
50241
  });
49832
50242
 
49833
50243
  // src/pr-groups/postgres.ts
@@ -55707,6 +56117,13 @@ function resolveSigningSecret(env = process.env) {
55707
56117
  function isPostgresBackendConfigured(env = process.env) {
55708
56118
  return Boolean(resolveCloudDatabaseUrl(env));
55709
56119
  }
56120
+ function schemaRetryMinIntervalMs(env = process.env) {
56121
+ const raw = env.HASNA_TODOS_SCHEMA_RETRY_MIN_MS;
56122
+ if (!raw)
56123
+ return DEFAULT_SCHEMA_RETRY_MIN_MS;
56124
+ const parsed = Number(raw);
56125
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_SCHEMA_RETRY_MIN_MS;
56126
+ }
55710
56127
  function getCloudTenantId() {
55711
56128
  return process.env.HASNA_TODOS_TENANT_ID ?? "default";
55712
56129
  }
@@ -55802,6 +56219,10 @@ function getCloudVerifier() {
55802
56219
  async function ensureCloudSchema() {
55803
56220
  if (schemaEnsured)
55804
56221
  return schemaEnsured;
56222
+ if (lastSchemaFailure !== null && Date.now() - lastSchemaAttemptAtMs < schemaRetryMinIntervalMs()) {
56223
+ throw lastSchemaFailure;
56224
+ }
56225
+ lastSchemaAttemptAtMs = Date.now();
55805
56226
  schemaEnsured = (async () => {
55806
56227
  const client = getClient();
55807
56228
  for (const sql of postgresTodosSyncSchemaSql()) {
@@ -55822,6 +56243,7 @@ async function ensureCloudSchema() {
55822
56243
  await getApiKeyStore().ensureSchema();
55823
56244
  })().catch((error) => {
55824
56245
  schemaEnsured = null;
56246
+ lastSchemaFailure = error;
55825
56247
  throw error;
55826
56248
  });
55827
56249
  return schemaEnsured;
@@ -55869,8 +56291,10 @@ async function closeCloud() {
55869
56291
  cachedTaskManifestAuthority = null;
55870
56292
  cachedTaskSubtreeTransferAuthority = null;
55871
56293
  schemaEnsured = null;
56294
+ lastSchemaAttemptAtMs = 0;
56295
+ lastSchemaFailure = null;
55872
56296
  }
55873
- var TODOS_APP_SLUG = "todos", cachedClient = null, cachedAdapter = null, cachedStore = null, cachedVerifier = null, cachedPrGroupLedger = null, cachedProjectRegistrationAuthority = null, cachedTaskManifestAuthority = null, cachedTaskSubtreeTransferAuthority = null, schemaEnsured = null;
56297
+ var TODOS_APP_SLUG = "todos", cachedClient = null, cachedAdapter = null, cachedStore = null, cachedVerifier = null, cachedPrGroupLedger = null, cachedProjectRegistrationAuthority = null, cachedTaskManifestAuthority = null, cachedTaskSubtreeTransferAuthority = null, schemaEnsured = null, lastSchemaAttemptAtMs = 0, lastSchemaFailure = null, DEFAULT_SCHEMA_RETRY_MIN_MS = 1e4;
55874
56298
  var init_cloud = __esm(() => {
55875
56299
  init_cloud_client();
55876
56300
  init_postgres_adapter();
@@ -56394,6 +56818,19 @@ function taskStatusQueryParam(url) {
56394
56818
  return { ok: false, message: result.message };
56395
56819
  return { ok: true, value: collapseEnumValues(result.values) };
56396
56820
  }
56821
+ function parsePaginationQueryParam(url, name) {
56822
+ const raw = url.searchParams.get(name);
56823
+ if (raw === null)
56824
+ return { ok: true, value: undefined };
56825
+ const min = name === "limit" ? 1 : 0;
56826
+ const message = name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer";
56827
+ if (!/^\d+$/.test(raw))
56828
+ return { ok: false, message };
56829
+ const value = Number(raw);
56830
+ if (!Number.isSafeInteger(value) || value < min)
56831
+ return { ok: false, message };
56832
+ return { ok: true, value };
56833
+ }
56397
56834
  async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
56398
56835
  const statusParam = taskStatusQueryParam(url);
56399
56836
  if (!statusParam.ok)
@@ -56401,16 +56838,20 @@ async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
56401
56838
  const projectId = url.searchParams.get("project_id") || undefined;
56402
56839
  const sessionId = url.searchParams.get("session_id") || undefined;
56403
56840
  const agentId = url.searchParams.get("agent_id") || undefined;
56404
- const limitParam = url.searchParams.get("limit");
56405
- const offsetParam = url.searchParams.get("offset");
56841
+ const limitParam = parsePaginationQueryParam(url, "limit");
56842
+ if (!limitParam.ok)
56843
+ return json5({ error: limitParam.message }, 400);
56844
+ const offsetParam = parsePaginationQueryParam(url, "offset");
56845
+ if (!offsetParam.ok)
56846
+ return json5({ error: offsetParam.message }, 400);
56406
56847
  const fields = parseFieldsParam(url);
56407
56848
  const tasks = listTasks({
56408
56849
  status: statusParam.value,
56409
56850
  project_id: projectId,
56410
56851
  session_id: sessionId,
56411
56852
  agent_id: agentId,
56412
- limit: limitParam ? parseInt(limitParam, 10) : undefined,
56413
- offset: offsetParam ? parseInt(offsetParam, 10) : undefined
56853
+ limit: limitParam.value,
56854
+ offset: offsetParam.value
56414
56855
  });
56415
56856
  return json5(tasks.map((t) => taskToSummary2(t, fields)));
56416
56857
  }
@@ -61279,6 +61720,26 @@ function parseSinceCursor(raw) {
61279
61720
  }
61280
61721
  return { ok: true, value: new Date(parsed).toISOString() };
61281
61722
  }
61723
+ function paginationQueryParam(url, name) {
61724
+ const raw = url.searchParams.get(name);
61725
+ if (raw === null)
61726
+ return { ok: true, value: undefined };
61727
+ const min = name === "limit" ? 1 : 0;
61728
+ if (!/^\d+$/.test(raw)) {
61729
+ return {
61730
+ ok: false,
61731
+ response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
61732
+ };
61733
+ }
61734
+ const value = Number(raw);
61735
+ if (!Number.isSafeInteger(value) || value < min) {
61736
+ return {
61737
+ ok: false,
61738
+ response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
61739
+ };
61740
+ }
61741
+ return { ok: true, value };
61742
+ }
61282
61743
  function validateTaskCompletion(value) {
61283
61744
  if (!value || typeof value !== "object" || Array.isArray(value))
61284
61745
  return { ok: false, message: "completion body must be an object" };
@@ -61807,6 +62268,12 @@ async function handleV1Request(req, url, dependencies = {}) {
61807
62268
  if (updatedAfter !== null && !updatedAfter.ok) {
61808
62269
  return error(400, updatedAfter.message);
61809
62270
  }
62271
+ const limitParam = paginationQueryParam(url, "limit");
62272
+ if (!limitParam.ok)
62273
+ return limitParam.response;
62274
+ const offsetParam = paginationQueryParam(url, "offset");
62275
+ if (!offsetParam.ok)
62276
+ return offsetParam.response;
61810
62277
  const filter = {
61811
62278
  ...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
61812
62279
  ...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
@@ -61823,8 +62290,8 @@ async function handleV1Request(req, url, dependencies = {}) {
61823
62290
  ...url.searchParams.get("tags") ? {
61824
62291
  tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
61825
62292
  } : {},
61826
- ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
61827
- ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
62293
+ ...limitParam.value !== undefined ? { limit: limitParam.value } : {},
62294
+ ...offsetParam.value !== undefined ? { offset: offsetParam.value } : {}
61828
62295
  };
61829
62296
  const tasks = await store.tasks.list(filter);
61830
62297
  const { limit: _l, offset: _o, ...countFilter } = filter;