@hasna/todos 0.15.9 → 0.15.11
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 +14 -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/index.js +4831 -3259
- package/dist/contracts.js +109 -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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7747 -1275
- package/dist/lib/plan-project-link-contract.d.ts +32 -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/mcp/index.js +1175 -50
- package/dist/mcp.js +7 -3
- package/dist/project-registration.js +846 -26
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +116 -10
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.js +21 -0
- package/dist/sdk/v1.generated.d.ts +53 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1179 -54
- package/dist/server/openapi.d.ts +370 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +26 -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 +835 -19
- 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 +60 -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.11",
|
|
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,63 @@ 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
|
+
init_types();
|
|
1174
|
+
PlanProjectLinkError = class PlanProjectLinkError extends Error {
|
|
1175
|
+
code;
|
|
1176
|
+
details;
|
|
1177
|
+
constructor(code, message, details = {}) {
|
|
1178
|
+
super(message);
|
|
1179
|
+
this.code = code;
|
|
1180
|
+
this.details = details;
|
|
1181
|
+
this.name = "PlanProjectLinkError";
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1125
1186
|
// src/lib/slugs.ts
|
|
1126
1187
|
function normalizeSlug(value) {
|
|
1127
1188
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
@@ -1219,6 +1280,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
1219
1280
|
RETURNS text
|
|
1220
1281
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
1221
1282
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
1283
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
1284
|
+
RETURNS timestamptz
|
|
1285
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
1286
|
+
SET DateStyle TO 'ISO, YMD'
|
|
1287
|
+
AS $$
|
|
1288
|
+
BEGIN
|
|
1289
|
+
RETURN CASE
|
|
1290
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
1291
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
1292
|
+
END;
|
|
1293
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
1294
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
1295
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
1296
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
1297
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
1222
1298
|
`ALTER TABLE ${tableName}
|
|
1223
1299
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
1224
1300
|
GENERATED ALWAYS AS (
|
|
@@ -2018,6 +2094,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
2018
2094
|
update: (id, input) => updatePlan(id, input, store),
|
|
2019
2095
|
delete: (id, context) => store.deletePlan(id, context)
|
|
2020
2096
|
},
|
|
2097
|
+
planProjectLinks: {
|
|
2098
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
2099
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
2100
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
2101
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
2102
|
+
},
|
|
2021
2103
|
agents: {
|
|
2022
2104
|
register: (input, context) => registerAgent(input, store, context),
|
|
2023
2105
|
get: (id) => store.get("agents", id),
|
|
@@ -2200,6 +2282,9 @@ class PostgresJsonRecordStore {
|
|
|
2200
2282
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
2201
2283
|
if (filter.not_created_by !== undefined)
|
|
2202
2284
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
2285
|
+
if (filter.updated_after !== undefined) {
|
|
2286
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
2287
|
+
}
|
|
2203
2288
|
if (filter.session_id !== undefined)
|
|
2204
2289
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
2205
2290
|
if (filter.tags?.length) {
|
|
@@ -2384,6 +2469,115 @@ class PostgresJsonRecordStore {
|
|
|
2384
2469
|
}
|
|
2385
2470
|
return value;
|
|
2386
2471
|
}
|
|
2472
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
2473
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
2474
|
+
if (planIds.length === 0)
|
|
2475
|
+
return this.upsert("tasks", value, context);
|
|
2476
|
+
await this.ensureSchema();
|
|
2477
|
+
const updatedAt = value.updated_at;
|
|
2478
|
+
const targetPlanId = value.plan_id;
|
|
2479
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
2480
|
+
locked_plans AS MATERIALIZED (
|
|
2481
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
2482
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
2483
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
2484
|
+
ORDER BY object_id
|
|
2485
|
+
FOR UPDATE
|
|
2486
|
+
), validation AS (
|
|
2487
|
+
SELECT
|
|
2488
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
2489
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
2490
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
2491
|
+
), guarded AS (
|
|
2492
|
+
SELECT
|
|
2493
|
+
validation.*,
|
|
2494
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
2495
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
2496
|
+
CASE
|
|
2497
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
2498
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
2499
|
+
END AS payload
|
|
2500
|
+
FROM validation
|
|
2501
|
+
), stored AS (
|
|
2502
|
+
INSERT INTO ${this.tableName} (
|
|
2503
|
+
service, object_type, object_id, payload, updated_at,
|
|
2504
|
+
deleted_at, source_machine_id, version
|
|
2505
|
+
)
|
|
2506
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
2507
|
+
FROM guarded
|
|
2508
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
2509
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
2510
|
+
payload = EXCLUDED.payload,
|
|
2511
|
+
updated_at = EXCLUDED.updated_at,
|
|
2512
|
+
deleted_at = NULL,
|
|
2513
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
2514
|
+
version = EXCLUDED.version
|
|
2515
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
2516
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
2517
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
2518
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
2519
|
+
RETURNING payload
|
|
2520
|
+
)
|
|
2521
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
2522
|
+
(SELECT payload FROM stored) AS payload
|
|
2523
|
+
FROM guarded`, [
|
|
2524
|
+
this.service,
|
|
2525
|
+
value.id,
|
|
2526
|
+
jsonbParam(value),
|
|
2527
|
+
updatedAt,
|
|
2528
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
2529
|
+
numberValue2(value.version),
|
|
2530
|
+
jsonbParam(planIds),
|
|
2531
|
+
targetPlanId,
|
|
2532
|
+
explicitProject
|
|
2533
|
+
]);
|
|
2534
|
+
const row = result.rows[0];
|
|
2535
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
2536
|
+
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 });
|
|
2537
|
+
}
|
|
2538
|
+
if (row.project_conflict) {
|
|
2539
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
2540
|
+
}
|
|
2541
|
+
if (!row.payload) {
|
|
2542
|
+
return await requireRecord("tasks", value.id, this);
|
|
2543
|
+
}
|
|
2544
|
+
return payloadRecord2(row.payload);
|
|
2545
|
+
}
|
|
2546
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
2547
|
+
await this.ensureSchema();
|
|
2548
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
2549
|
+
SELECT payload FROM ${this.tableName}
|
|
2550
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
2551
|
+
FOR UPDATE
|
|
2552
|
+
), stored AS (
|
|
2553
|
+
UPDATE ${this.tableName} r SET
|
|
2554
|
+
payload = jsonb_set(
|
|
2555
|
+
$3::jsonb,
|
|
2556
|
+
'{project_id}',
|
|
2557
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
2558
|
+
true
|
|
2559
|
+
),
|
|
2560
|
+
updated_at = $4::timestamptz,
|
|
2561
|
+
deleted_at = NULL,
|
|
2562
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
2563
|
+
version = COALESCE(r.version, 0) + 1
|
|
2564
|
+
FROM locked_plan
|
|
2565
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
2566
|
+
RETURNING r.payload
|
|
2567
|
+
)
|
|
2568
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
2569
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
2570
|
+
this.service,
|
|
2571
|
+
value.id,
|
|
2572
|
+
jsonbParam(value),
|
|
2573
|
+
value.updated_at,
|
|
2574
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
2575
|
+
]);
|
|
2576
|
+
const row = result.rows[0];
|
|
2577
|
+
if (!row?.plan_found || !row.payload)
|
|
2578
|
+
throw new PlanNotFoundError(value.id);
|
|
2579
|
+
return payloadRecord2(row.payload);
|
|
2580
|
+
}
|
|
2387
2581
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
2388
2582
|
await this.ensureSchema();
|
|
2389
2583
|
const records = [
|
|
@@ -2622,6 +2816,333 @@ class PostgresJsonRecordStore {
|
|
|
2622
2816
|
version: numberValue2(existing["version"])
|
|
2623
2817
|
}, context);
|
|
2624
2818
|
}
|
|
2819
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
2820
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
2821
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
2822
|
+
}
|
|
2823
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
2824
|
+
await this.ensureSchema();
|
|
2825
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
2826
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
2827
|
+
AND payload->>'idempotency_key' = $2
|
|
2828
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
2829
|
+
if (result.rows.length > 1) {
|
|
2830
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
2831
|
+
}
|
|
2832
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
2833
|
+
}
|
|
2834
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
2835
|
+
const [plan, project, tasks] = await Promise.all([
|
|
2836
|
+
this.get("plans", receipt.plan_id),
|
|
2837
|
+
this.get("projects", receipt.project_id),
|
|
2838
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
2839
|
+
]);
|
|
2840
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
2841
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
2842
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
2843
|
+
}
|
|
2844
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
|
|
2845
|
+
}
|
|
2846
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
2847
|
+
await this.ensureSchema();
|
|
2848
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
2849
|
+
if (existing) {
|
|
2850
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
2851
|
+
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 });
|
|
2852
|
+
}
|
|
2853
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
2854
|
+
if (rolledBack) {
|
|
2855
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
2856
|
+
}
|
|
2857
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
2858
|
+
}
|
|
2859
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
2860
|
+
this.get("plans", input.plan_id),
|
|
2861
|
+
this.get("projects", input.project_id),
|
|
2862
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
2863
|
+
this.list("plans")
|
|
2864
|
+
]);
|
|
2865
|
+
if (!plan)
|
|
2866
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
2867
|
+
if (!project)
|
|
2868
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
2869
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
2870
|
+
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 });
|
|
2871
|
+
}
|
|
2872
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
2873
|
+
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 });
|
|
2874
|
+
}
|
|
2875
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
2876
|
+
if (collision) {
|
|
2877
|
+
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 });
|
|
2878
|
+
}
|
|
2879
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
2880
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
2881
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
2882
|
+
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 });
|
|
2883
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
|
|
2884
|
+
const receipt = {
|
|
2885
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
2886
|
+
receipt_id: input.receipt_id,
|
|
2887
|
+
idempotency_key: input.idempotency_key,
|
|
2888
|
+
plan_id: plan.id,
|
|
2889
|
+
project_id: project.id,
|
|
2890
|
+
prior_plan_project_id: plan.project_id,
|
|
2891
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
2892
|
+
task_ids: sortedTasks.map((task) => task.id),
|
|
2893
|
+
task_count: sortedTasks.length,
|
|
2894
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
2895
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
2896
|
+
rollback_supported: true,
|
|
2897
|
+
created_at: input.created_at
|
|
2898
|
+
};
|
|
2899
|
+
let mutation;
|
|
2900
|
+
try {
|
|
2901
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
2902
|
+
target_plan AS MATERIALIZED (
|
|
2903
|
+
SELECT payload FROM ${this.tableName}
|
|
2904
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
2905
|
+
FOR UPDATE
|
|
2906
|
+
), target_project AS (
|
|
2907
|
+
SELECT payload FROM ${this.tableName}
|
|
2908
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
2909
|
+
FOR UPDATE
|
|
2910
|
+
), member_tasks AS MATERIALIZED (
|
|
2911
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
2912
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
2913
|
+
AND payload->>'plan_id' = $2
|
|
2914
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
2915
|
+
FOR UPDATE
|
|
2916
|
+
), existing AS (
|
|
2917
|
+
SELECT payload FROM ${this.tableName}
|
|
2918
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
2919
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
2920
|
+
FOR UPDATE
|
|
2921
|
+
), collision AS (
|
|
2922
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
2923
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
2924
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
2925
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
2926
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
2927
|
+
LIMIT 1
|
|
2928
|
+
), checks AS (
|
|
2929
|
+
SELECT
|
|
2930
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
2931
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
2932
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
2933
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
2934
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
2935
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
2936
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
2937
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
2938
|
+
), updated_plan AS (
|
|
2939
|
+
UPDATE ${this.tableName} r SET
|
|
2940
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
2941
|
+
updated_at = $10::timestamptz,
|
|
2942
|
+
version = COALESCE(r.version, 0) + 1,
|
|
2943
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
2944
|
+
FROM checks
|
|
2945
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
2946
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
2947
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
2948
|
+
RETURNING r.payload
|
|
2949
|
+
), updated_tasks AS (
|
|
2950
|
+
UPDATE ${this.tableName} r SET
|
|
2951
|
+
payload = r.payload || jsonb_build_object(
|
|
2952
|
+
'project_id', $3::text,
|
|
2953
|
+
'updated_at', $10::text,
|
|
2954
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
2955
|
+
),
|
|
2956
|
+
updated_at = $10::timestamptz,
|
|
2957
|
+
version = COALESCE(r.version, 0) + 1,
|
|
2958
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
2959
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
2960
|
+
AND r.payload->>'plan_id' = $2
|
|
2961
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
2962
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
2963
|
+
RETURNING 1
|
|
2964
|
+
), task_gate AS (
|
|
2965
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
2966
|
+
), inserted AS (
|
|
2967
|
+
INSERT INTO ${this.tableName}
|
|
2968
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
2969
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
2970
|
+
FROM checks, task_gate
|
|
2971
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
2972
|
+
RETURNING payload
|
|
2973
|
+
) SELECT
|
|
2974
|
+
checks.plan_found,
|
|
2975
|
+
checks.project_found,
|
|
2976
|
+
checks.plan_revision_ok,
|
|
2977
|
+
checks.project_revision_ok,
|
|
2978
|
+
checks.membership_ok,
|
|
2979
|
+
checks.collision,
|
|
2980
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
2981
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
2982
|
+
FROM checks`, [
|
|
2983
|
+
this.service,
|
|
2984
|
+
plan.id,
|
|
2985
|
+
project.id,
|
|
2986
|
+
input.expected_plan_revision,
|
|
2987
|
+
input.expected_project_revision,
|
|
2988
|
+
receipt.receipt_id,
|
|
2989
|
+
jsonbParam(receipt),
|
|
2990
|
+
jsonbParam(priorTaskProjectIds),
|
|
2991
|
+
jsonbParam(receipt.task_ids),
|
|
2992
|
+
input.created_at,
|
|
2993
|
+
this.machineId(context)
|
|
2994
|
+
]);
|
|
2995
|
+
} catch (error) {
|
|
2996
|
+
if (isPostgresUniqueViolation(error)) {
|
|
2997
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
2998
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
2999
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
3000
|
+
}
|
|
3001
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
3002
|
+
}
|
|
3003
|
+
throw error;
|
|
3004
|
+
}
|
|
3005
|
+
const row = mutation.rows[0];
|
|
3006
|
+
if (!row?.plan_found)
|
|
3007
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
3008
|
+
if (!row.project_found)
|
|
3009
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
3010
|
+
if (!row.plan_revision_ok)
|
|
3011
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
3012
|
+
if (!row.project_revision_ok)
|
|
3013
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
3014
|
+
if (!row.membership_ok)
|
|
3015
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
3016
|
+
if (row.collision)
|
|
3017
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
3018
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
3019
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
3020
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
3021
|
+
}
|
|
3022
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
3023
|
+
}
|
|
3024
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
3025
|
+
await this.ensureSchema();
|
|
3026
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
3027
|
+
if (existingRollback)
|
|
3028
|
+
return existingRollback;
|
|
3029
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
3030
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
3031
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
3032
|
+
}
|
|
3033
|
+
const [plan, tasks] = await Promise.all([
|
|
3034
|
+
this.get("plans", input.plan_id),
|
|
3035
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
3036
|
+
]);
|
|
3037
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
3038
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
3039
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
3040
|
+
}
|
|
3041
|
+
if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
3042
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
3043
|
+
}
|
|
3044
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
3045
|
+
const projectedTasks = sortedTasks.map((task) => ({
|
|
3046
|
+
...task,
|
|
3047
|
+
project_id: receipt.prior_task_project_ids[task.id] ?? null,
|
|
3048
|
+
updated_at: input.restored_at,
|
|
3049
|
+
version: task.version + 1
|
|
3050
|
+
}));
|
|
3051
|
+
const rollback = {
|
|
3052
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
3053
|
+
action: "restored",
|
|
3054
|
+
plan: projectedPlan,
|
|
3055
|
+
tasks: projectedTasks,
|
|
3056
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
3057
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
3058
|
+
restored_at: input.restored_at
|
|
3059
|
+
};
|
|
3060
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
3061
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
3062
|
+
target_plan AS MATERIALIZED (
|
|
3063
|
+
SELECT payload FROM ${this.tableName}
|
|
3064
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
3065
|
+
FOR UPDATE
|
|
3066
|
+
), member_tasks AS MATERIALIZED (
|
|
3067
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
3068
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
3069
|
+
AND payload->>'plan_id' = $2
|
|
3070
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
3071
|
+
FOR UPDATE
|
|
3072
|
+
), existing AS (
|
|
3073
|
+
SELECT payload FROM ${this.tableName}
|
|
3074
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
3075
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
3076
|
+
FOR UPDATE
|
|
3077
|
+
), checks AS (
|
|
3078
|
+
SELECT
|
|
3079
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
3080
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
3081
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
3082
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
3083
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
3084
|
+
), updated_plan AS (
|
|
3085
|
+
UPDATE ${this.tableName} r SET
|
|
3086
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
3087
|
+
updated_at = $10::timestamptz,
|
|
3088
|
+
version = COALESCE(r.version, 0) + 1,
|
|
3089
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
3090
|
+
FROM checks
|
|
3091
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
3092
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
3093
|
+
RETURNING 1
|
|
3094
|
+
), updated_tasks AS (
|
|
3095
|
+
UPDATE ${this.tableName} r SET
|
|
3096
|
+
payload = r.payload || jsonb_build_object(
|
|
3097
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
3098
|
+
'updated_at', $10::text,
|
|
3099
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
3100
|
+
),
|
|
3101
|
+
updated_at = $10::timestamptz,
|
|
3102
|
+
version = COALESCE(r.version, 0) + 1,
|
|
3103
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
3104
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
3105
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
3106
|
+
RETURNING 1
|
|
3107
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
3108
|
+
INSERT INTO ${this.tableName}
|
|
3109
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
3110
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
3111
|
+
FROM checks, task_gate
|
|
3112
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
3113
|
+
RETURNING payload
|
|
3114
|
+
) SELECT
|
|
3115
|
+
checks.plan_found,
|
|
3116
|
+
checks.plan_revision_ok,
|
|
3117
|
+
checks.membership_ok,
|
|
3118
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
3119
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
3120
|
+
FROM checks`, [
|
|
3121
|
+
this.service,
|
|
3122
|
+
input.plan_id,
|
|
3123
|
+
input.expected_plan_revision,
|
|
3124
|
+
input.receipt_id,
|
|
3125
|
+
input.rollback_receipt_id,
|
|
3126
|
+
jsonbParam(rollback),
|
|
3127
|
+
jsonbParam(currentTaskProjects),
|
|
3128
|
+
jsonbParam(receipt.task_ids),
|
|
3129
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
3130
|
+
input.restored_at,
|
|
3131
|
+
this.machineId(context),
|
|
3132
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
3133
|
+
]);
|
|
3134
|
+
const row = result.rows[0];
|
|
3135
|
+
if (!row?.plan_found)
|
|
3136
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
3137
|
+
if (!row.plan_revision_ok)
|
|
3138
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
3139
|
+
if (!row.membership_ok)
|
|
3140
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
3141
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
3142
|
+
if (!accepted)
|
|
3143
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
3144
|
+
return accepted;
|
|
3145
|
+
}
|
|
2625
3146
|
async deletePlan(id, context = {}) {
|
|
2626
3147
|
await this.ensureSchema();
|
|
2627
3148
|
const timestamp = new Date().toISOString();
|
|
@@ -2716,11 +3237,17 @@ class PostgresJsonRecordStore {
|
|
|
2716
3237
|
}
|
|
2717
3238
|
async function createTask(input, store, context) {
|
|
2718
3239
|
const timestamp = new Date().toISOString();
|
|
2719
|
-
const
|
|
3240
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
3241
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
3242
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
3243
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
3244
|
+
}
|
|
3245
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
3246
|
+
const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
|
|
2720
3247
|
const task = {
|
|
2721
3248
|
id: randomUUID(),
|
|
2722
3249
|
short_id: shortId,
|
|
2723
|
-
project_id:
|
|
3250
|
+
project_id: effectiveProjectId,
|
|
2724
3251
|
parent_id: input.parent_id ?? null,
|
|
2725
3252
|
plan_id: input.plan_id ?? null,
|
|
2726
3253
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -2774,15 +3301,27 @@ async function createTask(input, store, context) {
|
|
|
2774
3301
|
synced_at: null,
|
|
2775
3302
|
archived_at: null
|
|
2776
3303
|
};
|
|
2777
|
-
await store.
|
|
2778
|
-
await logTaskChange(
|
|
2779
|
-
return
|
|
3304
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
3305
|
+
await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
3306
|
+
return storedTask;
|
|
2780
3307
|
}
|
|
2781
3308
|
async function updateTask(id, input, store) {
|
|
2782
3309
|
const existing = await requireRecord("tasks", id, store);
|
|
2783
3310
|
if (existing.version !== input.version) {
|
|
2784
3311
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
2785
3312
|
}
|
|
3313
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
3314
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
3315
|
+
if (linkedPlan?.project_id) {
|
|
3316
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
3317
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
3318
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
3319
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
3320
|
+
} else {
|
|
3321
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
2786
3325
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
2787
3326
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
2788
3327
|
const task = {
|
|
@@ -2798,8 +3337,7 @@ async function updateTask(id, input, store) {
|
|
|
2798
3337
|
created_by: existing.created_by,
|
|
2799
3338
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
2800
3339
|
};
|
|
2801
|
-
|
|
2802
|
-
return task;
|
|
3340
|
+
return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
2803
3341
|
}
|
|
2804
3342
|
async function startTask(id, agentId, store) {
|
|
2805
3343
|
const task = await requireRecord("tasks", id, store);
|
|
@@ -2863,8 +3401,7 @@ async function patchTask(task, patch, store) {
|
|
|
2863
3401
|
version: task.version + 1,
|
|
2864
3402
|
updated_at: new Date().toISOString()
|
|
2865
3403
|
};
|
|
2866
|
-
|
|
2867
|
-
return updated;
|
|
3404
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
2868
3405
|
}
|
|
2869
3406
|
function sameCloudLockHolder(stored, incoming) {
|
|
2870
3407
|
if (!stored || !incoming)
|
|
@@ -3126,7 +3663,12 @@ async function updatePlan(id, input, store) {
|
|
|
3126
3663
|
excludeId: id
|
|
3127
3664
|
});
|
|
3128
3665
|
}
|
|
3129
|
-
return store.
|
|
3666
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
3667
|
+
...plan,
|
|
3668
|
+
...patch,
|
|
3669
|
+
project_id: plan.project_id,
|
|
3670
|
+
updated_at: new Date().toISOString()
|
|
3671
|
+
});
|
|
3130
3672
|
}
|
|
3131
3673
|
function matchAgentByName(agents, name, options) {
|
|
3132
3674
|
const target = normalizeAgentNameInput(name);
|
|
@@ -3514,6 +4056,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
|
|
|
3514
4056
|
var init_postgres_adapter = __esm(() => {
|
|
3515
4057
|
init_types();
|
|
3516
4058
|
init_creator_identity();
|
|
4059
|
+
init_plan_project_link_contract();
|
|
3517
4060
|
init_postgres_sync();
|
|
3518
4061
|
init_integrity();
|
|
3519
4062
|
init_redaction();
|
|
@@ -3536,9 +4079,9 @@ var init_types2 = __esm(() => {
|
|
|
3536
4079
|
});
|
|
3537
4080
|
|
|
3538
4081
|
// src/pr-groups/ledger.ts
|
|
3539
|
-
import { createHash as
|
|
4082
|
+
import { createHash as createHash3 } from "crypto";
|
|
3540
4083
|
function sha256(value) {
|
|
3541
|
-
return
|
|
4084
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
3542
4085
|
}
|
|
3543
4086
|
function stableValue(value) {
|
|
3544
4087
|
if (Array.isArray(value))
|
|
@@ -7610,6 +8153,29 @@ var init_migrations = __esm(() => {
|
|
|
7610
8153
|
${sqliteTodosProjectRegistrationSchemaSql()}
|
|
7611
8154
|
INSERT OR IGNORE INTO _migrations (id) VALUES (69);
|
|
7612
8155
|
COMMIT;
|
|
8156
|
+
`,
|
|
8157
|
+
`BEGIN;
|
|
8158
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
|
|
8159
|
+
receipt_id TEXT PRIMARY KEY,
|
|
8160
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
8161
|
+
plan_id TEXT NOT NULL,
|
|
8162
|
+
project_id TEXT NOT NULL,
|
|
8163
|
+
payload_hash TEXT NOT NULL,
|
|
8164
|
+
payload TEXT NOT NULL,
|
|
8165
|
+
created_at TEXT NOT NULL
|
|
8166
|
+
);
|
|
8167
|
+
CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
|
|
8168
|
+
ON plan_project_link_receipts(plan_id, created_at);
|
|
8169
|
+
|
|
8170
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
|
|
8171
|
+
rollback_receipt_id TEXT PRIMARY KEY,
|
|
8172
|
+
accepted_receipt_id TEXT NOT NULL UNIQUE
|
|
8173
|
+
REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
|
|
8174
|
+
payload TEXT NOT NULL,
|
|
8175
|
+
created_at TEXT NOT NULL
|
|
8176
|
+
);
|
|
8177
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
8178
|
+
COMMIT;
|
|
7613
8179
|
`
|
|
7614
8180
|
];
|
|
7615
8181
|
});
|
|
@@ -10952,7 +11518,7 @@ var init_runner_sandbox = __esm(() => {
|
|
|
10952
11518
|
});
|
|
10953
11519
|
|
|
10954
11520
|
// src/lib/event-hooks.ts
|
|
10955
|
-
import { createHash as
|
|
11521
|
+
import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
|
|
10956
11522
|
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
10957
11523
|
import { dirname as dirname3, resolve as resolve6 } from "path";
|
|
10958
11524
|
import { createConnection } from "net";
|
|
@@ -11019,7 +11585,7 @@ function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
|
|
|
11019
11585
|
payload: redactValue(payload ?? {}),
|
|
11020
11586
|
source: { package: "@hasna/todos", local_only: true }
|
|
11021
11587
|
};
|
|
11022
|
-
const digest =
|
|
11588
|
+
const digest = createHash4("sha256").update(canonicalEvent(base)).digest("hex");
|
|
11023
11589
|
return { ...base, integrity: { algorithm: "sha256", digest } };
|
|
11024
11590
|
}
|
|
11025
11591
|
function summarize(value) {
|
|
@@ -12838,6 +13404,14 @@ var init_checklists = __esm(() => {
|
|
|
12838
13404
|
init_database();
|
|
12839
13405
|
});
|
|
12840
13406
|
|
|
13407
|
+
// src/db/plan-row-serialization.ts
|
|
13408
|
+
function guardPlanRowsSqlite(planIds, db) {
|
|
13409
|
+
const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
|
|
13410
|
+
for (const id of ids) {
|
|
13411
|
+
db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
|
|
13412
|
+
}
|
|
13413
|
+
}
|
|
13414
|
+
|
|
12841
13415
|
// src/lib/recurrence.ts
|
|
12842
13416
|
function parseRecurrenceRule(rule) {
|
|
12843
13417
|
const normalized = rule.trim().toLowerCase();
|
|
@@ -14176,9 +14750,23 @@ function sanitizeUpdateTaskInput(input) {
|
|
|
14176
14750
|
metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
|
|
14177
14751
|
};
|
|
14178
14752
|
}
|
|
14179
|
-
function
|
|
14180
|
-
|
|
14181
|
-
|
|
14753
|
+
function linkedPlanProjectId(planId, db) {
|
|
14754
|
+
if (!planId)
|
|
14755
|
+
return null;
|
|
14756
|
+
const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
|
|
14757
|
+
return row?.project_id ?? null;
|
|
14758
|
+
}
|
|
14759
|
+
function resolveCreateProjectForPlan(input, db) {
|
|
14760
|
+
const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
|
|
14761
|
+
if (!linkedProjectId)
|
|
14762
|
+
return input.project_id || null;
|
|
14763
|
+
if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
|
|
14764
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
|
|
14765
|
+
}
|
|
14766
|
+
return linkedProjectId;
|
|
14767
|
+
}
|
|
14768
|
+
function createTaskStored(input, d) {
|
|
14769
|
+
const effectiveProjectId = resolveCreateProjectForPlan(input, d);
|
|
14182
14770
|
const timestamp2 = now();
|
|
14183
14771
|
const tags = input.tags || [];
|
|
14184
14772
|
const machineId = currentStorageMachineId(d);
|
|
@@ -14192,7 +14780,7 @@ function createTask2(input, db) {
|
|
|
14192
14780
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
14193
14781
|
id,
|
|
14194
14782
|
null,
|
|
14195
|
-
|
|
14783
|
+
effectiveProjectId,
|
|
14196
14784
|
input.parent_id || null,
|
|
14197
14785
|
input.plan_id || null,
|
|
14198
14786
|
input.task_list_id || null,
|
|
@@ -14242,7 +14830,15 @@ function createTask2(input, db) {
|
|
|
14242
14830
|
if (tags.length > 0) {
|
|
14243
14831
|
insertTaskTags(id, tags, d);
|
|
14244
14832
|
}
|
|
14245
|
-
|
|
14833
|
+
return getTask(id, d);
|
|
14834
|
+
}
|
|
14835
|
+
function createTask2(input, db) {
|
|
14836
|
+
input = sanitizeCreateTaskInput(input);
|
|
14837
|
+
const d = db || getDatabase();
|
|
14838
|
+
const task = input.plan_id ? d.transaction(() => {
|
|
14839
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
14840
|
+
return createTaskStored(input, d);
|
|
14841
|
+
})() : createTaskStored(input, d);
|
|
14246
14842
|
const payload = taskEventData(task);
|
|
14247
14843
|
const databasePath = databasePathFromDatabase(d);
|
|
14248
14844
|
dispatchWebhook2("task.created", payload, d).catch(() => {});
|
|
@@ -14342,6 +14938,10 @@ function listTasks2(filter = {}, db) {
|
|
|
14342
14938
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14343
14939
|
params.push(filter.not_created_by);
|
|
14344
14940
|
}
|
|
14941
|
+
if (filter.updated_after) {
|
|
14942
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
14943
|
+
params.push(filter.updated_after);
|
|
14944
|
+
}
|
|
14345
14945
|
if (filter.session_id) {
|
|
14346
14946
|
conditions.push("session_id = ?");
|
|
14347
14947
|
params.push(filter.session_id);
|
|
@@ -14502,6 +15102,10 @@ function countTasks(filter = {}, db) {
|
|
|
14502
15102
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14503
15103
|
params.push(filter.not_created_by);
|
|
14504
15104
|
}
|
|
15105
|
+
if (filter.updated_after) {
|
|
15106
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
15107
|
+
params.push(filter.updated_after);
|
|
15108
|
+
}
|
|
14505
15109
|
if (filter.session_id) {
|
|
14506
15110
|
conditions.push("session_id = ?");
|
|
14507
15111
|
params.push(filter.session_id);
|
|
@@ -14541,7 +15145,7 @@ function countTasks(filter = {}, db) {
|
|
|
14541
15145
|
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
14542
15146
|
return row.count;
|
|
14543
15147
|
}
|
|
14544
|
-
function
|
|
15148
|
+
function updateTaskStored(id, input, db) {
|
|
14545
15149
|
const d = db || getDatabase();
|
|
14546
15150
|
const task = getTask(id, d);
|
|
14547
15151
|
if (!task)
|
|
@@ -14550,6 +15154,18 @@ function updateTask2(id, input, db) {
|
|
|
14550
15154
|
throw new VersionConflictError(id, input.version, task.version);
|
|
14551
15155
|
}
|
|
14552
15156
|
input = sanitizeUpdateTaskInput(input);
|
|
15157
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
|
|
15158
|
+
const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
|
|
15159
|
+
if (linkedProjectId) {
|
|
15160
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
|
|
15161
|
+
if (effectiveProjectId !== linkedProjectId) {
|
|
15162
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
|
|
15163
|
+
input = { ...input, project_id: linkedProjectId };
|
|
15164
|
+
} else {
|
|
15165
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
|
|
15166
|
+
}
|
|
15167
|
+
}
|
|
15168
|
+
}
|
|
14553
15169
|
const timestamp2 = now();
|
|
14554
15170
|
const completionTimestamp = input.completed_at ?? timestamp2;
|
|
14555
15171
|
const sets = ["version = version + 1", "updated_at = ?"];
|
|
@@ -14757,6 +15373,21 @@ function updateTask2(id, input, db) {
|
|
|
14757
15373
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
14758
15374
|
return updatedTask;
|
|
14759
15375
|
}
|
|
15376
|
+
function updateTask2(id, input, db) {
|
|
15377
|
+
const d = db || getDatabase();
|
|
15378
|
+
const before = getTask(id, d);
|
|
15379
|
+
if (!before)
|
|
15380
|
+
throw new TaskNotFoundError(id);
|
|
15381
|
+
const guardedPlanIds = [before.plan_id, input.plan_id];
|
|
15382
|
+
if (!guardedPlanIds.some(Boolean))
|
|
15383
|
+
return updateTaskStored(id, input, d);
|
|
15384
|
+
return d.transaction(() => {
|
|
15385
|
+
guardPlanRowsSqlite(guardedPlanIds, d);
|
|
15386
|
+
const current = getTask(id, d);
|
|
15387
|
+
guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
|
|
15388
|
+
return updateTaskStored(id, input, d);
|
|
15389
|
+
})();
|
|
15390
|
+
}
|
|
14760
15391
|
function deleteTask(id, db) {
|
|
14761
15392
|
const d = db || getDatabase();
|
|
14762
15393
|
const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
@@ -15563,7 +16194,7 @@ function listPlans(projectId, db) {
|
|
|
15563
16194
|
}
|
|
15564
16195
|
return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
|
|
15565
16196
|
}
|
|
15566
|
-
function
|
|
16197
|
+
function updatePlanStored(id, input, db) {
|
|
15567
16198
|
const d = db || getDatabase();
|
|
15568
16199
|
const plan = getPlan(id, d);
|
|
15569
16200
|
if (!plan)
|
|
@@ -15608,6 +16239,13 @@ function updatePlan2(id, input, db) {
|
|
|
15608
16239
|
});
|
|
15609
16240
|
return updated;
|
|
15610
16241
|
}
|
|
16242
|
+
function updatePlan2(id, input, db) {
|
|
16243
|
+
const d = db || getDatabase();
|
|
16244
|
+
return d.transaction(() => {
|
|
16245
|
+
guardPlanRowsSqlite([id], d);
|
|
16246
|
+
return updatePlanStored(id, input, d);
|
|
16247
|
+
})();
|
|
16248
|
+
}
|
|
15611
16249
|
function deletePlan(id, db) {
|
|
15612
16250
|
const d = db || getDatabase();
|
|
15613
16251
|
const plan = getPlan(id, d);
|
|
@@ -16032,7 +16670,7 @@ var init_boards = __esm(() => {
|
|
|
16032
16670
|
});
|
|
16033
16671
|
|
|
16034
16672
|
// src/lib/artifact-store.ts
|
|
16035
|
-
import { createHash as
|
|
16673
|
+
import { createHash as createHash5 } from "crypto";
|
|
16036
16674
|
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
16037
16675
|
import { basename, dirname as dirname4, join as join6, resolve as resolve7 } from "path";
|
|
16038
16676
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -16057,7 +16695,7 @@ function artifactStorePath(relativePath) {
|
|
|
16057
16695
|
return join6(artifactStoreRoot(), normalized);
|
|
16058
16696
|
}
|
|
16059
16697
|
function sha2562(buffer) {
|
|
16060
|
-
return
|
|
16698
|
+
return createHash5("sha256").update(buffer).digest("hex");
|
|
16061
16699
|
}
|
|
16062
16700
|
function isTextLike(buffer, path) {
|
|
16063
16701
|
if (buffer.includes(0))
|
|
@@ -18344,6 +18982,187 @@ var init_integrity2 = __esm(() => {
|
|
|
18344
18982
|
REQUIRED_TABLES = { tasks: true, task_lists: true, projects: true };
|
|
18345
18983
|
});
|
|
18346
18984
|
|
|
18985
|
+
// src/db/plan-project-links.ts
|
|
18986
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
18987
|
+
const d = db || getDatabase();
|
|
18988
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
18989
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
18990
|
+
}
|
|
18991
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
18992
|
+
const d = db || getDatabase();
|
|
18993
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
18994
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
18995
|
+
}
|
|
18996
|
+
function exactTasks(planId, db) {
|
|
18997
|
+
return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
18998
|
+
}
|
|
18999
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
19000
|
+
const plan = getPlan(planId, db);
|
|
19001
|
+
const project = getProject(projectId, db);
|
|
19002
|
+
if (!plan || !project) {
|
|
19003
|
+
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 });
|
|
19004
|
+
}
|
|
19005
|
+
const tasks = exactTasks(planId, db);
|
|
19006
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
19007
|
+
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 });
|
|
19008
|
+
}
|
|
19009
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
19010
|
+
}
|
|
19011
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
19012
|
+
const d = db || getDatabase();
|
|
19013
|
+
const mutate = d.transaction(() => {
|
|
19014
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
19015
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
19016
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
19017
|
+
if (existingRow) {
|
|
19018
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
19019
|
+
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 });
|
|
19020
|
+
}
|
|
19021
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
19022
|
+
if (rolledBack) {
|
|
19023
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
19024
|
+
}
|
|
19025
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
19026
|
+
}
|
|
19027
|
+
const plan = getPlan(input.plan_id, d);
|
|
19028
|
+
if (!plan) {
|
|
19029
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
19030
|
+
}
|
|
19031
|
+
const project = getProject(input.project_id, d);
|
|
19032
|
+
if (!project) {
|
|
19033
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
19034
|
+
}
|
|
19035
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
19036
|
+
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 });
|
|
19037
|
+
}
|
|
19038
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
19039
|
+
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 });
|
|
19040
|
+
}
|
|
19041
|
+
if (plan.slug) {
|
|
19042
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
19043
|
+
if (collision) {
|
|
19044
|
+
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 });
|
|
19045
|
+
}
|
|
19046
|
+
}
|
|
19047
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
19048
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
|
|
19049
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
|
|
19050
|
+
const timestamp2 = input.created_at;
|
|
19051
|
+
if (plan.project_id !== project.id) {
|
|
19052
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
19053
|
+
project.id,
|
|
19054
|
+
timestamp2,
|
|
19055
|
+
plan.id,
|
|
19056
|
+
input.expected_plan_revision
|
|
19057
|
+
]);
|
|
19058
|
+
}
|
|
19059
|
+
for (const task of beforeTasks) {
|
|
19060
|
+
if (task.project_id === project.id)
|
|
19061
|
+
continue;
|
|
19062
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
|
|
19063
|
+
}
|
|
19064
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
19065
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
19066
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
|
|
19067
|
+
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 });
|
|
19068
|
+
}
|
|
19069
|
+
const receipt = {
|
|
19070
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
19071
|
+
receipt_id: input.receipt_id,
|
|
19072
|
+
idempotency_key: input.idempotency_key,
|
|
19073
|
+
plan_id: plan.id,
|
|
19074
|
+
project_id: project.id,
|
|
19075
|
+
prior_plan_project_id: plan.project_id,
|
|
19076
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
19077
|
+
task_ids: beforeTasks.map((task) => task.id),
|
|
19078
|
+
task_count: beforeTasks.length,
|
|
19079
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
19080
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
19081
|
+
rollback_supported: true,
|
|
19082
|
+
created_at: input.created_at
|
|
19083
|
+
};
|
|
19084
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
19085
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
19086
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
19087
|
+
receipt.receipt_id,
|
|
19088
|
+
receipt.idempotency_key,
|
|
19089
|
+
receipt.plan_id,
|
|
19090
|
+
receipt.project_id,
|
|
19091
|
+
requestHash,
|
|
19092
|
+
JSON.stringify(receipt),
|
|
19093
|
+
receipt.created_at
|
|
19094
|
+
]);
|
|
19095
|
+
return {
|
|
19096
|
+
mode: "apply",
|
|
19097
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
19098
|
+
plan: linkedPlan,
|
|
19099
|
+
project,
|
|
19100
|
+
tasks: linkedTasks,
|
|
19101
|
+
receipt
|
|
19102
|
+
};
|
|
19103
|
+
});
|
|
19104
|
+
return mutate();
|
|
19105
|
+
}
|
|
19106
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
19107
|
+
const d = db || getDatabase();
|
|
19108
|
+
return d.transaction(() => {
|
|
19109
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
19110
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
19111
|
+
if (priorRollback)
|
|
19112
|
+
return JSON.parse(priorRollback.payload);
|
|
19113
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
19114
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
19115
|
+
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 });
|
|
19116
|
+
}
|
|
19117
|
+
const plan = getPlan(input.plan_id, d);
|
|
19118
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
19119
|
+
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 });
|
|
19120
|
+
}
|
|
19121
|
+
const tasks = exactTasks(plan.id, d);
|
|
19122
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
|
|
19123
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
19124
|
+
}
|
|
19125
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
19126
|
+
receipt.prior_plan_project_id,
|
|
19127
|
+
input.restored_at,
|
|
19128
|
+
plan.id,
|
|
19129
|
+
input.expected_plan_revision
|
|
19130
|
+
]);
|
|
19131
|
+
for (const task of tasks) {
|
|
19132
|
+
const priorProjectId = receipt.prior_task_project_ids[task.id];
|
|
19133
|
+
if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
|
|
19134
|
+
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 });
|
|
19135
|
+
}
|
|
19136
|
+
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]);
|
|
19137
|
+
}
|
|
19138
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
19139
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
19140
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
|
|
19141
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
19142
|
+
}
|
|
19143
|
+
const result = {
|
|
19144
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
19145
|
+
action: "restored",
|
|
19146
|
+
plan: restoredPlan,
|
|
19147
|
+
tasks: restoredTasks,
|
|
19148
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
19149
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
19150
|
+
restored_at: input.restored_at
|
|
19151
|
+
};
|
|
19152
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
19153
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
19154
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
19155
|
+
return result;
|
|
19156
|
+
})();
|
|
19157
|
+
}
|
|
19158
|
+
var init_plan_project_links = __esm(() => {
|
|
19159
|
+
init_plan_project_link_contract();
|
|
19160
|
+
init_database();
|
|
19161
|
+
init_plans();
|
|
19162
|
+
init_projects();
|
|
19163
|
+
init_tasks();
|
|
19164
|
+
});
|
|
19165
|
+
|
|
18347
19166
|
// src/storage/sqlite-snapshot.ts
|
|
18348
19167
|
function exportSqliteTodosStorageSnapshot(db) {
|
|
18349
19168
|
const d = db ?? getDatabase();
|
|
@@ -18850,6 +19669,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
18850
19669
|
update: (id, input) => updatePlan2(id, input, database()),
|
|
18851
19670
|
delete: (id) => deletePlan(id, database())
|
|
18852
19671
|
},
|
|
19672
|
+
planProjectLinks: {
|
|
19673
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
19674
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
19675
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
19676
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
19677
|
+
},
|
|
18853
19678
|
agents: {
|
|
18854
19679
|
register: (input) => registerAgent2(input, database()),
|
|
18855
19680
|
get: (id) => getAgent(id, database()),
|
|
@@ -18924,6 +19749,7 @@ var init_local_sqlite = __esm(() => {
|
|
|
18924
19749
|
init_comments();
|
|
18925
19750
|
init_database();
|
|
18926
19751
|
init_integrity2();
|
|
19752
|
+
init_plan_project_links();
|
|
18927
19753
|
init_sqlite_snapshot();
|
|
18928
19754
|
TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18929
19755
|
});
|
|
@@ -19533,7 +20359,7 @@ var init_sqlite = __esm(() => {
|
|
|
19533
20359
|
});
|
|
19534
20360
|
|
|
19535
20361
|
// src/project-registration/authority.ts
|
|
19536
|
-
import { createHash as
|
|
20362
|
+
import { createHash as createHash6 } from "crypto";
|
|
19537
20363
|
function canonicalProjectRegistrationJson(value) {
|
|
19538
20364
|
return JSON.stringify(canonicalize(value));
|
|
19539
20365
|
}
|
|
@@ -19551,7 +20377,7 @@ function canonicalize(value) {
|
|
|
19551
20377
|
return out;
|
|
19552
20378
|
}
|
|
19553
20379
|
function digestProjectRegistrationValue(value) {
|
|
19554
|
-
return
|
|
20380
|
+
return createHash6("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
|
|
19555
20381
|
}
|
|
19556
20382
|
function deriveTodosProjectRegistrationIdempotencyKey(input) {
|
|
19557
20383
|
return `prk_${digestProjectRegistrationValue({
|
|
@@ -20829,7 +21655,7 @@ var init_cloud = __esm(() => {
|
|
|
20829
21655
|
});
|
|
20830
21656
|
|
|
20831
21657
|
// src/db/api-keys.ts
|
|
20832
|
-
import { createHash as
|
|
21658
|
+
import { createHash as createHash7, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
20833
21659
|
function rowToRecord2(row) {
|
|
20834
21660
|
return {
|
|
20835
21661
|
id: row.id,
|
|
@@ -20843,7 +21669,7 @@ function rowToRecord2(row) {
|
|
|
20843
21669
|
};
|
|
20844
21670
|
}
|
|
20845
21671
|
function hashApiKey(key) {
|
|
20846
|
-
return
|
|
21672
|
+
return createHash7("sha256").update(key).digest("hex");
|
|
20847
21673
|
}
|
|
20848
21674
|
function safeEqualHex(a, b) {
|
|
20849
21675
|
if (a.length !== b.length)
|
|
@@ -20851,8 +21677,8 @@ function safeEqualHex(a, b) {
|
|
|
20851
21677
|
return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
20852
21678
|
}
|
|
20853
21679
|
function safeEqualStrings(a, b) {
|
|
20854
|
-
const ah =
|
|
20855
|
-
const bh =
|
|
21680
|
+
const ah = createHash7("sha256").update(a, "utf8").digest();
|
|
21681
|
+
const bh = createHash7("sha256").update(b, "utf8").digest();
|
|
20856
21682
|
return timingSafeEqual3(ah, bh);
|
|
20857
21683
|
}
|
|
20858
21684
|
function hasActiveApiKeys(db) {
|
|
@@ -22878,6 +23704,9 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22878
23704
|
ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
|
|
22879
23705
|
TaskComment: taskCommentSchema,
|
|
22880
23706
|
Plan: planSchema,
|
|
23707
|
+
PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
|
|
23708
|
+
PlanProjectLinkResult: planProjectLinkResultSchema,
|
|
23709
|
+
PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
|
|
22881
23710
|
Template: templateSchema,
|
|
22882
23711
|
TemplateTask: templateTaskSchema,
|
|
22883
23712
|
TemplateVariable: templateVariableSchema,
|
|
@@ -22891,6 +23720,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22891
23720
|
status: { type: "string" },
|
|
22892
23721
|
priority: { type: "string" },
|
|
22893
23722
|
project_id: { type: "string" },
|
|
23723
|
+
plan_id: { type: "string" },
|
|
22894
23724
|
assigned_to: { type: "string" },
|
|
22895
23725
|
agent_id: { type: "string" },
|
|
22896
23726
|
tags: { type: "array", items: { type: "string" } }
|
|
@@ -22905,6 +23735,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22905
23735
|
priority: { type: "string" },
|
|
22906
23736
|
assigned_to: { type: "string" },
|
|
22907
23737
|
project_id: { type: "string", nullable: true },
|
|
23738
|
+
plan_id: { type: "string", nullable: true },
|
|
22908
23739
|
task_list_id: { type: "string", nullable: true },
|
|
22909
23740
|
version: { type: "number" }
|
|
22910
23741
|
}
|
|
@@ -22976,6 +23807,32 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
22976
23807
|
expected_task_list_revision: { type: "string", minLength: 1 }
|
|
22977
23808
|
}
|
|
22978
23809
|
},
|
|
23810
|
+
PlanProjectLinkApplyInput: {
|
|
23811
|
+
type: "object",
|
|
23812
|
+
additionalProperties: false,
|
|
23813
|
+
required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
|
|
23814
|
+
properties: {
|
|
23815
|
+
project_id: { type: "string", minLength: 1 },
|
|
23816
|
+
expected_plan_revision: { type: "string", minLength: 1 },
|
|
23817
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
23818
|
+
idempotency_key: {
|
|
23819
|
+
type: "string",
|
|
23820
|
+
minLength: 8,
|
|
23821
|
+
maxLength: 128,
|
|
23822
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
23823
|
+
}
|
|
23824
|
+
}
|
|
23825
|
+
},
|
|
23826
|
+
PlanProjectLinkRollbackInput: {
|
|
23827
|
+
type: "object",
|
|
23828
|
+
additionalProperties: false,
|
|
23829
|
+
required: ["project_id", "receipt_id", "expected_plan_revision"],
|
|
23830
|
+
properties: {
|
|
23831
|
+
project_id: { type: "string", minLength: 1 },
|
|
23832
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
23833
|
+
expected_plan_revision: { type: "string", minLength: 1 }
|
|
23834
|
+
}
|
|
23835
|
+
},
|
|
22979
23836
|
ErrorResponse: {
|
|
22980
23837
|
type: "object",
|
|
22981
23838
|
required: ["error"],
|
|
@@ -23859,6 +24716,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
23859
24716
|
{ name: "assigned_to", in: "query", schema: { type: "string" } },
|
|
23860
24717
|
{ name: "agent_id", in: "query", schema: { type: "string" } },
|
|
23861
24718
|
{ name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
|
|
24719
|
+
{
|
|
24720
|
+
name: "updated_after",
|
|
24721
|
+
in: "query",
|
|
24722
|
+
schema: { type: "string", format: "date-time" },
|
|
24723
|
+
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."
|
|
24724
|
+
},
|
|
23862
24725
|
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
|
|
23863
24726
|
{ name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
|
|
23864
24727
|
],
|
|
@@ -24187,6 +25050,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
24187
25050
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
24188
25051
|
}
|
|
24189
25052
|
},
|
|
25053
|
+
"/v1/plans/{id}/project-link": {
|
|
25054
|
+
get: {
|
|
25055
|
+
operationId: "planPlanProjectLink",
|
|
25056
|
+
summary: "Plan atomic linkage of an existing plan and every current member task to a project",
|
|
25057
|
+
parameters: [
|
|
25058
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
25059
|
+
{ name: "project_id", in: "query", required: true, schema: { type: "string" } }
|
|
25060
|
+
],
|
|
25061
|
+
responses: {
|
|
25062
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25063
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25064
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25065
|
+
}
|
|
25066
|
+
},
|
|
25067
|
+
post: {
|
|
25068
|
+
operationId: "applyPlanProjectLink",
|
|
25069
|
+
summary: "Atomically and idempotently link an existing plan and every current member task to a project",
|
|
25070
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
25071
|
+
requestBody: {
|
|
25072
|
+
required: true,
|
|
25073
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
|
|
25074
|
+
},
|
|
25075
|
+
responses: {
|
|
25076
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25077
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
25078
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25079
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25080
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25081
|
+
}
|
|
25082
|
+
}
|
|
25083
|
+
},
|
|
25084
|
+
"/v1/plans/{id}/project-link/rollback": {
|
|
25085
|
+
post: {
|
|
25086
|
+
operationId: "rollbackPlanProjectLink",
|
|
25087
|
+
summary: "Conditionally restore every exact prior project link from an accepted receipt",
|
|
25088
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
25089
|
+
requestBody: {
|
|
25090
|
+
required: true,
|
|
25091
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
|
|
25092
|
+
},
|
|
25093
|
+
responses: {
|
|
25094
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
|
|
25095
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25096
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
25097
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
25098
|
+
}
|
|
25099
|
+
}
|
|
25100
|
+
},
|
|
24190
25101
|
"/v1/templates": {
|
|
24191
25102
|
get: {
|
|
24192
25103
|
operationId: "listTemplates",
|
|
@@ -24404,7 +25315,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
24404
25315
|
}
|
|
24405
25316
|
};
|
|
24406
25317
|
}
|
|
24407
|
-
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
25318
|
+
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
24408
25319
|
var init_openapi = __esm(() => {
|
|
24409
25320
|
init_package_version();
|
|
24410
25321
|
init_types();
|
|
@@ -24556,6 +25467,75 @@ var init_openapi = __esm(() => {
|
|
|
24556
25467
|
updated_at: { type: "string", format: "date-time" }
|
|
24557
25468
|
}
|
|
24558
25469
|
};
|
|
25470
|
+
planProjectLinkReceiptSchema = {
|
|
25471
|
+
type: "object",
|
|
25472
|
+
additionalProperties: false,
|
|
25473
|
+
required: [
|
|
25474
|
+
"schema_version",
|
|
25475
|
+
"receipt_id",
|
|
25476
|
+
"idempotency_key",
|
|
25477
|
+
"plan_id",
|
|
25478
|
+
"project_id",
|
|
25479
|
+
"prior_plan_project_id",
|
|
25480
|
+
"prior_task_project_ids",
|
|
25481
|
+
"task_ids",
|
|
25482
|
+
"task_count",
|
|
25483
|
+
"result_plan_revision",
|
|
25484
|
+
"result_digest",
|
|
25485
|
+
"rollback_supported",
|
|
25486
|
+
"created_at"
|
|
25487
|
+
],
|
|
25488
|
+
properties: {
|
|
25489
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
25490
|
+
receipt_id: { type: "string" },
|
|
25491
|
+
idempotency_key: { type: "string" },
|
|
25492
|
+
plan_id: { type: "string" },
|
|
25493
|
+
project_id: { type: "string" },
|
|
25494
|
+
prior_plan_project_id: { type: "string", nullable: true },
|
|
25495
|
+
prior_task_project_ids: {
|
|
25496
|
+
type: "object",
|
|
25497
|
+
additionalProperties: { type: "string", nullable: true }
|
|
25498
|
+
},
|
|
25499
|
+
task_ids: { type: "array", items: { type: "string" } },
|
|
25500
|
+
task_count: { type: "integer", minimum: 0 },
|
|
25501
|
+
result_plan_revision: { type: "string" },
|
|
25502
|
+
result_digest: { type: "string" },
|
|
25503
|
+
rollback_supported: { type: "boolean", enum: [true] },
|
|
25504
|
+
created_at: { type: "string", format: "date-time" }
|
|
25505
|
+
}
|
|
25506
|
+
};
|
|
25507
|
+
planProjectLinkResultSchema = {
|
|
25508
|
+
type: "object",
|
|
25509
|
+
additionalProperties: false,
|
|
25510
|
+
required: ["mode", "action", "plan", "project", "tasks", "receipt"],
|
|
25511
|
+
properties: {
|
|
25512
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
25513
|
+
action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
|
|
25514
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
25515
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
25516
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
25517
|
+
receipt: {
|
|
25518
|
+
oneOf: [
|
|
25519
|
+
{ $ref: "#/components/schemas/PlanProjectLinkReceipt" },
|
|
25520
|
+
{ type: "null" }
|
|
25521
|
+
]
|
|
25522
|
+
}
|
|
25523
|
+
}
|
|
25524
|
+
};
|
|
25525
|
+
planProjectLinkRollbackResultSchema = {
|
|
25526
|
+
type: "object",
|
|
25527
|
+
additionalProperties: false,
|
|
25528
|
+
required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
|
|
25529
|
+
properties: {
|
|
25530
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
25531
|
+
action: { type: "string", enum: ["restored"] },
|
|
25532
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
25533
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
25534
|
+
accepted_receipt_id: { type: "string" },
|
|
25535
|
+
rollback_receipt_id: { type: "string" },
|
|
25536
|
+
restored_at: { type: "string", format: "date-time" }
|
|
25537
|
+
}
|
|
25538
|
+
};
|
|
24559
25539
|
templateTaskSchema = {
|
|
24560
25540
|
type: "object",
|
|
24561
25541
|
required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
|
|
@@ -24760,7 +25740,7 @@ function decodeCommentCursor(value) {
|
|
|
24760
25740
|
var MAX_COMMENT_CURSOR_LENGTH = 1024;
|
|
24761
25741
|
|
|
24762
25742
|
// src/lib/project-task-list-ensure.ts
|
|
24763
|
-
import { createHash as
|
|
25743
|
+
import { createHash as createHash8 } from "crypto";
|
|
24764
25744
|
function canonicalJson(value) {
|
|
24765
25745
|
if (value === null || typeof value !== "object")
|
|
24766
25746
|
return JSON.stringify(value);
|
|
@@ -24769,7 +25749,7 @@ function canonicalJson(value) {
|
|
|
24769
25749
|
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
24770
25750
|
}
|
|
24771
25751
|
function digest(value) {
|
|
24772
|
-
return
|
|
25752
|
+
return createHash8("sha256").update(canonicalJson(value)).digest("hex");
|
|
24773
25753
|
}
|
|
24774
25754
|
function deriveIdempotencyKey(projectId, slug) {
|
|
24775
25755
|
return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
|
|
@@ -25014,6 +25994,75 @@ var init_project_task_list_ensure = __esm(() => {
|
|
|
25014
25994
|
};
|
|
25015
25995
|
});
|
|
25016
25996
|
|
|
25997
|
+
// src/lib/plan-project-link.ts
|
|
25998
|
+
async function exactPlanProjectLinkState(store, planId, projectId) {
|
|
25999
|
+
const [plan, project] = await Promise.all([
|
|
26000
|
+
store.plans.get(planId),
|
|
26001
|
+
store.projects.get(projectId)
|
|
26002
|
+
]);
|
|
26003
|
+
if (!plan) {
|
|
26004
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
|
|
26005
|
+
}
|
|
26006
|
+
if (!project) {
|
|
26007
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
26008
|
+
}
|
|
26009
|
+
const [tasks, projectPlans] = await Promise.all([
|
|
26010
|
+
store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
|
|
26011
|
+
store.plans.list(project.id)
|
|
26012
|
+
]);
|
|
26013
|
+
const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
26014
|
+
if (collision) {
|
|
26015
|
+
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 });
|
|
26016
|
+
}
|
|
26017
|
+
return {
|
|
26018
|
+
plan,
|
|
26019
|
+
project,
|
|
26020
|
+
tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
|
|
26021
|
+
};
|
|
26022
|
+
}
|
|
26023
|
+
async function planPlanProjectLink(store, planId, projectId) {
|
|
26024
|
+
const state = await exactPlanProjectLinkState(store, planId, projectId);
|
|
26025
|
+
const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task) => task.project_id === state.project.id);
|
|
26026
|
+
return {
|
|
26027
|
+
mode: "plan",
|
|
26028
|
+
action: alreadyLinked ? "already_linked" : "would_link",
|
|
26029
|
+
...state,
|
|
26030
|
+
receipt: null
|
|
26031
|
+
};
|
|
26032
|
+
}
|
|
26033
|
+
async function applyPlanProjectLink(store, planId, projectId, options) {
|
|
26034
|
+
if (!store.planProjectLinks) {
|
|
26035
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
|
|
26036
|
+
}
|
|
26037
|
+
const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
|
|
26038
|
+
return store.planProjectLinks.apply({
|
|
26039
|
+
plan_id: planId,
|
|
26040
|
+
project_id: projectId,
|
|
26041
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
26042
|
+
expected_project_revision: options.expected_project_revision,
|
|
26043
|
+
idempotency_key: key,
|
|
26044
|
+
receipt_id: planProjectLinkReceiptId(key),
|
|
26045
|
+
created_at: new Date().toISOString()
|
|
26046
|
+
});
|
|
26047
|
+
}
|
|
26048
|
+
async function rollbackPlanProjectLink(store, planId, projectId, options) {
|
|
26049
|
+
if (!store.planProjectLinks) {
|
|
26050
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
|
|
26051
|
+
}
|
|
26052
|
+
return store.planProjectLinks.rollback({
|
|
26053
|
+
plan_id: planId,
|
|
26054
|
+
project_id: projectId,
|
|
26055
|
+
receipt_id: options.receipt_id,
|
|
26056
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
26057
|
+
rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
|
|
26058
|
+
restored_at: new Date().toISOString()
|
|
26059
|
+
});
|
|
26060
|
+
}
|
|
26061
|
+
var init_plan_project_link = __esm(() => {
|
|
26062
|
+
init_plan_project_link_contract();
|
|
26063
|
+
init_plan_project_link_contract();
|
|
26064
|
+
});
|
|
26065
|
+
|
|
25017
26066
|
// src/server/v1.ts
|
|
25018
26067
|
var exports_v1 = {};
|
|
25019
26068
|
__export(exports_v1, {
|
|
@@ -25036,6 +26085,21 @@ function enumQueryParam(url, name, vocabulary) {
|
|
|
25036
26085
|
return { ok: false, response: error(400, result.message) };
|
|
25037
26086
|
return { ok: true, value: collapseEnumValues(result.values) };
|
|
25038
26087
|
}
|
|
26088
|
+
function parseSinceCursor(raw) {
|
|
26089
|
+
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)}`;
|
|
26090
|
+
const match = RFC3339_DATE_TIME.exec(raw);
|
|
26091
|
+
if (!match)
|
|
26092
|
+
return { ok: false, message: shape };
|
|
26093
|
+
const parsed = Date.parse(raw);
|
|
26094
|
+
if (Number.isNaN(parsed))
|
|
26095
|
+
return { ok: false, message: shape };
|
|
26096
|
+
const [, year, month, day] = match;
|
|
26097
|
+
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
26098
|
+
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
|
|
26099
|
+
return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
|
|
26100
|
+
}
|
|
26101
|
+
return { ok: true, value: new Date(parsed).toISOString() };
|
|
26102
|
+
}
|
|
25039
26103
|
function validateTaskCompletion(value) {
|
|
25040
26104
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
25041
26105
|
return { ok: false, message: "completion body must be an object" };
|
|
@@ -25445,7 +26509,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
25445
26509
|
const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
|
|
25446
26510
|
if (!priorityParam.ok)
|
|
25447
26511
|
return priorityParam.response;
|
|
26512
|
+
const updatedAfterRaw = url.searchParams.get("updated_after");
|
|
26513
|
+
const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
|
|
26514
|
+
if (updatedAfter !== null && !updatedAfter.ok) {
|
|
26515
|
+
return error(400, updatedAfter.message);
|
|
26516
|
+
}
|
|
25448
26517
|
const filter = {
|
|
26518
|
+
...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
|
|
25449
26519
|
...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
|
|
25450
26520
|
...statusParam.value !== undefined ? { status: statusParam.value } : {},
|
|
25451
26521
|
...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
|
|
@@ -25907,6 +26977,54 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
25907
26977
|
const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
|
|
25908
26978
|
return json4({ plan }, 201);
|
|
25909
26979
|
}
|
|
26980
|
+
if (id && action === "project-link" && !subId) {
|
|
26981
|
+
if (method === "GET") {
|
|
26982
|
+
const projectId = url.searchParams.get("project_id");
|
|
26983
|
+
if (!projectId?.trim())
|
|
26984
|
+
return error(400, "project_id query parameter is required");
|
|
26985
|
+
return json4(await planPlanProjectLink(store, id, projectId));
|
|
26986
|
+
}
|
|
26987
|
+
if (method !== "POST")
|
|
26988
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
|
|
26989
|
+
const body = await readJson3(req);
|
|
26990
|
+
if (!body)
|
|
26991
|
+
return error(400, "invalid JSON body");
|
|
26992
|
+
const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
|
|
26993
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
26994
|
+
if (unknown)
|
|
26995
|
+
return error(400, `unknown plan-project-link field: ${unknown}`);
|
|
26996
|
+
for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
|
|
26997
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
26998
|
+
return error(400, `${field} must be a non-empty string`);
|
|
26999
|
+
}
|
|
27000
|
+
}
|
|
27001
|
+
const result = await applyPlanProjectLink(store, id, body.project_id, {
|
|
27002
|
+
expected_plan_revision: body.expected_plan_revision,
|
|
27003
|
+
expected_project_revision: body.expected_project_revision,
|
|
27004
|
+
idempotency_key: body.idempotency_key
|
|
27005
|
+
});
|
|
27006
|
+
return json4(result, result.action === "linked" ? 201 : 200);
|
|
27007
|
+
}
|
|
27008
|
+
if (id && action === "project-link" && subId === "rollback") {
|
|
27009
|
+
if (method !== "POST")
|
|
27010
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
|
|
27011
|
+
const body = await readJson3(req);
|
|
27012
|
+
if (!body)
|
|
27013
|
+
return error(400, "invalid JSON body");
|
|
27014
|
+
const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
|
|
27015
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
27016
|
+
if (unknown)
|
|
27017
|
+
return error(400, `unknown plan-project-link rollback field: ${unknown}`);
|
|
27018
|
+
for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
|
|
27019
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
27020
|
+
return error(400, `${field} must be a non-empty string`);
|
|
27021
|
+
}
|
|
27022
|
+
}
|
|
27023
|
+
return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
|
|
27024
|
+
receipt_id: body.receipt_id,
|
|
27025
|
+
expected_plan_revision: body.expected_plan_revision
|
|
27026
|
+
}));
|
|
27027
|
+
}
|
|
25910
27028
|
if (id && method === "GET") {
|
|
25911
27029
|
const plan = await store.plans.get(id);
|
|
25912
27030
|
return plan ? json4({ plan }) : error(404, "plan not found");
|
|
@@ -26185,6 +27303,10 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
26185
27303
|
}
|
|
26186
27304
|
return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
26187
27305
|
} catch (e) {
|
|
27306
|
+
if (e instanceof PlanProjectLinkError) {
|
|
27307
|
+
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;
|
|
27308
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
27309
|
+
}
|
|
26188
27310
|
if (e instanceof ProjectTaskListEnsureError) {
|
|
26189
27311
|
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;
|
|
26190
27312
|
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
@@ -26208,7 +27330,7 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
26208
27330
|
return error(500, e.message || "internal error");
|
|
26209
27331
|
}
|
|
26210
27332
|
}
|
|
26211
|
-
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
|
|
27333
|
+
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
|
|
26212
27334
|
var init_v1 = __esm(() => {
|
|
26213
27335
|
init_types();
|
|
26214
27336
|
init_cloud();
|
|
@@ -26216,7 +27338,9 @@ var init_v1 = __esm(() => {
|
|
|
26216
27338
|
init_project_registration();
|
|
26217
27339
|
init_redaction();
|
|
26218
27340
|
init_project_task_list_ensure();
|
|
27341
|
+
init_plan_project_link();
|
|
26219
27342
|
JSON_HEADERS3 = { "Content-Type": "application/json" };
|
|
27343
|
+
RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
26220
27344
|
});
|
|
26221
27345
|
|
|
26222
27346
|
// node_modules/.bun/zod@3.25.76/node_modules/zod/v4/core/core.js
|
|
@@ -61465,6 +62589,7 @@ var init_cloud_router = __esm(() => {
|
|
|
61465
62589
|
init_mode();
|
|
61466
62590
|
init_types();
|
|
61467
62591
|
init_redaction();
|
|
62592
|
+
init_plan_project_link_contract();
|
|
61468
62593
|
init_http_client();
|
|
61469
62594
|
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
61470
62595
|
TRANSPORT_TOKENS = {
|
|
@@ -63870,12 +64995,12 @@ var init_local_notifications = __esm(() => {
|
|
|
63870
64995
|
});
|
|
63871
64996
|
|
|
63872
64997
|
// src/lib/local-encryption.ts
|
|
63873
|
-
import { createCipheriv, createDecipheriv, createHash as
|
|
64998
|
+
import { createCipheriv, createDecipheriv, createHash as createHash9, randomBytes as randomBytes3, scryptSync, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
63874
64999
|
function now3() {
|
|
63875
65000
|
return new Date().toISOString();
|
|
63876
65001
|
}
|
|
63877
65002
|
function sha2563(value) {
|
|
63878
|
-
return
|
|
65003
|
+
return createHash9("sha256").update(value).digest("hex");
|
|
63879
65004
|
}
|
|
63880
65005
|
function normalizeProfileName(value) {
|
|
63881
65006
|
const name = (value || DEFAULT_ENCRYPTION_PROFILE).trim();
|
|
@@ -66013,7 +67138,7 @@ var init_capacity_forecasts = __esm(() => {
|
|
|
66013
67138
|
});
|
|
66014
67139
|
|
|
66015
67140
|
// src/lib/audit-ledger.ts
|
|
66016
|
-
import { createHash as
|
|
67141
|
+
import { createHash as createHash10 } from "crypto";
|
|
66017
67142
|
function canonicalize2(value) {
|
|
66018
67143
|
if (value === null || typeof value !== "object")
|
|
66019
67144
|
return JSON.stringify(value);
|
|
@@ -66023,7 +67148,7 @@ function canonicalize2(value) {
|
|
|
66023
67148
|
return `{${Object.keys(object4).sort().map((key) => `${JSON.stringify(key)}:${canonicalize2(object4[key])}`).join(",")}}`;
|
|
66024
67149
|
}
|
|
66025
67150
|
function hash(value) {
|
|
66026
|
-
return
|
|
67151
|
+
return createHash10("sha256").update(value).digest("hex");
|
|
66027
67152
|
}
|
|
66028
67153
|
function parsePayload3(value) {
|
|
66029
67154
|
if (!value)
|
|
@@ -99124,7 +100249,7 @@ var init_local_bridge = __esm(() => {
|
|
|
99124
100249
|
});
|
|
99125
100250
|
|
|
99126
100251
|
// src/lib/local-backups.ts
|
|
99127
|
-
import { createHash as
|
|
100252
|
+
import { createHash as createHash11 } from "crypto";
|
|
99128
100253
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
99129
100254
|
import { dirname as dirname7, resolve as resolve13 } from "path";
|
|
99130
100255
|
import { mkdirSync as mkdirSync6 } from "fs";
|
|
@@ -99137,7 +100262,7 @@ function stableJson2(value) {
|
|
|
99137
100262
|
return `{${Object.keys(record3).sort().map((key) => `${JSON.stringify(key)}:${stableJson2(record3[key])}`).join(",")}}`;
|
|
99138
100263
|
}
|
|
99139
100264
|
function sha2564(value) {
|
|
99140
|
-
return
|
|
100265
|
+
return createHash11("sha256").update(stableJson2(value)).digest("hex");
|
|
99141
100266
|
}
|
|
99142
100267
|
function sqliteIntegrity(db) {
|
|
99143
100268
|
let quick = "unknown";
|
|
@@ -99787,7 +100912,7 @@ var init_onboarding_fixtures = __esm(() => {
|
|
|
99787
100912
|
});
|
|
99788
100913
|
|
|
99789
100914
|
// src/lib/local-snapshots.ts
|
|
99790
|
-
import { createHash as
|
|
100915
|
+
import { createHash as createHash12 } from "crypto";
|
|
99791
100916
|
function source(version2) {
|
|
99792
100917
|
return {
|
|
99793
100918
|
packageName: "@hasna/todos",
|
|
@@ -99811,7 +100936,7 @@ function stable(value) {
|
|
|
99811
100936
|
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stable(item)]));
|
|
99812
100937
|
}
|
|
99813
100938
|
function sha2565(value) {
|
|
99814
|
-
return
|
|
100939
|
+
return createHash12("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
|
99815
100940
|
}
|
|
99816
100941
|
function latestTimestamp2(items, fallback) {
|
|
99817
100942
|
const timestamps = [];
|
|
@@ -100377,7 +101502,7 @@ var init_retrospectives = __esm(() => {
|
|
|
100377
101502
|
});
|
|
100378
101503
|
|
|
100379
101504
|
// src/lib/agent-replay-simulator.ts
|
|
100380
|
-
import { createHash as
|
|
101505
|
+
import { createHash as createHash13 } from "crypto";
|
|
100381
101506
|
function isObject2(value) {
|
|
100382
101507
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
100383
101508
|
}
|
|
@@ -100398,7 +101523,7 @@ function stable2(value) {
|
|
|
100398
101523
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable2(value[key])]));
|
|
100399
101524
|
}
|
|
100400
101525
|
function fingerprint2(value) {
|
|
100401
|
-
return
|
|
101526
|
+
return createHash13("sha256").update(JSON.stringify(stable2(value))).digest("hex");
|
|
100402
101527
|
}
|
|
100403
101528
|
function unpackFixture(input) {
|
|
100404
101529
|
if (!isObject2(input))
|
|
@@ -100685,7 +101810,7 @@ __export(exports_local_extensions, {
|
|
|
100685
101810
|
getLocalExtension: () => getLocalExtension,
|
|
100686
101811
|
discoverLocalExtensions: () => discoverLocalExtensions
|
|
100687
101812
|
});
|
|
100688
|
-
import { createHash as
|
|
101813
|
+
import { createHash as createHash14, createVerify } from "crypto";
|
|
100689
101814
|
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
|
|
100690
101815
|
import { basename as basename5, join as join12, resolve as resolve14 } from "path";
|
|
100691
101816
|
function isObject3(value) {
|
|
@@ -100771,7 +101896,7 @@ function parseJson(path) {
|
|
|
100771
101896
|
return JSON.parse(readFileSync9(path, "utf8"));
|
|
100772
101897
|
}
|
|
100773
101898
|
function sha2566(bytes) {
|
|
100774
|
-
return `sha256:${
|
|
101899
|
+
return `sha256:${createHash14("sha256").update(bytes).digest("hex")}`;
|
|
100775
101900
|
}
|
|
100776
101901
|
function compareVersions(a, b) {
|
|
100777
101902
|
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
@@ -101246,7 +102371,7 @@ function issueToTask(issue2, opts) {
|
|
|
101246
102371
|
var init_github = () => {};
|
|
101247
102372
|
|
|
101248
102373
|
// src/db/inbox.ts
|
|
101249
|
-
import { createHash as
|
|
102374
|
+
import { createHash as createHash15 } from "crypto";
|
|
101250
102375
|
function parseMetadata3(value) {
|
|
101251
102376
|
if (!value)
|
|
101252
102377
|
return {};
|
|
@@ -101266,7 +102391,7 @@ function compactWhitespace(value) {
|
|
|
101266
102391
|
function fingerprintInboxInput(input) {
|
|
101267
102392
|
const sourceType = input.source_type || detectInboxSourceType(input.body, input.source_url);
|
|
101268
102393
|
const normalized = compactWhitespace(sanitizePreWriteText(input.body, "inbox.fingerprint")).slice(0, 8000);
|
|
101269
|
-
return
|
|
102394
|
+
return createHash15("sha256").update(`${sourceType}
|
|
101270
102395
|
${input.source_url || ""}
|
|
101271
102396
|
${normalized}`).digest("hex");
|
|
101272
102397
|
}
|
|
@@ -105466,10 +106591,10 @@ __export(exports_extract, {
|
|
|
105466
106591
|
EXTRACT_TAGS: () => EXTRACT_TAGS
|
|
105467
106592
|
});
|
|
105468
106593
|
import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync7 } from "fs";
|
|
105469
|
-
import { createHash as
|
|
106594
|
+
import { createHash as createHash16 } from "crypto";
|
|
105470
106595
|
import { relative as relative5, resolve as resolve15, join as join13 } from "path";
|
|
105471
106596
|
function stableHash(value) {
|
|
105472
|
-
return
|
|
106597
|
+
return createHash16("sha256").update(value).digest("hex");
|
|
105473
106598
|
}
|
|
105474
106599
|
function normalizePathForMatch(value) {
|
|
105475
106600
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -107193,13 +108318,13 @@ __export(exports_environment_snapshots, {
|
|
|
107193
108318
|
compareEnvironmentSnapshotFiles: () => compareEnvironmentSnapshotFiles,
|
|
107194
108319
|
captureEnvironmentSnapshot: () => captureEnvironmentSnapshot
|
|
107195
108320
|
});
|
|
107196
|
-
import { createHash as
|
|
108321
|
+
import { createHash as createHash17 } from "crypto";
|
|
107197
108322
|
import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
|
|
107198
108323
|
import { hostname as hostname3, platform, arch } from "os";
|
|
107199
108324
|
import { dirname as dirname8, join as join15, resolve as resolve16 } from "path";
|
|
107200
108325
|
import { tmpdir as tmpdir3 } from "os";
|
|
107201
108326
|
function sha2567(value) {
|
|
107202
|
-
return
|
|
108327
|
+
return createHash17("sha256").update(value).digest("hex");
|
|
107203
108328
|
}
|
|
107204
108329
|
function fileRecord(root, relativePath) {
|
|
107205
108330
|
const path = join15(root, relativePath);
|