@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.
- package/dashboard/dist/assets/index-BNQ4gJua.js +342 -0
- package/dashboard/dist/assets/index-DjzvHUWt.css +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/commands/query-commands.d.ts.map +1 -1
- package/dist/cli/components/Dashboard.d.ts +1 -1
- package/dist/cli/components/Dashboard.d.ts.map +1 -1
- package/dist/cli/components/Header.d.ts +1 -1
- package/dist/cli/components/Header.d.ts.map +1 -1
- package/dist/cli/components/ProjectList.d.ts +1 -1
- package/dist/cli/components/ProjectList.d.ts.map +1 -1
- package/dist/cli/components/SearchView.d.ts +1 -1
- package/dist/cli/components/SearchView.d.ts.map +1 -1
- package/dist/cli/components/TaskDetail.d.ts +1 -1
- package/dist/cli/components/TaskDetail.d.ts.map +1 -1
- package/dist/cli/components/TaskForm.d.ts +1 -1
- package/dist/cli/components/TaskForm.d.ts.map +1 -1
- package/dist/cli/components/TaskList.d.ts +1 -1
- package/dist/cli/components/TaskList.d.ts.map +1 -1
- package/dist/cli/index.js +671 -155
- package/dist/contracts.js +396 -82
- package/dist/db/task-crud.d.ts +6 -0
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/tasks.d.ts +1 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/db/webhooks.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +561 -143
- package/dist/lib/dedupe-projection.d.ts +48 -0
- package/dist/lib/dedupe-projection.d.ts.map +1 -0
- package/dist/lib/redaction.d.ts.map +1 -1
- package/dist/lib/task-dedupe.d.ts +5 -9
- package/dist/lib/task-dedupe.d.ts.map +1 -1
- package/dist/mcp/index.js +621 -154
- package/dist/mcp/tools/task-crud.d.ts.map +1 -1
- package/dist/mcp.js +6 -4
- package/dist/project-registration.js +502 -141
- package/dist/registry.js +396 -82
- package/dist/release-provenance.json +5 -5
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1278 -342
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-errors.d.ts +15 -0
- package/dist/storage/postgres-errors.d.ts.map +1 -0
- package/dist/storage/postgres-sync.d.ts +7 -0
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow-outbox.d.ts.map +1 -1
- package/dist/storage/shadow.d.ts.map +1 -1
- package/dist/storage.js +510 -137
- package/dist/task-manifest.js +118 -47
- package/dist/task-subtree-transfer.js +326 -44
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +6 -4
- package/dashboard/dist/assets/index-DJm6m6Yy.css +0 -1
- package/dashboard/dist/assets/index-DVotjwab.js +0 -346
package/dist/index.js
CHANGED
|
@@ -5531,13 +5531,15 @@ function upsertSecretSafetyConfig(input) {
|
|
|
5531
5531
|
saveConfig({ ...config, secret_safety: next });
|
|
5532
5532
|
return next;
|
|
5533
5533
|
}
|
|
5534
|
-
var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
5534
|
+
var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
5535
5535
|
var init_redaction = __esm(() => {
|
|
5536
5536
|
init_config2();
|
|
5537
|
+
XAI_PREFIX = ["x", "ai", "-"].join("");
|
|
5537
5538
|
DEFAULT_SECRET_PATTERNS = [
|
|
5538
5539
|
{ name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
|
|
5539
5540
|
{ name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
|
|
5540
5541
|
{ name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
5542
|
+
{ name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
5541
5543
|
{ name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
|
|
5542
5544
|
{ name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
5543
5545
|
{ name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
@@ -6194,8 +6196,9 @@ var init_event_hooks = __esm(() => {
|
|
|
6194
6196
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
6195
6197
|
});
|
|
6196
6198
|
|
|
6197
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
6199
|
+
// node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
|
|
6198
6200
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6201
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
6199
6202
|
import { existsSync as existsSync5 } from "fs";
|
|
6200
6203
|
import { homedir as homedir2 } from "os";
|
|
6201
6204
|
import { join as join4 } from "path";
|
|
@@ -6304,11 +6307,13 @@ function getEventsDataDir(override) {
|
|
|
6304
6307
|
|
|
6305
6308
|
class JsonEventsStore {
|
|
6306
6309
|
dataDir;
|
|
6310
|
+
runtime;
|
|
6307
6311
|
channelsPath;
|
|
6308
6312
|
eventsPath;
|
|
6309
6313
|
deliveriesPath;
|
|
6310
6314
|
constructor(dataDir = getEventsDataDir()) {
|
|
6311
6315
|
this.dataDir = dataDir;
|
|
6316
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
6312
6317
|
this.channelsPath = join4(dataDir, "channels.json");
|
|
6313
6318
|
this.eventsPath = join4(dataDir, "events.json");
|
|
6314
6319
|
this.deliveriesPath = join4(dataDir, "deliveries.json");
|
|
@@ -6356,13 +6361,58 @@ class JsonEventsStore {
|
|
|
6356
6361
|
await this.writeJson(this.eventsPath, events);
|
|
6357
6362
|
return event;
|
|
6358
6363
|
}
|
|
6359
|
-
async
|
|
6364
|
+
async appendEventOnce(event, options = {}) {
|
|
6360
6365
|
await this.init();
|
|
6361
|
-
|
|
6366
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6367
|
+
const dedupe = options.dedupe !== false;
|
|
6368
|
+
if (dedupe) {
|
|
6369
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
6370
|
+
if (existing) {
|
|
6371
|
+
return {
|
|
6372
|
+
event: existing,
|
|
6373
|
+
stored: false,
|
|
6374
|
+
deduped: true,
|
|
6375
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
6376
|
+
};
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
events.push(event);
|
|
6380
|
+
await this.writeJson(this.eventsPath, events);
|
|
6381
|
+
return {
|
|
6382
|
+
event,
|
|
6383
|
+
stored: true,
|
|
6384
|
+
deduped: false,
|
|
6385
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
6386
|
+
};
|
|
6387
|
+
}
|
|
6388
|
+
async listEvents(options = {}) {
|
|
6389
|
+
await this.init();
|
|
6390
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6391
|
+
return queryEvents(events, options);
|
|
6392
|
+
}
|
|
6393
|
+
async listEventsPage(options = {}) {
|
|
6394
|
+
await this.init();
|
|
6395
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6396
|
+
const queried = queryEvents(events, {
|
|
6397
|
+
eventId: options.eventId,
|
|
6398
|
+
source: options.source,
|
|
6399
|
+
type: options.type
|
|
6400
|
+
});
|
|
6401
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6402
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
6403
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
6404
|
+
const nextOffset = offset + pageEvents.length;
|
|
6405
|
+
const hasMore = nextOffset < queried.length;
|
|
6406
|
+
return {
|
|
6407
|
+
events: pageEvents,
|
|
6408
|
+
cursor: options.cursor,
|
|
6409
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
6410
|
+
hasMore
|
|
6411
|
+
};
|
|
6362
6412
|
}
|
|
6363
6413
|
async findEventByIdentity(identity) {
|
|
6364
6414
|
const events = await this.listEvents();
|
|
6365
|
-
return events
|
|
6415
|
+
return findEventByIdentity(events, identity);
|
|
6366
6416
|
}
|
|
6367
6417
|
async appendDelivery(result) {
|
|
6368
6418
|
await this.init();
|
|
@@ -6413,6 +6463,83 @@ class JsonEventsStore {
|
|
|
6413
6463
|
});
|
|
6414
6464
|
}
|
|
6415
6465
|
}
|
|
6466
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
6467
|
+
return {
|
|
6468
|
+
mode: "local-files",
|
|
6469
|
+
name: "json-events-store",
|
|
6470
|
+
remote: false,
|
|
6471
|
+
localFiles: true,
|
|
6472
|
+
localSqlite: false,
|
|
6473
|
+
postgres: false,
|
|
6474
|
+
s3: false,
|
|
6475
|
+
aws: false,
|
|
6476
|
+
durable: true,
|
|
6477
|
+
idempotency: "best-effort-local",
|
|
6478
|
+
replayCursors: true,
|
|
6479
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
6480
|
+
};
|
|
6481
|
+
}
|
|
6482
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
6483
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6484
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
6485
|
+
const payload = {
|
|
6486
|
+
offset,
|
|
6487
|
+
eventId: options.eventId,
|
|
6488
|
+
source: options.source,
|
|
6489
|
+
type: options.type
|
|
6490
|
+
};
|
|
6491
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
6492
|
+
}
|
|
6493
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
6494
|
+
if (!cursor)
|
|
6495
|
+
return 0;
|
|
6496
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
6497
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6498
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
6499
|
+
let payload;
|
|
6500
|
+
try {
|
|
6501
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
6502
|
+
} catch {
|
|
6503
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6504
|
+
}
|
|
6505
|
+
const offset = payload.offset;
|
|
6506
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6507
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6508
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
6509
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
6510
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
6511
|
+
return offset;
|
|
6512
|
+
}
|
|
6513
|
+
function normalizeEventPageLimit(limit) {
|
|
6514
|
+
if (limit === undefined)
|
|
6515
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
6516
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
6517
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
6518
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
6519
|
+
}
|
|
6520
|
+
function queryEvents(events, options) {
|
|
6521
|
+
let rows = events;
|
|
6522
|
+
if (options.eventId)
|
|
6523
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
6524
|
+
if (options.source)
|
|
6525
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
6526
|
+
if (options.type)
|
|
6527
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
6528
|
+
if (options.cursor) {
|
|
6529
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6530
|
+
rows = rows.slice(offset);
|
|
6531
|
+
}
|
|
6532
|
+
if (options.limit !== undefined)
|
|
6533
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
6534
|
+
return rows;
|
|
6535
|
+
}
|
|
6536
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
6537
|
+
if (cursorValue !== optionValue)
|
|
6538
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
6539
|
+
}
|
|
6540
|
+
function findEventByIdentity(events, identity) {
|
|
6541
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
6542
|
+
}
|
|
6416
6543
|
function buildSignatureBase(timestamp2, body) {
|
|
6417
6544
|
return `${timestamp2}.${body}`;
|
|
6418
6545
|
}
|
|
@@ -6426,21 +6553,27 @@ function now2() {
|
|
|
6426
6553
|
function truncate(value, max = 4096) {
|
|
6427
6554
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
6428
6555
|
}
|
|
6429
|
-
function buildWebhookRequest(event, channel) {
|
|
6556
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
6430
6557
|
if (!channel.webhook)
|
|
6431
6558
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6559
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
6560
|
+
if (/^x-hasna-/i.test(name)) {
|
|
6561
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
6562
|
+
}
|
|
6563
|
+
}
|
|
6432
6564
|
const body = JSON.stringify(event);
|
|
6433
|
-
const timestamp2 =
|
|
6565
|
+
const timestamp2 = options.timestamp ?? new Date().toISOString();
|
|
6434
6566
|
const headers = {
|
|
6435
6567
|
"Content-Type": "application/json",
|
|
6436
6568
|
"User-Agent": "@hasna/events",
|
|
6437
6569
|
"X-Hasna-Event-Id": event.id,
|
|
6438
6570
|
"X-Hasna-Event-Type": event.type,
|
|
6439
|
-
|
|
6440
|
-
|
|
6571
|
+
...channel.webhook.headers,
|
|
6572
|
+
"X-Hasna-Timestamp": timestamp2
|
|
6441
6573
|
};
|
|
6442
|
-
|
|
6443
|
-
|
|
6574
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
6575
|
+
if (secret) {
|
|
6576
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
|
|
6444
6577
|
}
|
|
6445
6578
|
return { body, headers };
|
|
6446
6579
|
}
|
|
@@ -6448,7 +6581,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6448
6581
|
if (!channel.webhook)
|
|
6449
6582
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6450
6583
|
const startedAt = now2();
|
|
6451
|
-
|
|
6584
|
+
let secret = channel.webhook.secret;
|
|
6585
|
+
if (channel.webhook.secretRef) {
|
|
6586
|
+
if (!options.secretResolver) {
|
|
6587
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
6588
|
+
}
|
|
6589
|
+
try {
|
|
6590
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
6591
|
+
} catch {
|
|
6592
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
6593
|
+
}
|
|
6594
|
+
if (!secret)
|
|
6595
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
6596
|
+
}
|
|
6597
|
+
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
6598
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
6452
6599
|
const controller = new AbortController;
|
|
6453
6600
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
6454
6601
|
try {
|
|
@@ -6480,6 +6627,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6480
6627
|
clearTimeout(timeout);
|
|
6481
6628
|
}
|
|
6482
6629
|
}
|
|
6630
|
+
function failedAttempt(startedAt, error) {
|
|
6631
|
+
return {
|
|
6632
|
+
attempt: 1,
|
|
6633
|
+
status: "failed",
|
|
6634
|
+
startedAt,
|
|
6635
|
+
completedAt: now2(),
|
|
6636
|
+
error
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6483
6639
|
async function dispatchCommand(event, channel) {
|
|
6484
6640
|
if (!channel.command)
|
|
6485
6641
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -6568,6 +6724,76 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
6568
6724
|
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
6569
6725
|
};
|
|
6570
6726
|
}
|
|
6727
|
+
|
|
6728
|
+
class EventTypeCatalog {
|
|
6729
|
+
definitions = new Map;
|
|
6730
|
+
register(definition) {
|
|
6731
|
+
this.definitions.set(definition.type, definition);
|
|
6732
|
+
return this;
|
|
6733
|
+
}
|
|
6734
|
+
unregister(type) {
|
|
6735
|
+
return this.definitions.delete(type);
|
|
6736
|
+
}
|
|
6737
|
+
has(type) {
|
|
6738
|
+
return this.definitions.has(type);
|
|
6739
|
+
}
|
|
6740
|
+
get(type) {
|
|
6741
|
+
return this.definitions.get(type);
|
|
6742
|
+
}
|
|
6743
|
+
list() {
|
|
6744
|
+
return [...this.definitions.values()];
|
|
6745
|
+
}
|
|
6746
|
+
validateEvent(event) {
|
|
6747
|
+
const definition = this.definitions.get(event.type);
|
|
6748
|
+
if (!definition)
|
|
6749
|
+
return { ok: true };
|
|
6750
|
+
return definition.validate(event.data, event);
|
|
6751
|
+
}
|
|
6752
|
+
assertEventValid(event) {
|
|
6753
|
+
const result = this.validateEvent(event);
|
|
6754
|
+
if (!result.ok) {
|
|
6755
|
+
throw new EventValidationError(event.type, result.issues);
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
}
|
|
6759
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
6760
|
+
if (paths.length === 0)
|
|
6761
|
+
return event;
|
|
6762
|
+
const copy = structuredClone(event);
|
|
6763
|
+
for (const path of paths) {
|
|
6764
|
+
setPath(copy, path, replacement);
|
|
6765
|
+
}
|
|
6766
|
+
return copy;
|
|
6767
|
+
}
|
|
6768
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
6769
|
+
return redactValue2(event, replacement);
|
|
6770
|
+
}
|
|
6771
|
+
function shouldRedactKey(key) {
|
|
6772
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
6773
|
+
}
|
|
6774
|
+
function redactValue2(value, replacement) {
|
|
6775
|
+
if (Array.isArray(value))
|
|
6776
|
+
return value.map((item) => redactValue2(item, replacement));
|
|
6777
|
+
if (!value || typeof value !== "object")
|
|
6778
|
+
return value;
|
|
6779
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
6780
|
+
key,
|
|
6781
|
+
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
6782
|
+
]));
|
|
6783
|
+
}
|
|
6784
|
+
function setPath(input, path, replacement) {
|
|
6785
|
+
const parts = path.split(".");
|
|
6786
|
+
let cursor = input;
|
|
6787
|
+
for (const part of parts.slice(0, -1)) {
|
|
6788
|
+
const next = cursor[part];
|
|
6789
|
+
if (!next || typeof next !== "object")
|
|
6790
|
+
return;
|
|
6791
|
+
cursor = next;
|
|
6792
|
+
}
|
|
6793
|
+
const last = parts.at(-1);
|
|
6794
|
+
if (last && last in cursor)
|
|
6795
|
+
cursor[last] = replacement;
|
|
6796
|
+
}
|
|
6571
6797
|
function createEvent(input) {
|
|
6572
6798
|
return {
|
|
6573
6799
|
id: input.id ?? randomUUID22(),
|
|
@@ -6588,10 +6814,18 @@ class EventsClient {
|
|
|
6588
6814
|
store;
|
|
6589
6815
|
redactors;
|
|
6590
6816
|
transportOptions;
|
|
6817
|
+
catalog;
|
|
6818
|
+
validateCatalogTypes;
|
|
6591
6819
|
constructor(options = {}) {
|
|
6592
6820
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
6593
6821
|
this.redactors = options.redactors ?? [];
|
|
6594
|
-
this.transportOptions = {
|
|
6822
|
+
this.transportOptions = {
|
|
6823
|
+
fetchImpl: options.fetchImpl,
|
|
6824
|
+
secretResolver: options.secretResolver,
|
|
6825
|
+
now: options.now
|
|
6826
|
+
};
|
|
6827
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
6828
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
6595
6829
|
}
|
|
6596
6830
|
async addChannel(input) {
|
|
6597
6831
|
const timestamp2 = new Date().toISOString();
|
|
@@ -6609,18 +6843,40 @@ class EventsClient {
|
|
|
6609
6843
|
}
|
|
6610
6844
|
async emit(input, options = {}) {
|
|
6611
6845
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
6612
|
-
if (options.
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
6620
|
-
return { event, deliveries, deduped: false };
|
|
6621
|
-
}
|
|
6622
|
-
async listEvents() {
|
|
6623
|
-
|
|
6846
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
6847
|
+
this.catalog.assertEventValid(event);
|
|
6848
|
+
}
|
|
6849
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
6850
|
+
if (append.deduped) {
|
|
6851
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
6852
|
+
}
|
|
6853
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
6854
|
+
return { event: append.event, deliveries, deduped: false };
|
|
6855
|
+
}
|
|
6856
|
+
async listEvents(options = {}) {
|
|
6857
|
+
if (Object.keys(options).length === 0)
|
|
6858
|
+
return this.store.listEvents();
|
|
6859
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
6860
|
+
}
|
|
6861
|
+
async listEventsPage(options = {}) {
|
|
6862
|
+
if (this.store.listEventsPage)
|
|
6863
|
+
return this.store.listEventsPage(options);
|
|
6864
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
6865
|
+
eventId: options.eventId,
|
|
6866
|
+
source: options.source,
|
|
6867
|
+
type: options.type
|
|
6868
|
+
});
|
|
6869
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6870
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
6871
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
6872
|
+
const nextOffset = offset + pageEvents.length;
|
|
6873
|
+
const hasMore = nextOffset < events.length;
|
|
6874
|
+
return {
|
|
6875
|
+
events: pageEvents,
|
|
6876
|
+
cursor: options.cursor,
|
|
6877
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
6878
|
+
hasMore
|
|
6879
|
+
};
|
|
6624
6880
|
}
|
|
6625
6881
|
async listDeliveries() {
|
|
6626
6882
|
return this.store.listDeliveries();
|
|
@@ -6688,22 +6944,37 @@ class EventsClient {
|
|
|
6688
6944
|
return result;
|
|
6689
6945
|
}
|
|
6690
6946
|
async replay(options = {}) {
|
|
6691
|
-
const
|
|
6692
|
-
if (options.eventId && event.id !== options.eventId)
|
|
6693
|
-
return false;
|
|
6694
|
-
if (options.source && event.source !== options.source)
|
|
6695
|
-
return false;
|
|
6696
|
-
if (options.type && event.type !== options.type)
|
|
6697
|
-
return false;
|
|
6698
|
-
return true;
|
|
6699
|
-
});
|
|
6947
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
6700
6948
|
if (options.dryRun)
|
|
6701
|
-
return { events, deliveries: [] };
|
|
6949
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
6702
6950
|
const deliveries = [];
|
|
6703
|
-
for (const event of events) {
|
|
6951
|
+
for (const event of page.events) {
|
|
6704
6952
|
deliveries.push(...await this.deliver(event));
|
|
6705
6953
|
}
|
|
6706
|
-
return { events, deliveries };
|
|
6954
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
6955
|
+
}
|
|
6956
|
+
async appendEvent(event, options) {
|
|
6957
|
+
if (this.store.appendEventOnce) {
|
|
6958
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
6959
|
+
}
|
|
6960
|
+
if (options.dedupe) {
|
|
6961
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
6962
|
+
if (existing) {
|
|
6963
|
+
return {
|
|
6964
|
+
event: existing,
|
|
6965
|
+
stored: false,
|
|
6966
|
+
deduped: true,
|
|
6967
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
6968
|
+
};
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
const stored = await this.store.appendEvent(event);
|
|
6972
|
+
return {
|
|
6973
|
+
event: stored,
|
|
6974
|
+
stored: true,
|
|
6975
|
+
deduped: false,
|
|
6976
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
6977
|
+
};
|
|
6707
6978
|
}
|
|
6708
6979
|
async applyRedaction(event, channel) {
|
|
6709
6980
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -6730,43 +7001,19 @@ class EventsClient {
|
|
|
6730
7001
|
return createDeliveryResult(event, channel, attempts);
|
|
6731
7002
|
}
|
|
6732
7003
|
}
|
|
6733
|
-
function
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
6747
|
-
}
|
|
6748
|
-
function redactValue2(value, replacement) {
|
|
6749
|
-
if (Array.isArray(value))
|
|
6750
|
-
return value.map((item) => redactValue2(item, replacement));
|
|
6751
|
-
if (!value || typeof value !== "object")
|
|
6752
|
-
return value;
|
|
6753
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
6754
|
-
key,
|
|
6755
|
-
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
6756
|
-
]));
|
|
6757
|
-
}
|
|
6758
|
-
function setPath(input, path, replacement) {
|
|
6759
|
-
const parts = path.split(".");
|
|
6760
|
-
let cursor = input;
|
|
6761
|
-
for (const part of parts.slice(0, -1)) {
|
|
6762
|
-
const next = cursor[part];
|
|
6763
|
-
if (!next || typeof next !== "object")
|
|
6764
|
-
return;
|
|
6765
|
-
cursor = next;
|
|
6766
|
-
}
|
|
6767
|
-
const last = parts.at(-1);
|
|
6768
|
-
if (last && last in cursor)
|
|
6769
|
-
cursor[last] = replacement;
|
|
7004
|
+
function queryClientEvents(events, options) {
|
|
7005
|
+
let rows = events;
|
|
7006
|
+
if (options.eventId)
|
|
7007
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
7008
|
+
if (options.source)
|
|
7009
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
7010
|
+
if (options.type)
|
|
7011
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
7012
|
+
if (options.cursor)
|
|
7013
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
7014
|
+
if (options.limit !== undefined)
|
|
7015
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
7016
|
+
return rows;
|
|
6770
7017
|
}
|
|
6771
7018
|
function normalizeTime(value) {
|
|
6772
7019
|
if (!value)
|
|
@@ -6780,9 +7027,22 @@ function normalizeRetryPolicy(policy) {
|
|
|
6780
7027
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
6781
7028
|
};
|
|
6782
7029
|
}
|
|
6783
|
-
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
7030
|
+
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;
|
|
6784
7031
|
var init_dist = __esm(() => {
|
|
6785
7032
|
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7033
|
+
EventValidationError = class EventValidationError extends Error {
|
|
7034
|
+
eventType;
|
|
7035
|
+
issues;
|
|
7036
|
+
constructor(eventType, issues) {
|
|
7037
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
7038
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
7039
|
+
this.name = "EventValidationError";
|
|
7040
|
+
this.eventType = eventType;
|
|
7041
|
+
this.issues = issues;
|
|
7042
|
+
}
|
|
7043
|
+
};
|
|
7044
|
+
defaultEventTypeCatalog = new EventTypeCatalog;
|
|
7045
|
+
APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
6786
7046
|
});
|
|
6787
7047
|
|
|
6788
7048
|
// src/db/task-lists.ts
|
|
@@ -7886,8 +8146,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
|
|
|
7886
8146
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
7887
8147
|
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7888
8148
|
}
|
|
7889
|
-
const resp = await fetch(wh.url, { method: "POST", headers, body });
|
|
8149
|
+
const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
|
|
7890
8150
|
const respText = await resp.text().catch(() => "");
|
|
8151
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
8152
|
+
logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
|
|
8153
|
+
return;
|
|
8154
|
+
}
|
|
7891
8155
|
logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
|
|
7892
8156
|
if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
|
|
7893
8157
|
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -9671,6 +9935,12 @@ function listTasks(filter = {}, db) {
|
|
|
9671
9935
|
const d = db || getDatabase();
|
|
9672
9936
|
const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
|
|
9673
9937
|
clearExpiredLocks2(d);
|
|
9938
|
+
if (filter.limit !== undefined) {
|
|
9939
|
+
const limit = filter.limit;
|
|
9940
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
|
|
9941
|
+
throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
|
|
9942
|
+
}
|
|
9943
|
+
}
|
|
9674
9944
|
const conditions = [];
|
|
9675
9945
|
const params = [];
|
|
9676
9946
|
if (filter.project_id) {
|
|
@@ -10204,6 +10474,7 @@ function deleteTask(id, db) {
|
|
|
10204
10474
|
}
|
|
10205
10475
|
return result.changes > 0;
|
|
10206
10476
|
}
|
|
10477
|
+
var MAX_TASK_LIST_LIMIT = 1e5;
|
|
10207
10478
|
var init_task_crud = __esm(() => {
|
|
10208
10479
|
init_types();
|
|
10209
10480
|
init_database();
|
|
@@ -12922,7 +13193,7 @@ var init_dispatches = __esm(() => {
|
|
|
12922
13193
|
// package.json
|
|
12923
13194
|
var package_default = {
|
|
12924
13195
|
name: "@hasna/todos",
|
|
12925
|
-
version: "0.15.
|
|
13196
|
+
version: "0.15.49",
|
|
12926
13197
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12927
13198
|
type: "module",
|
|
12928
13199
|
main: "dist/index.js",
|
|
@@ -12986,6 +13257,7 @@ var package_default = {
|
|
|
12986
13257
|
],
|
|
12987
13258
|
scripts: {
|
|
12988
13259
|
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",
|
|
13260
|
+
"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",
|
|
12989
13261
|
"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/*'",
|
|
12990
13262
|
migrate: "bun run src/server/index.ts migrate",
|
|
12991
13263
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
@@ -13040,18 +13312,19 @@ var package_default = {
|
|
|
13040
13312
|
author: "Andrei Hasna <andrei@hasna.com>",
|
|
13041
13313
|
license: "Apache-2.0",
|
|
13042
13314
|
dependencies: {
|
|
13043
|
-
"@hasna/contracts": "0.
|
|
13315
|
+
"@hasna/contracts": "0.14.0",
|
|
13044
13316
|
"@hasna/events": "^0.1.11",
|
|
13045
13317
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
13046
13318
|
chalk: "^5.4.1",
|
|
13047
13319
|
commander: "^13.1.0",
|
|
13048
13320
|
ink: "^5.2.0",
|
|
13049
13321
|
react: "^18.3.1",
|
|
13050
|
-
zod: "
|
|
13322
|
+
zod: "3.25.76"
|
|
13051
13323
|
},
|
|
13052
13324
|
overrides: {
|
|
13053
13325
|
ajv: "8.20.0",
|
|
13054
|
-
"fast-uri": "3.1.2"
|
|
13326
|
+
"fast-uri": "3.1.2",
|
|
13327
|
+
zod: "3.25.76"
|
|
13055
13328
|
},
|
|
13056
13329
|
devDependencies: {
|
|
13057
13330
|
"@types/bun": "^1.2.4",
|
|
@@ -21866,6 +22139,47 @@ function autoDetectFileRelationships(taskId, db) {
|
|
|
21866
22139
|
|
|
21867
22140
|
// src/lib/task-dedupe.ts
|
|
21868
22141
|
init_tasks();
|
|
22142
|
+
|
|
22143
|
+
// src/lib/dedupe-projection.ts
|
|
22144
|
+
init_redaction();
|
|
22145
|
+
var DEDUPE_SOURCE_KEY_ALLOWLIST = [
|
|
22146
|
+
"github_url",
|
|
22147
|
+
"github_issue_url",
|
|
22148
|
+
"github_pr_url",
|
|
22149
|
+
"source_url",
|
|
22150
|
+
"url",
|
|
22151
|
+
"external_url",
|
|
22152
|
+
"issue_url",
|
|
22153
|
+
"github_owner",
|
|
22154
|
+
"github_repo",
|
|
22155
|
+
"github_number"
|
|
22156
|
+
];
|
|
22157
|
+
function projectTasksForDedupe(tasks) {
|
|
22158
|
+
return tasks.map((task2) => {
|
|
22159
|
+
const metadata = {};
|
|
22160
|
+
for (const key of DEDUPE_SOURCE_KEY_ALLOWLIST) {
|
|
22161
|
+
if (key in task2.metadata) {
|
|
22162
|
+
metadata[key] = redactValue(task2.metadata[key]);
|
|
22163
|
+
}
|
|
22164
|
+
}
|
|
22165
|
+
return {
|
|
22166
|
+
id: task2.id,
|
|
22167
|
+
short_id: task2.short_id,
|
|
22168
|
+
title: redactEvidenceText(task2.title),
|
|
22169
|
+
description: task2.description === null || task2.description === undefined ? null : redactEvidenceText(task2.description),
|
|
22170
|
+
status: task2.status,
|
|
22171
|
+
created_at: task2.created_at,
|
|
22172
|
+
updated_at: task2.updated_at,
|
|
22173
|
+
project_id: task2.project_id,
|
|
22174
|
+
task_list_id: task2.task_list_id,
|
|
22175
|
+
assigned_to: task2.assigned_to,
|
|
22176
|
+
priority: task2.priority,
|
|
22177
|
+
metadata
|
|
22178
|
+
};
|
|
22179
|
+
});
|
|
22180
|
+
}
|
|
22181
|
+
|
|
22182
|
+
// src/lib/task-dedupe.ts
|
|
21869
22183
|
var DEFAULT_THRESHOLD = 0.74;
|
|
21870
22184
|
var STOP_WORDS = new Set([
|
|
21871
22185
|
"a",
|
|
@@ -22058,10 +22372,10 @@ function olderFirst(left, right) {
|
|
|
22058
22372
|
function findDuplicateTasks(options = {}, db) {
|
|
22059
22373
|
const d = db || getDatabase();
|
|
22060
22374
|
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
22061
|
-
const fingerprints = listTasks({
|
|
22375
|
+
const fingerprints = projectTasksForDedupe(listTasks({
|
|
22062
22376
|
include_archived: Boolean(options.include_archived),
|
|
22063
22377
|
limit: options.limit ?? 1000
|
|
22064
|
-
}, d).map(fingerprint);
|
|
22378
|
+
}, d)).map(fingerprint);
|
|
22065
22379
|
const candidates = [];
|
|
22066
22380
|
for (let i = 0;i < fingerprints.length; i++) {
|
|
22067
22381
|
for (let j = i + 1;j < fingerprints.length; j++) {
|
|
@@ -27369,6 +27683,30 @@ function changedSinceStampNewer(stamp, since) {
|
|
|
27369
27683
|
// src/storage/postgres-adapter.ts
|
|
27370
27684
|
init_stale_lock_handoff();
|
|
27371
27685
|
|
|
27686
|
+
// src/storage/postgres-sync.ts
|
|
27687
|
+
init_types();
|
|
27688
|
+
|
|
27689
|
+
// src/storage/postgres-errors.ts
|
|
27690
|
+
function isPostgresUniqueViolation(error) {
|
|
27691
|
+
if (typeof error !== "object" || error === null)
|
|
27692
|
+
return false;
|
|
27693
|
+
const candidate = error;
|
|
27694
|
+
const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
|
|
27695
|
+
if (typeof candidate.cause === "object" && candidate.cause !== null) {
|
|
27696
|
+
const cause = candidate.cause;
|
|
27697
|
+
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
27698
|
+
}
|
|
27699
|
+
return states.some((state) => String(state) === "23505");
|
|
27700
|
+
}
|
|
27701
|
+
function postgresConstraintName(error) {
|
|
27702
|
+
if (typeof error !== "object" || error === null)
|
|
27703
|
+
return "";
|
|
27704
|
+
const candidate = error;
|
|
27705
|
+
const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
|
|
27706
|
+
const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
|
|
27707
|
+
return typeof constraint === "string" ? constraint : "";
|
|
27708
|
+
}
|
|
27709
|
+
|
|
27372
27710
|
// src/storage/postgres-sync.ts
|
|
27373
27711
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
27374
27712
|
var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
@@ -27571,53 +27909,98 @@ class PostgresTodosSyncStore {
|
|
|
27571
27909
|
if (routingErrors.length > 0) {
|
|
27572
27910
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
27573
27911
|
}
|
|
27574
|
-
const
|
|
27575
|
-
|
|
27576
|
-
|
|
27577
|
-
|
|
27578
|
-
|
|
27579
|
-
|
|
27580
|
-
const
|
|
27581
|
-
|
|
27582
|
-
|
|
27583
|
-
|
|
27584
|
-
|
|
27585
|
-
|
|
27586
|
-
|
|
27587
|
-
|
|
27588
|
-
|
|
27589
|
-
|
|
27590
|
-
|
|
27591
|
-
|
|
27592
|
-
|
|
27593
|
-
|
|
27594
|
-
|
|
27595
|
-
|
|
27596
|
-
|
|
27597
|
-
|
|
27598
|
-
|
|
27599
|
-
|
|
27600
|
-
|
|
27601
|
-
|
|
27602
|
-
|
|
27603
|
-
|
|
27604
|
-
|
|
27605
|
-
|
|
27606
|
-
|
|
27607
|
-
|
|
27608
|
-
|
|
27609
|
-
|
|
27610
|
-
|
|
27611
|
-
|
|
27612
|
-
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
|
|
27616
|
-
|
|
27617
|
-
|
|
27618
|
-
|
|
27912
|
+
const push = async (client) => {
|
|
27913
|
+
const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
27914
|
+
FROM ${this.tableName}
|
|
27915
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
27916
|
+
const existingProjects = [];
|
|
27917
|
+
const existingTaskLists = [];
|
|
27918
|
+
for (const row of existing.rows) {
|
|
27919
|
+
const payload = payloadRecord(row.payload);
|
|
27920
|
+
if (row.object_type === "projects")
|
|
27921
|
+
existingProjects.push(payload);
|
|
27922
|
+
if (row.object_type === "task_lists")
|
|
27923
|
+
existingTaskLists.push(payload);
|
|
27924
|
+
}
|
|
27925
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
27926
|
+
if (destinationErrors.length > 0) {
|
|
27927
|
+
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
27928
|
+
}
|
|
27929
|
+
const result = { records: 0, objectTypes: {} };
|
|
27930
|
+
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
27931
|
+
for (const entry of snapshotEntries(snapshot)) {
|
|
27932
|
+
if (entry.deletedAt === null)
|
|
27933
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
27934
|
+
try {
|
|
27935
|
+
await client.query(`INSERT INTO ${this.tableName} (
|
|
27936
|
+
service, object_type, object_id, payload, updated_at,
|
|
27937
|
+
deleted_at, source_machine_id, version
|
|
27938
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
|
|
27939
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
27940
|
+
payload = EXCLUDED.payload,
|
|
27941
|
+
updated_at = EXCLUDED.updated_at,
|
|
27942
|
+
deleted_at = EXCLUDED.deleted_at,
|
|
27943
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
27944
|
+
version = EXCLUDED.version
|
|
27945
|
+
WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
27946
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
27947
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
|
|
27948
|
+
this.service,
|
|
27949
|
+
entry.type,
|
|
27950
|
+
entry.id,
|
|
27951
|
+
entry.payload,
|
|
27952
|
+
entry.updatedAt,
|
|
27953
|
+
entry.deletedAt,
|
|
27954
|
+
sourceMachineId,
|
|
27955
|
+
entry.version
|
|
27956
|
+
]);
|
|
27957
|
+
} catch (error) {
|
|
27958
|
+
await this.classifySyncInsertConflict(error, entry);
|
|
27959
|
+
}
|
|
27960
|
+
result.records += 1;
|
|
27961
|
+
result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
|
|
27962
|
+
}
|
|
27963
|
+
return result;
|
|
27964
|
+
};
|
|
27965
|
+
if (typeof this.client.transaction === "function") {
|
|
27966
|
+
return this.client.transaction((client) => push(client));
|
|
27619
27967
|
}
|
|
27620
|
-
return
|
|
27968
|
+
return push(this.client);
|
|
27969
|
+
}
|
|
27970
|
+
async classifySyncInsertConflict(error, entry) {
|
|
27971
|
+
if (!isPostgresUniqueViolation(error))
|
|
27972
|
+
throw error;
|
|
27973
|
+
const payload = entry.payload;
|
|
27974
|
+
const taskListSlug = String(payload["slug"] ?? "");
|
|
27975
|
+
const projectSlug = String(payload["task_list_id"] ?? "");
|
|
27976
|
+
const constraintName = postgresConstraintName(error);
|
|
27977
|
+
if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
|
|
27978
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
27979
|
+
}
|
|
27980
|
+
if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
|
|
27981
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
27982
|
+
}
|
|
27983
|
+
if (entry.type === "task_lists") {
|
|
27984
|
+
const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
|
|
27985
|
+
const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
|
|
27986
|
+
SELECT 1 FROM ${this.tableName}
|
|
27987
|
+
WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
|
|
27988
|
+
AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
|
|
27989
|
+
) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
|
|
27990
|
+
if (conflict.rows[0]?.conflict) {
|
|
27991
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
27992
|
+
}
|
|
27993
|
+
} else if (entry.type === "projects") {
|
|
27994
|
+
const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
|
|
27995
|
+
SELECT 1 FROM ${this.tableName}
|
|
27996
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
27997
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
27998
|
+
) AS conflict`, [this.service, entry.id, projectSlug]);
|
|
27999
|
+
if (conflict.rows[0]?.conflict) {
|
|
28000
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
28001
|
+
}
|
|
28002
|
+
}
|
|
28003
|
+
throw error;
|
|
27621
28004
|
}
|
|
27622
28005
|
async pullSnapshot(options = {}) {
|
|
27623
28006
|
const params = [this.service];
|
|
@@ -27983,9 +28366,11 @@ class PostgresJsonRecordStore {
|
|
|
27983
28366
|
async ensureSchema() {
|
|
27984
28367
|
if (!this.schemaReady) {
|
|
27985
28368
|
this.schemaReady = (async () => {
|
|
27986
|
-
|
|
27987
|
-
|
|
27988
|
-
|
|
28369
|
+
await retryOnTransientPostgresError(async () => {
|
|
28370
|
+
for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
|
|
28371
|
+
await this.options.client.query(sql);
|
|
28372
|
+
}
|
|
28373
|
+
});
|
|
27989
28374
|
})().catch((error) => {
|
|
27990
28375
|
this.schemaReady = null;
|
|
27991
28376
|
throw error;
|
|
@@ -28399,13 +28784,14 @@ class PostgresJsonRecordStore {
|
|
|
28399
28784
|
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
28400
28785
|
}
|
|
28401
28786
|
async withTaskParentIntegrityTransaction(fn) {
|
|
28402
|
-
|
|
28787
|
+
const transaction = this.options.client.transaction;
|
|
28788
|
+
if (typeof transaction !== "function") {
|
|
28403
28789
|
throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
|
|
28404
28790
|
}
|
|
28405
|
-
return
|
|
28791
|
+
return retryOnTransientPostgresError(() => transaction(async (client) => {
|
|
28406
28792
|
await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
|
|
28407
28793
|
return fn(client);
|
|
28408
|
-
});
|
|
28794
|
+
}));
|
|
28409
28795
|
}
|
|
28410
28796
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
|
|
28411
28797
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
@@ -30291,7 +30677,13 @@ function compareClock(left, right) {
|
|
|
30291
30677
|
function numberValue3(value) {
|
|
30292
30678
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
30293
30679
|
}
|
|
30294
|
-
|
|
30680
|
+
var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
|
|
30681
|
+
var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
|
|
30682
|
+
"canceling statement due to lock timeout",
|
|
30683
|
+
"deadlock detected",
|
|
30684
|
+
"could not serialize access"
|
|
30685
|
+
];
|
|
30686
|
+
function isTransientPostgresError(error) {
|
|
30295
30687
|
if (typeof error !== "object" || error === null)
|
|
30296
30688
|
return false;
|
|
30297
30689
|
const candidate = error;
|
|
@@ -30300,18 +30692,29 @@ function isPostgresUniqueViolation(error) {
|
|
|
30300
30692
|
const cause = candidate.cause;
|
|
30301
30693
|
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
30302
30694
|
}
|
|
30303
|
-
|
|
30695
|
+
if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
|
|
30696
|
+
return true;
|
|
30697
|
+
}
|
|
30698
|
+
const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
|
|
30699
|
+
return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
|
|
30304
30700
|
}
|
|
30305
|
-
function
|
|
30306
|
-
|
|
30307
|
-
|
|
30308
|
-
|
|
30309
|
-
|
|
30310
|
-
|
|
30311
|
-
|
|
30701
|
+
async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
30702
|
+
let lastError;
|
|
30703
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
30704
|
+
try {
|
|
30705
|
+
return await fn();
|
|
30706
|
+
} catch (error) {
|
|
30707
|
+
lastError = error;
|
|
30708
|
+
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
30709
|
+
throw error;
|
|
30710
|
+
await new Promise((resolve10) => setTimeout(resolve10, delayMs * attempt));
|
|
30711
|
+
}
|
|
30712
|
+
}
|
|
30713
|
+
throw lastError;
|
|
30312
30714
|
}
|
|
30313
30715
|
|
|
30314
30716
|
// src/storage/shadow.ts
|
|
30717
|
+
init_types();
|
|
30315
30718
|
var SNAPSHOT_TO_OBJECT_TYPE = {
|
|
30316
30719
|
tasks: "tasks",
|
|
30317
30720
|
projects: "projects",
|
|
@@ -30453,6 +30856,12 @@ class TodosShadowMirror {
|
|
|
30453
30856
|
const message = error instanceof Error ? error.message : String(error);
|
|
30454
30857
|
const objectType = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
|
|
30455
30858
|
const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
|
|
30859
|
+
if (error instanceof ResourceConflictError) {
|
|
30860
|
+
this.metrics.failed += 1;
|
|
30861
|
+
this.metrics.lastError = message;
|
|
30862
|
+
this.onEvent?.({ type: "dropped", objectType, id, error: message });
|
|
30863
|
+
return;
|
|
30864
|
+
}
|
|
30456
30865
|
this.metrics.retries += 1;
|
|
30457
30866
|
this.metrics.lastError = message;
|
|
30458
30867
|
op.attempts += 1;
|
|
@@ -30803,6 +31212,7 @@ function assertPostgresAdapterCapabilities(adapter) {
|
|
|
30803
31212
|
}
|
|
30804
31213
|
}
|
|
30805
31214
|
// src/storage/shadow-outbox.ts
|
|
31215
|
+
init_types();
|
|
30806
31216
|
init_shadow_outbox_schema();
|
|
30807
31217
|
init_shadow_outbox_schema();
|
|
30808
31218
|
var MAX_BACKOFF_MS = 5 * 60000;
|
|
@@ -30932,6 +31342,12 @@ class TodosShadowOutbox {
|
|
|
30932
31342
|
this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
|
|
30933
31343
|
} catch (error) {
|
|
30934
31344
|
const message = error instanceof Error ? error.message : String(error);
|
|
31345
|
+
if (error instanceof ResourceConflictError) {
|
|
31346
|
+
this.metrics.lastError = message;
|
|
31347
|
+
this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
|
|
31348
|
+
this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
|
|
31349
|
+
return;
|
|
31350
|
+
}
|
|
30935
31351
|
this.metrics.retries += 1;
|
|
30936
31352
|
this.metrics.lastError = message;
|
|
30937
31353
|
const attempts = row.attempts + 1;
|
|
@@ -67937,6 +68353,7 @@ export {
|
|
|
67937
68353
|
queryTasksByWorkflowState,
|
|
67938
68354
|
queryTasksByLocalFields,
|
|
67939
68355
|
purgeArtifact,
|
|
68356
|
+
projectTasksForDedupe,
|
|
67940
68357
|
processDueReminders,
|
|
67941
68358
|
previewUserScaffold,
|
|
67942
68359
|
previewTemplate,
|
|
@@ -68905,6 +69322,7 @@ export {
|
|
|
68905
69322
|
DEFAULT_LEASE_MINUTES,
|
|
68906
69323
|
DEFAULT_ENCRYPTION_PROFILE,
|
|
68907
69324
|
DEFAULT_ENCRYPTION_KEY_ENV,
|
|
69325
|
+
DEDUPE_SOURCE_KEY_ALLOWLIST,
|
|
68908
69326
|
DECISION_STATUSES,
|
|
68909
69327
|
DECISION_RECORD_SCHEMA,
|
|
68910
69328
|
DB_BACKUP_SCHEMA,
|