@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/cli/index.js
CHANGED
|
@@ -2123,7 +2123,7 @@ var package_default;
|
|
|
2123
2123
|
var init_package = __esm(() => {
|
|
2124
2124
|
package_default = {
|
|
2125
2125
|
name: "@hasna/todos",
|
|
2126
|
-
version: "0.15.
|
|
2126
|
+
version: "0.15.49",
|
|
2127
2127
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
2128
2128
|
type: "module",
|
|
2129
2129
|
main: "dist/index.js",
|
|
@@ -2187,6 +2187,7 @@ var init_package = __esm(() => {
|
|
|
2187
2187
|
],
|
|
2188
2188
|
scripts: {
|
|
2189
2189
|
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",
|
|
2190
|
+
"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",
|
|
2190
2191
|
"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/*'",
|
|
2191
2192
|
migrate: "bun run src/server/index.ts migrate",
|
|
2192
2193
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
@@ -2241,18 +2242,19 @@ var init_package = __esm(() => {
|
|
|
2241
2242
|
author: "Andrei Hasna <andrei@hasna.com>",
|
|
2242
2243
|
license: "Apache-2.0",
|
|
2243
2244
|
dependencies: {
|
|
2244
|
-
"@hasna/contracts": "0.
|
|
2245
|
+
"@hasna/contracts": "0.14.0",
|
|
2245
2246
|
"@hasna/events": "^0.1.11",
|
|
2246
2247
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
2247
2248
|
chalk: "^5.4.1",
|
|
2248
2249
|
commander: "^13.1.0",
|
|
2249
2250
|
ink: "^5.2.0",
|
|
2250
2251
|
react: "^18.3.1",
|
|
2251
|
-
zod: "
|
|
2252
|
+
zod: "3.25.76"
|
|
2252
2253
|
},
|
|
2253
2254
|
overrides: {
|
|
2254
2255
|
ajv: "8.20.0",
|
|
2255
|
-
"fast-uri": "3.1.2"
|
|
2256
|
+
"fast-uri": "3.1.2",
|
|
2257
|
+
zod: "3.25.76"
|
|
2256
2258
|
},
|
|
2257
2259
|
devDependencies: {
|
|
2258
2260
|
"@types/bun": "^1.2.4",
|
|
@@ -2850,13 +2852,15 @@ function upsertSecretSafetyConfig(input) {
|
|
|
2850
2852
|
saveConfig({ ...config, secret_safety: next });
|
|
2851
2853
|
return next;
|
|
2852
2854
|
}
|
|
2853
|
-
var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
2855
|
+
var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
2854
2856
|
var init_redaction = __esm(() => {
|
|
2855
2857
|
init_config();
|
|
2858
|
+
XAI_PREFIX = ["x", "ai", "-"].join("");
|
|
2856
2859
|
DEFAULT_SECRET_PATTERNS = [
|
|
2857
2860
|
{ name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
|
|
2858
2861
|
{ name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
|
|
2859
2862
|
{ name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
2863
|
+
{ name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
2860
2864
|
{ name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
|
|
2861
2865
|
{ name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
2862
2866
|
{ name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
@@ -14578,8 +14582,9 @@ var init_event_hooks = __esm(() => {
|
|
|
14578
14582
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
14579
14583
|
});
|
|
14580
14584
|
|
|
14581
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
14585
|
+
// node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
|
|
14582
14586
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
14587
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
14583
14588
|
import { existsSync as existsSync6 } from "fs";
|
|
14584
14589
|
import { homedir as homedir2 } from "os";
|
|
14585
14590
|
import { join as join5 } from "path";
|
|
@@ -14688,11 +14693,13 @@ function getEventsDataDir(override) {
|
|
|
14688
14693
|
|
|
14689
14694
|
class JsonEventsStore {
|
|
14690
14695
|
dataDir;
|
|
14696
|
+
runtime;
|
|
14691
14697
|
channelsPath;
|
|
14692
14698
|
eventsPath;
|
|
14693
14699
|
deliveriesPath;
|
|
14694
14700
|
constructor(dataDir = getEventsDataDir()) {
|
|
14695
14701
|
this.dataDir = dataDir;
|
|
14702
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
14696
14703
|
this.channelsPath = join5(dataDir, "channels.json");
|
|
14697
14704
|
this.eventsPath = join5(dataDir, "events.json");
|
|
14698
14705
|
this.deliveriesPath = join5(dataDir, "deliveries.json");
|
|
@@ -14740,13 +14747,58 @@ class JsonEventsStore {
|
|
|
14740
14747
|
await this.writeJson(this.eventsPath, events);
|
|
14741
14748
|
return event;
|
|
14742
14749
|
}
|
|
14743
|
-
async
|
|
14750
|
+
async appendEventOnce(event, options = {}) {
|
|
14744
14751
|
await this.init();
|
|
14745
|
-
|
|
14752
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
14753
|
+
const dedupe2 = options.dedupe !== false;
|
|
14754
|
+
if (dedupe2) {
|
|
14755
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
14756
|
+
if (existing) {
|
|
14757
|
+
return {
|
|
14758
|
+
event: existing,
|
|
14759
|
+
stored: false,
|
|
14760
|
+
deduped: true,
|
|
14761
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
14762
|
+
};
|
|
14763
|
+
}
|
|
14764
|
+
}
|
|
14765
|
+
events.push(event);
|
|
14766
|
+
await this.writeJson(this.eventsPath, events);
|
|
14767
|
+
return {
|
|
14768
|
+
event,
|
|
14769
|
+
stored: true,
|
|
14770
|
+
deduped: false,
|
|
14771
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
14772
|
+
};
|
|
14773
|
+
}
|
|
14774
|
+
async listEvents(options = {}) {
|
|
14775
|
+
await this.init();
|
|
14776
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
14777
|
+
return queryEvents(events, options);
|
|
14778
|
+
}
|
|
14779
|
+
async listEventsPage(options = {}) {
|
|
14780
|
+
await this.init();
|
|
14781
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
14782
|
+
const queried = queryEvents(events, {
|
|
14783
|
+
eventId: options.eventId,
|
|
14784
|
+
source: options.source,
|
|
14785
|
+
type: options.type
|
|
14786
|
+
});
|
|
14787
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
14788
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
14789
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
14790
|
+
const nextOffset = offset + pageEvents.length;
|
|
14791
|
+
const hasMore = nextOffset < queried.length;
|
|
14792
|
+
return {
|
|
14793
|
+
events: pageEvents,
|
|
14794
|
+
cursor: options.cursor,
|
|
14795
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
14796
|
+
hasMore
|
|
14797
|
+
};
|
|
14746
14798
|
}
|
|
14747
14799
|
async findEventByIdentity(identity) {
|
|
14748
14800
|
const events = await this.listEvents();
|
|
14749
|
-
return events
|
|
14801
|
+
return findEventByIdentity(events, identity);
|
|
14750
14802
|
}
|
|
14751
14803
|
async appendDelivery(result) {
|
|
14752
14804
|
await this.init();
|
|
@@ -14797,6 +14849,83 @@ class JsonEventsStore {
|
|
|
14797
14849
|
});
|
|
14798
14850
|
}
|
|
14799
14851
|
}
|
|
14852
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
14853
|
+
return {
|
|
14854
|
+
mode: "local-files",
|
|
14855
|
+
name: "json-events-store",
|
|
14856
|
+
remote: false,
|
|
14857
|
+
localFiles: true,
|
|
14858
|
+
localSqlite: false,
|
|
14859
|
+
postgres: false,
|
|
14860
|
+
s3: false,
|
|
14861
|
+
aws: false,
|
|
14862
|
+
durable: true,
|
|
14863
|
+
idempotency: "best-effort-local",
|
|
14864
|
+
replayCursors: true,
|
|
14865
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
14866
|
+
};
|
|
14867
|
+
}
|
|
14868
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
14869
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
14870
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
14871
|
+
const payload = {
|
|
14872
|
+
offset,
|
|
14873
|
+
eventId: options.eventId,
|
|
14874
|
+
source: options.source,
|
|
14875
|
+
type: options.type
|
|
14876
|
+
};
|
|
14877
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
14878
|
+
}
|
|
14879
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
14880
|
+
if (!cursor)
|
|
14881
|
+
return 0;
|
|
14882
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
14883
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
14884
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
14885
|
+
let payload;
|
|
14886
|
+
try {
|
|
14887
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
14888
|
+
} catch {
|
|
14889
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
14890
|
+
}
|
|
14891
|
+
const offset = payload.offset;
|
|
14892
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
14893
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
14894
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
14895
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
14896
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
14897
|
+
return offset;
|
|
14898
|
+
}
|
|
14899
|
+
function normalizeEventPageLimit(limit) {
|
|
14900
|
+
if (limit === undefined)
|
|
14901
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
14902
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
14903
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
14904
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
14905
|
+
}
|
|
14906
|
+
function queryEvents(events, options) {
|
|
14907
|
+
let rows = events;
|
|
14908
|
+
if (options.eventId)
|
|
14909
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
14910
|
+
if (options.source)
|
|
14911
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
14912
|
+
if (options.type)
|
|
14913
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
14914
|
+
if (options.cursor) {
|
|
14915
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
14916
|
+
rows = rows.slice(offset);
|
|
14917
|
+
}
|
|
14918
|
+
if (options.limit !== undefined)
|
|
14919
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
14920
|
+
return rows;
|
|
14921
|
+
}
|
|
14922
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
14923
|
+
if (cursorValue !== optionValue)
|
|
14924
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
14925
|
+
}
|
|
14926
|
+
function findEventByIdentity(events, identity) {
|
|
14927
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
14928
|
+
}
|
|
14800
14929
|
function buildSignatureBase(timestamp2, body) {
|
|
14801
14930
|
return `${timestamp2}.${body}`;
|
|
14802
14931
|
}
|
|
@@ -14810,21 +14939,27 @@ function now2() {
|
|
|
14810
14939
|
function truncate(value, max = 4096) {
|
|
14811
14940
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
14812
14941
|
}
|
|
14813
|
-
function buildWebhookRequest(event, channel) {
|
|
14942
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
14814
14943
|
if (!channel.webhook)
|
|
14815
14944
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
14945
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
14946
|
+
if (/^x-hasna-/i.test(name)) {
|
|
14947
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
14948
|
+
}
|
|
14949
|
+
}
|
|
14816
14950
|
const body = JSON.stringify(event);
|
|
14817
|
-
const timestamp2 =
|
|
14951
|
+
const timestamp2 = options.timestamp ?? new Date().toISOString();
|
|
14818
14952
|
const headers = {
|
|
14819
14953
|
"Content-Type": "application/json",
|
|
14820
14954
|
"User-Agent": "@hasna/events",
|
|
14821
14955
|
"X-Hasna-Event-Id": event.id,
|
|
14822
14956
|
"X-Hasna-Event-Type": event.type,
|
|
14823
|
-
|
|
14824
|
-
|
|
14957
|
+
...channel.webhook.headers,
|
|
14958
|
+
"X-Hasna-Timestamp": timestamp2
|
|
14825
14959
|
};
|
|
14826
|
-
|
|
14827
|
-
|
|
14960
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
14961
|
+
if (secret) {
|
|
14962
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
|
|
14828
14963
|
}
|
|
14829
14964
|
return { body, headers };
|
|
14830
14965
|
}
|
|
@@ -14832,7 +14967,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
14832
14967
|
if (!channel.webhook)
|
|
14833
14968
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
14834
14969
|
const startedAt = now2();
|
|
14835
|
-
|
|
14970
|
+
let secret = channel.webhook.secret;
|
|
14971
|
+
if (channel.webhook.secretRef) {
|
|
14972
|
+
if (!options.secretResolver) {
|
|
14973
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
14974
|
+
}
|
|
14975
|
+
try {
|
|
14976
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
14977
|
+
} catch {
|
|
14978
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
14979
|
+
}
|
|
14980
|
+
if (!secret)
|
|
14981
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
14982
|
+
}
|
|
14983
|
+
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
14984
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
14836
14985
|
const controller = new AbortController;
|
|
14837
14986
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
14838
14987
|
try {
|
|
@@ -14864,6 +15013,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
14864
15013
|
clearTimeout(timeout);
|
|
14865
15014
|
}
|
|
14866
15015
|
}
|
|
15016
|
+
function failedAttempt(startedAt, error) {
|
|
15017
|
+
return {
|
|
15018
|
+
attempt: 1,
|
|
15019
|
+
status: "failed",
|
|
15020
|
+
startedAt,
|
|
15021
|
+
completedAt: now2(),
|
|
15022
|
+
error
|
|
15023
|
+
};
|
|
15024
|
+
}
|
|
14867
15025
|
async function dispatchCommand(event, channel) {
|
|
14868
15026
|
if (!channel.command)
|
|
14869
15027
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -14952,6 +15110,76 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
14952
15110
|
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
14953
15111
|
};
|
|
14954
15112
|
}
|
|
15113
|
+
|
|
15114
|
+
class EventTypeCatalog {
|
|
15115
|
+
definitions = new Map;
|
|
15116
|
+
register(definition) {
|
|
15117
|
+
this.definitions.set(definition.type, definition);
|
|
15118
|
+
return this;
|
|
15119
|
+
}
|
|
15120
|
+
unregister(type) {
|
|
15121
|
+
return this.definitions.delete(type);
|
|
15122
|
+
}
|
|
15123
|
+
has(type) {
|
|
15124
|
+
return this.definitions.has(type);
|
|
15125
|
+
}
|
|
15126
|
+
get(type) {
|
|
15127
|
+
return this.definitions.get(type);
|
|
15128
|
+
}
|
|
15129
|
+
list() {
|
|
15130
|
+
return [...this.definitions.values()];
|
|
15131
|
+
}
|
|
15132
|
+
validateEvent(event) {
|
|
15133
|
+
const definition = this.definitions.get(event.type);
|
|
15134
|
+
if (!definition)
|
|
15135
|
+
return { ok: true };
|
|
15136
|
+
return definition.validate(event.data, event);
|
|
15137
|
+
}
|
|
15138
|
+
assertEventValid(event) {
|
|
15139
|
+
const result = this.validateEvent(event);
|
|
15140
|
+
if (!result.ok) {
|
|
15141
|
+
throw new EventValidationError(event.type, result.issues);
|
|
15142
|
+
}
|
|
15143
|
+
}
|
|
15144
|
+
}
|
|
15145
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
15146
|
+
if (paths.length === 0)
|
|
15147
|
+
return event;
|
|
15148
|
+
const copy = structuredClone(event);
|
|
15149
|
+
for (const path of paths) {
|
|
15150
|
+
setPath(copy, path, replacement);
|
|
15151
|
+
}
|
|
15152
|
+
return copy;
|
|
15153
|
+
}
|
|
15154
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
15155
|
+
return redactValue2(event, replacement);
|
|
15156
|
+
}
|
|
15157
|
+
function shouldRedactKey(key) {
|
|
15158
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
15159
|
+
}
|
|
15160
|
+
function redactValue2(value, replacement) {
|
|
15161
|
+
if (Array.isArray(value))
|
|
15162
|
+
return value.map((item) => redactValue2(item, replacement));
|
|
15163
|
+
if (!value || typeof value !== "object")
|
|
15164
|
+
return value;
|
|
15165
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
15166
|
+
key,
|
|
15167
|
+
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
15168
|
+
]));
|
|
15169
|
+
}
|
|
15170
|
+
function setPath(input, path, replacement) {
|
|
15171
|
+
const parts = path.split(".");
|
|
15172
|
+
let cursor = input;
|
|
15173
|
+
for (const part of parts.slice(0, -1)) {
|
|
15174
|
+
const next = cursor[part];
|
|
15175
|
+
if (!next || typeof next !== "object")
|
|
15176
|
+
return;
|
|
15177
|
+
cursor = next;
|
|
15178
|
+
}
|
|
15179
|
+
const last = parts.at(-1);
|
|
15180
|
+
if (last && last in cursor)
|
|
15181
|
+
cursor[last] = replacement;
|
|
15182
|
+
}
|
|
14955
15183
|
function createEvent(input) {
|
|
14956
15184
|
return {
|
|
14957
15185
|
id: input.id ?? randomUUID22(),
|
|
@@ -14972,10 +15200,18 @@ class EventsClient {
|
|
|
14972
15200
|
store;
|
|
14973
15201
|
redactors;
|
|
14974
15202
|
transportOptions;
|
|
15203
|
+
catalog;
|
|
15204
|
+
validateCatalogTypes;
|
|
14975
15205
|
constructor(options = {}) {
|
|
14976
15206
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
14977
15207
|
this.redactors = options.redactors ?? [];
|
|
14978
|
-
this.transportOptions = {
|
|
15208
|
+
this.transportOptions = {
|
|
15209
|
+
fetchImpl: options.fetchImpl,
|
|
15210
|
+
secretResolver: options.secretResolver,
|
|
15211
|
+
now: options.now
|
|
15212
|
+
};
|
|
15213
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
15214
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
14979
15215
|
}
|
|
14980
15216
|
async addChannel(input) {
|
|
14981
15217
|
const timestamp2 = new Date().toISOString();
|
|
@@ -14993,18 +15229,40 @@ class EventsClient {
|
|
|
14993
15229
|
}
|
|
14994
15230
|
async emit(input, options = {}) {
|
|
14995
15231
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
14996
|
-
if (options.
|
|
14997
|
-
|
|
14998
|
-
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
15004
|
-
return { event, deliveries, deduped: false };
|
|
15005
|
-
}
|
|
15006
|
-
async listEvents() {
|
|
15007
|
-
|
|
15232
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
15233
|
+
this.catalog.assertEventValid(event);
|
|
15234
|
+
}
|
|
15235
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
15236
|
+
if (append.deduped) {
|
|
15237
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
15238
|
+
}
|
|
15239
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
15240
|
+
return { event: append.event, deliveries, deduped: false };
|
|
15241
|
+
}
|
|
15242
|
+
async listEvents(options = {}) {
|
|
15243
|
+
if (Object.keys(options).length === 0)
|
|
15244
|
+
return this.store.listEvents();
|
|
15245
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
15246
|
+
}
|
|
15247
|
+
async listEventsPage(options = {}) {
|
|
15248
|
+
if (this.store.listEventsPage)
|
|
15249
|
+
return this.store.listEventsPage(options);
|
|
15250
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
15251
|
+
eventId: options.eventId,
|
|
15252
|
+
source: options.source,
|
|
15253
|
+
type: options.type
|
|
15254
|
+
});
|
|
15255
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
15256
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
15257
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
15258
|
+
const nextOffset = offset + pageEvents.length;
|
|
15259
|
+
const hasMore = nextOffset < events.length;
|
|
15260
|
+
return {
|
|
15261
|
+
events: pageEvents,
|
|
15262
|
+
cursor: options.cursor,
|
|
15263
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
15264
|
+
hasMore
|
|
15265
|
+
};
|
|
15008
15266
|
}
|
|
15009
15267
|
async listDeliveries() {
|
|
15010
15268
|
return this.store.listDeliveries();
|
|
@@ -15072,22 +15330,37 @@ class EventsClient {
|
|
|
15072
15330
|
return result;
|
|
15073
15331
|
}
|
|
15074
15332
|
async replay(options = {}) {
|
|
15075
|
-
const
|
|
15076
|
-
if (options.eventId && event.id !== options.eventId)
|
|
15077
|
-
return false;
|
|
15078
|
-
if (options.source && event.source !== options.source)
|
|
15079
|
-
return false;
|
|
15080
|
-
if (options.type && event.type !== options.type)
|
|
15081
|
-
return false;
|
|
15082
|
-
return true;
|
|
15083
|
-
});
|
|
15333
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
15084
15334
|
if (options.dryRun)
|
|
15085
|
-
return { events, deliveries: [] };
|
|
15335
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
15086
15336
|
const deliveries = [];
|
|
15087
|
-
for (const event of events) {
|
|
15337
|
+
for (const event of page.events) {
|
|
15088
15338
|
deliveries.push(...await this.deliver(event));
|
|
15089
15339
|
}
|
|
15090
|
-
return { events, deliveries };
|
|
15340
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
15341
|
+
}
|
|
15342
|
+
async appendEvent(event, options) {
|
|
15343
|
+
if (this.store.appendEventOnce) {
|
|
15344
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
15345
|
+
}
|
|
15346
|
+
if (options.dedupe) {
|
|
15347
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
15348
|
+
if (existing) {
|
|
15349
|
+
return {
|
|
15350
|
+
event: existing,
|
|
15351
|
+
stored: false,
|
|
15352
|
+
deduped: true,
|
|
15353
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
15354
|
+
};
|
|
15355
|
+
}
|
|
15356
|
+
}
|
|
15357
|
+
const stored = await this.store.appendEvent(event);
|
|
15358
|
+
return {
|
|
15359
|
+
event: stored,
|
|
15360
|
+
stored: true,
|
|
15361
|
+
deduped: false,
|
|
15362
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
15363
|
+
};
|
|
15091
15364
|
}
|
|
15092
15365
|
async applyRedaction(event, channel) {
|
|
15093
15366
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -15114,43 +15387,19 @@ class EventsClient {
|
|
|
15114
15387
|
return createDeliveryResult(event, channel, attempts);
|
|
15115
15388
|
}
|
|
15116
15389
|
}
|
|
15117
|
-
function
|
|
15118
|
-
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
15122
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
15126
|
-
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
|
|
15130
|
-
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
15131
|
-
}
|
|
15132
|
-
function redactValue2(value, replacement) {
|
|
15133
|
-
if (Array.isArray(value))
|
|
15134
|
-
return value.map((item) => redactValue2(item, replacement));
|
|
15135
|
-
if (!value || typeof value !== "object")
|
|
15136
|
-
return value;
|
|
15137
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
15138
|
-
key,
|
|
15139
|
-
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
15140
|
-
]));
|
|
15141
|
-
}
|
|
15142
|
-
function setPath(input, path, replacement) {
|
|
15143
|
-
const parts = path.split(".");
|
|
15144
|
-
let cursor = input;
|
|
15145
|
-
for (const part of parts.slice(0, -1)) {
|
|
15146
|
-
const next = cursor[part];
|
|
15147
|
-
if (!next || typeof next !== "object")
|
|
15148
|
-
return;
|
|
15149
|
-
cursor = next;
|
|
15150
|
-
}
|
|
15151
|
-
const last = parts.at(-1);
|
|
15152
|
-
if (last && last in cursor)
|
|
15153
|
-
cursor[last] = replacement;
|
|
15390
|
+
function queryClientEvents(events, options) {
|
|
15391
|
+
let rows = events;
|
|
15392
|
+
if (options.eventId)
|
|
15393
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
15394
|
+
if (options.source)
|
|
15395
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
15396
|
+
if (options.type)
|
|
15397
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
15398
|
+
if (options.cursor)
|
|
15399
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
15400
|
+
if (options.limit !== undefined)
|
|
15401
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
15402
|
+
return rows;
|
|
15154
15403
|
}
|
|
15155
15404
|
function normalizeTime(value) {
|
|
15156
15405
|
if (!value)
|
|
@@ -15164,9 +15413,22 @@ function normalizeRetryPolicy(policy) {
|
|
|
15164
15413
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
15165
15414
|
};
|
|
15166
15415
|
}
|
|
15167
|
-
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
15416
|
+
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;
|
|
15168
15417
|
var init_dist = __esm(() => {
|
|
15169
15418
|
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
15419
|
+
EventValidationError = class EventValidationError extends Error {
|
|
15420
|
+
eventType;
|
|
15421
|
+
issues;
|
|
15422
|
+
constructor(eventType, issues) {
|
|
15423
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
15424
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
15425
|
+
this.name = "EventValidationError";
|
|
15426
|
+
this.eventType = eventType;
|
|
15427
|
+
this.issues = issues;
|
|
15428
|
+
}
|
|
15429
|
+
};
|
|
15430
|
+
defaultEventTypeCatalog = new EventTypeCatalog;
|
|
15431
|
+
APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
15170
15432
|
});
|
|
15171
15433
|
|
|
15172
15434
|
// src/db/task-lists.ts
|
|
@@ -16221,8 +16483,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
|
|
|
16221
16483
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
16222
16484
|
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
16223
16485
|
}
|
|
16224
|
-
const resp = await fetch(wh.url, { method: "POST", headers, body });
|
|
16486
|
+
const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
|
|
16225
16487
|
const respText = await resp.text().catch(() => "");
|
|
16488
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
16489
|
+
logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
|
|
16490
|
+
return;
|
|
16491
|
+
}
|
|
16226
16492
|
logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
|
|
16227
16493
|
if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
|
|
16228
16494
|
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -18051,6 +18317,12 @@ function listTasks(filter = {}, db) {
|
|
|
18051
18317
|
const d = db || getDatabase();
|
|
18052
18318
|
const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
|
|
18053
18319
|
clearExpiredLocks2(d);
|
|
18320
|
+
if (filter.limit !== undefined) {
|
|
18321
|
+
const limit = filter.limit;
|
|
18322
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
|
|
18323
|
+
throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
|
|
18324
|
+
}
|
|
18325
|
+
}
|
|
18054
18326
|
const conditions = [];
|
|
18055
18327
|
const params = [];
|
|
18056
18328
|
if (filter.project_id) {
|
|
@@ -18584,6 +18856,7 @@ function deleteTask(id, db) {
|
|
|
18584
18856
|
}
|
|
18585
18857
|
return result.changes > 0;
|
|
18586
18858
|
}
|
|
18859
|
+
var MAX_TASK_LIST_LIMIT = 1e5;
|
|
18587
18860
|
var init_task_crud = __esm(() => {
|
|
18588
18861
|
init_types();
|
|
18589
18862
|
init_database();
|
|
@@ -21535,7 +21808,8 @@ __export(exports_tasks, {
|
|
|
21535
21808
|
buildTaskBoardSnapshot: () => buildTaskBoardSnapshot,
|
|
21536
21809
|
archiveTasks: () => archiveTasks,
|
|
21537
21810
|
archiveCompletedTasks: () => archiveCompletedTasks,
|
|
21538
|
-
addDependency: () => addDependency
|
|
21811
|
+
addDependency: () => addDependency,
|
|
21812
|
+
MAX_TASK_LIST_LIMIT: () => MAX_TASK_LIST_LIMIT
|
|
21539
21813
|
});
|
|
21540
21814
|
var init_tasks = __esm(() => {
|
|
21541
21815
|
init_task_crud();
|
|
@@ -31975,6 +32249,27 @@ var init_project_commands = __esm(() => {
|
|
|
31975
32249
|
};
|
|
31976
32250
|
});
|
|
31977
32251
|
|
|
32252
|
+
// src/storage/postgres-errors.ts
|
|
32253
|
+
function isPostgresUniqueViolation(error) {
|
|
32254
|
+
if (typeof error !== "object" || error === null)
|
|
32255
|
+
return false;
|
|
32256
|
+
const candidate = error;
|
|
32257
|
+
const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
|
|
32258
|
+
if (typeof candidate.cause === "object" && candidate.cause !== null) {
|
|
32259
|
+
const cause = candidate.cause;
|
|
32260
|
+
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
32261
|
+
}
|
|
32262
|
+
return states.some((state) => String(state) === "23505");
|
|
32263
|
+
}
|
|
32264
|
+
function postgresConstraintName(error) {
|
|
32265
|
+
if (typeof error !== "object" || error === null)
|
|
32266
|
+
return "";
|
|
32267
|
+
const candidate = error;
|
|
32268
|
+
const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
|
|
32269
|
+
const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
|
|
32270
|
+
return typeof constraint === "string" ? constraint : "";
|
|
32271
|
+
}
|
|
32272
|
+
|
|
31978
32273
|
// src/storage/postgres-sync.ts
|
|
31979
32274
|
function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
|
|
31980
32275
|
assertSafeIdentifier(tableName);
|
|
@@ -32169,53 +32464,98 @@ class PostgresTodosSyncStore {
|
|
|
32169
32464
|
if (routingErrors.length > 0) {
|
|
32170
32465
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
32171
32466
|
}
|
|
32172
|
-
const
|
|
32173
|
-
|
|
32174
|
-
|
|
32175
|
-
|
|
32176
|
-
|
|
32177
|
-
|
|
32178
|
-
const
|
|
32179
|
-
|
|
32180
|
-
|
|
32181
|
-
|
|
32182
|
-
|
|
32183
|
-
|
|
32184
|
-
|
|
32185
|
-
|
|
32186
|
-
|
|
32187
|
-
|
|
32188
|
-
|
|
32189
|
-
|
|
32190
|
-
|
|
32191
|
-
|
|
32192
|
-
|
|
32193
|
-
|
|
32194
|
-
|
|
32195
|
-
|
|
32196
|
-
|
|
32197
|
-
|
|
32198
|
-
|
|
32199
|
-
|
|
32200
|
-
|
|
32201
|
-
|
|
32202
|
-
|
|
32203
|
-
|
|
32204
|
-
|
|
32205
|
-
|
|
32206
|
-
|
|
32207
|
-
|
|
32208
|
-
|
|
32209
|
-
|
|
32210
|
-
|
|
32211
|
-
|
|
32212
|
-
|
|
32213
|
-
|
|
32214
|
-
|
|
32215
|
-
|
|
32216
|
-
|
|
32467
|
+
const push = async (client) => {
|
|
32468
|
+
const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
32469
|
+
FROM ${this.tableName}
|
|
32470
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
32471
|
+
const existingProjects = [];
|
|
32472
|
+
const existingTaskLists = [];
|
|
32473
|
+
for (const row of existing.rows) {
|
|
32474
|
+
const payload = payloadRecord(row.payload);
|
|
32475
|
+
if (row.object_type === "projects")
|
|
32476
|
+
existingProjects.push(payload);
|
|
32477
|
+
if (row.object_type === "task_lists")
|
|
32478
|
+
existingTaskLists.push(payload);
|
|
32479
|
+
}
|
|
32480
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
32481
|
+
if (destinationErrors.length > 0) {
|
|
32482
|
+
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
32483
|
+
}
|
|
32484
|
+
const result = { records: 0, objectTypes: {} };
|
|
32485
|
+
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
32486
|
+
for (const entry of snapshotEntries(snapshot)) {
|
|
32487
|
+
if (entry.deletedAt === null)
|
|
32488
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
32489
|
+
try {
|
|
32490
|
+
await client.query(`INSERT INTO ${this.tableName} (
|
|
32491
|
+
service, object_type, object_id, payload, updated_at,
|
|
32492
|
+
deleted_at, source_machine_id, version
|
|
32493
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
|
|
32494
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
32495
|
+
payload = EXCLUDED.payload,
|
|
32496
|
+
updated_at = EXCLUDED.updated_at,
|
|
32497
|
+
deleted_at = EXCLUDED.deleted_at,
|
|
32498
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
32499
|
+
version = EXCLUDED.version
|
|
32500
|
+
WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
32501
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
32502
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
|
|
32503
|
+
this.service,
|
|
32504
|
+
entry.type,
|
|
32505
|
+
entry.id,
|
|
32506
|
+
entry.payload,
|
|
32507
|
+
entry.updatedAt,
|
|
32508
|
+
entry.deletedAt,
|
|
32509
|
+
sourceMachineId,
|
|
32510
|
+
entry.version
|
|
32511
|
+
]);
|
|
32512
|
+
} catch (error) {
|
|
32513
|
+
await this.classifySyncInsertConflict(error, entry);
|
|
32514
|
+
}
|
|
32515
|
+
result.records += 1;
|
|
32516
|
+
result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
|
|
32517
|
+
}
|
|
32518
|
+
return result;
|
|
32519
|
+
};
|
|
32520
|
+
if (typeof this.client.transaction === "function") {
|
|
32521
|
+
return this.client.transaction((client) => push(client));
|
|
32217
32522
|
}
|
|
32218
|
-
return
|
|
32523
|
+
return push(this.client);
|
|
32524
|
+
}
|
|
32525
|
+
async classifySyncInsertConflict(error, entry) {
|
|
32526
|
+
if (!isPostgresUniqueViolation(error))
|
|
32527
|
+
throw error;
|
|
32528
|
+
const payload = entry.payload;
|
|
32529
|
+
const taskListSlug = String(payload["slug"] ?? "");
|
|
32530
|
+
const projectSlug = String(payload["task_list_id"] ?? "");
|
|
32531
|
+
const constraintName = postgresConstraintName(error);
|
|
32532
|
+
if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
|
|
32533
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
32534
|
+
}
|
|
32535
|
+
if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
|
|
32536
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
32537
|
+
}
|
|
32538
|
+
if (entry.type === "task_lists") {
|
|
32539
|
+
const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
|
|
32540
|
+
const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
|
|
32541
|
+
SELECT 1 FROM ${this.tableName}
|
|
32542
|
+
WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
|
|
32543
|
+
AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
|
|
32544
|
+
) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
|
|
32545
|
+
if (conflict.rows[0]?.conflict) {
|
|
32546
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
32547
|
+
}
|
|
32548
|
+
} else if (entry.type === "projects") {
|
|
32549
|
+
const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
|
|
32550
|
+
SELECT 1 FROM ${this.tableName}
|
|
32551
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
32552
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
32553
|
+
) AS conflict`, [this.service, entry.id, projectSlug]);
|
|
32554
|
+
if (conflict.rows[0]?.conflict) {
|
|
32555
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
32556
|
+
}
|
|
32557
|
+
}
|
|
32558
|
+
throw error;
|
|
32219
32559
|
}
|
|
32220
32560
|
async pullSnapshot(options = {}) {
|
|
32221
32561
|
const params = [this.service];
|
|
@@ -32372,6 +32712,7 @@ function assertSafeIdentifier(value) {
|
|
|
32372
32712
|
}
|
|
32373
32713
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
|
|
32374
32714
|
var init_postgres_sync = __esm(() => {
|
|
32715
|
+
init_types();
|
|
32375
32716
|
PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
|
|
32376
32717
|
conflicts;
|
|
32377
32718
|
constructor(conflicts) {
|
|
@@ -32599,9 +32940,11 @@ class PostgresJsonRecordStore {
|
|
|
32599
32940
|
async ensureSchema() {
|
|
32600
32941
|
if (!this.schemaReady) {
|
|
32601
32942
|
this.schemaReady = (async () => {
|
|
32602
|
-
|
|
32603
|
-
|
|
32604
|
-
|
|
32943
|
+
await retryOnTransientPostgresError(async () => {
|
|
32944
|
+
for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
|
|
32945
|
+
await this.options.client.query(sql);
|
|
32946
|
+
}
|
|
32947
|
+
});
|
|
32605
32948
|
})().catch((error) => {
|
|
32606
32949
|
this.schemaReady = null;
|
|
32607
32950
|
throw error;
|
|
@@ -33015,13 +33358,14 @@ class PostgresJsonRecordStore {
|
|
|
33015
33358
|
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
33016
33359
|
}
|
|
33017
33360
|
async withTaskParentIntegrityTransaction(fn) {
|
|
33018
|
-
|
|
33361
|
+
const transaction = this.options.client.transaction;
|
|
33362
|
+
if (typeof transaction !== "function") {
|
|
33019
33363
|
throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
|
|
33020
33364
|
}
|
|
33021
|
-
return
|
|
33365
|
+
return retryOnTransientPostgresError(() => transaction(async (client) => {
|
|
33022
33366
|
await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
|
|
33023
33367
|
return fn(client);
|
|
33024
|
-
});
|
|
33368
|
+
}));
|
|
33025
33369
|
}
|
|
33026
33370
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
|
|
33027
33371
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
@@ -34904,7 +35248,7 @@ function compareClock(left, right) {
|
|
|
34904
35248
|
function numberValue2(value) {
|
|
34905
35249
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
34906
35250
|
}
|
|
34907
|
-
function
|
|
35251
|
+
function isTransientPostgresError(error) {
|
|
34908
35252
|
if (typeof error !== "object" || error === null)
|
|
34909
35253
|
return false;
|
|
34910
35254
|
const candidate = error;
|
|
@@ -34913,17 +35257,27 @@ function isPostgresUniqueViolation(error) {
|
|
|
34913
35257
|
const cause = candidate.cause;
|
|
34914
35258
|
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
34915
35259
|
}
|
|
34916
|
-
|
|
35260
|
+
if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
|
|
35261
|
+
return true;
|
|
35262
|
+
}
|
|
35263
|
+
const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
|
|
35264
|
+
return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
|
|
34917
35265
|
}
|
|
34918
|
-
function
|
|
34919
|
-
|
|
34920
|
-
|
|
34921
|
-
|
|
34922
|
-
|
|
34923
|
-
|
|
34924
|
-
|
|
35266
|
+
async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
35267
|
+
let lastError;
|
|
35268
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
35269
|
+
try {
|
|
35270
|
+
return await fn();
|
|
35271
|
+
} catch (error) {
|
|
35272
|
+
lastError = error;
|
|
35273
|
+
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
35274
|
+
throw error;
|
|
35275
|
+
await new Promise((resolve14) => setTimeout(resolve14, delayMs * attempt));
|
|
35276
|
+
}
|
|
35277
|
+
}
|
|
35278
|
+
throw lastError;
|
|
34925
35279
|
}
|
|
34926
|
-
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;
|
|
35280
|
+
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;
|
|
34927
35281
|
var init_postgres_adapter = __esm(() => {
|
|
34928
35282
|
init_types();
|
|
34929
35283
|
init_creator_identity();
|
|
@@ -34937,6 +35291,12 @@ var init_postgres_adapter = __esm(() => {
|
|
|
34937
35291
|
init_audit_history_import();
|
|
34938
35292
|
init_canonical();
|
|
34939
35293
|
TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
|
|
35294
|
+
TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
|
|
35295
|
+
TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
|
|
35296
|
+
"canceling statement due to lock timeout",
|
|
35297
|
+
"deadlock detected",
|
|
35298
|
+
"could not serialize access"
|
|
35299
|
+
];
|
|
34940
35300
|
});
|
|
34941
35301
|
|
|
34942
35302
|
// src/project-registration/postgres.ts
|
|
@@ -51893,6 +52253,13 @@ function resolveSigningSecret(env = process.env) {
|
|
|
51893
52253
|
function isPostgresBackendConfigured(env = process.env) {
|
|
51894
52254
|
return Boolean(resolveCloudDatabaseUrl(env));
|
|
51895
52255
|
}
|
|
52256
|
+
function schemaRetryMinIntervalMs(env = process.env) {
|
|
52257
|
+
const raw = env.HASNA_TODOS_SCHEMA_RETRY_MIN_MS;
|
|
52258
|
+
if (!raw)
|
|
52259
|
+
return DEFAULT_SCHEMA_RETRY_MIN_MS;
|
|
52260
|
+
const parsed = Number(raw);
|
|
52261
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_SCHEMA_RETRY_MIN_MS;
|
|
52262
|
+
}
|
|
51896
52263
|
function getCloudTenantId() {
|
|
51897
52264
|
return process.env.HASNA_TODOS_TENANT_ID ?? "default";
|
|
51898
52265
|
}
|
|
@@ -51988,6 +52355,10 @@ function getCloudVerifier() {
|
|
|
51988
52355
|
async function ensureCloudSchema() {
|
|
51989
52356
|
if (schemaEnsured)
|
|
51990
52357
|
return schemaEnsured;
|
|
52358
|
+
if (lastSchemaFailure !== null && Date.now() - lastSchemaAttemptAtMs < schemaRetryMinIntervalMs()) {
|
|
52359
|
+
throw lastSchemaFailure;
|
|
52360
|
+
}
|
|
52361
|
+
lastSchemaAttemptAtMs = Date.now();
|
|
51991
52362
|
schemaEnsured = (async () => {
|
|
51992
52363
|
const client = getClient();
|
|
51993
52364
|
for (const sql of postgresTodosSyncSchemaSql()) {
|
|
@@ -52008,6 +52379,7 @@ async function ensureCloudSchema() {
|
|
|
52008
52379
|
await getApiKeyStore().ensureSchema();
|
|
52009
52380
|
})().catch((error) => {
|
|
52010
52381
|
schemaEnsured = null;
|
|
52382
|
+
lastSchemaFailure = error;
|
|
52011
52383
|
throw error;
|
|
52012
52384
|
});
|
|
52013
52385
|
return schemaEnsured;
|
|
@@ -52055,8 +52427,10 @@ async function closeCloud() {
|
|
|
52055
52427
|
cachedTaskManifestAuthority = null;
|
|
52056
52428
|
cachedTaskSubtreeTransferAuthority = null;
|
|
52057
52429
|
schemaEnsured = null;
|
|
52430
|
+
lastSchemaAttemptAtMs = 0;
|
|
52431
|
+
lastSchemaFailure = null;
|
|
52058
52432
|
}
|
|
52059
|
-
var TODOS_APP_SLUG = "todos", cachedClient = null, cachedAdapter = null, cachedStore = null, cachedVerifier = null, cachedPrGroupLedger = null, cachedProjectRegistrationAuthority = null, cachedTaskManifestAuthority = null, cachedTaskSubtreeTransferAuthority = null, schemaEnsured = null;
|
|
52433
|
+
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;
|
|
52060
52434
|
var init_cloud = __esm(() => {
|
|
52061
52435
|
init_cloud_client();
|
|
52062
52436
|
init_postgres_adapter();
|
|
@@ -52922,6 +53296,19 @@ function taskStatusQueryParam(url) {
|
|
|
52922
53296
|
return { ok: false, message: result.message };
|
|
52923
53297
|
return { ok: true, value: collapseEnumValues(result.values) };
|
|
52924
53298
|
}
|
|
53299
|
+
function parsePaginationQueryParam(url, name) {
|
|
53300
|
+
const raw = url.searchParams.get(name);
|
|
53301
|
+
if (raw === null)
|
|
53302
|
+
return { ok: true, value: undefined };
|
|
53303
|
+
const min = name === "limit" ? 1 : 0;
|
|
53304
|
+
const message = name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer";
|
|
53305
|
+
if (!/^\d+$/.test(raw))
|
|
53306
|
+
return { ok: false, message };
|
|
53307
|
+
const value = Number(raw);
|
|
53308
|
+
if (!Number.isSafeInteger(value) || value < min)
|
|
53309
|
+
return { ok: false, message };
|
|
53310
|
+
return { ok: true, value };
|
|
53311
|
+
}
|
|
52925
53312
|
async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
|
|
52926
53313
|
const statusParam = taskStatusQueryParam(url);
|
|
52927
53314
|
if (!statusParam.ok)
|
|
@@ -52929,16 +53316,20 @@ async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
|
|
|
52929
53316
|
const projectId = url.searchParams.get("project_id") || undefined;
|
|
52930
53317
|
const sessionId = url.searchParams.get("session_id") || undefined;
|
|
52931
53318
|
const agentId = url.searchParams.get("agent_id") || undefined;
|
|
52932
|
-
const limitParam = url
|
|
52933
|
-
|
|
53319
|
+
const limitParam = parsePaginationQueryParam(url, "limit");
|
|
53320
|
+
if (!limitParam.ok)
|
|
53321
|
+
return json5({ error: limitParam.message }, 400);
|
|
53322
|
+
const offsetParam = parsePaginationQueryParam(url, "offset");
|
|
53323
|
+
if (!offsetParam.ok)
|
|
53324
|
+
return json5({ error: offsetParam.message }, 400);
|
|
52934
53325
|
const fields = parseFieldsParam(url);
|
|
52935
53326
|
const tasks = listTasks({
|
|
52936
53327
|
status: statusParam.value,
|
|
52937
53328
|
project_id: projectId,
|
|
52938
53329
|
session_id: sessionId,
|
|
52939
53330
|
agent_id: agentId,
|
|
52940
|
-
limit: limitParam
|
|
52941
|
-
offset: offsetParam
|
|
53331
|
+
limit: limitParam.value,
|
|
53332
|
+
offset: offsetParam.value
|
|
52942
53333
|
});
|
|
52943
53334
|
return json5(tasks.map((t) => taskToSummary2(t, fields)));
|
|
52944
53335
|
}
|
|
@@ -53730,6 +54121,12 @@ class TodosShadowOutbox {
|
|
|
53730
54121
|
this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
|
|
53731
54122
|
} catch (error) {
|
|
53732
54123
|
const message = error instanceof Error ? error.message : String(error);
|
|
54124
|
+
if (error instanceof ResourceConflictError) {
|
|
54125
|
+
this.metrics.lastError = message;
|
|
54126
|
+
this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
|
|
54127
|
+
this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
|
|
54128
|
+
return;
|
|
54129
|
+
}
|
|
53733
54130
|
this.metrics.retries += 1;
|
|
53734
54131
|
this.metrics.lastError = message;
|
|
53735
54132
|
const attempts = row.attempts + 1;
|
|
@@ -53857,6 +54254,7 @@ function createTodosShadowOutbox(options) {
|
|
|
53857
54254
|
}
|
|
53858
54255
|
var MAX_BACKOFF_MS;
|
|
53859
54256
|
var init_shadow_outbox = __esm(() => {
|
|
54257
|
+
init_types();
|
|
53860
54258
|
init_local_sqlite();
|
|
53861
54259
|
init_postgres_sync();
|
|
53862
54260
|
init_shadow_outbox_schema();
|
|
@@ -57831,6 +58229,26 @@ function parseSinceCursor(raw) {
|
|
|
57831
58229
|
}
|
|
57832
58230
|
return { ok: true, value: new Date(parsed).toISOString() };
|
|
57833
58231
|
}
|
|
58232
|
+
function paginationQueryParam(url, name) {
|
|
58233
|
+
const raw = url.searchParams.get(name);
|
|
58234
|
+
if (raw === null)
|
|
58235
|
+
return { ok: true, value: undefined };
|
|
58236
|
+
const min = name === "limit" ? 1 : 0;
|
|
58237
|
+
if (!/^\d+$/.test(raw)) {
|
|
58238
|
+
return {
|
|
58239
|
+
ok: false,
|
|
58240
|
+
response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
|
|
58241
|
+
};
|
|
58242
|
+
}
|
|
58243
|
+
const value = Number(raw);
|
|
58244
|
+
if (!Number.isSafeInteger(value) || value < min) {
|
|
58245
|
+
return {
|
|
58246
|
+
ok: false,
|
|
58247
|
+
response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
|
|
58248
|
+
};
|
|
58249
|
+
}
|
|
58250
|
+
return { ok: true, value };
|
|
58251
|
+
}
|
|
57834
58252
|
function validateTaskCompletion(value) {
|
|
57835
58253
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
57836
58254
|
return { ok: false, message: "completion body must be an object" };
|
|
@@ -58359,6 +58777,12 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
58359
58777
|
if (updatedAfter !== null && !updatedAfter.ok) {
|
|
58360
58778
|
return error(400, updatedAfter.message);
|
|
58361
58779
|
}
|
|
58780
|
+
const limitParam = paginationQueryParam(url, "limit");
|
|
58781
|
+
if (!limitParam.ok)
|
|
58782
|
+
return limitParam.response;
|
|
58783
|
+
const offsetParam = paginationQueryParam(url, "offset");
|
|
58784
|
+
if (!offsetParam.ok)
|
|
58785
|
+
return offsetParam.response;
|
|
58362
58786
|
const filter = {
|
|
58363
58787
|
...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
|
|
58364
58788
|
...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
|
|
@@ -58375,8 +58799,8 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
58375
58799
|
...url.searchParams.get("tags") ? {
|
|
58376
58800
|
tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
|
|
58377
58801
|
} : {},
|
|
58378
|
-
...
|
|
58379
|
-
...
|
|
58802
|
+
...limitParam.value !== undefined ? { limit: limitParam.value } : {},
|
|
58803
|
+
...offsetParam.value !== undefined ? { offset: offsetParam.value } : {}
|
|
58380
58804
|
};
|
|
58381
58805
|
const tasks = await store.tasks.list(filter);
|
|
58382
58806
|
const { limit: _l, offset: _o, ...countFilter } = filter;
|
|
@@ -60136,8 +60560,8 @@ function registerTaskCrudTools(server, ctx) {
|
|
|
60136
60560
|
tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (AND logic)"),
|
|
60137
60561
|
created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
|
|
60138
60562
|
created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
|
|
60139
|
-
limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
|
|
60140
|
-
offset: exports_external.number().optional().describe("Pagination offset"),
|
|
60563
|
+
limit: exports_external.number().int().min(1).optional().describe("Max results (default: 50, max 500)"),
|
|
60564
|
+
offset: exports_external.number().int().min(0).optional().describe("Pagination offset"),
|
|
60141
60565
|
metadata: exports_external.record(exports_external.unknown()).optional().describe("Exact top-level metadata filters")
|
|
60142
60566
|
}, async (params) => {
|
|
60143
60567
|
try {
|
|
@@ -62099,6 +62523,48 @@ var init_task_relationships = __esm(() => {
|
|
|
62099
62523
|
];
|
|
62100
62524
|
});
|
|
62101
62525
|
|
|
62526
|
+
// src/lib/dedupe-projection.ts
|
|
62527
|
+
function projectTasksForDedupe(tasks) {
|
|
62528
|
+
return tasks.map((task2) => {
|
|
62529
|
+
const metadata = {};
|
|
62530
|
+
for (const key2 of DEDUPE_SOURCE_KEY_ALLOWLIST) {
|
|
62531
|
+
if (key2 in task2.metadata) {
|
|
62532
|
+
metadata[key2] = redactValue(task2.metadata[key2]);
|
|
62533
|
+
}
|
|
62534
|
+
}
|
|
62535
|
+
return {
|
|
62536
|
+
id: task2.id,
|
|
62537
|
+
short_id: task2.short_id,
|
|
62538
|
+
title: redactEvidenceText(task2.title),
|
|
62539
|
+
description: task2.description === null || task2.description === undefined ? null : redactEvidenceText(task2.description),
|
|
62540
|
+
status: task2.status,
|
|
62541
|
+
created_at: task2.created_at,
|
|
62542
|
+
updated_at: task2.updated_at,
|
|
62543
|
+
project_id: task2.project_id,
|
|
62544
|
+
task_list_id: task2.task_list_id,
|
|
62545
|
+
assigned_to: task2.assigned_to,
|
|
62546
|
+
priority: task2.priority,
|
|
62547
|
+
metadata
|
|
62548
|
+
};
|
|
62549
|
+
});
|
|
62550
|
+
}
|
|
62551
|
+
var DEDUPE_SOURCE_KEY_ALLOWLIST;
|
|
62552
|
+
var init_dedupe_projection = __esm(() => {
|
|
62553
|
+
init_redaction();
|
|
62554
|
+
DEDUPE_SOURCE_KEY_ALLOWLIST = [
|
|
62555
|
+
"github_url",
|
|
62556
|
+
"github_issue_url",
|
|
62557
|
+
"github_pr_url",
|
|
62558
|
+
"source_url",
|
|
62559
|
+
"url",
|
|
62560
|
+
"external_url",
|
|
62561
|
+
"issue_url",
|
|
62562
|
+
"github_owner",
|
|
62563
|
+
"github_repo",
|
|
62564
|
+
"github_number"
|
|
62565
|
+
];
|
|
62566
|
+
});
|
|
62567
|
+
|
|
62102
62568
|
// src/lib/task-dedupe.ts
|
|
62103
62569
|
function asObject(value) {
|
|
62104
62570
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -62263,10 +62729,10 @@ function olderFirst(left, right) {
|
|
|
62263
62729
|
function findDuplicateTasks(options = {}, db) {
|
|
62264
62730
|
const d = db || getDatabase();
|
|
62265
62731
|
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
62266
|
-
const fingerprints = listTasks({
|
|
62732
|
+
const fingerprints = projectTasksForDedupe(listTasks({
|
|
62267
62733
|
include_archived: Boolean(options.include_archived),
|
|
62268
62734
|
limit: options.limit ?? 1000
|
|
62269
|
-
}, d).map(fingerprint);
|
|
62735
|
+
}, d)).map(fingerprint);
|
|
62270
62736
|
const candidates = [];
|
|
62271
62737
|
for (let i = 0;i < fingerprints.length; i++) {
|
|
62272
62738
|
for (let j = i + 1;j < fingerprints.length; j++) {
|
|
@@ -62501,6 +62967,7 @@ var init_task_dedupe = __esm(() => {
|
|
|
62501
62967
|
init_database();
|
|
62502
62968
|
init_task_relationships();
|
|
62503
62969
|
init_tasks();
|
|
62970
|
+
init_dedupe_projection();
|
|
62504
62971
|
STOP_WORDS = new Set([
|
|
62505
62972
|
"a",
|
|
62506
62973
|
"an",
|
|
@@ -86972,13 +87439,25 @@ Findings`));
|
|
|
86972
87439
|
handleError(e);
|
|
86973
87440
|
}
|
|
86974
87441
|
});
|
|
87442
|
+
function parseDedupeLimit(raw) {
|
|
87443
|
+
const value = Number(raw);
|
|
87444
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TASK_LIST_LIMIT) {
|
|
87445
|
+
return null;
|
|
87446
|
+
}
|
|
87447
|
+
return value;
|
|
87448
|
+
}
|
|
86975
87449
|
const dedupe2 = program2.command("dedupe").description("Find and merge likely duplicate local tasks");
|
|
86976
87450
|
dedupe2.command("scan").description("Scan local tasks for likely duplicates").option("--threshold <n>", "Minimum duplicate score from 0 to 1", "0.74").option("--limit <n>", "Maximum tasks to compare", "1000").option("--include-archived", "Include archived tasks").option("-j, --json", "Output as JSON").action((opts) => {
|
|
86977
87451
|
const globalOpts = program2.opts();
|
|
86978
87452
|
try {
|
|
87453
|
+
const limit = parseDedupeLimit(opts.limit);
|
|
87454
|
+
if (limit === null) {
|
|
87455
|
+
handleError(new Error(`--limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT} (got: ${opts.limit})`));
|
|
87456
|
+
return;
|
|
87457
|
+
}
|
|
86979
87458
|
const candidates = findDuplicateTasks({
|
|
86980
87459
|
threshold: Number(opts.threshold),
|
|
86981
|
-
limit
|
|
87460
|
+
limit,
|
|
86982
87461
|
include_archived: Boolean(opts.includeArchived)
|
|
86983
87462
|
});
|
|
86984
87463
|
if (opts.json || globalOpts.json) {
|
|
@@ -86998,6 +87477,35 @@ Findings`));
|
|
|
86998
87477
|
handleError(e);
|
|
86999
87478
|
}
|
|
87000
87479
|
});
|
|
87480
|
+
dedupe2.command("project <ref>").description("Project a project's tasks into the bounded dedup projection (safe to emit: no free-form metadata, tags, comments, or run fields)").option("--limit <n>", "Maximum tasks to project", "1000").option("--include-archived", "Include archived tasks").option("-j, --json", "Output as JSON").action((ref, opts) => {
|
|
87481
|
+
const globalOpts = program2.opts();
|
|
87482
|
+
try {
|
|
87483
|
+
const d = getDatabase();
|
|
87484
|
+
const projectId = resolvePartialId(d, "projects", ref) || d.query("SELECT id FROM projects WHERE path = ? OR name = ? OR task_list_id = ?").get(ref, ref, ref)?.id;
|
|
87485
|
+
if (!projectId) {
|
|
87486
|
+
handleError(new Error(`Could not resolve project: ${ref}`));
|
|
87487
|
+
return;
|
|
87488
|
+
}
|
|
87489
|
+
const limit = parseDedupeLimit(opts.limit);
|
|
87490
|
+
if (limit === null) {
|
|
87491
|
+
handleError(new Error(`--limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT} (got: ${opts.limit})`));
|
|
87492
|
+
return;
|
|
87493
|
+
}
|
|
87494
|
+
const tasks = listTasks({
|
|
87495
|
+
project_id: projectId,
|
|
87496
|
+
include_archived: Boolean(opts.includeArchived),
|
|
87497
|
+
limit
|
|
87498
|
+
}, d);
|
|
87499
|
+
const projected = projectTasksForDedupe(tasks);
|
|
87500
|
+
if (opts.json || globalOpts.json) {
|
|
87501
|
+
output({ project_id: projectId, count: projected.length, tasks: projected }, true);
|
|
87502
|
+
return;
|
|
87503
|
+
}
|
|
87504
|
+
console.log(chalk9.dim(`${projected.length} task(s) projected for dedupe (bounded \u2014 no free-form metadata, tags, comments, or run fields).`));
|
|
87505
|
+
} catch (e) {
|
|
87506
|
+
handleError(e);
|
|
87507
|
+
}
|
|
87508
|
+
});
|
|
87001
87509
|
dedupe2.command("merge <primary-task-id> <duplicate-task-id>").description("Merge a duplicate task into a primary task and archive the duplicate").option("--agent <agent>", "Agent ID recording the merge").option("--reason <reason>", "Human-readable merge reason").option("-j, --json", "Output as JSON").action((primaryTaskId, duplicateTaskId, opts) => {
|
|
87002
87510
|
const globalOpts = program2.opts();
|
|
87003
87511
|
try {
|
|
@@ -87302,6 +87810,7 @@ var init_query_commands = __esm(() => {
|
|
|
87302
87810
|
init_audit();
|
|
87303
87811
|
init_handoffs();
|
|
87304
87812
|
init_task_dedupe();
|
|
87813
|
+
init_dedupe_projection();
|
|
87305
87814
|
init_local_fields();
|
|
87306
87815
|
init_workflow_states();
|
|
87307
87816
|
init_local_reports();
|
|
@@ -95207,6 +95716,12 @@ class TodosShadowMirror {
|
|
|
95207
95716
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
95208
95717
|
const objectType2 = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
|
|
95209
95718
|
const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
|
|
95719
|
+
if (error2 instanceof ResourceConflictError) {
|
|
95720
|
+
this.metrics.failed += 1;
|
|
95721
|
+
this.metrics.lastError = message;
|
|
95722
|
+
this.onEvent?.({ type: "dropped", objectType: objectType2, id, error: message });
|
|
95723
|
+
return;
|
|
95724
|
+
}
|
|
95210
95725
|
this.metrics.retries += 1;
|
|
95211
95726
|
this.metrics.lastError = message;
|
|
95212
95727
|
op.attempts += 1;
|
|
@@ -95454,6 +95969,7 @@ function isAgent(value) {
|
|
|
95454
95969
|
}
|
|
95455
95970
|
var SNAPSHOT_TO_OBJECT_TYPE;
|
|
95456
95971
|
var init_shadow = __esm(() => {
|
|
95972
|
+
init_types();
|
|
95457
95973
|
init_local_sqlite();
|
|
95458
95974
|
init_postgres_sync();
|
|
95459
95975
|
SNAPSHOT_TO_OBJECT_TYPE = {
|