@hasna/todos 0.15.7 → 0.15.10
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/dist/cli/cloud-router.d.ts +26 -1
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +3836 -1465
- package/dist/contracts.js +143 -10
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plan-project-links.d.ts +8 -0
- package/dist/db/plan-project-links.d.ts.map +1 -0
- package/dist/db/plan-row-serialization.d.ts +8 -0
- package/dist/db/plan-row-serialization.d.ts.map +1 -0
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts +5 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8037 -1112
- package/dist/lib/assignee-validation.d.ts +44 -0
- package/dist/lib/assignee-validation.d.ts.map +1 -1
- package/dist/lib/plan-project-link-contract.d.ts +17 -0
- package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
- package/dist/lib/plan-project-link.d.ts +14 -0
- package/dist/lib/plan-project-link.d.ts.map +1 -0
- package/dist/lib/project-task-list-ensure.d.ts +19 -0
- package/dist/lib/project-task-list-ensure.d.ts.map +1 -0
- package/dist/mcp/index.js +2234 -31
- package/dist/mcp.js +7 -3
- package/dist/project-registration/sqlite.d.ts.map +1 -1
- package/dist/project-registration.js +1335 -60
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +150 -10
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +42 -0
- package/dist/sdk/v1.generated.d.ts +96 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +2419 -216
- package/dist/server/openapi.d.ts +705 -24
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +37 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage.js +870 -20
- package/dist/task-manifest/authority.d.ts +19 -0
- package/dist/task-manifest/authority.d.ts.map +1 -0
- package/dist/task-manifest/backend.d.ts +27 -0
- package/dist/task-manifest/backend.d.ts.map +1 -0
- package/dist/task-manifest/canonical.d.ts +4 -0
- package/dist/task-manifest/canonical.d.ts.map +1 -0
- package/dist/task-manifest/http.d.ts +16 -0
- package/dist/task-manifest/http.d.ts.map +1 -0
- package/dist/task-manifest/index.d.ts +10 -0
- package/dist/task-manifest/index.d.ts.map +1 -0
- package/dist/task-manifest/postgres.d.ts +18 -0
- package/dist/task-manifest/postgres.d.ts.map +1 -0
- package/dist/task-manifest/reference-guard.d.ts +27 -0
- package/dist/task-manifest/reference-guard.d.ts.map +1 -0
- package/dist/task-manifest/schema-sql.d.ts +3 -0
- package/dist/task-manifest/schema-sql.d.ts.map +1 -0
- package/dist/task-manifest/schema.d.ts +15 -0
- package/dist/task-manifest/schema.d.ts.map +1 -0
- package/dist/task-manifest/sqlite.d.ts +15 -0
- package/dist/task-manifest/sqlite.d.ts.map +1 -0
- package/dist/task-manifest/types.d.ts +166 -0
- package/dist/task-manifest/types.d.ts.map +1 -0
- package/dist/task-manifest.d.ts +2 -0
- package/dist/task-manifest.d.ts.map +1 -0
- package/dist/task-manifest.js +6816 -0
- package/dist/types/index.d.ts +89 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -3
package/dist/server/index.js
CHANGED
|
@@ -70,7 +70,7 @@ var package_default;
|
|
|
70
70
|
var init_package = __esm(() => {
|
|
71
71
|
package_default = {
|
|
72
72
|
name: "@hasna/todos",
|
|
73
|
-
version: "0.15.
|
|
73
|
+
version: "0.15.10",
|
|
74
74
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
75
75
|
type: "module",
|
|
76
76
|
main: "dist/index.js",
|
|
@@ -112,6 +112,10 @@ var init_package = __esm(() => {
|
|
|
112
112
|
"./project-registration": {
|
|
113
113
|
types: "./dist/project-registration.d.ts",
|
|
114
114
|
import: "./dist/project-registration.js"
|
|
115
|
+
},
|
|
116
|
+
"./task-manifest": {
|
|
117
|
+
types: "./dist/task-manifest.d.ts",
|
|
118
|
+
import: "./dist/task-manifest.js"
|
|
115
119
|
}
|
|
116
120
|
},
|
|
117
121
|
workspaces: [
|
|
@@ -124,8 +128,8 @@ var init_package = __esm(() => {
|
|
|
124
128
|
"README.md"
|
|
125
129
|
],
|
|
126
130
|
scripts: {
|
|
127
|
-
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 --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
128
|
-
"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 --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
131
|
+
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 --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
132
|
+
"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 --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
129
133
|
migrate: "bun run src/server/index.ts migrate",
|
|
130
134
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
131
135
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
@@ -1122,6 +1126,62 @@ var init_creator_identity = __esm(() => {
|
|
|
1122
1126
|
init_sync_utils();
|
|
1123
1127
|
});
|
|
1124
1128
|
|
|
1129
|
+
// src/lib/plan-project-link-contract.ts
|
|
1130
|
+
import { createHash as createHash2 } from "crypto";
|
|
1131
|
+
function canonicalPlanProjectLinkJson(value) {
|
|
1132
|
+
if (value === null || typeof value !== "object")
|
|
1133
|
+
return JSON.stringify(value);
|
|
1134
|
+
if (Array.isArray(value))
|
|
1135
|
+
return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
|
|
1136
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
|
|
1137
|
+
}
|
|
1138
|
+
function planProjectLinkDigest(value) {
|
|
1139
|
+
return createHash2("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
|
|
1140
|
+
}
|
|
1141
|
+
function normalizePlanProjectLinkIdempotencyKey(value) {
|
|
1142
|
+
const key = value?.trim() ?? "";
|
|
1143
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
1144
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
1145
|
+
}
|
|
1146
|
+
return key;
|
|
1147
|
+
}
|
|
1148
|
+
function planProjectLinkReceiptId(idempotencyKey) {
|
|
1149
|
+
return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
1150
|
+
}
|
|
1151
|
+
function planProjectLinkRollbackReceiptId(receiptId) {
|
|
1152
|
+
return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
|
|
1153
|
+
}
|
|
1154
|
+
function planProjectLinkRequestHash(planId, projectId) {
|
|
1155
|
+
return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
|
|
1156
|
+
}
|
|
1157
|
+
function planProjectLinkResultDigest(plan, tasks) {
|
|
1158
|
+
return planProjectLinkDigest({
|
|
1159
|
+
plan_id: plan.id,
|
|
1160
|
+
plan_project_id: plan.project_id,
|
|
1161
|
+
tasks: tasks.map((task) => ({ id: task.id, plan_id: task.plan_id, project_id: task.project_id })).sort((left, right) => left.id.localeCompare(right.id))
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
function assertPlanProjectLinkReceipt(value) {
|
|
1165
|
+
const receipt = value;
|
|
1166
|
+
if (!receipt || typeof receipt !== "object" || receipt.schema_version !== PLAN_PROJECT_LINK_SCHEMA_VERSION || typeof receipt.receipt_id !== "string" || typeof receipt.idempotency_key !== "string" || typeof receipt.plan_id !== "string" || typeof receipt.project_id !== "string" || !Array.isArray(receipt.task_ids) || receipt.task_ids.some((id) => typeof id !== "string") || !receipt.prior_task_project_ids || typeof receipt.prior_task_project_ids !== "object" || typeof receipt.result_digest !== "string") {
|
|
1167
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
|
|
1168
|
+
}
|
|
1169
|
+
return receipt;
|
|
1170
|
+
}
|
|
1171
|
+
var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1", PlanProjectLinkError;
|
|
1172
|
+
var init_plan_project_link_contract = __esm(() => {
|
|
1173
|
+
PlanProjectLinkError = class PlanProjectLinkError extends Error {
|
|
1174
|
+
code;
|
|
1175
|
+
details;
|
|
1176
|
+
constructor(code, message, details = {}) {
|
|
1177
|
+
super(message);
|
|
1178
|
+
this.code = code;
|
|
1179
|
+
this.details = details;
|
|
1180
|
+
this.name = "PlanProjectLinkError";
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1125
1185
|
// src/lib/slugs.ts
|
|
1126
1186
|
function normalizeSlug(value) {
|
|
1127
1187
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
@@ -1219,6 +1279,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
1219
1279
|
RETURNS text
|
|
1220
1280
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
1221
1281
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
1282
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
1283
|
+
RETURNS timestamptz
|
|
1284
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
1285
|
+
SET DateStyle TO 'ISO, YMD'
|
|
1286
|
+
AS $$
|
|
1287
|
+
BEGIN
|
|
1288
|
+
RETURN CASE
|
|
1289
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
1290
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
1291
|
+
END;
|
|
1292
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
1293
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
1294
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
1295
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
1296
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
1222
1297
|
`ALTER TABLE ${tableName}
|
|
1223
1298
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
1224
1299
|
GENERATED ALWAYS AS (
|
|
@@ -2018,6 +2093,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
2018
2093
|
update: (id, input) => updatePlan(id, input, store),
|
|
2019
2094
|
delete: (id, context) => store.deletePlan(id, context)
|
|
2020
2095
|
},
|
|
2096
|
+
planProjectLinks: {
|
|
2097
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
2098
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
2099
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
2100
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
2101
|
+
},
|
|
2021
2102
|
agents: {
|
|
2022
2103
|
register: (input, context) => registerAgent(input, store, context),
|
|
2023
2104
|
get: (id) => store.get("agents", id),
|
|
@@ -2200,6 +2281,9 @@ class PostgresJsonRecordStore {
|
|
|
2200
2281
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
2201
2282
|
if (filter.not_created_by !== undefined)
|
|
2202
2283
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
2284
|
+
if (filter.updated_after !== undefined) {
|
|
2285
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
2286
|
+
}
|
|
2203
2287
|
if (filter.session_id !== undefined)
|
|
2204
2288
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
2205
2289
|
if (filter.tags?.length) {
|
|
@@ -2384,6 +2468,115 @@ class PostgresJsonRecordStore {
|
|
|
2384
2468
|
}
|
|
2385
2469
|
return value;
|
|
2386
2470
|
}
|
|
2471
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
2472
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
2473
|
+
if (planIds.length === 0)
|
|
2474
|
+
return this.upsert("tasks", value, context);
|
|
2475
|
+
await this.ensureSchema();
|
|
2476
|
+
const updatedAt = value.updated_at;
|
|
2477
|
+
const targetPlanId = value.plan_id;
|
|
2478
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
2479
|
+
locked_plans AS MATERIALIZED (
|
|
2480
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
2481
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
2482
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
2483
|
+
ORDER BY object_id
|
|
2484
|
+
FOR UPDATE
|
|
2485
|
+
), validation AS (
|
|
2486
|
+
SELECT
|
|
2487
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
2488
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
2489
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
2490
|
+
), guarded AS (
|
|
2491
|
+
SELECT
|
|
2492
|
+
validation.*,
|
|
2493
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
2494
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
2495
|
+
CASE
|
|
2496
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
2497
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
2498
|
+
END AS payload
|
|
2499
|
+
FROM validation
|
|
2500
|
+
), stored AS (
|
|
2501
|
+
INSERT INTO ${this.tableName} (
|
|
2502
|
+
service, object_type, object_id, payload, updated_at,
|
|
2503
|
+
deleted_at, source_machine_id, version
|
|
2504
|
+
)
|
|
2505
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
2506
|
+
FROM guarded
|
|
2507
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
2508
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
2509
|
+
payload = EXCLUDED.payload,
|
|
2510
|
+
updated_at = EXCLUDED.updated_at,
|
|
2511
|
+
deleted_at = NULL,
|
|
2512
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
2513
|
+
version = EXCLUDED.version
|
|
2514
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
2515
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
2516
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
2517
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
2518
|
+
RETURNING payload
|
|
2519
|
+
)
|
|
2520
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
2521
|
+
(SELECT payload FROM stored) AS payload
|
|
2522
|
+
FROM guarded`, [
|
|
2523
|
+
this.service,
|
|
2524
|
+
value.id,
|
|
2525
|
+
jsonbParam(value),
|
|
2526
|
+
updatedAt,
|
|
2527
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
2528
|
+
numberValue2(value.version),
|
|
2529
|
+
jsonbParam(planIds),
|
|
2530
|
+
targetPlanId,
|
|
2531
|
+
explicitProject
|
|
2532
|
+
]);
|
|
2533
|
+
const row = result.rows[0];
|
|
2534
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
2535
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan membership changed through a missing plan: ${targetPlanId ?? planIds.join(", ")}`, { plan_ids: planIds, target_plan_id: targetPlanId });
|
|
2536
|
+
}
|
|
2537
|
+
if (row.project_conflict) {
|
|
2538
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
2539
|
+
}
|
|
2540
|
+
if (!row.payload) {
|
|
2541
|
+
return await requireRecord("tasks", value.id, this);
|
|
2542
|
+
}
|
|
2543
|
+
return payloadRecord2(row.payload);
|
|
2544
|
+
}
|
|
2545
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
2546
|
+
await this.ensureSchema();
|
|
2547
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
2548
|
+
SELECT payload FROM ${this.tableName}
|
|
2549
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
2550
|
+
FOR UPDATE
|
|
2551
|
+
), stored AS (
|
|
2552
|
+
UPDATE ${this.tableName} r SET
|
|
2553
|
+
payload = jsonb_set(
|
|
2554
|
+
$3::jsonb,
|
|
2555
|
+
'{project_id}',
|
|
2556
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
2557
|
+
true
|
|
2558
|
+
),
|
|
2559
|
+
updated_at = $4::timestamptz,
|
|
2560
|
+
deleted_at = NULL,
|
|
2561
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
2562
|
+
version = COALESCE(r.version, 0) + 1
|
|
2563
|
+
FROM locked_plan
|
|
2564
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
2565
|
+
RETURNING r.payload
|
|
2566
|
+
)
|
|
2567
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
2568
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
2569
|
+
this.service,
|
|
2570
|
+
value.id,
|
|
2571
|
+
jsonbParam(value),
|
|
2572
|
+
value.updated_at,
|
|
2573
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
2574
|
+
]);
|
|
2575
|
+
const row = result.rows[0];
|
|
2576
|
+
if (!row?.plan_found || !row.payload)
|
|
2577
|
+
throw new PlanNotFoundError(value.id);
|
|
2578
|
+
return payloadRecord2(row.payload);
|
|
2579
|
+
}
|
|
2387
2580
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
2388
2581
|
await this.ensureSchema();
|
|
2389
2582
|
const records = [
|
|
@@ -2622,6 +2815,333 @@ class PostgresJsonRecordStore {
|
|
|
2622
2815
|
version: numberValue2(existing["version"])
|
|
2623
2816
|
}, context);
|
|
2624
2817
|
}
|
|
2818
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
2819
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
2820
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
2821
|
+
}
|
|
2822
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
2823
|
+
await this.ensureSchema();
|
|
2824
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
2825
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
2826
|
+
AND payload->>'idempotency_key' = $2
|
|
2827
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
2828
|
+
if (result.rows.length > 1) {
|
|
2829
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
2830
|
+
}
|
|
2831
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
2832
|
+
}
|
|
2833
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
2834
|
+
const [plan, project, tasks] = await Promise.all([
|
|
2835
|
+
this.get("plans", receipt.plan_id),
|
|
2836
|
+
this.get("projects", receipt.project_id),
|
|
2837
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
2838
|
+
]);
|
|
2839
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
2840
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
2841
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
2842
|
+
}
|
|
2843
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
|
|
2844
|
+
}
|
|
2845
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
2846
|
+
await this.ensureSchema();
|
|
2847
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
2848
|
+
if (existing) {
|
|
2849
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
2850
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key, receipt_id: existing.receipt_id });
|
|
2851
|
+
}
|
|
2852
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
2853
|
+
if (rolledBack) {
|
|
2854
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
2855
|
+
}
|
|
2856
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
2857
|
+
}
|
|
2858
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
2859
|
+
this.get("plans", input.plan_id),
|
|
2860
|
+
this.get("projects", input.project_id),
|
|
2861
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
2862
|
+
this.list("plans")
|
|
2863
|
+
]);
|
|
2864
|
+
if (!plan)
|
|
2865
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
2866
|
+
if (!project)
|
|
2867
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
2868
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
2869
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
|
|
2870
|
+
}
|
|
2871
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
2872
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
|
|
2873
|
+
}
|
|
2874
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
2875
|
+
if (collision) {
|
|
2876
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
|
|
2877
|
+
}
|
|
2878
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
2879
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
2880
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
2881
|
+
const projectedTasks = sortedTasks.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
|
|
2882
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
|
|
2883
|
+
const receipt = {
|
|
2884
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
2885
|
+
receipt_id: input.receipt_id,
|
|
2886
|
+
idempotency_key: input.idempotency_key,
|
|
2887
|
+
plan_id: plan.id,
|
|
2888
|
+
project_id: project.id,
|
|
2889
|
+
prior_plan_project_id: plan.project_id,
|
|
2890
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
2891
|
+
task_ids: sortedTasks.map((task) => task.id),
|
|
2892
|
+
task_count: sortedTasks.length,
|
|
2893
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
2894
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
2895
|
+
rollback_supported: true,
|
|
2896
|
+
created_at: input.created_at
|
|
2897
|
+
};
|
|
2898
|
+
let mutation;
|
|
2899
|
+
try {
|
|
2900
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
2901
|
+
target_plan AS MATERIALIZED (
|
|
2902
|
+
SELECT payload FROM ${this.tableName}
|
|
2903
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
2904
|
+
FOR UPDATE
|
|
2905
|
+
), target_project AS (
|
|
2906
|
+
SELECT payload FROM ${this.tableName}
|
|
2907
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
2908
|
+
FOR UPDATE
|
|
2909
|
+
), member_tasks AS MATERIALIZED (
|
|
2910
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
2911
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
2912
|
+
AND payload->>'plan_id' = $2
|
|
2913
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
2914
|
+
FOR UPDATE
|
|
2915
|
+
), existing AS (
|
|
2916
|
+
SELECT payload FROM ${this.tableName}
|
|
2917
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
2918
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
2919
|
+
FOR UPDATE
|
|
2920
|
+
), collision AS (
|
|
2921
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
2922
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
2923
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
2924
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
2925
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
2926
|
+
LIMIT 1
|
|
2927
|
+
), checks AS (
|
|
2928
|
+
SELECT
|
|
2929
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
2930
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
2931
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
2932
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
2933
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
2934
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
2935
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
2936
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
2937
|
+
), updated_plan AS (
|
|
2938
|
+
UPDATE ${this.tableName} r SET
|
|
2939
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
2940
|
+
updated_at = $10::timestamptz,
|
|
2941
|
+
version = COALESCE(r.version, 0) + 1,
|
|
2942
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
2943
|
+
FROM checks
|
|
2944
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
2945
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
2946
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
2947
|
+
RETURNING r.payload
|
|
2948
|
+
), updated_tasks AS (
|
|
2949
|
+
UPDATE ${this.tableName} r SET
|
|
2950
|
+
payload = r.payload || jsonb_build_object(
|
|
2951
|
+
'project_id', $3::text,
|
|
2952
|
+
'updated_at', $10::text,
|
|
2953
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
2954
|
+
),
|
|
2955
|
+
updated_at = $10::timestamptz,
|
|
2956
|
+
version = COALESCE(r.version, 0) + 1,
|
|
2957
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
2958
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
2959
|
+
AND r.payload->>'plan_id' = $2
|
|
2960
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
2961
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
2962
|
+
RETURNING 1
|
|
2963
|
+
), task_gate AS (
|
|
2964
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
2965
|
+
), inserted AS (
|
|
2966
|
+
INSERT INTO ${this.tableName}
|
|
2967
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
2968
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
2969
|
+
FROM checks, task_gate
|
|
2970
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
2971
|
+
RETURNING payload
|
|
2972
|
+
) SELECT
|
|
2973
|
+
checks.plan_found,
|
|
2974
|
+
checks.project_found,
|
|
2975
|
+
checks.plan_revision_ok,
|
|
2976
|
+
checks.project_revision_ok,
|
|
2977
|
+
checks.membership_ok,
|
|
2978
|
+
checks.collision,
|
|
2979
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
2980
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
2981
|
+
FROM checks`, [
|
|
2982
|
+
this.service,
|
|
2983
|
+
plan.id,
|
|
2984
|
+
project.id,
|
|
2985
|
+
input.expected_plan_revision,
|
|
2986
|
+
input.expected_project_revision,
|
|
2987
|
+
receipt.receipt_id,
|
|
2988
|
+
jsonbParam(receipt),
|
|
2989
|
+
jsonbParam(priorTaskProjectIds),
|
|
2990
|
+
jsonbParam(receipt.task_ids),
|
|
2991
|
+
input.created_at,
|
|
2992
|
+
this.machineId(context)
|
|
2993
|
+
]);
|
|
2994
|
+
} catch (error) {
|
|
2995
|
+
if (isPostgresUniqueViolation(error)) {
|
|
2996
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
2997
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
2998
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
2999
|
+
}
|
|
3000
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
3001
|
+
}
|
|
3002
|
+
throw error;
|
|
3003
|
+
}
|
|
3004
|
+
const row = mutation.rows[0];
|
|
3005
|
+
if (!row?.plan_found)
|
|
3006
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
3007
|
+
if (!row.project_found)
|
|
3008
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
3009
|
+
if (!row.plan_revision_ok)
|
|
3010
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
3011
|
+
if (!row.project_revision_ok)
|
|
3012
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
3013
|
+
if (!row.membership_ok)
|
|
3014
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
3015
|
+
if (row.collision)
|
|
3016
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
3017
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
3018
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
3019
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
3020
|
+
}
|
|
3021
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
3022
|
+
}
|
|
3023
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
3024
|
+
await this.ensureSchema();
|
|
3025
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
3026
|
+
if (existingRollback)
|
|
3027
|
+
return existingRollback;
|
|
3028
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
3029
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
3030
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
3031
|
+
}
|
|
3032
|
+
const [plan, tasks] = await Promise.all([
|
|
3033
|
+
this.get("plans", input.plan_id),
|
|
3034
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
3035
|
+
]);
|
|
3036
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
3037
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
3038
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
3039
|
+
}
|
|
3040
|
+
if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
3041
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
3042
|
+
}
|
|
3043
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
3044
|
+
const projectedTasks = sortedTasks.map((task) => ({
|
|
3045
|
+
...task,
|
|
3046
|
+
project_id: receipt.prior_task_project_ids[task.id] ?? null,
|
|
3047
|
+
updated_at: input.restored_at,
|
|
3048
|
+
version: task.version + 1
|
|
3049
|
+
}));
|
|
3050
|
+
const rollback = {
|
|
3051
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
3052
|
+
action: "restored",
|
|
3053
|
+
plan: projectedPlan,
|
|
3054
|
+
tasks: projectedTasks,
|
|
3055
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
3056
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
3057
|
+
restored_at: input.restored_at
|
|
3058
|
+
};
|
|
3059
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
3060
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
3061
|
+
target_plan AS MATERIALIZED (
|
|
3062
|
+
SELECT payload FROM ${this.tableName}
|
|
3063
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
3064
|
+
FOR UPDATE
|
|
3065
|
+
), member_tasks AS MATERIALIZED (
|
|
3066
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
3067
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
3068
|
+
AND payload->>'plan_id' = $2
|
|
3069
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
3070
|
+
FOR UPDATE
|
|
3071
|
+
), existing AS (
|
|
3072
|
+
SELECT payload FROM ${this.tableName}
|
|
3073
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
3074
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
3075
|
+
FOR UPDATE
|
|
3076
|
+
), checks AS (
|
|
3077
|
+
SELECT
|
|
3078
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
3079
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
3080
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
3081
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
3082
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
3083
|
+
), updated_plan AS (
|
|
3084
|
+
UPDATE ${this.tableName} r SET
|
|
3085
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
3086
|
+
updated_at = $10::timestamptz,
|
|
3087
|
+
version = COALESCE(r.version, 0) + 1,
|
|
3088
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
3089
|
+
FROM checks
|
|
3090
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
3091
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
3092
|
+
RETURNING 1
|
|
3093
|
+
), updated_tasks AS (
|
|
3094
|
+
UPDATE ${this.tableName} r SET
|
|
3095
|
+
payload = r.payload || jsonb_build_object(
|
|
3096
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
3097
|
+
'updated_at', $10::text,
|
|
3098
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
3099
|
+
),
|
|
3100
|
+
updated_at = $10::timestamptz,
|
|
3101
|
+
version = COALESCE(r.version, 0) + 1,
|
|
3102
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
3103
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
3104
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
3105
|
+
RETURNING 1
|
|
3106
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
3107
|
+
INSERT INTO ${this.tableName}
|
|
3108
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
3109
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
3110
|
+
FROM checks, task_gate
|
|
3111
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
3112
|
+
RETURNING payload
|
|
3113
|
+
) SELECT
|
|
3114
|
+
checks.plan_found,
|
|
3115
|
+
checks.plan_revision_ok,
|
|
3116
|
+
checks.membership_ok,
|
|
3117
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
3118
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
3119
|
+
FROM checks`, [
|
|
3120
|
+
this.service,
|
|
3121
|
+
input.plan_id,
|
|
3122
|
+
input.expected_plan_revision,
|
|
3123
|
+
input.receipt_id,
|
|
3124
|
+
input.rollback_receipt_id,
|
|
3125
|
+
jsonbParam(rollback),
|
|
3126
|
+
jsonbParam(currentTaskProjects),
|
|
3127
|
+
jsonbParam(receipt.task_ids),
|
|
3128
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
3129
|
+
input.restored_at,
|
|
3130
|
+
this.machineId(context),
|
|
3131
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
3132
|
+
]);
|
|
3133
|
+
const row = result.rows[0];
|
|
3134
|
+
if (!row?.plan_found)
|
|
3135
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
3136
|
+
if (!row.plan_revision_ok)
|
|
3137
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
3138
|
+
if (!row.membership_ok)
|
|
3139
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
3140
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
3141
|
+
if (!accepted)
|
|
3142
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
3143
|
+
return accepted;
|
|
3144
|
+
}
|
|
2625
3145
|
async deletePlan(id, context = {}) {
|
|
2626
3146
|
await this.ensureSchema();
|
|
2627
3147
|
const timestamp = new Date().toISOString();
|
|
@@ -2716,11 +3236,17 @@ class PostgresJsonRecordStore {
|
|
|
2716
3236
|
}
|
|
2717
3237
|
async function createTask(input, store, context) {
|
|
2718
3238
|
const timestamp = new Date().toISOString();
|
|
2719
|
-
const
|
|
3239
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
3240
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
3241
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
3242
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
3243
|
+
}
|
|
3244
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
3245
|
+
const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
|
|
2720
3246
|
const task = {
|
|
2721
3247
|
id: randomUUID(),
|
|
2722
3248
|
short_id: shortId,
|
|
2723
|
-
project_id:
|
|
3249
|
+
project_id: effectiveProjectId,
|
|
2724
3250
|
parent_id: input.parent_id ?? null,
|
|
2725
3251
|
plan_id: input.plan_id ?? null,
|
|
2726
3252
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -2774,15 +3300,27 @@ async function createTask(input, store, context) {
|
|
|
2774
3300
|
synced_at: null,
|
|
2775
3301
|
archived_at: null
|
|
2776
3302
|
};
|
|
2777
|
-
await store.
|
|
2778
|
-
await logTaskChange(
|
|
2779
|
-
return
|
|
3303
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
3304
|
+
await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
3305
|
+
return storedTask;
|
|
2780
3306
|
}
|
|
2781
3307
|
async function updateTask(id, input, store) {
|
|
2782
3308
|
const existing = await requireRecord("tasks", id, store);
|
|
2783
3309
|
if (existing.version !== input.version) {
|
|
2784
3310
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
2785
3311
|
}
|
|
3312
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
3313
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
3314
|
+
if (linkedPlan?.project_id) {
|
|
3315
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
3316
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
3317
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
3318
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
3319
|
+
} else {
|
|
3320
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
2786
3324
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
2787
3325
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
2788
3326
|
const task = {
|
|
@@ -2798,8 +3336,7 @@ async function updateTask(id, input, store) {
|
|
|
2798
3336
|
created_by: existing.created_by,
|
|
2799
3337
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
2800
3338
|
};
|
|
2801
|
-
|
|
2802
|
-
return task;
|
|
3339
|
+
return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
2803
3340
|
}
|
|
2804
3341
|
async function startTask(id, agentId, store) {
|
|
2805
3342
|
const task = await requireRecord("tasks", id, store);
|
|
@@ -2863,8 +3400,7 @@ async function patchTask(task, patch, store) {
|
|
|
2863
3400
|
version: task.version + 1,
|
|
2864
3401
|
updated_at: new Date().toISOString()
|
|
2865
3402
|
};
|
|
2866
|
-
|
|
2867
|
-
return updated;
|
|
3403
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
2868
3404
|
}
|
|
2869
3405
|
function sameCloudLockHolder(stored, incoming) {
|
|
2870
3406
|
if (!stored || !incoming)
|
|
@@ -3126,7 +3662,12 @@ async function updatePlan(id, input, store) {
|
|
|
3126
3662
|
excludeId: id
|
|
3127
3663
|
});
|
|
3128
3664
|
}
|
|
3129
|
-
return store.
|
|
3665
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
3666
|
+
...plan,
|
|
3667
|
+
...patch,
|
|
3668
|
+
project_id: plan.project_id,
|
|
3669
|
+
updated_at: new Date().toISOString()
|
|
3670
|
+
});
|
|
3130
3671
|
}
|
|
3131
3672
|
function matchAgentByName(agents, name, options) {
|
|
3132
3673
|
const target = normalizeAgentNameInput(name);
|
|
@@ -3514,6 +4055,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
|
|
|
3514
4055
|
var init_postgres_adapter = __esm(() => {
|
|
3515
4056
|
init_types();
|
|
3516
4057
|
init_creator_identity();
|
|
4058
|
+
init_plan_project_link_contract();
|
|
3517
4059
|
init_postgres_sync();
|
|
3518
4060
|
init_integrity();
|
|
3519
4061
|
init_redaction();
|
|
@@ -3536,9 +4078,9 @@ var init_types2 = __esm(() => {
|
|
|
3536
4078
|
});
|
|
3537
4079
|
|
|
3538
4080
|
// src/pr-groups/ledger.ts
|
|
3539
|
-
import { createHash as
|
|
4081
|
+
import { createHash as createHash3 } from "crypto";
|
|
3540
4082
|
function sha256(value) {
|
|
3541
|
-
return
|
|
4083
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
3542
4084
|
}
|
|
3543
4085
|
function stableValue(value) {
|
|
3544
4086
|
if (Array.isArray(value))
|
|
@@ -7610,6 +8152,29 @@ var init_migrations = __esm(() => {
|
|
|
7610
8152
|
${sqliteTodosProjectRegistrationSchemaSql()}
|
|
7611
8153
|
INSERT OR IGNORE INTO _migrations (id) VALUES (69);
|
|
7612
8154
|
COMMIT;
|
|
8155
|
+
`,
|
|
8156
|
+
`BEGIN;
|
|
8157
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
|
|
8158
|
+
receipt_id TEXT PRIMARY KEY,
|
|
8159
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
8160
|
+
plan_id TEXT NOT NULL,
|
|
8161
|
+
project_id TEXT NOT NULL,
|
|
8162
|
+
payload_hash TEXT NOT NULL,
|
|
8163
|
+
payload TEXT NOT NULL,
|
|
8164
|
+
created_at TEXT NOT NULL
|
|
8165
|
+
);
|
|
8166
|
+
CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
|
|
8167
|
+
ON plan_project_link_receipts(plan_id, created_at);
|
|
8168
|
+
|
|
8169
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
|
|
8170
|
+
rollback_receipt_id TEXT PRIMARY KEY,
|
|
8171
|
+
accepted_receipt_id TEXT NOT NULL UNIQUE
|
|
8172
|
+
REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
|
|
8173
|
+
payload TEXT NOT NULL,
|
|
8174
|
+
created_at TEXT NOT NULL
|
|
8175
|
+
);
|
|
8176
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
8177
|
+
COMMIT;
|
|
7613
8178
|
`
|
|
7614
8179
|
];
|
|
7615
8180
|
});
|
|
@@ -9943,165 +10508,6 @@ var init_database = __esm(() => {
|
|
|
9943
10508
|
ALLOWED_TABLES = new Set(["tasks", "projects", "agents", "plans", "task_lists", "task_templates", "project_knowledge_records", "project_risks", "local_retrospectives"]);
|
|
9944
10509
|
});
|
|
9945
10510
|
|
|
9946
|
-
// src/lib/search.ts
|
|
9947
|
-
var exports_search = {};
|
|
9948
|
-
__export(exports_search, {
|
|
9949
|
-
searchTasks: () => searchTasks,
|
|
9950
|
-
DEFAULT_SEARCH_LIMIT: () => DEFAULT_SEARCH_LIMIT
|
|
9951
|
-
});
|
|
9952
|
-
function rowToTask(row) {
|
|
9953
|
-
return {
|
|
9954
|
-
...row,
|
|
9955
|
-
tags: JSON.parse(row.tags || "[]"),
|
|
9956
|
-
metadata: JSON.parse(row.metadata || "{}"),
|
|
9957
|
-
status: row.status,
|
|
9958
|
-
priority: row.priority,
|
|
9959
|
-
requires_approval: Boolean(row.requires_approval)
|
|
9960
|
-
};
|
|
9961
|
-
}
|
|
9962
|
-
function hasFts(db) {
|
|
9963
|
-
try {
|
|
9964
|
-
const result = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks_fts'").get();
|
|
9965
|
-
return result !== null;
|
|
9966
|
-
} catch {
|
|
9967
|
-
return false;
|
|
9968
|
-
}
|
|
9969
|
-
}
|
|
9970
|
-
function buildFtsMatchQuery(raw) {
|
|
9971
|
-
const terms = [];
|
|
9972
|
-
const re = /"([^"]*)"|(\S+)/g;
|
|
9973
|
-
let m;
|
|
9974
|
-
while ((m = re.exec(raw)) !== null) {
|
|
9975
|
-
if (m[1] !== undefined) {
|
|
9976
|
-
const phrase = m[1].replace(/"/g, " ").replace(/\s+/g, " ").trim();
|
|
9977
|
-
if (phrase)
|
|
9978
|
-
terms.push(`"${phrase}"`);
|
|
9979
|
-
} else {
|
|
9980
|
-
const cleaned = m[2].replace(/["*^():]/g, " ").replace(/\s+/g, " ").trim();
|
|
9981
|
-
if (cleaned)
|
|
9982
|
-
terms.push(`"${cleaned}"*`);
|
|
9983
|
-
}
|
|
9984
|
-
}
|
|
9985
|
-
return terms.join(" AND ");
|
|
9986
|
-
}
|
|
9987
|
-
function boundedLimit(limit) {
|
|
9988
|
-
if (!Number.isFinite(limit) || !limit || limit <= 0)
|
|
9989
|
-
return DEFAULT_SEARCH_LIMIT;
|
|
9990
|
-
return Math.trunc(limit);
|
|
9991
|
-
}
|
|
9992
|
-
function searchTasks(options, projectId, taskListId, db) {
|
|
9993
|
-
const opts = typeof options === "string" ? { query: options || undefined, project_id: projectId, task_list_id: taskListId } : options;
|
|
9994
|
-
const d = db || getDatabase();
|
|
9995
|
-
clearExpiredLocks(d);
|
|
9996
|
-
const params = [];
|
|
9997
|
-
let sql;
|
|
9998
|
-
const raw = opts.query?.trim() ?? "";
|
|
9999
|
-
const q = raw === "*" ? "" : raw;
|
|
10000
|
-
const ftsMatch = q ? buildFtsMatchQuery(q) : "";
|
|
10001
|
-
const useFts = hasFts(d) && q !== "" && ftsMatch !== "";
|
|
10002
|
-
if (useFts) {
|
|
10003
|
-
const pattern = `%${q}%`;
|
|
10004
|
-
sql = `SELECT t.* FROM tasks t
|
|
10005
|
-
LEFT JOIN (
|
|
10006
|
-
SELECT rowid, bm25(tasks_fts, ${BM25_WEIGHTS}) AS rank
|
|
10007
|
-
FROM tasks_fts WHERE tasks_fts MATCH ?
|
|
10008
|
-
) fts ON fts.rowid = t.rowid
|
|
10009
|
-
WHERE (
|
|
10010
|
-
fts.rowid IS NOT NULL
|
|
10011
|
-
OR t.id LIKE ?
|
|
10012
|
-
OR t.short_id LIKE ?
|
|
10013
|
-
OR t.title LIKE ?
|
|
10014
|
-
OR t.description LIKE ?
|
|
10015
|
-
OR t.working_dir LIKE ?
|
|
10016
|
-
OR t.metadata LIKE ?
|
|
10017
|
-
OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = t.id AND tag LIKE ?)
|
|
10018
|
-
)`;
|
|
10019
|
-
params.push(ftsMatch, pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
|
10020
|
-
} else if (q) {
|
|
10021
|
-
const pattern = `%${q}%`;
|
|
10022
|
-
sql = `SELECT t.* FROM tasks t WHERE (
|
|
10023
|
-
t.id LIKE ?
|
|
10024
|
-
OR t.short_id LIKE ?
|
|
10025
|
-
OR t.title LIKE ?
|
|
10026
|
-
OR t.description LIKE ?
|
|
10027
|
-
OR t.working_dir LIKE ?
|
|
10028
|
-
OR t.metadata LIKE ?
|
|
10029
|
-
OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = t.id AND tag LIKE ?)
|
|
10030
|
-
)`;
|
|
10031
|
-
params.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
|
10032
|
-
} else {
|
|
10033
|
-
sql = `SELECT t.* FROM tasks t WHERE 1=1`;
|
|
10034
|
-
}
|
|
10035
|
-
if (opts.project_id) {
|
|
10036
|
-
sql += " AND t.project_id = ?";
|
|
10037
|
-
params.push(opts.project_id);
|
|
10038
|
-
}
|
|
10039
|
-
if (opts.task_list_id) {
|
|
10040
|
-
sql += " AND t.task_list_id = ?";
|
|
10041
|
-
params.push(opts.task_list_id);
|
|
10042
|
-
}
|
|
10043
|
-
if (opts.status) {
|
|
10044
|
-
if (Array.isArray(opts.status)) {
|
|
10045
|
-
sql += ` AND t.status IN (${opts.status.map(() => "?").join(",")})`;
|
|
10046
|
-
params.push(...opts.status);
|
|
10047
|
-
} else {
|
|
10048
|
-
sql += " AND t.status = ?";
|
|
10049
|
-
params.push(opts.status);
|
|
10050
|
-
}
|
|
10051
|
-
}
|
|
10052
|
-
if (opts.priority) {
|
|
10053
|
-
if (Array.isArray(opts.priority)) {
|
|
10054
|
-
sql += ` AND t.priority IN (${opts.priority.map(() => "?").join(",")})`;
|
|
10055
|
-
params.push(...opts.priority);
|
|
10056
|
-
} else {
|
|
10057
|
-
sql += " AND t.priority = ?";
|
|
10058
|
-
params.push(opts.priority);
|
|
10059
|
-
}
|
|
10060
|
-
}
|
|
10061
|
-
if (opts.assigned_to) {
|
|
10062
|
-
sql += ` AND ${lowerInClause("t.assigned_to", resolveAssignedToAliases(d, opts.assigned_to), params)}`;
|
|
10063
|
-
}
|
|
10064
|
-
if (opts.agent_id) {
|
|
10065
|
-
sql += " AND t.agent_id = ?";
|
|
10066
|
-
params.push(opts.agent_id);
|
|
10067
|
-
}
|
|
10068
|
-
if (opts.created_after) {
|
|
10069
|
-
sql += " AND t.created_at > ?";
|
|
10070
|
-
params.push(opts.created_after);
|
|
10071
|
-
}
|
|
10072
|
-
if (opts.updated_after) {
|
|
10073
|
-
sql += " AND t.updated_at > ?";
|
|
10074
|
-
params.push(opts.updated_after);
|
|
10075
|
-
}
|
|
10076
|
-
if (opts.has_dependencies === true) {
|
|
10077
|
-
sql += " AND t.id IN (SELECT task_id FROM task_dependencies)";
|
|
10078
|
-
} else if (opts.has_dependencies === false) {
|
|
10079
|
-
sql += " AND t.id NOT IN (SELECT task_id FROM task_dependencies)";
|
|
10080
|
-
}
|
|
10081
|
-
if (opts.is_blocked === true) {
|
|
10082
|
-
sql += " AND t.id IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
10083
|
-
} else if (opts.is_blocked === false) {
|
|
10084
|
-
sql += " AND t.id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
10085
|
-
}
|
|
10086
|
-
if (useFts) {
|
|
10087
|
-
sql += ` ORDER BY (fts.rowid IS NULL) ASC, fts.rank ASC,
|
|
10088
|
-
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
10089
|
-
t.created_at DESC`;
|
|
10090
|
-
} else {
|
|
10091
|
-
sql += ` ORDER BY
|
|
10092
|
-
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
10093
|
-
t.created_at DESC`;
|
|
10094
|
-
}
|
|
10095
|
-
sql += ` LIMIT ?`;
|
|
10096
|
-
params.push(boundedLimit(opts.limit));
|
|
10097
|
-
const rows = d.query(sql).all(...params);
|
|
10098
|
-
return rows.map(rowToTask);
|
|
10099
|
-
}
|
|
10100
|
-
var DEFAULT_SEARCH_LIMIT = 1000, BM25_WEIGHTS = "0.0, 10.0, 5.0, 3.0";
|
|
10101
|
-
var init_search = __esm(() => {
|
|
10102
|
-
init_database();
|
|
10103
|
-
});
|
|
10104
|
-
|
|
10105
10511
|
// src/db/storage-tombstones.ts
|
|
10106
10512
|
function recordStorageTombstone(input, db) {
|
|
10107
10513
|
const d = db ?? getDatabase();
|
|
@@ -10176,6 +10582,165 @@ var init_storage_tombstones = __esm(() => {
|
|
|
10176
10582
|
init_machines();
|
|
10177
10583
|
});
|
|
10178
10584
|
|
|
10585
|
+
// src/lib/search.ts
|
|
10586
|
+
var exports_search = {};
|
|
10587
|
+
__export(exports_search, {
|
|
10588
|
+
searchTasks: () => searchTasks,
|
|
10589
|
+
DEFAULT_SEARCH_LIMIT: () => DEFAULT_SEARCH_LIMIT
|
|
10590
|
+
});
|
|
10591
|
+
function rowToTask(row) {
|
|
10592
|
+
return {
|
|
10593
|
+
...row,
|
|
10594
|
+
tags: JSON.parse(row.tags || "[]"),
|
|
10595
|
+
metadata: JSON.parse(row.metadata || "{}"),
|
|
10596
|
+
status: row.status,
|
|
10597
|
+
priority: row.priority,
|
|
10598
|
+
requires_approval: Boolean(row.requires_approval)
|
|
10599
|
+
};
|
|
10600
|
+
}
|
|
10601
|
+
function hasFts(db) {
|
|
10602
|
+
try {
|
|
10603
|
+
const result = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks_fts'").get();
|
|
10604
|
+
return result !== null;
|
|
10605
|
+
} catch {
|
|
10606
|
+
return false;
|
|
10607
|
+
}
|
|
10608
|
+
}
|
|
10609
|
+
function buildFtsMatchQuery(raw) {
|
|
10610
|
+
const terms = [];
|
|
10611
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
10612
|
+
let m;
|
|
10613
|
+
while ((m = re.exec(raw)) !== null) {
|
|
10614
|
+
if (m[1] !== undefined) {
|
|
10615
|
+
const phrase = m[1].replace(/"/g, " ").replace(/\s+/g, " ").trim();
|
|
10616
|
+
if (phrase)
|
|
10617
|
+
terms.push(`"${phrase}"`);
|
|
10618
|
+
} else {
|
|
10619
|
+
const cleaned = m[2].replace(/["*^():]/g, " ").replace(/\s+/g, " ").trim();
|
|
10620
|
+
if (cleaned)
|
|
10621
|
+
terms.push(`"${cleaned}"*`);
|
|
10622
|
+
}
|
|
10623
|
+
}
|
|
10624
|
+
return terms.join(" AND ");
|
|
10625
|
+
}
|
|
10626
|
+
function boundedLimit(limit) {
|
|
10627
|
+
if (!Number.isFinite(limit) || !limit || limit <= 0)
|
|
10628
|
+
return DEFAULT_SEARCH_LIMIT;
|
|
10629
|
+
return Math.trunc(limit);
|
|
10630
|
+
}
|
|
10631
|
+
function searchTasks(options, projectId, taskListId, db) {
|
|
10632
|
+
const opts = typeof options === "string" ? { query: options || undefined, project_id: projectId, task_list_id: taskListId } : options;
|
|
10633
|
+
const d = db || getDatabase();
|
|
10634
|
+
clearExpiredLocks(d);
|
|
10635
|
+
const params = [];
|
|
10636
|
+
let sql;
|
|
10637
|
+
const raw = opts.query?.trim() ?? "";
|
|
10638
|
+
const q = raw === "*" ? "" : raw;
|
|
10639
|
+
const ftsMatch = q ? buildFtsMatchQuery(q) : "";
|
|
10640
|
+
const useFts = hasFts(d) && q !== "" && ftsMatch !== "";
|
|
10641
|
+
if (useFts) {
|
|
10642
|
+
const pattern = `%${q}%`;
|
|
10643
|
+
sql = `SELECT t.* FROM tasks t
|
|
10644
|
+
LEFT JOIN (
|
|
10645
|
+
SELECT rowid, bm25(tasks_fts, ${BM25_WEIGHTS}) AS rank
|
|
10646
|
+
FROM tasks_fts WHERE tasks_fts MATCH ?
|
|
10647
|
+
) fts ON fts.rowid = t.rowid
|
|
10648
|
+
WHERE (
|
|
10649
|
+
fts.rowid IS NOT NULL
|
|
10650
|
+
OR t.id LIKE ?
|
|
10651
|
+
OR t.short_id LIKE ?
|
|
10652
|
+
OR t.title LIKE ?
|
|
10653
|
+
OR t.description LIKE ?
|
|
10654
|
+
OR t.working_dir LIKE ?
|
|
10655
|
+
OR t.metadata LIKE ?
|
|
10656
|
+
OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = t.id AND tag LIKE ?)
|
|
10657
|
+
)`;
|
|
10658
|
+
params.push(ftsMatch, pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
|
10659
|
+
} else if (q) {
|
|
10660
|
+
const pattern = `%${q}%`;
|
|
10661
|
+
sql = `SELECT t.* FROM tasks t WHERE (
|
|
10662
|
+
t.id LIKE ?
|
|
10663
|
+
OR t.short_id LIKE ?
|
|
10664
|
+
OR t.title LIKE ?
|
|
10665
|
+
OR t.description LIKE ?
|
|
10666
|
+
OR t.working_dir LIKE ?
|
|
10667
|
+
OR t.metadata LIKE ?
|
|
10668
|
+
OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = t.id AND tag LIKE ?)
|
|
10669
|
+
)`;
|
|
10670
|
+
params.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
|
10671
|
+
} else {
|
|
10672
|
+
sql = `SELECT t.* FROM tasks t WHERE 1=1`;
|
|
10673
|
+
}
|
|
10674
|
+
if (opts.project_id) {
|
|
10675
|
+
sql += " AND t.project_id = ?";
|
|
10676
|
+
params.push(opts.project_id);
|
|
10677
|
+
}
|
|
10678
|
+
if (opts.task_list_id) {
|
|
10679
|
+
sql += " AND t.task_list_id = ?";
|
|
10680
|
+
params.push(opts.task_list_id);
|
|
10681
|
+
}
|
|
10682
|
+
if (opts.status) {
|
|
10683
|
+
if (Array.isArray(opts.status)) {
|
|
10684
|
+
sql += ` AND t.status IN (${opts.status.map(() => "?").join(",")})`;
|
|
10685
|
+
params.push(...opts.status);
|
|
10686
|
+
} else {
|
|
10687
|
+
sql += " AND t.status = ?";
|
|
10688
|
+
params.push(opts.status);
|
|
10689
|
+
}
|
|
10690
|
+
}
|
|
10691
|
+
if (opts.priority) {
|
|
10692
|
+
if (Array.isArray(opts.priority)) {
|
|
10693
|
+
sql += ` AND t.priority IN (${opts.priority.map(() => "?").join(",")})`;
|
|
10694
|
+
params.push(...opts.priority);
|
|
10695
|
+
} else {
|
|
10696
|
+
sql += " AND t.priority = ?";
|
|
10697
|
+
params.push(opts.priority);
|
|
10698
|
+
}
|
|
10699
|
+
}
|
|
10700
|
+
if (opts.assigned_to) {
|
|
10701
|
+
sql += ` AND ${lowerInClause("t.assigned_to", resolveAssignedToAliases(d, opts.assigned_to), params)}`;
|
|
10702
|
+
}
|
|
10703
|
+
if (opts.agent_id) {
|
|
10704
|
+
sql += " AND t.agent_id = ?";
|
|
10705
|
+
params.push(opts.agent_id);
|
|
10706
|
+
}
|
|
10707
|
+
if (opts.created_after) {
|
|
10708
|
+
sql += " AND t.created_at > ?";
|
|
10709
|
+
params.push(opts.created_after);
|
|
10710
|
+
}
|
|
10711
|
+
if (opts.updated_after) {
|
|
10712
|
+
sql += " AND t.updated_at > ?";
|
|
10713
|
+
params.push(opts.updated_after);
|
|
10714
|
+
}
|
|
10715
|
+
if (opts.has_dependencies === true) {
|
|
10716
|
+
sql += " AND t.id IN (SELECT task_id FROM task_dependencies)";
|
|
10717
|
+
} else if (opts.has_dependencies === false) {
|
|
10718
|
+
sql += " AND t.id NOT IN (SELECT task_id FROM task_dependencies)";
|
|
10719
|
+
}
|
|
10720
|
+
if (opts.is_blocked === true) {
|
|
10721
|
+
sql += " AND t.id IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
10722
|
+
} else if (opts.is_blocked === false) {
|
|
10723
|
+
sql += " AND t.id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')";
|
|
10724
|
+
}
|
|
10725
|
+
if (useFts) {
|
|
10726
|
+
sql += ` ORDER BY (fts.rowid IS NULL) ASC, fts.rank ASC,
|
|
10727
|
+
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
10728
|
+
t.created_at DESC`;
|
|
10729
|
+
} else {
|
|
10730
|
+
sql += ` ORDER BY
|
|
10731
|
+
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
10732
|
+
t.created_at DESC`;
|
|
10733
|
+
}
|
|
10734
|
+
sql += ` LIMIT ?`;
|
|
10735
|
+
params.push(boundedLimit(opts.limit));
|
|
10736
|
+
const rows = d.query(sql).all(...params);
|
|
10737
|
+
return rows.map(rowToTask);
|
|
10738
|
+
}
|
|
10739
|
+
var DEFAULT_SEARCH_LIMIT = 1000, BM25_WEIGHTS = "0.0, 10.0, 5.0, 3.0";
|
|
10740
|
+
var init_search = __esm(() => {
|
|
10741
|
+
init_database();
|
|
10742
|
+
});
|
|
10743
|
+
|
|
10179
10744
|
// src/db/slug-claims.ts
|
|
10180
10745
|
function taskListSlugScopeKey(projectId) {
|
|
10181
10746
|
return projectId ? `project:${projectId}` : "standalone:";
|
|
@@ -10952,7 +11517,7 @@ var init_runner_sandbox = __esm(() => {
|
|
|
10952
11517
|
});
|
|
10953
11518
|
|
|
10954
11519
|
// src/lib/event-hooks.ts
|
|
10955
|
-
import { createHash as
|
|
11520
|
+
import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
|
|
10956
11521
|
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
10957
11522
|
import { dirname as dirname3, resolve as resolve6 } from "path";
|
|
10958
11523
|
import { createConnection } from "net";
|
|
@@ -11019,7 +11584,7 @@ function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
|
|
|
11019
11584
|
payload: redactValue(payload ?? {}),
|
|
11020
11585
|
source: { package: "@hasna/todos", local_only: true }
|
|
11021
11586
|
};
|
|
11022
|
-
const digest =
|
|
11587
|
+
const digest = createHash4("sha256").update(canonicalEvent(base)).digest("hex");
|
|
11023
11588
|
return { ...base, integrity: { algorithm: "sha256", digest } };
|
|
11024
11589
|
}
|
|
11025
11590
|
function summarize(value) {
|
|
@@ -11906,6 +12471,40 @@ function deleteTaskList(id, db) {
|
|
|
11906
12471
|
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
11907
12472
|
})();
|
|
11908
12473
|
}
|
|
12474
|
+
function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
|
|
12475
|
+
const d = db || getDatabase();
|
|
12476
|
+
return d.transaction(() => {
|
|
12477
|
+
const current = getTaskList(id, d);
|
|
12478
|
+
if (!current) {
|
|
12479
|
+
return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
|
|
12480
|
+
}
|
|
12481
|
+
const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
|
|
12482
|
+
if (changed) {
|
|
12483
|
+
return { status: "changed", task_dependents: 0, plan_dependents: 0 };
|
|
12484
|
+
}
|
|
12485
|
+
const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
|
|
12486
|
+
const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
|
|
12487
|
+
if (taskDependents > 0 || planDependents > 0) {
|
|
12488
|
+
return {
|
|
12489
|
+
status: "has_dependents",
|
|
12490
|
+
task_dependents: taskDependents,
|
|
12491
|
+
plan_dependents: planDependents
|
|
12492
|
+
};
|
|
12493
|
+
}
|
|
12494
|
+
recordStorageTombstone({
|
|
12495
|
+
object_type: "task_lists",
|
|
12496
|
+
object_id: id,
|
|
12497
|
+
payload: current
|
|
12498
|
+
}, d);
|
|
12499
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
12500
|
+
const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
12501
|
+
return {
|
|
12502
|
+
status: deleted ? "deleted" : "not_found",
|
|
12503
|
+
task_dependents: 0,
|
|
12504
|
+
plan_dependents: 0
|
|
12505
|
+
};
|
|
12506
|
+
})();
|
|
12507
|
+
}
|
|
11909
12508
|
function ensureTaskList(name, slug, projectId, db) {
|
|
11910
12509
|
const d = db || getDatabase();
|
|
11911
12510
|
const existing = getTaskListBySlug(slug, projectId, d);
|
|
@@ -12804,6 +13403,14 @@ var init_checklists = __esm(() => {
|
|
|
12804
13403
|
init_database();
|
|
12805
13404
|
});
|
|
12806
13405
|
|
|
13406
|
+
// src/db/plan-row-serialization.ts
|
|
13407
|
+
function guardPlanRowsSqlite(planIds, db) {
|
|
13408
|
+
const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
|
|
13409
|
+
for (const id of ids) {
|
|
13410
|
+
db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
|
|
13411
|
+
}
|
|
13412
|
+
}
|
|
13413
|
+
|
|
12807
13414
|
// src/lib/recurrence.ts
|
|
12808
13415
|
function parseRecurrenceRule(rule) {
|
|
12809
13416
|
const normalized = rule.trim().toLowerCase();
|
|
@@ -14142,9 +14749,23 @@ function sanitizeUpdateTaskInput(input) {
|
|
|
14142
14749
|
metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
|
|
14143
14750
|
};
|
|
14144
14751
|
}
|
|
14145
|
-
function
|
|
14146
|
-
|
|
14147
|
-
|
|
14752
|
+
function linkedPlanProjectId(planId, db) {
|
|
14753
|
+
if (!planId)
|
|
14754
|
+
return null;
|
|
14755
|
+
const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
|
|
14756
|
+
return row?.project_id ?? null;
|
|
14757
|
+
}
|
|
14758
|
+
function resolveCreateProjectForPlan(input, db) {
|
|
14759
|
+
const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
|
|
14760
|
+
if (!linkedProjectId)
|
|
14761
|
+
return input.project_id || null;
|
|
14762
|
+
if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
|
|
14763
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
|
|
14764
|
+
}
|
|
14765
|
+
return linkedProjectId;
|
|
14766
|
+
}
|
|
14767
|
+
function createTaskStored(input, d) {
|
|
14768
|
+
const effectiveProjectId = resolveCreateProjectForPlan(input, d);
|
|
14148
14769
|
const timestamp2 = now();
|
|
14149
14770
|
const tags = input.tags || [];
|
|
14150
14771
|
const machineId = currentStorageMachineId(d);
|
|
@@ -14158,7 +14779,7 @@ function createTask2(input, db) {
|
|
|
14158
14779
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
14159
14780
|
id,
|
|
14160
14781
|
null,
|
|
14161
|
-
|
|
14782
|
+
effectiveProjectId,
|
|
14162
14783
|
input.parent_id || null,
|
|
14163
14784
|
input.plan_id || null,
|
|
14164
14785
|
input.task_list_id || null,
|
|
@@ -14208,7 +14829,15 @@ function createTask2(input, db) {
|
|
|
14208
14829
|
if (tags.length > 0) {
|
|
14209
14830
|
insertTaskTags(id, tags, d);
|
|
14210
14831
|
}
|
|
14211
|
-
|
|
14832
|
+
return getTask(id, d);
|
|
14833
|
+
}
|
|
14834
|
+
function createTask2(input, db) {
|
|
14835
|
+
input = sanitizeCreateTaskInput(input);
|
|
14836
|
+
const d = db || getDatabase();
|
|
14837
|
+
const task = input.plan_id ? d.transaction(() => {
|
|
14838
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
14839
|
+
return createTaskStored(input, d);
|
|
14840
|
+
})() : createTaskStored(input, d);
|
|
14212
14841
|
const payload = taskEventData(task);
|
|
14213
14842
|
const databasePath = databasePathFromDatabase(d);
|
|
14214
14843
|
dispatchWebhook2("task.created", payload, d).catch(() => {});
|
|
@@ -14308,6 +14937,10 @@ function listTasks2(filter = {}, db) {
|
|
|
14308
14937
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14309
14938
|
params.push(filter.not_created_by);
|
|
14310
14939
|
}
|
|
14940
|
+
if (filter.updated_after) {
|
|
14941
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
14942
|
+
params.push(filter.updated_after);
|
|
14943
|
+
}
|
|
14311
14944
|
if (filter.session_id) {
|
|
14312
14945
|
conditions.push("session_id = ?");
|
|
14313
14946
|
params.push(filter.session_id);
|
|
@@ -14468,6 +15101,10 @@ function countTasks(filter = {}, db) {
|
|
|
14468
15101
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14469
15102
|
params.push(filter.not_created_by);
|
|
14470
15103
|
}
|
|
15104
|
+
if (filter.updated_after) {
|
|
15105
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
15106
|
+
params.push(filter.updated_after);
|
|
15107
|
+
}
|
|
14471
15108
|
if (filter.session_id) {
|
|
14472
15109
|
conditions.push("session_id = ?");
|
|
14473
15110
|
params.push(filter.session_id);
|
|
@@ -14507,7 +15144,7 @@ function countTasks(filter = {}, db) {
|
|
|
14507
15144
|
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
14508
15145
|
return row.count;
|
|
14509
15146
|
}
|
|
14510
|
-
function
|
|
15147
|
+
function updateTaskStored(id, input, db) {
|
|
14511
15148
|
const d = db || getDatabase();
|
|
14512
15149
|
const task = getTask(id, d);
|
|
14513
15150
|
if (!task)
|
|
@@ -14516,6 +15153,18 @@ function updateTask2(id, input, db) {
|
|
|
14516
15153
|
throw new VersionConflictError(id, input.version, task.version);
|
|
14517
15154
|
}
|
|
14518
15155
|
input = sanitizeUpdateTaskInput(input);
|
|
15156
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
|
|
15157
|
+
const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
|
|
15158
|
+
if (linkedProjectId) {
|
|
15159
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
|
|
15160
|
+
if (effectiveProjectId !== linkedProjectId) {
|
|
15161
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
|
|
15162
|
+
input = { ...input, project_id: linkedProjectId };
|
|
15163
|
+
} else {
|
|
15164
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
|
|
15165
|
+
}
|
|
15166
|
+
}
|
|
15167
|
+
}
|
|
14519
15168
|
const timestamp2 = now();
|
|
14520
15169
|
const completionTimestamp = input.completed_at ?? timestamp2;
|
|
14521
15170
|
const sets = ["version = version + 1", "updated_at = ?"];
|
|
@@ -14723,6 +15372,21 @@ function updateTask2(id, input, db) {
|
|
|
14723
15372
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
14724
15373
|
return updatedTask;
|
|
14725
15374
|
}
|
|
15375
|
+
function updateTask2(id, input, db) {
|
|
15376
|
+
const d = db || getDatabase();
|
|
15377
|
+
const before = getTask(id, d);
|
|
15378
|
+
if (!before)
|
|
15379
|
+
throw new TaskNotFoundError(id);
|
|
15380
|
+
const guardedPlanIds = [before.plan_id, input.plan_id];
|
|
15381
|
+
if (!guardedPlanIds.some(Boolean))
|
|
15382
|
+
return updateTaskStored(id, input, d);
|
|
15383
|
+
return d.transaction(() => {
|
|
15384
|
+
guardPlanRowsSqlite(guardedPlanIds, d);
|
|
15385
|
+
const current = getTask(id, d);
|
|
15386
|
+
guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
|
|
15387
|
+
return updateTaskStored(id, input, d);
|
|
15388
|
+
})();
|
|
15389
|
+
}
|
|
14726
15390
|
function deleteTask(id, db) {
|
|
14727
15391
|
const d = db || getDatabase();
|
|
14728
15392
|
const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
@@ -15529,7 +16193,7 @@ function listPlans(projectId, db) {
|
|
|
15529
16193
|
}
|
|
15530
16194
|
return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
|
|
15531
16195
|
}
|
|
15532
|
-
function
|
|
16196
|
+
function updatePlanStored(id, input, db) {
|
|
15533
16197
|
const d = db || getDatabase();
|
|
15534
16198
|
const plan = getPlan(id, d);
|
|
15535
16199
|
if (!plan)
|
|
@@ -15574,6 +16238,13 @@ function updatePlan2(id, input, db) {
|
|
|
15574
16238
|
});
|
|
15575
16239
|
return updated;
|
|
15576
16240
|
}
|
|
16241
|
+
function updatePlan2(id, input, db) {
|
|
16242
|
+
const d = db || getDatabase();
|
|
16243
|
+
return d.transaction(() => {
|
|
16244
|
+
guardPlanRowsSqlite([id], d);
|
|
16245
|
+
return updatePlanStored(id, input, d);
|
|
16246
|
+
})();
|
|
16247
|
+
}
|
|
15577
16248
|
function deletePlan(id, db) {
|
|
15578
16249
|
const d = db || getDatabase();
|
|
15579
16250
|
const plan = getPlan(id, d);
|
|
@@ -15998,7 +16669,7 @@ var init_boards = __esm(() => {
|
|
|
15998
16669
|
});
|
|
15999
16670
|
|
|
16000
16671
|
// src/lib/artifact-store.ts
|
|
16001
|
-
import { createHash as
|
|
16672
|
+
import { createHash as createHash5 } from "crypto";
|
|
16002
16673
|
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
16003
16674
|
import { basename, dirname as dirname4, join as join6, resolve as resolve7 } from "path";
|
|
16004
16675
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -16023,7 +16694,7 @@ function artifactStorePath(relativePath) {
|
|
|
16023
16694
|
return join6(artifactStoreRoot(), normalized);
|
|
16024
16695
|
}
|
|
16025
16696
|
function sha2562(buffer) {
|
|
16026
|
-
return
|
|
16697
|
+
return createHash5("sha256").update(buffer).digest("hex");
|
|
16027
16698
|
}
|
|
16028
16699
|
function isTextLike(buffer, path) {
|
|
16029
16700
|
if (buffer.includes(0))
|
|
@@ -18310,6 +18981,187 @@ var init_integrity2 = __esm(() => {
|
|
|
18310
18981
|
REQUIRED_TABLES = { tasks: true, task_lists: true, projects: true };
|
|
18311
18982
|
});
|
|
18312
18983
|
|
|
18984
|
+
// src/db/plan-project-links.ts
|
|
18985
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
18986
|
+
const d = db || getDatabase();
|
|
18987
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
18988
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
18989
|
+
}
|
|
18990
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
18991
|
+
const d = db || getDatabase();
|
|
18992
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
18993
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
18994
|
+
}
|
|
18995
|
+
function exactTasks(planId, db) {
|
|
18996
|
+
return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
18997
|
+
}
|
|
18998
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
18999
|
+
const plan = getPlan(planId, db);
|
|
19000
|
+
const project = getProject(projectId, db);
|
|
19001
|
+
if (!plan || !project) {
|
|
19002
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "An accepted plan-project-link target no longer resolves", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
|
|
19003
|
+
}
|
|
19004
|
+
const tasks = exactTasks(planId, db);
|
|
19005
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
19006
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
|
|
19007
|
+
}
|
|
19008
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
19009
|
+
}
|
|
19010
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
19011
|
+
const d = db || getDatabase();
|
|
19012
|
+
const mutate = d.transaction(() => {
|
|
19013
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
19014
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
19015
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
19016
|
+
if (existingRow) {
|
|
19017
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
19018
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
19019
|
+
}
|
|
19020
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
19021
|
+
if (rolledBack) {
|
|
19022
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
19023
|
+
}
|
|
19024
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
19025
|
+
}
|
|
19026
|
+
const plan = getPlan(input.plan_id, d);
|
|
19027
|
+
if (!plan) {
|
|
19028
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
19029
|
+
}
|
|
19030
|
+
const project = getProject(input.project_id, d);
|
|
19031
|
+
if (!project) {
|
|
19032
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
19033
|
+
}
|
|
19034
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
19035
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
|
|
19036
|
+
}
|
|
19037
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
19038
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
|
|
19039
|
+
}
|
|
19040
|
+
if (plan.slug) {
|
|
19041
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
19042
|
+
if (collision) {
|
|
19043
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
|
|
19044
|
+
}
|
|
19045
|
+
}
|
|
19046
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
19047
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
|
|
19048
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
|
|
19049
|
+
const timestamp2 = input.created_at;
|
|
19050
|
+
if (plan.project_id !== project.id) {
|
|
19051
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
19052
|
+
project.id,
|
|
19053
|
+
timestamp2,
|
|
19054
|
+
plan.id,
|
|
19055
|
+
input.expected_plan_revision
|
|
19056
|
+
]);
|
|
19057
|
+
}
|
|
19058
|
+
for (const task of beforeTasks) {
|
|
19059
|
+
if (task.project_id === project.id)
|
|
19060
|
+
continue;
|
|
19061
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
|
|
19062
|
+
}
|
|
19063
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
19064
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
19065
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
|
|
19066
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Atomic plan-project link readback did not preserve the exact plan membership", { plan_id: plan.id, project_id: project.id });
|
|
19067
|
+
}
|
|
19068
|
+
const receipt = {
|
|
19069
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
19070
|
+
receipt_id: input.receipt_id,
|
|
19071
|
+
idempotency_key: input.idempotency_key,
|
|
19072
|
+
plan_id: plan.id,
|
|
19073
|
+
project_id: project.id,
|
|
19074
|
+
prior_plan_project_id: plan.project_id,
|
|
19075
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
19076
|
+
task_ids: beforeTasks.map((task) => task.id),
|
|
19077
|
+
task_count: beforeTasks.length,
|
|
19078
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
19079
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
19080
|
+
rollback_supported: true,
|
|
19081
|
+
created_at: input.created_at
|
|
19082
|
+
};
|
|
19083
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
19084
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
19085
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
19086
|
+
receipt.receipt_id,
|
|
19087
|
+
receipt.idempotency_key,
|
|
19088
|
+
receipt.plan_id,
|
|
19089
|
+
receipt.project_id,
|
|
19090
|
+
requestHash,
|
|
19091
|
+
JSON.stringify(receipt),
|
|
19092
|
+
receipt.created_at
|
|
19093
|
+
]);
|
|
19094
|
+
return {
|
|
19095
|
+
mode: "apply",
|
|
19096
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
19097
|
+
plan: linkedPlan,
|
|
19098
|
+
project,
|
|
19099
|
+
tasks: linkedTasks,
|
|
19100
|
+
receipt
|
|
19101
|
+
};
|
|
19102
|
+
});
|
|
19103
|
+
return mutate();
|
|
19104
|
+
}
|
|
19105
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
19106
|
+
const d = db || getDatabase();
|
|
19107
|
+
return d.transaction(() => {
|
|
19108
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
19109
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
19110
|
+
if (priorRollback)
|
|
19111
|
+
return JSON.parse(priorRollback.payload);
|
|
19112
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
19113
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
19114
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id, plan_id: input.plan_id, project_id: input.project_id });
|
|
19115
|
+
}
|
|
19116
|
+
const plan = getPlan(input.plan_id, d);
|
|
19117
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
19118
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan?.updated_at ?? null });
|
|
19119
|
+
}
|
|
19120
|
+
const tasks = exactTasks(plan.id, d);
|
|
19121
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
|
|
19122
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
19123
|
+
}
|
|
19124
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
19125
|
+
receipt.prior_plan_project_id,
|
|
19126
|
+
input.restored_at,
|
|
19127
|
+
plan.id,
|
|
19128
|
+
input.expected_plan_revision
|
|
19129
|
+
]);
|
|
19130
|
+
for (const task of tasks) {
|
|
19131
|
+
const priorProjectId = receipt.prior_task_project_ids[task.id];
|
|
19132
|
+
if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
|
|
19133
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Receipt does not contain the exact prior project for every member task", { task_id: task.id, receipt_id: receipt.receipt_id });
|
|
19134
|
+
}
|
|
19135
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [priorProjectId, input.restored_at, task.id, plan.id]);
|
|
19136
|
+
}
|
|
19137
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
19138
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
19139
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
|
|
19140
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
19141
|
+
}
|
|
19142
|
+
const result = {
|
|
19143
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
19144
|
+
action: "restored",
|
|
19145
|
+
plan: restoredPlan,
|
|
19146
|
+
tasks: restoredTasks,
|
|
19147
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
19148
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
19149
|
+
restored_at: input.restored_at
|
|
19150
|
+
};
|
|
19151
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
19152
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
19153
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
19154
|
+
return result;
|
|
19155
|
+
})();
|
|
19156
|
+
}
|
|
19157
|
+
var init_plan_project_links = __esm(() => {
|
|
19158
|
+
init_plan_project_link_contract();
|
|
19159
|
+
init_database();
|
|
19160
|
+
init_plans();
|
|
19161
|
+
init_projects();
|
|
19162
|
+
init_tasks();
|
|
19163
|
+
});
|
|
19164
|
+
|
|
18313
19165
|
// src/storage/sqlite-snapshot.ts
|
|
18314
19166
|
function exportSqliteTodosStorageSnapshot(db) {
|
|
18315
19167
|
const d = db ?? getDatabase();
|
|
@@ -18816,6 +19668,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
18816
19668
|
update: (id, input) => updatePlan2(id, input, database()),
|
|
18817
19669
|
delete: (id) => deletePlan(id, database())
|
|
18818
19670
|
},
|
|
19671
|
+
planProjectLinks: {
|
|
19672
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
19673
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
19674
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
19675
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
19676
|
+
},
|
|
18819
19677
|
agents: {
|
|
18820
19678
|
register: (input) => registerAgent2(input, database()),
|
|
18821
19679
|
get: (id) => getAgent(id, database()),
|
|
@@ -18829,7 +19687,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
18829
19687
|
getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
|
|
18830
19688
|
list: (projectId) => listTaskLists(projectId, database()),
|
|
18831
19689
|
update: (id, input) => updateTaskList2(id, input, database()),
|
|
18832
|
-
delete: (id) => deleteTaskList(id, database())
|
|
19690
|
+
delete: (id) => deleteTaskList(id, database()),
|
|
19691
|
+
deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
|
|
18833
19692
|
},
|
|
18834
19693
|
templates: {
|
|
18835
19694
|
create: (input) => createTemplate2(input, database()),
|
|
@@ -18889,13 +19748,598 @@ var init_local_sqlite = __esm(() => {
|
|
|
18889
19748
|
init_comments();
|
|
18890
19749
|
init_database();
|
|
18891
19750
|
init_integrity2();
|
|
19751
|
+
init_plan_project_links();
|
|
18892
19752
|
init_sqlite_snapshot();
|
|
18893
19753
|
TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18894
19754
|
});
|
|
18895
19755
|
|
|
18896
19756
|
// src/project-registration/sqlite.ts
|
|
18897
|
-
|
|
19757
|
+
function sameSqliteValue(left, right) {
|
|
19758
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
19759
|
+
}
|
|
19760
|
+
function taskListFromRow(row) {
|
|
19761
|
+
return {
|
|
19762
|
+
...row,
|
|
19763
|
+
metadata: JSON.parse(row.metadata || "{}")
|
|
19764
|
+
};
|
|
19765
|
+
}
|
|
19766
|
+
function selectProject(db, id) {
|
|
19767
|
+
return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
|
|
19768
|
+
}
|
|
19769
|
+
function selectTaskList(db, id) {
|
|
19770
|
+
const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
|
|
19771
|
+
return row ? taskListFromRow(row) : null;
|
|
19772
|
+
}
|
|
19773
|
+
function selectProjectConflict(db, path, taskListSlug) {
|
|
19774
|
+
return db.query(`
|
|
19775
|
+
SELECT * FROM projects
|
|
19776
|
+
WHERE path = ? OR task_list_id = ?
|
|
19777
|
+
ORDER BY created_at ASC, id ASC
|
|
19778
|
+
LIMIT 1
|
|
19779
|
+
`).get(path, taskListSlug);
|
|
19780
|
+
}
|
|
19781
|
+
function selectTaskListConflict(db, projectId, slug) {
|
|
19782
|
+
const row = db.query(`
|
|
19783
|
+
SELECT * FROM task_lists
|
|
19784
|
+
WHERE project_id = ? AND slug = ?
|
|
19785
|
+
LIMIT 1
|
|
19786
|
+
`).get(projectId, slug);
|
|
19787
|
+
return row ? taskListFromRow(row) : null;
|
|
19788
|
+
}
|
|
19789
|
+
function quoteSqliteIdentifier(value) {
|
|
19790
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
19791
|
+
}
|
|
19792
|
+
function hasSqliteDependents(db, resourceKind, targetId) {
|
|
19793
|
+
const targetTable = resourceKind === "project" ? "projects" : "task_lists";
|
|
19794
|
+
const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
|
|
19795
|
+
const tables = db.query(`
|
|
19796
|
+
SELECT name FROM sqlite_schema
|
|
19797
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
19798
|
+
ORDER BY name
|
|
19799
|
+
`).all();
|
|
19800
|
+
for (const { name: tableName } of tables) {
|
|
19801
|
+
const quotedTable = quoteSqliteIdentifier(tableName);
|
|
19802
|
+
const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
|
|
19803
|
+
const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
|
|
19804
|
+
const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
|
|
19805
|
+
for (const columnName of referenceColumns) {
|
|
19806
|
+
const row = db.query(`
|
|
19807
|
+
SELECT 1 AS found
|
|
19808
|
+
FROM ${quotedTable}
|
|
19809
|
+
WHERE ${quoteSqliteIdentifier(columnName)} = ?
|
|
19810
|
+
LIMIT 1
|
|
19811
|
+
`).get(targetId);
|
|
19812
|
+
if (row)
|
|
19813
|
+
return true;
|
|
19814
|
+
}
|
|
19815
|
+
}
|
|
19816
|
+
return false;
|
|
19817
|
+
}
|
|
19818
|
+
function receiptFromRow2(row) {
|
|
19819
|
+
return {
|
|
19820
|
+
...row,
|
|
19821
|
+
authority: "todos",
|
|
19822
|
+
created_by_operation: Number(row["created_by_operation"]) === 1
|
|
19823
|
+
};
|
|
19824
|
+
}
|
|
19825
|
+
function bindingFromRow2(row) {
|
|
19826
|
+
return row;
|
|
19827
|
+
}
|
|
19828
|
+
|
|
19829
|
+
class SqliteTodosProjectRegistrationTransaction {
|
|
19830
|
+
db;
|
|
19831
|
+
storage;
|
|
19832
|
+
constructor(db) {
|
|
19833
|
+
this.db = db;
|
|
19834
|
+
this.storage = createLocalSqliteTodosStorageAdapter({ db });
|
|
19835
|
+
}
|
|
19836
|
+
async lockStep(_identity) {}
|
|
19837
|
+
async getReceiptForLookup(identity) {
|
|
19838
|
+
const row = this.db.query(`
|
|
19839
|
+
SELECT * FROM todos_project_registration_receipts
|
|
19840
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19841
|
+
AND operation_id = ? AND step_id = ? AND resource_kind = ?
|
|
19842
|
+
AND direction = ? AND idempotency_key = ? AND target_selector = ?
|
|
19843
|
+
ORDER BY CASE outcome
|
|
19844
|
+
WHEN 'terminal_nonacceptance' THEN 0
|
|
19845
|
+
WHEN 'duplicate_of_accepted' THEN 1
|
|
19846
|
+
ELSE 2
|
|
19847
|
+
END, created_at DESC, receipt_id DESC
|
|
19848
|
+
LIMIT 1
|
|
19849
|
+
`).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction, identity.idempotency_key, identity.target_selector);
|
|
19850
|
+
return row ? receiptFromRow2(row) : null;
|
|
19851
|
+
}
|
|
19852
|
+
async getReceiptById(receiptId) {
|
|
19853
|
+
const row = this.db.query("SELECT * FROM todos_project_registration_receipts WHERE receipt_id = ? LIMIT 1").get(receiptId);
|
|
19854
|
+
return row ? receiptFromRow2(row) : null;
|
|
19855
|
+
}
|
|
19856
|
+
async getAcceptedReceiptForStep(identity) {
|
|
19857
|
+
const row = this.db.query(`
|
|
19858
|
+
SELECT * FROM todos_project_registration_receipts
|
|
19859
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19860
|
+
AND operation_id = ? AND step_id = ? AND resource_kind = ?
|
|
19861
|
+
AND direction = ? AND outcome = 'accepted'
|
|
19862
|
+
ORDER BY created_at ASC, receipt_id ASC
|
|
19863
|
+
LIMIT 1
|
|
19864
|
+
`).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction);
|
|
19865
|
+
return row ? receiptFromRow2(row) : null;
|
|
19866
|
+
}
|
|
19867
|
+
async insertReceipt(receipt) {
|
|
19868
|
+
const result = this.db.query(`
|
|
19869
|
+
INSERT OR IGNORE INTO todos_project_registration_receipts (
|
|
19870
|
+
receipt_id, authority, route, package_version, authority_id, tenant_id,
|
|
19871
|
+
corpus_id, operation_id, step_id, resource_kind, direction,
|
|
19872
|
+
target_selector, idempotency_key, request_digest, precondition_digest,
|
|
19873
|
+
normalized_call_digest, outcome, reason, target_id, result_revision,
|
|
19874
|
+
result_digest, duplicate_of_receipt_id, accepted_receipt_id,
|
|
19875
|
+
created_by_operation, created_at
|
|
19876
|
+
) VALUES (
|
|
19877
|
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
19878
|
+
)
|
|
19879
|
+
`).run(receipt.receipt_id, receipt.authority, receipt.route, receipt.package_version, receipt.authority_id, receipt.tenant_id, receipt.corpus_id, receipt.operation_id, receipt.step_id, receipt.resource_kind, receipt.direction, receipt.target_selector, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.normalized_call_digest, receipt.outcome, receipt.reason, receipt.target_id, receipt.result_revision, receipt.result_digest, receipt.duplicate_of_receipt_id, receipt.accepted_receipt_id, receipt.created_by_operation ? 1 : 0, receipt.created_at);
|
|
19880
|
+
return result.changes === 1;
|
|
19881
|
+
}
|
|
19882
|
+
async getBinding(scope, resourceKind, targetSelector) {
|
|
19883
|
+
const row = this.db.query(`
|
|
19884
|
+
SELECT * FROM todos_project_registration_bindings
|
|
19885
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19886
|
+
AND resource_kind = ? AND target_selector = ?
|
|
19887
|
+
LIMIT 1
|
|
19888
|
+
`).get(scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
19889
|
+
return row ? bindingFromRow2(row) : null;
|
|
19890
|
+
}
|
|
19891
|
+
async claimBinding(binding) {
|
|
19892
|
+
const result = this.db.query(`
|
|
19893
|
+
INSERT OR IGNORE INTO todos_project_registration_bindings (
|
|
19894
|
+
authority_id, tenant_id, corpus_id, resource_kind, target_selector,
|
|
19895
|
+
operation_id, step_id, direction, idempotency_key, request_digest,
|
|
19896
|
+
precondition_digest, normalized_call_digest, state, target_id,
|
|
19897
|
+
accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
|
|
19898
|
+
created_at, updated_at
|
|
19899
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
19900
|
+
`).run(binding.authority_id, binding.tenant_id, binding.corpus_id, binding.resource_kind, binding.target_selector, binding.operation_id, binding.step_id, binding.direction, binding.idempotency_key, binding.request_digest, binding.precondition_digest, binding.normalized_call_digest, binding.state, binding.target_id, binding.accepted_receipt_id, binding.result_revision, binding.result_digest, binding.removed_receipt_id, binding.created_at, binding.updated_at);
|
|
19901
|
+
return result.changes === 1;
|
|
19902
|
+
}
|
|
19903
|
+
async setBindingAccepted(scope, resourceKind, targetSelector, update) {
|
|
19904
|
+
const result = this.db.query(`
|
|
19905
|
+
UPDATE todos_project_registration_bindings
|
|
19906
|
+
SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
|
|
19907
|
+
result_revision = ?, result_digest = ?, updated_at = ?
|
|
19908
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19909
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
19910
|
+
`).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
19911
|
+
if (result.changes !== 1) {
|
|
19912
|
+
throw new Error("Todos project registration binding was not pending at acceptance");
|
|
19913
|
+
}
|
|
19914
|
+
}
|
|
19915
|
+
async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
|
|
19916
|
+
this.db.query(`
|
|
19917
|
+
UPDATE todos_project_registration_bindings
|
|
19918
|
+
SET state = 'terminal_nonacceptance', updated_at = ?
|
|
19919
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19920
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
19921
|
+
`).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
19922
|
+
}
|
|
19923
|
+
async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
|
|
19924
|
+
const result = this.db.query(`
|
|
19925
|
+
UPDATE todos_project_registration_bindings
|
|
19926
|
+
SET state = 'removed', removed_receipt_id = ?, updated_at = ?
|
|
19927
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
19928
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
|
|
19929
|
+
`).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
19930
|
+
if (result.changes !== 1) {
|
|
19931
|
+
throw new Error("Todos project registration binding was not accepted at removal");
|
|
19932
|
+
}
|
|
19933
|
+
}
|
|
19934
|
+
async findProjectConflict(path, taskListSlug) {
|
|
19935
|
+
const row = this.db.query(`
|
|
19936
|
+
SELECT * FROM projects
|
|
19937
|
+
WHERE path = ? OR task_list_id = ?
|
|
19938
|
+
ORDER BY created_at ASC, id ASC
|
|
19939
|
+
LIMIT 1
|
|
19940
|
+
`).get(path, taskListSlug);
|
|
19941
|
+
return row ?? null;
|
|
19942
|
+
}
|
|
19943
|
+
async findTaskListConflict(projectId, slug) {
|
|
19944
|
+
return await this.storage.taskLists.getBySlug(slug, projectId);
|
|
19945
|
+
}
|
|
19946
|
+
async createProject(input) {
|
|
19947
|
+
return await this.storage.projects.create(input);
|
|
19948
|
+
}
|
|
19949
|
+
async createTaskList(input) {
|
|
19950
|
+
return await this.storage.taskLists.create(input);
|
|
19951
|
+
}
|
|
19952
|
+
async getProject(id) {
|
|
19953
|
+
return await this.storage.projects.get(id);
|
|
19954
|
+
}
|
|
19955
|
+
async getTaskList(id) {
|
|
19956
|
+
return await this.storage.taskLists.get(id);
|
|
19957
|
+
}
|
|
19958
|
+
async lockCompensationWrites() {}
|
|
19959
|
+
async hasDependents(resourceKind, targetId) {
|
|
19960
|
+
return hasSqliteDependents(this.db, resourceKind, targetId);
|
|
19961
|
+
}
|
|
19962
|
+
async deleteProject(id) {
|
|
19963
|
+
return await this.storage.projects.delete(id);
|
|
19964
|
+
}
|
|
19965
|
+
async deleteTaskList(id) {
|
|
19966
|
+
return await this.storage.taskLists.delete(id);
|
|
19967
|
+
}
|
|
19968
|
+
}
|
|
19969
|
+
|
|
19970
|
+
class StagedSqliteTodosProjectRegistrationTransaction {
|
|
19971
|
+
db;
|
|
19972
|
+
direct;
|
|
19973
|
+
validators = [];
|
|
19974
|
+
mutations = [];
|
|
19975
|
+
receipts = new Map;
|
|
19976
|
+
bindings = new Map;
|
|
19977
|
+
projects = new Map;
|
|
19978
|
+
taskLists = new Map;
|
|
19979
|
+
constructor(db) {
|
|
19980
|
+
this.db = db;
|
|
19981
|
+
this.direct = new SqliteTodosProjectRegistrationTransaction(db);
|
|
19982
|
+
}
|
|
19983
|
+
commit() {
|
|
19984
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
19985
|
+
try {
|
|
19986
|
+
for (const validate of this.validators) {
|
|
19987
|
+
if (!validate()) {
|
|
19988
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
|
|
19989
|
+
}
|
|
19990
|
+
}
|
|
19991
|
+
for (const mutate of this.mutations)
|
|
19992
|
+
mutate();
|
|
19993
|
+
this.db.exec("COMMIT");
|
|
19994
|
+
} catch (error) {
|
|
19995
|
+
try {
|
|
19996
|
+
this.db.exec("ROLLBACK");
|
|
19997
|
+
} catch {}
|
|
19998
|
+
throw error;
|
|
19999
|
+
}
|
|
20000
|
+
}
|
|
20001
|
+
async lockStep(_identity) {}
|
|
20002
|
+
async getReceiptForLookup(identity) {
|
|
20003
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
|
|
20004
|
+
const stored = await this.direct.getReceiptForLookup(identity);
|
|
20005
|
+
if (stored)
|
|
20006
|
+
staged.push(stored);
|
|
20007
|
+
const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
|
|
20008
|
+
return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
|
|
20009
|
+
}
|
|
20010
|
+
async getReceiptById(receiptId) {
|
|
20011
|
+
return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
|
|
20012
|
+
}
|
|
20013
|
+
async getAcceptedReceiptForStep(identity) {
|
|
20014
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
|
|
20015
|
+
const stored = await this.direct.getAcceptedReceiptForStep(identity);
|
|
20016
|
+
if (stored)
|
|
20017
|
+
staged.push(stored);
|
|
20018
|
+
return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
|
|
20019
|
+
}
|
|
20020
|
+
async insertReceipt(receipt) {
|
|
20021
|
+
if (this.receipts.has(receipt.receipt_id))
|
|
20022
|
+
return false;
|
|
20023
|
+
if (await this.direct.getReceiptById(receipt.receipt_id))
|
|
20024
|
+
return false;
|
|
20025
|
+
const planned = { ...receipt };
|
|
20026
|
+
this.receipts.set(planned.receipt_id, planned);
|
|
20027
|
+
this.mutations.push(() => {
|
|
20028
|
+
try {
|
|
20029
|
+
const result = this.db.query(`
|
|
20030
|
+
INSERT OR IGNORE INTO todos_project_registration_receipts (
|
|
20031
|
+
receipt_id, authority, route, package_version, authority_id, tenant_id,
|
|
20032
|
+
corpus_id, operation_id, step_id, resource_kind, direction,
|
|
20033
|
+
target_selector, idempotency_key, request_digest, precondition_digest,
|
|
20034
|
+
normalized_call_digest, outcome, reason, target_id, result_revision,
|
|
20035
|
+
result_digest, duplicate_of_receipt_id, accepted_receipt_id,
|
|
20036
|
+
created_by_operation, created_at
|
|
20037
|
+
) VALUES (
|
|
20038
|
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
20039
|
+
)
|
|
20040
|
+
`).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
|
|
20041
|
+
if (result.changes !== 1) {
|
|
20042
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
|
|
20043
|
+
}
|
|
20044
|
+
} catch (error) {
|
|
20045
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
20046
|
+
throw error;
|
|
20047
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
|
|
20048
|
+
}
|
|
20049
|
+
});
|
|
20050
|
+
return true;
|
|
20051
|
+
}
|
|
20052
|
+
async getBinding(scope, resourceKind, targetSelector) {
|
|
20053
|
+
const key = this.bindingKey(scope, resourceKind, targetSelector);
|
|
20054
|
+
return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
|
|
20055
|
+
}
|
|
20056
|
+
async claimBinding(binding) {
|
|
20057
|
+
const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
|
|
20058
|
+
if (this.bindings.has(key))
|
|
20059
|
+
return false;
|
|
20060
|
+
if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
|
|
20061
|
+
return false;
|
|
20062
|
+
}
|
|
20063
|
+
const planned = { ...binding };
|
|
20064
|
+
this.bindings.set(key, planned);
|
|
20065
|
+
this.mutations.push(() => {
|
|
20066
|
+
try {
|
|
20067
|
+
const result = this.db.query(`
|
|
20068
|
+
INSERT OR IGNORE INTO todos_project_registration_bindings (
|
|
20069
|
+
authority_id, tenant_id, corpus_id, resource_kind, target_selector,
|
|
20070
|
+
operation_id, step_id, direction, idempotency_key, request_digest,
|
|
20071
|
+
precondition_digest, normalized_call_digest, state, target_id,
|
|
20072
|
+
accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
|
|
20073
|
+
created_at, updated_at
|
|
20074
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
20075
|
+
`).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
|
|
20076
|
+
if (result.changes !== 1) {
|
|
20077
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
|
|
20078
|
+
}
|
|
20079
|
+
} catch (error) {
|
|
20080
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
20081
|
+
throw error;
|
|
20082
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
|
|
20083
|
+
}
|
|
20084
|
+
});
|
|
20085
|
+
return true;
|
|
20086
|
+
}
|
|
20087
|
+
async setBindingAccepted(scope, resourceKind, targetSelector, update) {
|
|
20088
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
20089
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
20090
|
+
...binding,
|
|
20091
|
+
state: "accepted",
|
|
20092
|
+
target_id: update.target_id,
|
|
20093
|
+
accepted_receipt_id: update.accepted_receipt_id,
|
|
20094
|
+
result_revision: update.result_revision,
|
|
20095
|
+
result_digest: update.result_digest,
|
|
20096
|
+
updated_at: update.updated_at
|
|
20097
|
+
});
|
|
20098
|
+
this.mutations.push(() => {
|
|
20099
|
+
const result = this.db.query(`
|
|
20100
|
+
UPDATE todos_project_registration_bindings
|
|
20101
|
+
SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
|
|
20102
|
+
result_revision = ?, result_digest = ?, updated_at = ?
|
|
20103
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
20104
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
20105
|
+
`).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
20106
|
+
if (result.changes !== 1) {
|
|
20107
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
20108
|
+
}
|
|
20109
|
+
});
|
|
20110
|
+
}
|
|
20111
|
+
async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
|
|
20112
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
20113
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
20114
|
+
...binding,
|
|
20115
|
+
state: "terminal_nonacceptance",
|
|
20116
|
+
updated_at: updatedAt
|
|
20117
|
+
});
|
|
20118
|
+
this.mutations.push(() => {
|
|
20119
|
+
const result = this.db.query(`
|
|
20120
|
+
UPDATE todos_project_registration_bindings
|
|
20121
|
+
SET state = 'terminal_nonacceptance', updated_at = ?
|
|
20122
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
20123
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
20124
|
+
`).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
20125
|
+
if (result.changes !== 1) {
|
|
20126
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
20127
|
+
}
|
|
20128
|
+
});
|
|
20129
|
+
}
|
|
20130
|
+
async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
|
|
20131
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
|
|
20132
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
20133
|
+
...binding,
|
|
20134
|
+
state: "removed",
|
|
20135
|
+
removed_receipt_id: removedReceiptId,
|
|
20136
|
+
updated_at: updatedAt
|
|
20137
|
+
});
|
|
20138
|
+
this.mutations.push(() => {
|
|
20139
|
+
const result = this.db.query(`
|
|
20140
|
+
UPDATE todos_project_registration_bindings
|
|
20141
|
+
SET state = 'removed', removed_receipt_id = ?, updated_at = ?
|
|
20142
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
20143
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
|
|
20144
|
+
`).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
20145
|
+
if (result.changes !== 1) {
|
|
20146
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
|
|
20147
|
+
}
|
|
20148
|
+
});
|
|
20149
|
+
}
|
|
20150
|
+
async findProjectConflict(path, taskListSlug) {
|
|
20151
|
+
const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
|
|
20152
|
+
if (planned)
|
|
20153
|
+
return planned;
|
|
20154
|
+
const observed = selectProjectConflict(this.db, path, taskListSlug);
|
|
20155
|
+
this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
|
|
20156
|
+
return observed;
|
|
20157
|
+
}
|
|
20158
|
+
async findTaskListConflict(projectId, slug) {
|
|
20159
|
+
const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
|
|
20160
|
+
if (planned)
|
|
20161
|
+
return planned;
|
|
20162
|
+
const observed = selectTaskListConflict(this.db, projectId, slug);
|
|
20163
|
+
this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
|
|
20164
|
+
return observed;
|
|
20165
|
+
}
|
|
20166
|
+
async createProject(input) {
|
|
20167
|
+
const derivedSlug = normalizeSlug(input.name);
|
|
20168
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
|
|
20169
|
+
if (!derivedSlug || !taskListId) {
|
|
20170
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
20171
|
+
}
|
|
20172
|
+
const project = {
|
|
20173
|
+
id: uuid(),
|
|
20174
|
+
name: input.name,
|
|
20175
|
+
path: input.path,
|
|
20176
|
+
description: input.description || null,
|
|
20177
|
+
task_list_id: taskListId,
|
|
20178
|
+
task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
|
|
20179
|
+
task_counter: 0,
|
|
20180
|
+
created_at: now(),
|
|
20181
|
+
updated_at: now(),
|
|
20182
|
+
machine_id: currentStorageMachineId(this.db)
|
|
20183
|
+
};
|
|
20184
|
+
project.updated_at = project.created_at;
|
|
20185
|
+
this.projects.set(project.id, project);
|
|
20186
|
+
this.mutations.push(() => {
|
|
20187
|
+
try {
|
|
20188
|
+
const result = this.db.run(`INSERT INTO projects (
|
|
20189
|
+
id, name, path, description, task_list_id, task_prefix,
|
|
20190
|
+
task_counter, created_at, updated_at, machine_id
|
|
20191
|
+
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
|
|
20192
|
+
project.id,
|
|
20193
|
+
project.name,
|
|
20194
|
+
project.path,
|
|
20195
|
+
project.description,
|
|
20196
|
+
project.task_list_id,
|
|
20197
|
+
project.task_prefix,
|
|
20198
|
+
project.created_at,
|
|
20199
|
+
project.updated_at,
|
|
20200
|
+
project.machine_id ?? null
|
|
20201
|
+
]);
|
|
20202
|
+
if (result.changes < 1) {
|
|
20203
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
|
|
20204
|
+
}
|
|
20205
|
+
} catch (error) {
|
|
20206
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
20207
|
+
throw error;
|
|
20208
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
|
|
20209
|
+
}
|
|
20210
|
+
});
|
|
20211
|
+
return project;
|
|
20212
|
+
}
|
|
20213
|
+
async createTaskList(input) {
|
|
20214
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
20215
|
+
if (!slug)
|
|
20216
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
20217
|
+
const taskList = {
|
|
20218
|
+
id: uuid(),
|
|
20219
|
+
project_id: input.project_id || null,
|
|
20220
|
+
slug,
|
|
20221
|
+
name: input.name,
|
|
20222
|
+
description: input.description || null,
|
|
20223
|
+
metadata: input.metadata ?? {},
|
|
20224
|
+
created_at: now(),
|
|
20225
|
+
updated_at: now(),
|
|
20226
|
+
machine_id: currentStorageMachineId(this.db)
|
|
20227
|
+
};
|
|
20228
|
+
taskList.updated_at = taskList.created_at;
|
|
20229
|
+
this.taskLists.set(taskList.id, taskList);
|
|
20230
|
+
this.mutations.push(() => {
|
|
20231
|
+
try {
|
|
20232
|
+
const result = this.db.run(`INSERT INTO task_lists (
|
|
20233
|
+
id, project_id, slug, name, description, metadata,
|
|
20234
|
+
created_at, updated_at, machine_id
|
|
20235
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
20236
|
+
taskList.id,
|
|
20237
|
+
taskList.project_id,
|
|
20238
|
+
taskList.slug,
|
|
20239
|
+
taskList.name,
|
|
20240
|
+
taskList.description,
|
|
20241
|
+
JSON.stringify(taskList.metadata),
|
|
20242
|
+
taskList.created_at,
|
|
20243
|
+
taskList.updated_at,
|
|
20244
|
+
taskList.machine_id ?? null
|
|
20245
|
+
]);
|
|
20246
|
+
if (result.changes < 1) {
|
|
20247
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
|
|
20248
|
+
}
|
|
20249
|
+
} catch (error) {
|
|
20250
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
20251
|
+
throw error;
|
|
20252
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
|
|
20253
|
+
}
|
|
20254
|
+
});
|
|
20255
|
+
return taskList;
|
|
20256
|
+
}
|
|
20257
|
+
async getProject(id) {
|
|
20258
|
+
if (this.projects.has(id))
|
|
20259
|
+
return this.projects.get(id) ?? null;
|
|
20260
|
+
const observed = selectProject(this.db, id);
|
|
20261
|
+
this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
|
|
20262
|
+
return observed;
|
|
20263
|
+
}
|
|
20264
|
+
async getTaskList(id) {
|
|
20265
|
+
if (this.taskLists.has(id))
|
|
20266
|
+
return this.taskLists.get(id) ?? null;
|
|
20267
|
+
const observed = selectTaskList(this.db, id);
|
|
20268
|
+
this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
|
|
20269
|
+
return observed;
|
|
20270
|
+
}
|
|
20271
|
+
async lockCompensationWrites() {}
|
|
20272
|
+
async hasDependents(resourceKind, targetId) {
|
|
20273
|
+
const observed = hasSqliteDependents(this.db, resourceKind, targetId);
|
|
20274
|
+
this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
|
|
20275
|
+
return observed;
|
|
20276
|
+
}
|
|
20277
|
+
async deleteProject(id) {
|
|
20278
|
+
const project = await this.getProject(id);
|
|
20279
|
+
if (!project)
|
|
20280
|
+
return false;
|
|
20281
|
+
this.projects.set(id, null);
|
|
20282
|
+
this.mutations.push(() => {
|
|
20283
|
+
recordStorageTombstone({
|
|
20284
|
+
object_type: "projects",
|
|
20285
|
+
object_id: id,
|
|
20286
|
+
payload: project
|
|
20287
|
+
}, this.db);
|
|
20288
|
+
if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
|
|
20289
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
|
|
20290
|
+
}
|
|
20291
|
+
});
|
|
20292
|
+
return true;
|
|
20293
|
+
}
|
|
20294
|
+
async deleteTaskList(id) {
|
|
20295
|
+
const taskList = await this.getTaskList(id);
|
|
20296
|
+
if (!taskList)
|
|
20297
|
+
return false;
|
|
20298
|
+
this.taskLists.set(id, null);
|
|
20299
|
+
this.mutations.push(() => {
|
|
20300
|
+
recordStorageTombstone({
|
|
20301
|
+
object_type: "task_lists",
|
|
20302
|
+
object_id: id,
|
|
20303
|
+
payload: taskList
|
|
20304
|
+
}, this.db);
|
|
20305
|
+
if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
|
|
20306
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
|
|
20307
|
+
}
|
|
20308
|
+
});
|
|
20309
|
+
return true;
|
|
20310
|
+
}
|
|
20311
|
+
bindingKey(scope, resourceKind, targetSelector) {
|
|
20312
|
+
return JSON.stringify([
|
|
20313
|
+
scope.authority_id,
|
|
20314
|
+
scope.tenant_id,
|
|
20315
|
+
scope.corpus_id,
|
|
20316
|
+
resourceKind,
|
|
20317
|
+
targetSelector
|
|
20318
|
+
]);
|
|
20319
|
+
}
|
|
20320
|
+
async requireBinding(scope, resourceKind, targetSelector, state) {
|
|
20321
|
+
const binding = await this.getBinding(scope, resourceKind, targetSelector);
|
|
20322
|
+
if (!binding || binding.state !== state) {
|
|
20323
|
+
throw new Error(`Todos project registration binding was not ${state}`);
|
|
20324
|
+
}
|
|
20325
|
+
return binding;
|
|
20326
|
+
}
|
|
20327
|
+
availableProjectPrefix(name) {
|
|
20328
|
+
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
20329
|
+
const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
|
|
20330
|
+
let candidate = prefix;
|
|
20331
|
+
let suffix = 1;
|
|
20332
|
+
while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
|
|
20333
|
+
suffix += 1;
|
|
20334
|
+
candidate = `${prefix}${suffix}`;
|
|
20335
|
+
}
|
|
20336
|
+
return candidate;
|
|
20337
|
+
}
|
|
20338
|
+
}
|
|
20339
|
+
var sqliteTransactionTails, PROJECT_REFERENCE_COLUMNS, TASK_LIST_REFERENCE_COLUMNS, SqliteRegistrationOptimisticConflict;
|
|
18898
20340
|
var init_sqlite = __esm(() => {
|
|
20341
|
+
init_database();
|
|
20342
|
+
init_storage_tombstones();
|
|
18899
20343
|
init_local_sqlite();
|
|
18900
20344
|
sqliteTransactionTails = new WeakMap;
|
|
18901
20345
|
PROJECT_REFERENCE_COLUMNS = new Set([
|
|
@@ -18905,10 +20349,16 @@ var init_sqlite = __esm(() => {
|
|
|
18905
20349
|
"external_project_id"
|
|
18906
20350
|
]);
|
|
18907
20351
|
TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
|
|
20352
|
+
SqliteRegistrationOptimisticConflict = class SqliteRegistrationOptimisticConflict extends Error {
|
|
20353
|
+
constructor(message, options = {}) {
|
|
20354
|
+
super(message, options);
|
|
20355
|
+
this.name = "SqliteRegistrationOptimisticConflict";
|
|
20356
|
+
}
|
|
20357
|
+
};
|
|
18908
20358
|
});
|
|
18909
20359
|
|
|
18910
20360
|
// src/project-registration/authority.ts
|
|
18911
|
-
import { createHash as
|
|
20361
|
+
import { createHash as createHash6 } from "crypto";
|
|
18912
20362
|
function canonicalProjectRegistrationJson(value) {
|
|
18913
20363
|
return JSON.stringify(canonicalize(value));
|
|
18914
20364
|
}
|
|
@@ -18926,7 +20376,7 @@ function canonicalize(value) {
|
|
|
18926
20376
|
return out;
|
|
18927
20377
|
}
|
|
18928
20378
|
function digestProjectRegistrationValue(value) {
|
|
18929
|
-
return
|
|
20379
|
+
return createHash6("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
|
|
18930
20380
|
}
|
|
18931
20381
|
function deriveTodosProjectRegistrationIdempotencyKey(input) {
|
|
18932
20382
|
return `prk_${digestProjectRegistrationValue({
|
|
@@ -20204,7 +21654,7 @@ var init_cloud = __esm(() => {
|
|
|
20204
21654
|
});
|
|
20205
21655
|
|
|
20206
21656
|
// src/db/api-keys.ts
|
|
20207
|
-
import { createHash as
|
|
21657
|
+
import { createHash as createHash7, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
20208
21658
|
function rowToRecord2(row) {
|
|
20209
21659
|
return {
|
|
20210
21660
|
id: row.id,
|
|
@@ -20218,7 +21668,7 @@ function rowToRecord2(row) {
|
|
|
20218
21668
|
};
|
|
20219
21669
|
}
|
|
20220
21670
|
function hashApiKey(key) {
|
|
20221
|
-
return
|
|
21671
|
+
return createHash7("sha256").update(key).digest("hex");
|
|
20222
21672
|
}
|
|
20223
21673
|
function safeEqualHex(a, b) {
|
|
20224
21674
|
if (a.length !== b.length)
|
|
@@ -20226,8 +21676,8 @@ function safeEqualHex(a, b) {
|
|
|
20226
21676
|
return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
20227
21677
|
}
|
|
20228
21678
|
function safeEqualStrings(a, b) {
|
|
20229
|
-
const ah =
|
|
20230
|
-
const bh =
|
|
21679
|
+
const ah = createHash7("sha256").update(a, "utf8").digest();
|
|
21680
|
+
const bh = createHash7("sha256").update(b, "utf8").digest();
|
|
20231
21681
|
return timingSafeEqual3(ah, bh);
|
|
20232
21682
|
}
|
|
20233
21683
|
function hasActiveApiKeys(db) {
|
|
@@ -22248,8 +23698,14 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22248
23698
|
Task: taskSchema,
|
|
22249
23699
|
Project: projectSchema,
|
|
22250
23700
|
TaskList: taskListSchema,
|
|
23701
|
+
ProjectTaskListEnsureReceipt: projectTaskListEnsureReceiptSchema,
|
|
23702
|
+
ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
|
|
23703
|
+
ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
|
|
22251
23704
|
TaskComment: taskCommentSchema,
|
|
22252
23705
|
Plan: planSchema,
|
|
23706
|
+
PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
|
|
23707
|
+
PlanProjectLinkResult: planProjectLinkResultSchema,
|
|
23708
|
+
PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
|
|
22253
23709
|
Template: templateSchema,
|
|
22254
23710
|
TemplateTask: templateTaskSchema,
|
|
22255
23711
|
TemplateVariable: templateVariableSchema,
|
|
@@ -22263,6 +23719,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22263
23719
|
status: { type: "string" },
|
|
22264
23720
|
priority: { type: "string" },
|
|
22265
23721
|
project_id: { type: "string" },
|
|
23722
|
+
plan_id: { type: "string" },
|
|
22266
23723
|
assigned_to: { type: "string" },
|
|
22267
23724
|
agent_id: { type: "string" },
|
|
22268
23725
|
tags: { type: "array", items: { type: "string" } }
|
|
@@ -22277,6 +23734,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22277
23734
|
priority: { type: "string" },
|
|
22278
23735
|
assigned_to: { type: "string" },
|
|
22279
23736
|
project_id: { type: "string", nullable: true },
|
|
23737
|
+
plan_id: { type: "string", nullable: true },
|
|
22280
23738
|
task_list_id: { type: "string", nullable: true },
|
|
22281
23739
|
version: { type: "number" }
|
|
22282
23740
|
}
|
|
@@ -22325,6 +23783,55 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22325
23783
|
name: { type: "string", minLength: 1 }
|
|
22326
23784
|
}
|
|
22327
23785
|
},
|
|
23786
|
+
ProjectTaskListEnsureApplyInput: {
|
|
23787
|
+
type: "object",
|
|
23788
|
+
additionalProperties: false,
|
|
23789
|
+
required: ["expected_project_revision"],
|
|
23790
|
+
properties: {
|
|
23791
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
23792
|
+
idempotency_key: {
|
|
23793
|
+
type: "string",
|
|
23794
|
+
minLength: 8,
|
|
23795
|
+
maxLength: 128,
|
|
23796
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
23797
|
+
}
|
|
23798
|
+
}
|
|
23799
|
+
},
|
|
23800
|
+
ProjectTaskListRollbackInput: {
|
|
23801
|
+
type: "object",
|
|
23802
|
+
additionalProperties: false,
|
|
23803
|
+
required: ["receipt_id", "expected_task_list_revision"],
|
|
23804
|
+
properties: {
|
|
23805
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
23806
|
+
expected_task_list_revision: { type: "string", minLength: 1 }
|
|
23807
|
+
}
|
|
23808
|
+
},
|
|
23809
|
+
PlanProjectLinkApplyInput: {
|
|
23810
|
+
type: "object",
|
|
23811
|
+
additionalProperties: false,
|
|
23812
|
+
required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
|
|
23813
|
+
properties: {
|
|
23814
|
+
project_id: { type: "string", minLength: 1 },
|
|
23815
|
+
expected_plan_revision: { type: "string", minLength: 1 },
|
|
23816
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
23817
|
+
idempotency_key: {
|
|
23818
|
+
type: "string",
|
|
23819
|
+
minLength: 8,
|
|
23820
|
+
maxLength: 128,
|
|
23821
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
23822
|
+
}
|
|
23823
|
+
}
|
|
23824
|
+
},
|
|
23825
|
+
PlanProjectLinkRollbackInput: {
|
|
23826
|
+
type: "object",
|
|
23827
|
+
additionalProperties: false,
|
|
23828
|
+
required: ["project_id", "receipt_id", "expected_plan_revision"],
|
|
23829
|
+
properties: {
|
|
23830
|
+
project_id: { type: "string", minLength: 1 },
|
|
23831
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
23832
|
+
expected_plan_revision: { type: "string", minLength: 1 }
|
|
23833
|
+
}
|
|
23834
|
+
},
|
|
22328
23835
|
ErrorResponse: {
|
|
22329
23836
|
type: "object",
|
|
22330
23837
|
required: ["error"],
|
|
@@ -23208,6 +24715,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
23208
24715
|
{ name: "assigned_to", in: "query", schema: { type: "string" } },
|
|
23209
24716
|
{ name: "agent_id", in: "query", schema: { type: "string" } },
|
|
23210
24717
|
{ name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
|
|
24718
|
+
{
|
|
24719
|
+
name: "updated_after",
|
|
24720
|
+
in: "query",
|
|
24721
|
+
schema: { type: "string", format: "date-time" },
|
|
24722
|
+
description: "Since-cursor. Returns only tasks whose updated_at is strictly after this instant, and `total` respects it too. " + "Intended for pollers: re-read what changed instead of the whole table. Must be a full RFC 3339 date-time with " + "an explicit offset (e.g. 2026-08-07T12:00:00Z or 2026-08-07T12:00:00+03:00); a reduced-precision value such as " + "`2026` or `2026-08`, or any other malformed value, is rejected with 400 rather than ignored. Timestamps stored " + "without an offset are read as UTC on every backend."
|
|
24723
|
+
},
|
|
23211
24724
|
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
|
|
23212
24725
|
{ name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
|
|
23213
24726
|
],
|
|
@@ -23427,6 +24940,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
23427
24940
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
23428
24941
|
}
|
|
23429
24942
|
},
|
|
24943
|
+
"/v1/projects/{id}/task-list/ensure": {
|
|
24944
|
+
get: {
|
|
24945
|
+
operationId: "planProjectTaskListEnsure",
|
|
24946
|
+
summary: "Plan a non-mutating repair of a project's declared task list",
|
|
24947
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
24948
|
+
responses: {
|
|
24949
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
24950
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
24951
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
24952
|
+
}
|
|
24953
|
+
},
|
|
24954
|
+
post: {
|
|
24955
|
+
operationId: "ensureProjectTaskList",
|
|
24956
|
+
summary: "Idempotently create an existing project's declared task list",
|
|
24957
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
24958
|
+
requestBody: {
|
|
24959
|
+
required: true,
|
|
24960
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureApplyInput" } } }
|
|
24961
|
+
},
|
|
24962
|
+
responses: {
|
|
24963
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
24964
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
24965
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
24966
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
24967
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
24968
|
+
}
|
|
24969
|
+
}
|
|
24970
|
+
},
|
|
24971
|
+
"/v1/projects/{id}/task-list/rollback": {
|
|
24972
|
+
post: {
|
|
24973
|
+
operationId: "rollbackProjectTaskListEnsure",
|
|
24974
|
+
summary: "Conditionally remove an unchanged task list created by an accepted ensure receipt",
|
|
24975
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
24976
|
+
requestBody: {
|
|
24977
|
+
required: true,
|
|
24978
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackInput" } } }
|
|
24979
|
+
},
|
|
24980
|
+
responses: {
|
|
24981
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackResult" } } } },
|
|
24982
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
24983
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
24984
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
24985
|
+
}
|
|
24986
|
+
}
|
|
24987
|
+
},
|
|
23430
24988
|
"/v1/projects/{id}/rename": {
|
|
23431
24989
|
post: {
|
|
23432
24990
|
operationId: "renameProject",
|
|
@@ -23491,6 +25049,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
23491
25049
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
23492
25050
|
}
|
|
23493
25051
|
},
|
|
25052
|
+
"/v1/plans/{id}/project-link": {
|
|
25053
|
+
get: {
|
|
25054
|
+
operationId: "planPlanProjectLink",
|
|
25055
|
+
summary: "Plan atomic linkage of an existing plan and every current member task to a project",
|
|
25056
|
+
parameters: [
|
|
25057
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
25058
|
+
{ name: "project_id", in: "query", required: true, schema: { type: "string" } }
|
|
25059
|
+
],
|
|
25060
|
+
responses: {
|
|
25061
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25062
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25063
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25064
|
+
}
|
|
25065
|
+
},
|
|
25066
|
+
post: {
|
|
25067
|
+
operationId: "applyPlanProjectLink",
|
|
25068
|
+
summary: "Atomically and idempotently link an existing plan and every current member task to a project",
|
|
25069
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
25070
|
+
requestBody: {
|
|
25071
|
+
required: true,
|
|
25072
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
|
|
25073
|
+
},
|
|
25074
|
+
responses: {
|
|
25075
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25076
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25077
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25078
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25079
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25080
|
+
}
|
|
25081
|
+
}
|
|
25082
|
+
},
|
|
25083
|
+
"/v1/plans/{id}/project-link/rollback": {
|
|
25084
|
+
post: {
|
|
25085
|
+
operationId: "rollbackPlanProjectLink",
|
|
25086
|
+
summary: "Conditionally restore every exact prior project link from an accepted receipt",
|
|
25087
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
25088
|
+
requestBody: {
|
|
25089
|
+
required: true,
|
|
25090
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
|
|
25091
|
+
},
|
|
25092
|
+
responses: {
|
|
25093
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
|
|
25094
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25095
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25096
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25097
|
+
}
|
|
25098
|
+
}
|
|
25099
|
+
},
|
|
23494
25100
|
"/v1/templates": {
|
|
23495
25101
|
get: {
|
|
23496
25102
|
operationId: "listTemplates",
|
|
@@ -23708,7 +25314,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
23708
25314
|
}
|
|
23709
25315
|
};
|
|
23710
25316
|
}
|
|
23711
|
-
var taskSchema, projectSchema, taskListSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
25317
|
+
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
23712
25318
|
var init_openapi = __esm(() => {
|
|
23713
25319
|
init_package_version();
|
|
23714
25320
|
init_types();
|
|
@@ -23756,6 +25362,80 @@ var init_openapi = __esm(() => {
|
|
|
23756
25362
|
updated_at: { type: "string" }
|
|
23757
25363
|
}
|
|
23758
25364
|
};
|
|
25365
|
+
projectTaskListEnsureReceiptSchema = {
|
|
25366
|
+
type: "object",
|
|
25367
|
+
additionalProperties: false,
|
|
25368
|
+
required: [
|
|
25369
|
+
"schema_version",
|
|
25370
|
+
"receipt_id",
|
|
25371
|
+
"idempotency_key",
|
|
25372
|
+
"project_id",
|
|
25373
|
+
"task_list_id",
|
|
25374
|
+
"slug",
|
|
25375
|
+
"created_by_operation",
|
|
25376
|
+
"result_revision",
|
|
25377
|
+
"result_digest",
|
|
25378
|
+
"rollback_supported",
|
|
25379
|
+
"created_at"
|
|
25380
|
+
],
|
|
25381
|
+
properties: {
|
|
25382
|
+
schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
|
|
25383
|
+
receipt_id: { type: "string" },
|
|
25384
|
+
idempotency_key: { type: "string" },
|
|
25385
|
+
project_id: { type: "string" },
|
|
25386
|
+
task_list_id: { type: "string" },
|
|
25387
|
+
slug: { type: "string" },
|
|
25388
|
+
created_by_operation: { type: "boolean" },
|
|
25389
|
+
result_revision: { type: "string" },
|
|
25390
|
+
result_digest: { type: "string" },
|
|
25391
|
+
rollback_supported: { type: "boolean" },
|
|
25392
|
+
created_at: { type: "string", format: "date-time" }
|
|
25393
|
+
}
|
|
25394
|
+
};
|
|
25395
|
+
projectTaskListEnsureResultSchema = {
|
|
25396
|
+
type: "object",
|
|
25397
|
+
additionalProperties: false,
|
|
25398
|
+
required: ["mode", "action", "project", "task_list", "receipt"],
|
|
25399
|
+
properties: {
|
|
25400
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
25401
|
+
action: { type: "string", enum: ["would_create", "created", "already_present"] },
|
|
25402
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
25403
|
+
task_list: {
|
|
25404
|
+
oneOf: [
|
|
25405
|
+
{ $ref: "#/components/schemas/TaskList" },
|
|
25406
|
+
{ type: "null" }
|
|
25407
|
+
]
|
|
25408
|
+
},
|
|
25409
|
+
receipt: {
|
|
25410
|
+
oneOf: [
|
|
25411
|
+
{ $ref: "#/components/schemas/ProjectTaskListEnsureReceipt" },
|
|
25412
|
+
{ type: "null" }
|
|
25413
|
+
]
|
|
25414
|
+
}
|
|
25415
|
+
}
|
|
25416
|
+
};
|
|
25417
|
+
projectTaskListRollbackResultSchema = {
|
|
25418
|
+
type: "object",
|
|
25419
|
+
additionalProperties: false,
|
|
25420
|
+
required: [
|
|
25421
|
+
"schema_version",
|
|
25422
|
+
"action",
|
|
25423
|
+
"project_id",
|
|
25424
|
+
"task_list_id",
|
|
25425
|
+
"accepted_receipt_id",
|
|
25426
|
+
"rollback_receipt_id",
|
|
25427
|
+
"removed_at"
|
|
25428
|
+
],
|
|
25429
|
+
properties: {
|
|
25430
|
+
schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
|
|
25431
|
+
action: { type: "string", enum: ["removed"] },
|
|
25432
|
+
project_id: { type: "string" },
|
|
25433
|
+
task_list_id: { type: "string" },
|
|
25434
|
+
accepted_receipt_id: { type: "string" },
|
|
25435
|
+
rollback_receipt_id: { type: "string" },
|
|
25436
|
+
removed_at: { type: "string", format: "date-time" }
|
|
25437
|
+
}
|
|
25438
|
+
};
|
|
23759
25439
|
taskCommentSchema = {
|
|
23760
25440
|
type: "object",
|
|
23761
25441
|
required: ["id", "task_id", "agent_id", "session_id", "content", "type", "progress_pct", "created_at"],
|
|
@@ -23786,6 +25466,75 @@ var init_openapi = __esm(() => {
|
|
|
23786
25466
|
updated_at: { type: "string", format: "date-time" }
|
|
23787
25467
|
}
|
|
23788
25468
|
};
|
|
25469
|
+
planProjectLinkReceiptSchema = {
|
|
25470
|
+
type: "object",
|
|
25471
|
+
additionalProperties: false,
|
|
25472
|
+
required: [
|
|
25473
|
+
"schema_version",
|
|
25474
|
+
"receipt_id",
|
|
25475
|
+
"idempotency_key",
|
|
25476
|
+
"plan_id",
|
|
25477
|
+
"project_id",
|
|
25478
|
+
"prior_plan_project_id",
|
|
25479
|
+
"prior_task_project_ids",
|
|
25480
|
+
"task_ids",
|
|
25481
|
+
"task_count",
|
|
25482
|
+
"result_plan_revision",
|
|
25483
|
+
"result_digest",
|
|
25484
|
+
"rollback_supported",
|
|
25485
|
+
"created_at"
|
|
25486
|
+
],
|
|
25487
|
+
properties: {
|
|
25488
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
25489
|
+
receipt_id: { type: "string" },
|
|
25490
|
+
idempotency_key: { type: "string" },
|
|
25491
|
+
plan_id: { type: "string" },
|
|
25492
|
+
project_id: { type: "string" },
|
|
25493
|
+
prior_plan_project_id: { type: "string", nullable: true },
|
|
25494
|
+
prior_task_project_ids: {
|
|
25495
|
+
type: "object",
|
|
25496
|
+
additionalProperties: { type: "string", nullable: true }
|
|
25497
|
+
},
|
|
25498
|
+
task_ids: { type: "array", items: { type: "string" } },
|
|
25499
|
+
task_count: { type: "integer", minimum: 0 },
|
|
25500
|
+
result_plan_revision: { type: "string" },
|
|
25501
|
+
result_digest: { type: "string" },
|
|
25502
|
+
rollback_supported: { type: "boolean", enum: [true] },
|
|
25503
|
+
created_at: { type: "string", format: "date-time" }
|
|
25504
|
+
}
|
|
25505
|
+
};
|
|
25506
|
+
planProjectLinkResultSchema = {
|
|
25507
|
+
type: "object",
|
|
25508
|
+
additionalProperties: false,
|
|
25509
|
+
required: ["mode", "action", "plan", "project", "tasks", "receipt"],
|
|
25510
|
+
properties: {
|
|
25511
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
25512
|
+
action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
|
|
25513
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
25514
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
25515
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
25516
|
+
receipt: {
|
|
25517
|
+
oneOf: [
|
|
25518
|
+
{ $ref: "#/components/schemas/PlanProjectLinkReceipt" },
|
|
25519
|
+
{ type: "null" }
|
|
25520
|
+
]
|
|
25521
|
+
}
|
|
25522
|
+
}
|
|
25523
|
+
};
|
|
25524
|
+
planProjectLinkRollbackResultSchema = {
|
|
25525
|
+
type: "object",
|
|
25526
|
+
additionalProperties: false,
|
|
25527
|
+
required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
|
|
25528
|
+
properties: {
|
|
25529
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
25530
|
+
action: { type: "string", enum: ["restored"] },
|
|
25531
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
25532
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
25533
|
+
accepted_receipt_id: { type: "string" },
|
|
25534
|
+
rollback_receipt_id: { type: "string" },
|
|
25535
|
+
restored_at: { type: "string", format: "date-time" }
|
|
25536
|
+
}
|
|
25537
|
+
};
|
|
23789
25538
|
templateTaskSchema = {
|
|
23790
25539
|
type: "object",
|
|
23791
25540
|
required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
|
|
@@ -23989,6 +25738,330 @@ function decodeCommentCursor(value) {
|
|
|
23989
25738
|
}
|
|
23990
25739
|
var MAX_COMMENT_CURSOR_LENGTH = 1024;
|
|
23991
25740
|
|
|
25741
|
+
// src/lib/project-task-list-ensure.ts
|
|
25742
|
+
import { createHash as createHash8 } from "crypto";
|
|
25743
|
+
function canonicalJson(value) {
|
|
25744
|
+
if (value === null || typeof value !== "object")
|
|
25745
|
+
return JSON.stringify(value);
|
|
25746
|
+
if (Array.isArray(value))
|
|
25747
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
25748
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
25749
|
+
}
|
|
25750
|
+
function digest(value) {
|
|
25751
|
+
return createHash8("sha256").update(canonicalJson(value)).digest("hex");
|
|
25752
|
+
}
|
|
25753
|
+
function deriveIdempotencyKey(projectId, slug) {
|
|
25754
|
+
return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
|
|
25755
|
+
}
|
|
25756
|
+
function normalizeIdempotencyKey(value, projectId, slug) {
|
|
25757
|
+
const key = value?.trim() || deriveIdempotencyKey(projectId, slug);
|
|
25758
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
25759
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
25760
|
+
}
|
|
25761
|
+
return key;
|
|
25762
|
+
}
|
|
25763
|
+
function receiptId2(projectId, slug, idempotencyKey) {
|
|
25764
|
+
return `ptlr_${digest({ project_id: projectId, slug, idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
25765
|
+
}
|
|
25766
|
+
function semanticListDigest(list) {
|
|
25767
|
+
const metadata = { ...list.metadata ?? {} };
|
|
25768
|
+
delete metadata[RECEIPT_METADATA_KEY];
|
|
25769
|
+
return digest({
|
|
25770
|
+
project_id: list.project_id,
|
|
25771
|
+
slug: list.slug,
|
|
25772
|
+
name: list.name,
|
|
25773
|
+
description: list.description,
|
|
25774
|
+
metadata
|
|
25775
|
+
});
|
|
25776
|
+
}
|
|
25777
|
+
function storedMarker(list) {
|
|
25778
|
+
const value = list.metadata?.[RECEIPT_METADATA_KEY];
|
|
25779
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
25780
|
+
return null;
|
|
25781
|
+
const marker = value;
|
|
25782
|
+
if (marker.schema_version !== PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION || typeof marker.receipt_id !== "string" || typeof marker.idempotency_key !== "string" || typeof marker.project_id !== "string" || typeof marker.slug !== "string" || typeof marker.result_digest !== "string" || typeof marker.created_at !== "string")
|
|
25783
|
+
return null;
|
|
25784
|
+
return marker;
|
|
25785
|
+
}
|
|
25786
|
+
function receiptFor(store, project, list, idempotencyKey) {
|
|
25787
|
+
const marker = storedMarker(list);
|
|
25788
|
+
const owned = marker?.project_id === project.id && marker.slug === list.slug;
|
|
25789
|
+
if (owned && marker.idempotency_key !== idempotencyKey) {
|
|
25790
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_CONFLICT", "The operation-owned task list was created under a different idempotency key", {
|
|
25791
|
+
project_id: project.id,
|
|
25792
|
+
task_list_id: list.id,
|
|
25793
|
+
receipt_id: marker.receipt_id
|
|
25794
|
+
});
|
|
25795
|
+
}
|
|
25796
|
+
return {
|
|
25797
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
25798
|
+
receipt_id: owned ? marker.receipt_id : `ptlr_existing_${digest({ project_id: project.id, task_list_id: list.id }).slice(0, 39)}`,
|
|
25799
|
+
idempotency_key: owned ? marker.idempotency_key : idempotencyKey,
|
|
25800
|
+
project_id: project.id,
|
|
25801
|
+
task_list_id: list.id,
|
|
25802
|
+
slug: list.slug,
|
|
25803
|
+
created_by_operation: owned,
|
|
25804
|
+
result_revision: list.updated_at,
|
|
25805
|
+
result_digest: owned ? marker.result_digest : semanticListDigest(list),
|
|
25806
|
+
rollback_supported: Boolean(owned && semanticListDigest(list) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused),
|
|
25807
|
+
created_at: owned ? marker.created_at : list.created_at
|
|
25808
|
+
};
|
|
25809
|
+
}
|
|
25810
|
+
async function exactProjectState(store, projectId) {
|
|
25811
|
+
const project = await store.projects.get(projectId);
|
|
25812
|
+
if (!project) {
|
|
25813
|
+
throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
25814
|
+
}
|
|
25815
|
+
const slug = project.task_list_id?.trim();
|
|
25816
|
+
if (!slug) {
|
|
25817
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_NOT_DECLARED", "Project does not declare a canonical task_list_id slug", { project_id: project.id });
|
|
25818
|
+
}
|
|
25819
|
+
const all = await store.taskLists.list();
|
|
25820
|
+
const scopedMatches = all.filter((list) => list.project_id === project.id && list.slug === slug);
|
|
25821
|
+
if (scopedMatches.length > 1) {
|
|
25822
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "More than one task list matches the project's exact id and declared slug", { project_id: project.id, slug, task_list_ids: scopedMatches.map((list) => list.id) });
|
|
25823
|
+
}
|
|
25824
|
+
const globalMatches = all.filter((list) => list.project_id === null && list.slug === slug);
|
|
25825
|
+
if (globalMatches.length > 0 && scopedMatches.length === 0) {
|
|
25826
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "A legacy global task list already owns the declared slug; refusing to create a second locator", { project_id: project.id, slug, task_list_ids: globalMatches.map((list) => list.id) });
|
|
25827
|
+
}
|
|
25828
|
+
return { project, scoped: scopedMatches[0] ?? null, globalCollision: globalMatches[0] ?? null };
|
|
25829
|
+
}
|
|
25830
|
+
async function planProjectTaskListEnsure(store, projectId) {
|
|
25831
|
+
const { project, scoped } = await exactProjectState(store, projectId);
|
|
25832
|
+
return {
|
|
25833
|
+
mode: "plan",
|
|
25834
|
+
action: scoped ? "already_present" : "would_create",
|
|
25835
|
+
project,
|
|
25836
|
+
task_list: scoped,
|
|
25837
|
+
receipt: null
|
|
25838
|
+
};
|
|
25839
|
+
}
|
|
25840
|
+
async function applyProjectTaskListEnsure(store, projectId, options) {
|
|
25841
|
+
const state = await exactProjectState(store, projectId);
|
|
25842
|
+
const { project } = state;
|
|
25843
|
+
if (project.updated_at !== options.expected_project_revision) {
|
|
25844
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed after the ensure plan; fetch a fresh plan before applying", {
|
|
25845
|
+
project_id: project.id,
|
|
25846
|
+
expected_project_revision: options.expected_project_revision,
|
|
25847
|
+
current_project_revision: project.updated_at
|
|
25848
|
+
});
|
|
25849
|
+
}
|
|
25850
|
+
const slug = project.task_list_id;
|
|
25851
|
+
const idempotencyKey = normalizeIdempotencyKey(options.idempotency_key, project.id, slug);
|
|
25852
|
+
if (state.scoped) {
|
|
25853
|
+
return {
|
|
25854
|
+
mode: "apply",
|
|
25855
|
+
action: "already_present",
|
|
25856
|
+
project,
|
|
25857
|
+
task_list: state.scoped,
|
|
25858
|
+
receipt: receiptFor(store, project, state.scoped, idempotencyKey)
|
|
25859
|
+
};
|
|
25860
|
+
}
|
|
25861
|
+
const marker = {
|
|
25862
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
25863
|
+
receipt_id: receiptId2(project.id, slug, idempotencyKey),
|
|
25864
|
+
idempotency_key: idempotencyKey,
|
|
25865
|
+
project_id: project.id,
|
|
25866
|
+
slug,
|
|
25867
|
+
result_digest: semanticListDigest({
|
|
25868
|
+
project_id: project.id,
|
|
25869
|
+
slug,
|
|
25870
|
+
name: project.name,
|
|
25871
|
+
description: null,
|
|
25872
|
+
metadata: {}
|
|
25873
|
+
}),
|
|
25874
|
+
created_at: new Date().toISOString()
|
|
25875
|
+
};
|
|
25876
|
+
let list;
|
|
25877
|
+
try {
|
|
25878
|
+
list = await store.taskLists.create({
|
|
25879
|
+
name: project.name,
|
|
25880
|
+
slug,
|
|
25881
|
+
project_id: project.id,
|
|
25882
|
+
metadata: { [RECEIPT_METADATA_KEY]: marker }
|
|
25883
|
+
});
|
|
25884
|
+
} catch (error) {
|
|
25885
|
+
if (!(error instanceof ResourceConflictError))
|
|
25886
|
+
throw error;
|
|
25887
|
+
const raced = await exactProjectState(store, projectId);
|
|
25888
|
+
if (!raced.scoped)
|
|
25889
|
+
throw error;
|
|
25890
|
+
if (raced.project.updated_at !== options.expected_project_revision || raced.project.task_list_id !== slug) {
|
|
25891
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed while the task list was being created; fetch a fresh plan before retrying", {
|
|
25892
|
+
project_id: raced.project.id,
|
|
25893
|
+
expected_project_revision: options.expected_project_revision,
|
|
25894
|
+
current_project_revision: raced.project.updated_at
|
|
25895
|
+
});
|
|
25896
|
+
}
|
|
25897
|
+
return {
|
|
25898
|
+
mode: "apply",
|
|
25899
|
+
action: "already_present",
|
|
25900
|
+
project: raced.project,
|
|
25901
|
+
task_list: raced.scoped,
|
|
25902
|
+
receipt: receiptFor(store, raced.project, raced.scoped, idempotencyKey)
|
|
25903
|
+
};
|
|
25904
|
+
}
|
|
25905
|
+
const projectReadback = await store.projects.get(project.id);
|
|
25906
|
+
if (!projectReadback || projectReadback.updated_at !== options.expected_project_revision || projectReadback.task_list_id !== slug) {
|
|
25907
|
+
let compensated = false;
|
|
25908
|
+
const unchanged = await store.taskLists.get(list.id);
|
|
25909
|
+
const unchangedMarker = unchanged ? storedMarker(unchanged) : null;
|
|
25910
|
+
if (unchanged && unchangedMarker?.receipt_id === marker.receipt_id && semanticListDigest(unchanged) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused) {
|
|
25911
|
+
const deletion = await store.taskLists.deleteIfUnchangedAndUnused(list.id, {
|
|
25912
|
+
project_id: unchanged.project_id,
|
|
25913
|
+
slug: unchanged.slug,
|
|
25914
|
+
name: unchanged.name,
|
|
25915
|
+
description: unchanged.description,
|
|
25916
|
+
metadata: unchanged.metadata,
|
|
25917
|
+
updated_at: unchanged.updated_at
|
|
25918
|
+
});
|
|
25919
|
+
compensated = deletion.status === "deleted";
|
|
25920
|
+
}
|
|
25921
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", compensated ? "Project changed while the task list was being created; the new list was rolled back" : "Project changed while the task list was being created; the new list was retained because safe conditional rollback could not be proven", { project_id: project.id, task_list_id: list.id, compensated });
|
|
25922
|
+
}
|
|
25923
|
+
const readback = await store.taskLists.get(list.id);
|
|
25924
|
+
if (!readback || readback.project_id !== project.id || readback.slug !== slug) {
|
|
25925
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "Task-list create did not preserve the exact project id and declared slug", { project_id: project.id, task_list_id: list.id, slug });
|
|
25926
|
+
}
|
|
25927
|
+
return {
|
|
25928
|
+
mode: "apply",
|
|
25929
|
+
action: "created",
|
|
25930
|
+
project: projectReadback,
|
|
25931
|
+
task_list: readback,
|
|
25932
|
+
receipt: receiptFor(store, projectReadback, readback, idempotencyKey)
|
|
25933
|
+
};
|
|
25934
|
+
}
|
|
25935
|
+
async function rollbackProjectTaskListEnsure(store, projectId, options) {
|
|
25936
|
+
const conditionalDelete = store.taskLists.deleteIfUnchangedAndUnused;
|
|
25937
|
+
if (!conditionalDelete) {
|
|
25938
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "This storage backend cannot guarantee atomic conditional rollback; refusing to delete", { project_id: projectId, receipt_id: options.receipt_id });
|
|
25939
|
+
}
|
|
25940
|
+
const project = await store.projects.get(projectId);
|
|
25941
|
+
if (!project) {
|
|
25942
|
+
throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`);
|
|
25943
|
+
}
|
|
25944
|
+
const candidates = (await store.taskLists.list(project.id)).filter((list2) => storedMarker(list2)?.receipt_id === options.receipt_id);
|
|
25945
|
+
if (candidates.length !== 1) {
|
|
25946
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_RECEIPT_NOT_FOUND", "No exact operation-owned task list matches this rollback receipt", { project_id: project.id, receipt_id: options.receipt_id });
|
|
25947
|
+
}
|
|
25948
|
+
const list = candidates[0];
|
|
25949
|
+
const marker = storedMarker(list);
|
|
25950
|
+
if (marker.project_id !== project.id || marker.slug !== list.slug || list.project_id !== project.id || list.updated_at !== options.expected_task_list_revision || semanticListDigest(list) !== marker.result_digest) {
|
|
25951
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "The operation-owned task list drifted; refusing conditional rollback", { project_id: project.id, task_list_id: list.id, receipt_id: options.receipt_id });
|
|
25952
|
+
}
|
|
25953
|
+
const deletion = await conditionalDelete.call(store.taskLists, list.id, {
|
|
25954
|
+
project_id: list.project_id,
|
|
25955
|
+
slug: list.slug,
|
|
25956
|
+
name: list.name,
|
|
25957
|
+
description: list.description,
|
|
25958
|
+
metadata: list.metadata,
|
|
25959
|
+
updated_at: list.updated_at
|
|
25960
|
+
});
|
|
25961
|
+
if (deletion.status === "has_dependents") {
|
|
25962
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_HAS_DEPENDENTS", "The operation-owned task list has dependents; refusing conditional rollback", {
|
|
25963
|
+
task_list_id: list.id,
|
|
25964
|
+
task_dependents: deletion.task_dependents,
|
|
25965
|
+
plan_dependents: deletion.plan_dependents
|
|
25966
|
+
});
|
|
25967
|
+
}
|
|
25968
|
+
if (deletion.status !== "deleted" || await store.taskLists.get(list.id)) {
|
|
25969
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "Conditional rollback did not remove the exact task list", { task_list_id: list.id });
|
|
25970
|
+
}
|
|
25971
|
+
return {
|
|
25972
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
25973
|
+
action: "removed",
|
|
25974
|
+
project_id: project.id,
|
|
25975
|
+
task_list_id: list.id,
|
|
25976
|
+
accepted_receipt_id: options.receipt_id,
|
|
25977
|
+
rollback_receipt_id: `ptlr_inverse_${digest({ accepted_receipt_id: options.receipt_id }).slice(0, 38)}`,
|
|
25978
|
+
removed_at: new Date().toISOString()
|
|
25979
|
+
};
|
|
25980
|
+
}
|
|
25981
|
+
var PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION = "todos.project-task-list-ensure.v1", RECEIPT_METADATA_KEY = "todos_project_task_list_ensure", ProjectTaskListEnsureError;
|
|
25982
|
+
var init_project_task_list_ensure = __esm(() => {
|
|
25983
|
+
init_types();
|
|
25984
|
+
ProjectTaskListEnsureError = class ProjectTaskListEnsureError extends Error {
|
|
25985
|
+
code;
|
|
25986
|
+
details;
|
|
25987
|
+
constructor(code, message, details = {}) {
|
|
25988
|
+
super(message);
|
|
25989
|
+
this.code = code;
|
|
25990
|
+
this.details = details;
|
|
25991
|
+
this.name = "ProjectTaskListEnsureError";
|
|
25992
|
+
}
|
|
25993
|
+
};
|
|
25994
|
+
});
|
|
25995
|
+
|
|
25996
|
+
// src/lib/plan-project-link.ts
|
|
25997
|
+
async function exactPlanProjectLinkState(store, planId, projectId) {
|
|
25998
|
+
const [plan, project] = await Promise.all([
|
|
25999
|
+
store.plans.get(planId),
|
|
26000
|
+
store.projects.get(projectId)
|
|
26001
|
+
]);
|
|
26002
|
+
if (!plan) {
|
|
26003
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
|
|
26004
|
+
}
|
|
26005
|
+
if (!project) {
|
|
26006
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
26007
|
+
}
|
|
26008
|
+
const [tasks, projectPlans] = await Promise.all([
|
|
26009
|
+
store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
|
|
26010
|
+
store.plans.list(project.id)
|
|
26011
|
+
]);
|
|
26012
|
+
const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
26013
|
+
if (collision) {
|
|
26014
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { plan_id: plan.id, project_id: project.id, conflicting_plan_id: collision.id, slug: plan.slug });
|
|
26015
|
+
}
|
|
26016
|
+
return {
|
|
26017
|
+
plan,
|
|
26018
|
+
project,
|
|
26019
|
+
tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
|
|
26020
|
+
};
|
|
26021
|
+
}
|
|
26022
|
+
async function planPlanProjectLink(store, planId, projectId) {
|
|
26023
|
+
const state = await exactPlanProjectLinkState(store, planId, projectId);
|
|
26024
|
+
const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task) => task.project_id === state.project.id);
|
|
26025
|
+
return {
|
|
26026
|
+
mode: "plan",
|
|
26027
|
+
action: alreadyLinked ? "already_linked" : "would_link",
|
|
26028
|
+
...state,
|
|
26029
|
+
receipt: null
|
|
26030
|
+
};
|
|
26031
|
+
}
|
|
26032
|
+
async function applyPlanProjectLink(store, planId, projectId, options) {
|
|
26033
|
+
if (!store.planProjectLinks) {
|
|
26034
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
|
|
26035
|
+
}
|
|
26036
|
+
const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
|
|
26037
|
+
return store.planProjectLinks.apply({
|
|
26038
|
+
plan_id: planId,
|
|
26039
|
+
project_id: projectId,
|
|
26040
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
26041
|
+
expected_project_revision: options.expected_project_revision,
|
|
26042
|
+
idempotency_key: key,
|
|
26043
|
+
receipt_id: planProjectLinkReceiptId(key),
|
|
26044
|
+
created_at: new Date().toISOString()
|
|
26045
|
+
});
|
|
26046
|
+
}
|
|
26047
|
+
async function rollbackPlanProjectLink(store, planId, projectId, options) {
|
|
26048
|
+
if (!store.planProjectLinks) {
|
|
26049
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
|
|
26050
|
+
}
|
|
26051
|
+
return store.planProjectLinks.rollback({
|
|
26052
|
+
plan_id: planId,
|
|
26053
|
+
project_id: projectId,
|
|
26054
|
+
receipt_id: options.receipt_id,
|
|
26055
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
26056
|
+
rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
|
|
26057
|
+
restored_at: new Date().toISOString()
|
|
26058
|
+
});
|
|
26059
|
+
}
|
|
26060
|
+
var init_plan_project_link = __esm(() => {
|
|
26061
|
+
init_plan_project_link_contract();
|
|
26062
|
+
init_plan_project_link_contract();
|
|
26063
|
+
});
|
|
26064
|
+
|
|
23992
26065
|
// src/server/v1.ts
|
|
23993
26066
|
var exports_v1 = {};
|
|
23994
26067
|
__export(exports_v1, {
|
|
@@ -24011,6 +26084,21 @@ function enumQueryParam(url, name, vocabulary) {
|
|
|
24011
26084
|
return { ok: false, response: error(400, result.message) };
|
|
24012
26085
|
return { ok: true, value: collapseEnumValues(result.values) };
|
|
24013
26086
|
}
|
|
26087
|
+
function parseSinceCursor(raw) {
|
|
26088
|
+
const shape = `updated_after must be an RFC 3339 date-time with an explicit offset, ` + `e.g. 2026-08-07T12:00:00Z or 2026-08-07T12:00:00+03:00; got ${JSON.stringify(raw)}`;
|
|
26089
|
+
const match = RFC3339_DATE_TIME.exec(raw);
|
|
26090
|
+
if (!match)
|
|
26091
|
+
return { ok: false, message: shape };
|
|
26092
|
+
const parsed = Date.parse(raw);
|
|
26093
|
+
if (Number.isNaN(parsed))
|
|
26094
|
+
return { ok: false, message: shape };
|
|
26095
|
+
const [, year, month, day] = match;
|
|
26096
|
+
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
26097
|
+
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
|
|
26098
|
+
return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
|
|
26099
|
+
}
|
|
26100
|
+
return { ok: true, value: new Date(parsed).toISOString() };
|
|
26101
|
+
}
|
|
24014
26102
|
function validateTaskCompletion(value) {
|
|
24015
26103
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
24016
26104
|
return { ok: false, message: "completion body must be an object" };
|
|
@@ -24420,7 +26508,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
24420
26508
|
const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
|
|
24421
26509
|
if (!priorityParam.ok)
|
|
24422
26510
|
return priorityParam.response;
|
|
26511
|
+
const updatedAfterRaw = url.searchParams.get("updated_after");
|
|
26512
|
+
const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
|
|
26513
|
+
if (updatedAfter !== null && !updatedAfter.ok) {
|
|
26514
|
+
return error(400, updatedAfter.message);
|
|
26515
|
+
}
|
|
24423
26516
|
const filter = {
|
|
26517
|
+
...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
|
|
24424
26518
|
...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
|
|
24425
26519
|
...statusParam.value !== undefined ? { status: statusParam.value } : {},
|
|
24426
26520
|
...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
|
|
@@ -24776,6 +26870,52 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
24776
26870
|
}
|
|
24777
26871
|
return error(405, `method ${method} not allowed on /v1/projects`);
|
|
24778
26872
|
}
|
|
26873
|
+
if (action === "task-list" && subId === "ensure") {
|
|
26874
|
+
if (method === "GET") {
|
|
26875
|
+
return json4(await planProjectTaskListEnsure(store, id));
|
|
26876
|
+
}
|
|
26877
|
+
if (method !== "POST") {
|
|
26878
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/ensure`);
|
|
26879
|
+
}
|
|
26880
|
+
const body = await readJson3(req);
|
|
26881
|
+
if (!body)
|
|
26882
|
+
return error(400, "invalid JSON body");
|
|
26883
|
+
const unknown = Object.keys(body).find((key) => !["expected_project_revision", "idempotency_key"].includes(key));
|
|
26884
|
+
if (unknown)
|
|
26885
|
+
return error(400, `unknown task-list ensure field: ${unknown}`);
|
|
26886
|
+
if (typeof body.expected_project_revision !== "string" || !body.expected_project_revision.trim()) {
|
|
26887
|
+
return error(400, "expected_project_revision must be a non-empty string from a fresh ensure plan");
|
|
26888
|
+
}
|
|
26889
|
+
if (body.idempotency_key !== undefined && typeof body.idempotency_key !== "string") {
|
|
26890
|
+
return error(400, "idempotency_key must be a string");
|
|
26891
|
+
}
|
|
26892
|
+
const result = await applyProjectTaskListEnsure(store, id, {
|
|
26893
|
+
expected_project_revision: body.expected_project_revision,
|
|
26894
|
+
...typeof body.idempotency_key === "string" ? { idempotency_key: body.idempotency_key } : {}
|
|
26895
|
+
});
|
|
26896
|
+
return json4(result, result.action === "created" ? 201 : 200);
|
|
26897
|
+
}
|
|
26898
|
+
if (action === "task-list" && subId === "rollback") {
|
|
26899
|
+
if (method !== "POST") {
|
|
26900
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/rollback`);
|
|
26901
|
+
}
|
|
26902
|
+
const body = await readJson3(req);
|
|
26903
|
+
if (!body)
|
|
26904
|
+
return error(400, "invalid JSON body");
|
|
26905
|
+
const unknown = Object.keys(body).find((key) => !["receipt_id", "expected_task_list_revision"].includes(key));
|
|
26906
|
+
if (unknown)
|
|
26907
|
+
return error(400, `unknown task-list rollback field: ${unknown}`);
|
|
26908
|
+
if (typeof body.receipt_id !== "string" || !body.receipt_id.trim()) {
|
|
26909
|
+
return error(400, "receipt_id must be a non-empty string");
|
|
26910
|
+
}
|
|
26911
|
+
if (typeof body.expected_task_list_revision !== "string" || !body.expected_task_list_revision.trim()) {
|
|
26912
|
+
return error(400, "expected_task_list_revision must be a non-empty string from the accepted receipt");
|
|
26913
|
+
}
|
|
26914
|
+
return json4(await rollbackProjectTaskListEnsure(store, id, {
|
|
26915
|
+
receipt_id: body.receipt_id,
|
|
26916
|
+
expected_task_list_revision: body.expected_task_list_revision
|
|
26917
|
+
}));
|
|
26918
|
+
}
|
|
24779
26919
|
if (action === "rename") {
|
|
24780
26920
|
if (method !== "POST")
|
|
24781
26921
|
return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
|
|
@@ -24836,6 +26976,54 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
24836
26976
|
const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
|
|
24837
26977
|
return json4({ plan }, 201);
|
|
24838
26978
|
}
|
|
26979
|
+
if (id && action === "project-link" && !subId) {
|
|
26980
|
+
if (method === "GET") {
|
|
26981
|
+
const projectId = url.searchParams.get("project_id");
|
|
26982
|
+
if (!projectId?.trim())
|
|
26983
|
+
return error(400, "project_id query parameter is required");
|
|
26984
|
+
return json4(await planPlanProjectLink(store, id, projectId));
|
|
26985
|
+
}
|
|
26986
|
+
if (method !== "POST")
|
|
26987
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
|
|
26988
|
+
const body = await readJson3(req);
|
|
26989
|
+
if (!body)
|
|
26990
|
+
return error(400, "invalid JSON body");
|
|
26991
|
+
const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
|
|
26992
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
26993
|
+
if (unknown)
|
|
26994
|
+
return error(400, `unknown plan-project-link field: ${unknown}`);
|
|
26995
|
+
for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
|
|
26996
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
26997
|
+
return error(400, `${field} must be a non-empty string`);
|
|
26998
|
+
}
|
|
26999
|
+
}
|
|
27000
|
+
const result = await applyPlanProjectLink(store, id, body.project_id, {
|
|
27001
|
+
expected_plan_revision: body.expected_plan_revision,
|
|
27002
|
+
expected_project_revision: body.expected_project_revision,
|
|
27003
|
+
idempotency_key: body.idempotency_key
|
|
27004
|
+
});
|
|
27005
|
+
return json4(result, result.action === "linked" ? 201 : 200);
|
|
27006
|
+
}
|
|
27007
|
+
if (id && action === "project-link" && subId === "rollback") {
|
|
27008
|
+
if (method !== "POST")
|
|
27009
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
|
|
27010
|
+
const body = await readJson3(req);
|
|
27011
|
+
if (!body)
|
|
27012
|
+
return error(400, "invalid JSON body");
|
|
27013
|
+
const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
|
|
27014
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
27015
|
+
if (unknown)
|
|
27016
|
+
return error(400, `unknown plan-project-link rollback field: ${unknown}`);
|
|
27017
|
+
for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
|
|
27018
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
27019
|
+
return error(400, `${field} must be a non-empty string`);
|
|
27020
|
+
}
|
|
27021
|
+
}
|
|
27022
|
+
return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
|
|
27023
|
+
receipt_id: body.receipt_id,
|
|
27024
|
+
expected_plan_revision: body.expected_plan_revision
|
|
27025
|
+
}));
|
|
27026
|
+
}
|
|
24839
27027
|
if (id && method === "GET") {
|
|
24840
27028
|
const plan = await store.plans.get(id);
|
|
24841
27029
|
return plan ? json4({ plan }) : error(404, "plan not found");
|
|
@@ -25114,6 +27302,14 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
25114
27302
|
}
|
|
25115
27303
|
return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
25116
27304
|
} catch (e) {
|
|
27305
|
+
if (e instanceof PlanProjectLinkError) {
|
|
27306
|
+
const status = e.code === "PLAN_PROJECT_LINK_PLAN_NOT_FOUND" || e.code === "PLAN_PROJECT_LINK_PROJECT_NOT_FOUND" || e.code === "PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND" ? 404 : e.code === "PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID" ? 400 : e.code === "PLAN_PROJECT_LINK_UNSUPPORTED" ? 501 : 409;
|
|
27307
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
27308
|
+
}
|
|
27309
|
+
if (e instanceof ProjectTaskListEnsureError) {
|
|
27310
|
+
const status = e.code === "PROJECT_NOT_FOUND" || e.code === "PROJECT_TASK_LIST_RECEIPT_NOT_FOUND" ? 404 : e.code === "PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID" ? 400 : 409;
|
|
27311
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
27312
|
+
}
|
|
25117
27313
|
if (e instanceof TaskReferenceAmbiguousError) {
|
|
25118
27314
|
return error(409, e.message, {
|
|
25119
27315
|
code: TaskReferenceAmbiguousError.code,
|
|
@@ -25133,14 +27329,17 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
25133
27329
|
return error(500, e.message || "internal error");
|
|
25134
27330
|
}
|
|
25135
27331
|
}
|
|
25136
|
-
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
|
|
27332
|
+
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
|
|
25137
27333
|
var init_v1 = __esm(() => {
|
|
25138
27334
|
init_types();
|
|
25139
27335
|
init_cloud();
|
|
25140
27336
|
init_pr_groups();
|
|
25141
27337
|
init_project_registration();
|
|
25142
27338
|
init_redaction();
|
|
27339
|
+
init_project_task_list_ensure();
|
|
27340
|
+
init_plan_project_link();
|
|
25143
27341
|
JSON_HEADERS3 = { "Content-Type": "application/json" };
|
|
27342
|
+
RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
25144
27343
|
});
|
|
25145
27344
|
|
|
25146
27345
|
// node_modules/.bun/zod@3.25.76/node_modules/zod/v4/core/core.js
|
|
@@ -60082,12 +62281,16 @@ function protectRemoteClient(client) {
|
|
|
60082
62281
|
function remoteAuthorityBase(client) {
|
|
60083
62282
|
return client.baseUrl.replace(/\/v1\/?$/, "");
|
|
60084
62283
|
}
|
|
60085
|
-
async function requiredRemoteRoute(client, route, request) {
|
|
62284
|
+
async function requiredRemoteRoute(client, route, request, recognized404Codes = []) {
|
|
60086
62285
|
try {
|
|
60087
62286
|
return await request();
|
|
60088
62287
|
} catch (error3) {
|
|
60089
62288
|
const status = error3 && typeof error3 === "object" ? error3.status : undefined;
|
|
60090
62289
|
if (status === 404) {
|
|
62290
|
+
const body = error3 && typeof error3 === "object" ? error3.body : undefined;
|
|
62291
|
+
const code = body && typeof body === "object" && !Array.isArray(body) ? body.code : undefined;
|
|
62292
|
+
if (typeof code === "string" && recognized404Codes.includes(code))
|
|
62293
|
+
throw error3;
|
|
60091
62294
|
throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} does not expose ${route}; ` + "deploy the @hasna/todos /v1 server contract before retrying; local SQLite fallback is disabled", { cause: error3 });
|
|
60092
62295
|
}
|
|
60093
62296
|
throw error3;
|
|
@@ -62790,12 +64993,12 @@ var init_local_notifications = __esm(() => {
|
|
|
62790
64993
|
});
|
|
62791
64994
|
|
|
62792
64995
|
// src/lib/local-encryption.ts
|
|
62793
|
-
import { createCipheriv, createDecipheriv, createHash as
|
|
64996
|
+
import { createCipheriv, createDecipheriv, createHash as createHash9, randomBytes as randomBytes3, scryptSync, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
62794
64997
|
function now3() {
|
|
62795
64998
|
return new Date().toISOString();
|
|
62796
64999
|
}
|
|
62797
65000
|
function sha2563(value) {
|
|
62798
|
-
return
|
|
65001
|
+
return createHash9("sha256").update(value).digest("hex");
|
|
62799
65002
|
}
|
|
62800
65003
|
function normalizeProfileName(value) {
|
|
62801
65004
|
const name = (value || DEFAULT_ENCRYPTION_PROFILE).trim();
|
|
@@ -64933,7 +67136,7 @@ var init_capacity_forecasts = __esm(() => {
|
|
|
64933
67136
|
});
|
|
64934
67137
|
|
|
64935
67138
|
// src/lib/audit-ledger.ts
|
|
64936
|
-
import { createHash as
|
|
67139
|
+
import { createHash as createHash10 } from "crypto";
|
|
64937
67140
|
function canonicalize2(value) {
|
|
64938
67141
|
if (value === null || typeof value !== "object")
|
|
64939
67142
|
return JSON.stringify(value);
|
|
@@ -64943,7 +67146,7 @@ function canonicalize2(value) {
|
|
|
64943
67146
|
return `{${Object.keys(object4).sort().map((key) => `${JSON.stringify(key)}:${canonicalize2(object4[key])}`).join(",")}}`;
|
|
64944
67147
|
}
|
|
64945
67148
|
function hash(value) {
|
|
64946
|
-
return
|
|
67149
|
+
return createHash10("sha256").update(value).digest("hex");
|
|
64947
67150
|
}
|
|
64948
67151
|
function parsePayload3(value) {
|
|
64949
67152
|
if (!value)
|
|
@@ -98044,7 +100247,7 @@ var init_local_bridge = __esm(() => {
|
|
|
98044
100247
|
});
|
|
98045
100248
|
|
|
98046
100249
|
// src/lib/local-backups.ts
|
|
98047
|
-
import { createHash as
|
|
100250
|
+
import { createHash as createHash11 } from "crypto";
|
|
98048
100251
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
98049
100252
|
import { dirname as dirname7, resolve as resolve13 } from "path";
|
|
98050
100253
|
import { mkdirSync as mkdirSync6 } from "fs";
|
|
@@ -98057,7 +100260,7 @@ function stableJson2(value) {
|
|
|
98057
100260
|
return `{${Object.keys(record3).sort().map((key) => `${JSON.stringify(key)}:${stableJson2(record3[key])}`).join(",")}}`;
|
|
98058
100261
|
}
|
|
98059
100262
|
function sha2564(value) {
|
|
98060
|
-
return
|
|
100263
|
+
return createHash11("sha256").update(stableJson2(value)).digest("hex");
|
|
98061
100264
|
}
|
|
98062
100265
|
function sqliteIntegrity(db) {
|
|
98063
100266
|
let quick = "unknown";
|
|
@@ -98707,7 +100910,7 @@ var init_onboarding_fixtures = __esm(() => {
|
|
|
98707
100910
|
});
|
|
98708
100911
|
|
|
98709
100912
|
// src/lib/local-snapshots.ts
|
|
98710
|
-
import { createHash as
|
|
100913
|
+
import { createHash as createHash12 } from "crypto";
|
|
98711
100914
|
function source(version2) {
|
|
98712
100915
|
return {
|
|
98713
100916
|
packageName: "@hasna/todos",
|
|
@@ -98731,7 +100934,7 @@ function stable(value) {
|
|
|
98731
100934
|
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stable(item)]));
|
|
98732
100935
|
}
|
|
98733
100936
|
function sha2565(value) {
|
|
98734
|
-
return
|
|
100937
|
+
return createHash12("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
|
98735
100938
|
}
|
|
98736
100939
|
function latestTimestamp2(items, fallback) {
|
|
98737
100940
|
const timestamps = [];
|
|
@@ -99297,7 +101500,7 @@ var init_retrospectives = __esm(() => {
|
|
|
99297
101500
|
});
|
|
99298
101501
|
|
|
99299
101502
|
// src/lib/agent-replay-simulator.ts
|
|
99300
|
-
import { createHash as
|
|
101503
|
+
import { createHash as createHash13 } from "crypto";
|
|
99301
101504
|
function isObject2(value) {
|
|
99302
101505
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
99303
101506
|
}
|
|
@@ -99318,7 +101521,7 @@ function stable2(value) {
|
|
|
99318
101521
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable2(value[key])]));
|
|
99319
101522
|
}
|
|
99320
101523
|
function fingerprint2(value) {
|
|
99321
|
-
return
|
|
101524
|
+
return createHash13("sha256").update(JSON.stringify(stable2(value))).digest("hex");
|
|
99322
101525
|
}
|
|
99323
101526
|
function unpackFixture(input) {
|
|
99324
101527
|
if (!isObject2(input))
|
|
@@ -99605,7 +101808,7 @@ __export(exports_local_extensions, {
|
|
|
99605
101808
|
getLocalExtension: () => getLocalExtension,
|
|
99606
101809
|
discoverLocalExtensions: () => discoverLocalExtensions
|
|
99607
101810
|
});
|
|
99608
|
-
import { createHash as
|
|
101811
|
+
import { createHash as createHash14, createVerify } from "crypto";
|
|
99609
101812
|
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
|
|
99610
101813
|
import { basename as basename5, join as join12, resolve as resolve14 } from "path";
|
|
99611
101814
|
function isObject3(value) {
|
|
@@ -99691,7 +101894,7 @@ function parseJson(path) {
|
|
|
99691
101894
|
return JSON.parse(readFileSync9(path, "utf8"));
|
|
99692
101895
|
}
|
|
99693
101896
|
function sha2566(bytes) {
|
|
99694
|
-
return `sha256:${
|
|
101897
|
+
return `sha256:${createHash14("sha256").update(bytes).digest("hex")}`;
|
|
99695
101898
|
}
|
|
99696
101899
|
function compareVersions(a, b) {
|
|
99697
101900
|
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
@@ -100166,7 +102369,7 @@ function issueToTask(issue2, opts) {
|
|
|
100166
102369
|
var init_github = () => {};
|
|
100167
102370
|
|
|
100168
102371
|
// src/db/inbox.ts
|
|
100169
|
-
import { createHash as
|
|
102372
|
+
import { createHash as createHash15 } from "crypto";
|
|
100170
102373
|
function parseMetadata3(value) {
|
|
100171
102374
|
if (!value)
|
|
100172
102375
|
return {};
|
|
@@ -100186,7 +102389,7 @@ function compactWhitespace(value) {
|
|
|
100186
102389
|
function fingerprintInboxInput(input) {
|
|
100187
102390
|
const sourceType = input.source_type || detectInboxSourceType(input.body, input.source_url);
|
|
100188
102391
|
const normalized = compactWhitespace(sanitizePreWriteText(input.body, "inbox.fingerprint")).slice(0, 8000);
|
|
100189
|
-
return
|
|
102392
|
+
return createHash15("sha256").update(`${sourceType}
|
|
100190
102393
|
${input.source_url || ""}
|
|
100191
102394
|
${normalized}`).digest("hex");
|
|
100192
102395
|
}
|
|
@@ -104386,10 +106589,10 @@ __export(exports_extract, {
|
|
|
104386
106589
|
EXTRACT_TAGS: () => EXTRACT_TAGS
|
|
104387
106590
|
});
|
|
104388
106591
|
import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync7 } from "fs";
|
|
104389
|
-
import { createHash as
|
|
106592
|
+
import { createHash as createHash16 } from "crypto";
|
|
104390
106593
|
import { relative as relative5, resolve as resolve15, join as join13 } from "path";
|
|
104391
106594
|
function stableHash(value) {
|
|
104392
|
-
return
|
|
106595
|
+
return createHash16("sha256").update(value).digest("hex");
|
|
104393
106596
|
}
|
|
104394
106597
|
function normalizePathForMatch(value) {
|
|
104395
106598
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -106113,13 +108316,13 @@ __export(exports_environment_snapshots, {
|
|
|
106113
108316
|
compareEnvironmentSnapshotFiles: () => compareEnvironmentSnapshotFiles,
|
|
106114
108317
|
captureEnvironmentSnapshot: () => captureEnvironmentSnapshot
|
|
106115
108318
|
});
|
|
106116
|
-
import { createHash as
|
|
108319
|
+
import { createHash as createHash17 } from "crypto";
|
|
106117
108320
|
import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
|
|
106118
108321
|
import { hostname as hostname3, platform, arch } from "os";
|
|
106119
108322
|
import { dirname as dirname8, join as join15, resolve as resolve16 } from "path";
|
|
106120
108323
|
import { tmpdir as tmpdir3 } from "os";
|
|
106121
108324
|
function sha2567(value) {
|
|
106122
|
-
return
|
|
108325
|
+
return createHash17("sha256").update(value).digest("hex");
|
|
106123
108326
|
}
|
|
106124
108327
|
function fileRecord(root, relativePath) {
|
|
106125
108328
|
const path = join15(root, relativePath);
|
|
@@ -106234,8 +108437,8 @@ function defaultSnapshotDir() {
|
|
|
106234
108437
|
return join15(dirname8(resolve16(dbPath)), "environment-snapshots");
|
|
106235
108438
|
}
|
|
106236
108439
|
function snapshotWithId(snapshot) {
|
|
106237
|
-
const
|
|
106238
|
-
return { id: `env_${
|
|
108440
|
+
const digest2 = sha2567(JSON.stringify(snapshot)).slice(0, 24);
|
|
108441
|
+
return { id: `env_${digest2}`, ...snapshot };
|
|
106239
108442
|
}
|
|
106240
108443
|
function captureEnvironmentSnapshot(input = {}) {
|
|
106241
108444
|
const root = resolve16(input.root || process.cwd());
|