@hasna/todos 0.15.41 → 0.15.47

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 (45) 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/cloud-router.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 +521 -120
  20. package/dist/contracts.js +346 -83
  21. package/dist/db/task-graph.d.ts.map +1 -1
  22. package/dist/db/task-lifecycle.d.ts.map +1 -1
  23. package/dist/db/webhooks.d.ts.map +1 -1
  24. package/dist/index.js +499 -113
  25. package/dist/lib/instant-compare.d.ts +35 -0
  26. package/dist/lib/instant-compare.d.ts.map +1 -0
  27. package/dist/mcp/index.js +520 -119
  28. package/dist/mcp.js +5 -4
  29. package/dist/pr-groups/postgres.d.ts.map +1 -1
  30. package/dist/project-registration/postgres.d.ts.map +1 -1
  31. package/dist/project-registration.js +466 -100
  32. package/dist/registry.js +346 -83
  33. package/dist/release-provenance.json +5 -5
  34. package/dist/server/cloud.d.ts.map +1 -1
  35. package/dist/server/index.js +1179 -309
  36. package/dist/storage/local-sqlite.d.ts.map +1 -1
  37. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  38. package/dist/storage.js +447 -89
  39. package/dist/task-manifest/postgres.d.ts.map +1 -1
  40. package/dist/task-manifest.js +15 -6
  41. package/dist/task-subtree-transfer/postgres.d.ts.map +1 -1
  42. package/dist/task-subtree-transfer.js +15 -6
  43. package/package.json +5 -4
  44. package/dashboard/dist/assets/index-DJm6m6Yy.css +0 -1
  45. package/dashboard/dist/assets/index-DVotjwab.js +0 -346
@@ -6607,8 +6607,9 @@ var init_event_hooks = __esm(() => {
6607
6607
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
6608
6608
  });
6609
6609
 
6610
- // node_modules/.bun/@hasna+events@0.1.11/node_modules/@hasna/events/dist/index.js
6610
+ // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
6611
6611
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6612
+ import { Buffer as Buffer2 } from "buffer";
6612
6613
  import { existsSync as existsSync6 } from "fs";
6613
6614
  import { homedir as homedir2 } from "os";
6614
6615
  import { join as join4 } from "path";
@@ -6717,11 +6718,13 @@ function getEventsDataDir(override) {
6717
6718
 
6718
6719
  class JsonEventsStore {
6719
6720
  dataDir;
6721
+ runtime;
6720
6722
  channelsPath;
6721
6723
  eventsPath;
6722
6724
  deliveriesPath;
6723
6725
  constructor(dataDir = getEventsDataDir()) {
6724
6726
  this.dataDir = dataDir;
6727
+ this.runtime = localJsonRuntime(dataDir);
6725
6728
  this.channelsPath = join4(dataDir, "channels.json");
6726
6729
  this.eventsPath = join4(dataDir, "events.json");
6727
6730
  this.deliveriesPath = join4(dataDir, "deliveries.json");
@@ -6769,13 +6772,58 @@ class JsonEventsStore {
6769
6772
  await this.writeJson(this.eventsPath, events);
6770
6773
  return event;
6771
6774
  }
6772
- async listEvents() {
6775
+ async appendEventOnce(event, options = {}) {
6773
6776
  await this.init();
6774
- return this.readJson(this.eventsPath, []);
6777
+ const events = await this.readJson(this.eventsPath, []);
6778
+ const dedupe = options.dedupe !== false;
6779
+ if (dedupe) {
6780
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
6781
+ if (existing) {
6782
+ return {
6783
+ event: existing,
6784
+ stored: false,
6785
+ deduped: true,
6786
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
6787
+ };
6788
+ }
6789
+ }
6790
+ events.push(event);
6791
+ await this.writeJson(this.eventsPath, events);
6792
+ return {
6793
+ event,
6794
+ stored: true,
6795
+ deduped: false,
6796
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
6797
+ };
6798
+ }
6799
+ async listEvents(options = {}) {
6800
+ await this.init();
6801
+ const events = await this.readJson(this.eventsPath, []);
6802
+ return queryEvents(events, options);
6803
+ }
6804
+ async listEventsPage(options = {}) {
6805
+ await this.init();
6806
+ const events = await this.readJson(this.eventsPath, []);
6807
+ const queried = queryEvents(events, {
6808
+ eventId: options.eventId,
6809
+ source: options.source,
6810
+ type: options.type
6811
+ });
6812
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6813
+ const limit = normalizeEventPageLimit(options.limit);
6814
+ const pageEvents = queried.slice(offset, offset + limit);
6815
+ const nextOffset = offset + pageEvents.length;
6816
+ const hasMore = nextOffset < queried.length;
6817
+ return {
6818
+ events: pageEvents,
6819
+ cursor: options.cursor,
6820
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6821
+ hasMore
6822
+ };
6775
6823
  }
6776
6824
  async findEventByIdentity(identity) {
6777
6825
  const events = await this.listEvents();
6778
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6826
+ return findEventByIdentity(events, identity);
6779
6827
  }
6780
6828
  async appendDelivery(result) {
6781
6829
  await this.init();
@@ -6826,6 +6874,83 @@ class JsonEventsStore {
6826
6874
  });
6827
6875
  }
6828
6876
  }
6877
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
6878
+ return {
6879
+ mode: "local-files",
6880
+ name: "json-events-store",
6881
+ remote: false,
6882
+ localFiles: true,
6883
+ localSqlite: false,
6884
+ postgres: false,
6885
+ s3: false,
6886
+ aws: false,
6887
+ durable: true,
6888
+ idempotency: "best-effort-local",
6889
+ replayCursors: true,
6890
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6891
+ };
6892
+ }
6893
+ function encodeLocalJsonEventCursor(offset, options = {}) {
6894
+ if (!Number.isInteger(offset) || offset < 0)
6895
+ throw new Error(`Invalid event cursor offset: ${offset}`);
6896
+ const payload = {
6897
+ offset,
6898
+ eventId: options.eventId,
6899
+ source: options.source,
6900
+ type: options.type
6901
+ };
6902
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
6903
+ }
6904
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
6905
+ if (!cursor)
6906
+ return 0;
6907
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
6908
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6909
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
6910
+ let payload;
6911
+ try {
6912
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
6913
+ } catch {
6914
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6915
+ }
6916
+ const offset = payload.offset;
6917
+ if (!Number.isInteger(offset) || offset < 0)
6918
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6919
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
6920
+ assertCursorFilter("source", payload.source, options.source);
6921
+ assertCursorFilter("type", payload.type, options.type);
6922
+ return offset;
6923
+ }
6924
+ function normalizeEventPageLimit(limit) {
6925
+ if (limit === undefined)
6926
+ return DEFAULT_EVENT_PAGE_LIMIT;
6927
+ if (!Number.isInteger(limit) || limit < 1)
6928
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
6929
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
6930
+ }
6931
+ function queryEvents(events, options) {
6932
+ let rows = events;
6933
+ if (options.eventId)
6934
+ rows = rows.filter((event) => event.id === options.eventId);
6935
+ if (options.source)
6936
+ rows = rows.filter((event) => event.source === options.source);
6937
+ if (options.type)
6938
+ rows = rows.filter((event) => event.type === options.type);
6939
+ if (options.cursor) {
6940
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6941
+ rows = rows.slice(offset);
6942
+ }
6943
+ if (options.limit !== undefined)
6944
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
6945
+ return rows;
6946
+ }
6947
+ function assertCursorFilter(name, cursorValue, optionValue) {
6948
+ if (cursorValue !== optionValue)
6949
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
6950
+ }
6951
+ function findEventByIdentity(events, identity) {
6952
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6953
+ }
6829
6954
  function buildSignatureBase(timestamp2, body) {
6830
6955
  return `${timestamp2}.${body}`;
6831
6956
  }
@@ -6839,21 +6964,27 @@ function now2() {
6839
6964
  function truncate(value, max = 4096) {
6840
6965
  return value.length > max ? `${value.slice(0, max)}...` : value;
6841
6966
  }
6842
- function buildWebhookRequest(event, channel) {
6967
+ function buildWebhookRequest(event, channel, options = {}) {
6843
6968
  if (!channel.webhook)
6844
6969
  throw new Error(`Channel ${channel.id} has no webhook config`);
6970
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
6971
+ if (/^x-hasna-/i.test(name)) {
6972
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
6973
+ }
6974
+ }
6845
6975
  const body = JSON.stringify(event);
6846
- const timestamp2 = event.time;
6976
+ const timestamp2 = options.timestamp ?? new Date().toISOString();
6847
6977
  const headers = {
6848
6978
  "Content-Type": "application/json",
6849
6979
  "User-Agent": "@hasna/events",
6850
6980
  "X-Hasna-Event-Id": event.id,
6851
6981
  "X-Hasna-Event-Type": event.type,
6852
- "X-Hasna-Timestamp": timestamp2,
6853
- ...channel.webhook.headers
6982
+ ...channel.webhook.headers,
6983
+ "X-Hasna-Timestamp": timestamp2
6854
6984
  };
6855
- if (channel.webhook.secret) {
6856
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp2, body);
6985
+ const secret = options.secret ?? channel.webhook.secret;
6986
+ if (secret) {
6987
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
6857
6988
  }
6858
6989
  return { body, headers };
6859
6990
  }
@@ -6861,7 +6992,21 @@ async function dispatchWebhook(event, channel, options = {}) {
6861
6992
  if (!channel.webhook)
6862
6993
  throw new Error(`Channel ${channel.id} has no webhook config`);
6863
6994
  const startedAt = now2();
6864
- const { body, headers } = buildWebhookRequest(event, channel);
6995
+ let secret = channel.webhook.secret;
6996
+ if (channel.webhook.secretRef) {
6997
+ if (!options.secretResolver) {
6998
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
6999
+ }
7000
+ try {
7001
+ secret = await options.secretResolver(channel.webhook.secretRef);
7002
+ } catch {
7003
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
7004
+ }
7005
+ if (!secret)
7006
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
7007
+ }
7008
+ const timestamp2 = (options.now?.() ?? new Date).toISOString();
7009
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
6865
7010
  const controller = new AbortController;
6866
7011
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
6867
7012
  try {
@@ -6893,6 +7038,15 @@ async function dispatchWebhook(event, channel, options = {}) {
6893
7038
  clearTimeout(timeout);
6894
7039
  }
6895
7040
  }
7041
+ function failedAttempt(startedAt, error) {
7042
+ return {
7043
+ attempt: 1,
7044
+ status: "failed",
7045
+ startedAt,
7046
+ completedAt: now2(),
7047
+ error
7048
+ };
7049
+ }
6896
7050
  async function dispatchCommand(event, channel) {
6897
7051
  if (!channel.command)
6898
7052
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -6981,6 +7135,76 @@ function createDeliveryResult(event, channel, attempts) {
6981
7135
  completedAt: attempts.at(-1)?.completedAt ?? now2()
6982
7136
  };
6983
7137
  }
7138
+
7139
+ class EventTypeCatalog {
7140
+ definitions = new Map;
7141
+ register(definition) {
7142
+ this.definitions.set(definition.type, definition);
7143
+ return this;
7144
+ }
7145
+ unregister(type) {
7146
+ return this.definitions.delete(type);
7147
+ }
7148
+ has(type) {
7149
+ return this.definitions.has(type);
7150
+ }
7151
+ get(type) {
7152
+ return this.definitions.get(type);
7153
+ }
7154
+ list() {
7155
+ return [...this.definitions.values()];
7156
+ }
7157
+ validateEvent(event) {
7158
+ const definition = this.definitions.get(event.type);
7159
+ if (!definition)
7160
+ return { ok: true };
7161
+ return definition.validate(event.data, event);
7162
+ }
7163
+ assertEventValid(event) {
7164
+ const result = this.validateEvent(event);
7165
+ if (!result.ok) {
7166
+ throw new EventValidationError(event.type, result.issues);
7167
+ }
7168
+ }
7169
+ }
7170
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
7171
+ if (paths.length === 0)
7172
+ return event;
7173
+ const copy = structuredClone(event);
7174
+ for (const path of paths) {
7175
+ setPath(copy, path, replacement);
7176
+ }
7177
+ return copy;
7178
+ }
7179
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
7180
+ return redactValue2(event, replacement);
7181
+ }
7182
+ function shouldRedactKey(key) {
7183
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
7184
+ }
7185
+ function redactValue2(value, replacement) {
7186
+ if (Array.isArray(value))
7187
+ return value.map((item) => redactValue2(item, replacement));
7188
+ if (!value || typeof value !== "object")
7189
+ return value;
7190
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
7191
+ key,
7192
+ shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
7193
+ ]));
7194
+ }
7195
+ function setPath(input, path, replacement) {
7196
+ const parts = path.split(".");
7197
+ let cursor = input;
7198
+ for (const part of parts.slice(0, -1)) {
7199
+ const next = cursor[part];
7200
+ if (!next || typeof next !== "object")
7201
+ return;
7202
+ cursor = next;
7203
+ }
7204
+ const last = parts.at(-1);
7205
+ if (last && last in cursor)
7206
+ cursor[last] = replacement;
7207
+ }
6984
7208
  function createEvent(input) {
6985
7209
  return {
6986
7210
  id: input.id ?? randomUUID22(),
@@ -7001,10 +7225,18 @@ class EventsClient {
7001
7225
  store;
7002
7226
  redactors;
7003
7227
  transportOptions;
7228
+ catalog;
7229
+ validateCatalogTypes;
7004
7230
  constructor(options = {}) {
7005
7231
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
7006
7232
  this.redactors = options.redactors ?? [];
7007
- this.transportOptions = { fetchImpl: options.fetchImpl };
7233
+ this.transportOptions = {
7234
+ fetchImpl: options.fetchImpl,
7235
+ secretResolver: options.secretResolver,
7236
+ now: options.now
7237
+ };
7238
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
7239
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
7008
7240
  }
7009
7241
  async addChannel(input) {
7010
7242
  const timestamp2 = new Date().toISOString();
@@ -7022,18 +7254,40 @@ class EventsClient {
7022
7254
  }
7023
7255
  async emit(input, options = {}) {
7024
7256
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
7025
- if (options.dedupe !== false) {
7026
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
7027
- if (existing) {
7028
- return { event: existing, deliveries: [], deduped: true };
7029
- }
7030
- }
7031
- await this.store.appendEvent(event);
7032
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
7033
- return { event, deliveries, deduped: false };
7034
- }
7035
- async listEvents() {
7036
- return this.store.listEvents();
7257
+ if (options.validate ?? this.validateCatalogTypes) {
7258
+ this.catalog.assertEventValid(event);
7259
+ }
7260
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
7261
+ if (append.deduped) {
7262
+ return { event: append.event, deliveries: [], deduped: true };
7263
+ }
7264
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
7265
+ return { event: append.event, deliveries, deduped: false };
7266
+ }
7267
+ async listEvents(options = {}) {
7268
+ if (Object.keys(options).length === 0)
7269
+ return this.store.listEvents();
7270
+ return queryClientEvents(await this.store.listEvents(), options);
7271
+ }
7272
+ async listEventsPage(options = {}) {
7273
+ if (this.store.listEventsPage)
7274
+ return this.store.listEventsPage(options);
7275
+ const events = queryClientEvents(await this.store.listEvents(), {
7276
+ eventId: options.eventId,
7277
+ source: options.source,
7278
+ type: options.type
7279
+ });
7280
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
7281
+ const limit = normalizeEventPageLimit(options.limit);
7282
+ const pageEvents = events.slice(offset, offset + limit);
7283
+ const nextOffset = offset + pageEvents.length;
7284
+ const hasMore = nextOffset < events.length;
7285
+ return {
7286
+ events: pageEvents,
7287
+ cursor: options.cursor,
7288
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
7289
+ hasMore
7290
+ };
7037
7291
  }
7038
7292
  async listDeliveries() {
7039
7293
  return this.store.listDeliveries();
@@ -7101,22 +7355,37 @@ class EventsClient {
7101
7355
  return result;
7102
7356
  }
7103
7357
  async replay(options = {}) {
7104
- const events = (await this.store.listEvents()).filter((event) => {
7105
- if (options.eventId && event.id !== options.eventId)
7106
- return false;
7107
- if (options.source && event.source !== options.source)
7108
- return false;
7109
- if (options.type && event.type !== options.type)
7110
- return false;
7111
- return true;
7112
- });
7358
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
7113
7359
  if (options.dryRun)
7114
- return { events, deliveries: [] };
7360
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
7115
7361
  const deliveries = [];
7116
- for (const event of events) {
7362
+ for (const event of page.events) {
7117
7363
  deliveries.push(...await this.deliver(event));
7118
7364
  }
7119
- return { events, deliveries };
7365
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
7366
+ }
7367
+ async appendEvent(event, options) {
7368
+ if (this.store.appendEventOnce) {
7369
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
7370
+ }
7371
+ if (options.dedupe) {
7372
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
7373
+ if (existing) {
7374
+ return {
7375
+ event: existing,
7376
+ stored: false,
7377
+ deduped: true,
7378
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
7379
+ };
7380
+ }
7381
+ }
7382
+ const stored = await this.store.appendEvent(event);
7383
+ return {
7384
+ event: stored,
7385
+ stored: true,
7386
+ deduped: false,
7387
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
7388
+ };
7120
7389
  }
7121
7390
  async applyRedaction(event, channel) {
7122
7391
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -7143,43 +7412,19 @@ class EventsClient {
7143
7412
  return createDeliveryResult(event, channel, attempts);
7144
7413
  }
7145
7414
  }
7146
- function redactPaths(event, paths, replacement = "[REDACTED]") {
7147
- if (paths.length === 0)
7148
- return event;
7149
- const copy = structuredClone(event);
7150
- for (const path of paths) {
7151
- setPath(copy, path, replacement);
7152
- }
7153
- return copy;
7154
- }
7155
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
7156
- return redactValue2(event, replacement);
7157
- }
7158
- function shouldRedactKey(key) {
7159
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
7160
- }
7161
- function redactValue2(value, replacement) {
7162
- if (Array.isArray(value))
7163
- return value.map((item) => redactValue2(item, replacement));
7164
- if (!value || typeof value !== "object")
7165
- return value;
7166
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
7167
- key,
7168
- shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
7169
- ]));
7170
- }
7171
- function setPath(input, path, replacement) {
7172
- const parts = path.split(".");
7173
- let cursor = input;
7174
- for (const part of parts.slice(0, -1)) {
7175
- const next = cursor[part];
7176
- if (!next || typeof next !== "object")
7177
- return;
7178
- cursor = next;
7179
- }
7180
- const last = parts.at(-1);
7181
- if (last && last in cursor)
7182
- cursor[last] = replacement;
7415
+ function queryClientEvents(events, options) {
7416
+ let rows = events;
7417
+ if (options.eventId)
7418
+ rows = rows.filter((event) => event.id === options.eventId);
7419
+ if (options.source)
7420
+ rows = rows.filter((event) => event.source === options.source);
7421
+ if (options.type)
7422
+ rows = rows.filter((event) => event.type === options.type);
7423
+ if (options.cursor)
7424
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
7425
+ if (options.limit !== undefined)
7426
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
7427
+ return rows;
7183
7428
  }
7184
7429
  function normalizeTime(value) {
7185
7430
  if (!value)
@@ -7193,9 +7438,22 @@ function normalizeRetryPolicy(policy) {
7193
7438
  multiplier: Math.max(1, policy?.multiplier ?? 2)
7194
7439
  };
7195
7440
  }
7196
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
7441
+ 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;
7197
7442
  var init_dist = __esm(() => {
7198
7443
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7444
+ EventValidationError = class EventValidationError extends Error {
7445
+ eventType;
7446
+ issues;
7447
+ constructor(eventType, issues) {
7448
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
7449
+ super(`Event validation failed for type "${eventType}": ${detail}`);
7450
+ this.name = "EventValidationError";
7451
+ this.eventType = eventType;
7452
+ this.issues = issues;
7453
+ }
7454
+ };
7455
+ defaultEventTypeCatalog = new EventTypeCatalog;
7456
+ APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
7199
7457
  });
7200
7458
 
7201
7459
  // src/db/task-lists.ts
@@ -8070,8 +8328,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
8070
8328
  const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
8071
8329
  headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
8072
8330
  }
8073
- const resp = await fetch(wh.url, { method: "POST", headers, body });
8331
+ const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
8074
8332
  const respText = await resp.text().catch(() => "");
8333
+ if (resp.status >= 300 && resp.status < 400) {
8334
+ logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
8335
+ return;
8336
+ }
8075
8337
  logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
8076
8338
  if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
8077
8339
  const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -8787,7 +9049,7 @@ function getTaskGraph(taskId, direction = "both", db) {
8787
9049
  const deps = getTaskDependencies(t.id, d);
8788
9050
  const hasUnfinishedDeps = deps.some((dep) => {
8789
9051
  const depTask = getTask(dep.depends_on, d);
8790
- return depTask && depTask.status !== "completed";
9052
+ return depTask && isBlockingDependencyStatus(depTask.status);
8791
9053
  });
8792
9054
  return { id: t.id, short_id: t.short_id, title: t.title, status: t.status, priority: t.priority, is_blocked: hasUnfinishedDeps };
8793
9055
  }
@@ -8911,7 +9173,7 @@ function getBlockingDeps(id, db) {
8911
9173
  const blocking = [];
8912
9174
  for (const dep of deps) {
8913
9175
  const task = getTask(dep.depends_on, d);
8914
- if (task && task.status !== "completed")
9176
+ if (task && isBlockingDependencyStatus(task.status))
8915
9177
  blocking.push(task);
8916
9178
  }
8917
9179
  return blocking;
@@ -9235,7 +9497,7 @@ function getNextTask2(agentId, filters, db) {
9235
9497
  conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
9236
9498
  params.push(...filters.tags);
9237
9499
  }
9238
- conditions.push("id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')");
9500
+ conditions.push("id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status NOT IN ('completed', 'cancelled'))");
9239
9501
  const where = conditions.join(" AND ");
9240
9502
  let recentProjectIds = [];
9241
9503
  const assignedAliasParams = [];
@@ -9279,7 +9541,7 @@ function getActiveWork2(filters, db) {
9279
9541
  }
9280
9542
  function getTasksChangedSince(since, filters, db) {
9281
9543
  const d = db || getDatabase();
9282
- const conditions = ["updated_at > ?"];
9544
+ const conditions = ["(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))"];
9283
9545
  const params = [since];
9284
9546
  if (filters?.project_id) {
9285
9547
  conditions.push("project_id = ?");
@@ -12812,7 +13074,7 @@ import { createHash as createHash7 } from "crypto";
12812
13074
  // package.json
12813
13075
  var package_default = {
12814
13076
  name: "@hasna/todos",
12815
- version: "0.15.41",
13077
+ version: "0.15.47",
12816
13078
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12817
13079
  type: "module",
12818
13080
  main: "dist/index.js",
@@ -12930,18 +13192,19 @@ var package_default = {
12930
13192
  author: "Andrei Hasna <andrei@hasna.com>",
12931
13193
  license: "Apache-2.0",
12932
13194
  dependencies: {
12933
- "@hasna/contracts": "0.13.3",
13195
+ "@hasna/contracts": "0.13.4",
12934
13196
  "@hasna/events": "^0.1.11",
12935
13197
  "@modelcontextprotocol/sdk": "^1.12.1",
12936
13198
  chalk: "^5.4.1",
12937
13199
  commander: "^13.1.0",
12938
13200
  ink: "^5.2.0",
12939
13201
  react: "^18.3.1",
12940
- zod: "^3.24.2"
13202
+ zod: "3.25.76"
12941
13203
  },
12942
13204
  overrides: {
12943
13205
  ajv: "8.20.0",
12944
- "fast-uri": "3.1.2"
13206
+ "fast-uri": "3.1.2",
13207
+ zod: "3.25.76"
12945
13208
  },
12946
13209
  devDependencies: {
12947
13210
  "@types/bun": "^1.2.4",
@@ -12968,6 +13231,57 @@ function normalizeAgentNameInput(name) {
12968
13231
  // src/storage/postgres-adapter.ts
12969
13232
  init_creator_identity();
12970
13233
 
13234
+ // src/lib/instant-compare.ts
13235
+ var SQLITE_STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/;
13236
+ var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
13237
+ function isLeapYear(year) {
13238
+ return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
13239
+ }
13240
+ function sqliteJulianDay(value) {
13241
+ const m = SQLITE_STAMP.exec(value);
13242
+ if (!m)
13243
+ return null;
13244
+ const year = Number(m[1]);
13245
+ const month = Number(m[2]);
13246
+ const day = Number(m[3]);
13247
+ const hour = m[4] === undefined ? 0 : Number(m[4]);
13248
+ const minute = m[5] === undefined ? 0 : Number(m[5]);
13249
+ const second = m[6] === undefined ? 0 : Number(m[6]);
13250
+ const frac = m[7];
13251
+ const sign = m[8];
13252
+ const offsetHour = m[9];
13253
+ const offsetMinute = m[10];
13254
+ if (month < 1 || month > 12)
13255
+ return null;
13256
+ if (hour > 23 || minute > 59 || second > 59)
13257
+ return null;
13258
+ const maxDay = (DAYS_IN_MONTH[month - 1] ?? 0) + (month === 2 && isLeapYear(year) ? 1 : 0);
13259
+ if (day < 1 || day > maxDay)
13260
+ return null;
13261
+ let offsetMinutes = 0;
13262
+ if (sign !== undefined) {
13263
+ const oh = Number(offsetHour ?? "0");
13264
+ const om = Number(offsetMinute ?? "0");
13265
+ if (oh > 23 || om > 59)
13266
+ return null;
13267
+ offsetMinutes = oh * 60 + om;
13268
+ if (sign === "-")
13269
+ offsetMinutes = -offsetMinutes;
13270
+ }
13271
+ const micros = frac === undefined ? 0 : Number((frac + "000000").slice(0, 6));
13272
+ const ms = Date.UTC(year, month - 1, day, hour, minute, second);
13273
+ return (ms + micros / 1000) / 86400000 + 2440587.5 - offsetMinutes / 1440;
13274
+ }
13275
+ function changedSinceStampNewer(stamp, since) {
13276
+ const stampJd = sqliteJulianDay(stamp);
13277
+ if (stampJd === null)
13278
+ return true;
13279
+ const sinceJd = sqliteJulianDay(since);
13280
+ if (sinceJd === null)
13281
+ return false;
13282
+ return stampJd > sinceJd;
13283
+ }
13284
+
12971
13285
  // src/lib/plan-project-link-contract.ts
12972
13286
  init_types();
12973
13287
  import { createHash as createHash2 } from "crypto";
@@ -13904,11 +14218,18 @@ class PostgresJsonRecordStore {
13904
14218
  return context?.requestId ?? this.sourceMachineId ?? null;
13905
14219
  }
13906
14220
  async ensureSchema() {
13907
- this.schemaReady ??= (async () => {
13908
- for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
13909
- await this.options.client.query(sql);
13910
- }
13911
- })();
14221
+ if (!this.schemaReady) {
14222
+ this.schemaReady = (async () => {
14223
+ await retryOnTransientPostgresError(async () => {
14224
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
14225
+ await this.options.client.query(sql);
14226
+ }
14227
+ });
14228
+ })().catch((error) => {
14229
+ this.schemaReady = null;
14230
+ throw error;
14231
+ });
14232
+ }
13912
14233
  await this.schemaReady;
13913
14234
  }
13914
14235
  async get(type, id) {
@@ -14317,13 +14638,14 @@ class PostgresJsonRecordStore {
14317
14638
  throw new Error(divergentAuditHistoryReplayError(value.id));
14318
14639
  }
14319
14640
  async withTaskParentIntegrityTransaction(fn) {
14320
- if (typeof this.options.client.transaction !== "function") {
14641
+ const transaction = this.options.client.transaction;
14642
+ if (typeof transaction !== "function") {
14321
14643
  throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
14322
14644
  }
14323
- return this.options.client.transaction(async (client) => {
14645
+ return retryOnTransientPostgresError(() => transaction(async (client) => {
14324
14646
  await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
14325
14647
  return fn(client);
14326
- });
14648
+ }));
14327
14649
  }
14328
14650
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
14329
14651
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
@@ -15673,7 +15995,7 @@ async function getActiveWork(filters, store) {
15673
15995
  }));
15674
15996
  }
15675
15997
  async function getChangedSince(since, filters, store) {
15676
- return (await listTasks(filters ?? {}, store)).filter((task) => task.updated_at > since);
15998
+ return (await listTasks(filters ?? {}, store)).filter((task) => changedSinceStampNewer(task.updated_at ?? "", since));
15677
15999
  }
15678
16000
  async function createProject(input, store, context) {
15679
16001
  const timestamp = new Date().toISOString();
@@ -16209,6 +16531,41 @@ function compareClock(left, right) {
16209
16531
  function numberValue2(value) {
16210
16532
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
16211
16533
  }
16534
+ var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
16535
+ var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
16536
+ "canceling statement due to lock timeout",
16537
+ "deadlock detected",
16538
+ "could not serialize access"
16539
+ ];
16540
+ function isTransientPostgresError(error) {
16541
+ if (typeof error !== "object" || error === null)
16542
+ return false;
16543
+ const candidate = error;
16544
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
16545
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
16546
+ const cause = candidate.cause;
16547
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
16548
+ }
16549
+ if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
16550
+ return true;
16551
+ }
16552
+ const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
16553
+ return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
16554
+ }
16555
+ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
16556
+ let lastError;
16557
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
16558
+ try {
16559
+ return await fn();
16560
+ } catch (error) {
16561
+ lastError = error;
16562
+ if (!isTransientPostgresError(error) || attempt === attempts)
16563
+ throw error;
16564
+ await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
16565
+ }
16566
+ }
16567
+ throw lastError;
16568
+ }
16212
16569
  function isPostgresUniqueViolation(error) {
16213
16570
  if (typeof error !== "object" || error === null)
16214
16571
  return false;
@@ -16604,14 +16961,23 @@ class PostgresTodosProjectRegistrationBackend {
16604
16961
  this.cursorTableName = safeIdentifier(options.cursorTableName ?? "todos_sync_cursors", "cursorTableName");
16605
16962
  }
16606
16963
  async ensureSchema() {
16607
- this.schemaReady ??= (async () => {
16608
- for (const statement of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
16609
- await this.client.query(statement);
16610
- }
16611
- for (const statement of postgresTodosProjectRegistrationSchemaSql()) {
16612
- await this.client.query(statement);
16964
+ if (this.schemaReady === null) {
16965
+ const attempt = (async () => {
16966
+ for (const statement of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
16967
+ await this.client.query(statement);
16968
+ }
16969
+ for (const statement of postgresTodosProjectRegistrationSchemaSql()) {
16970
+ await this.client.query(statement);
16971
+ }
16972
+ })();
16973
+ this.schemaReady = attempt;
16974
+ try {
16975
+ await attempt;
16976
+ } catch (error) {
16977
+ this.schemaReady = null;
16978
+ throw error;
16613
16979
  }
16614
- })();
16980
+ }
16615
16981
  await this.schemaReady;
16616
16982
  }
16617
16983
  async transaction(fn) {
@@ -18293,7 +18659,7 @@ function matchesExtraFilters(task, filter) {
18293
18659
  }
18294
18660
  if (filter.tags?.length) {
18295
18661
  const taskTags = new Set(task.tags ?? []);
18296
- if (!filter.tags.every((tag) => taskTags.has(tag)))
18662
+ if (!filter.tags.some((tag) => taskTags.has(tag)))
18297
18663
  return false;
18298
18664
  }
18299
18665
  if (filter.include_subtasks !== true && filter.parent_id === undefined && task.parent_id)