@hasna/todos 0.15.7 → 0.15.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cloud-router.d.ts +26 -1
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +3836 -1465
- package/dist/contracts.js +143 -10
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plan-project-links.d.ts +8 -0
- package/dist/db/plan-project-links.d.ts.map +1 -0
- package/dist/db/plan-row-serialization.d.ts +8 -0
- package/dist/db/plan-row-serialization.d.ts.map +1 -0
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts +5 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8037 -1112
- package/dist/lib/assignee-validation.d.ts +44 -0
- package/dist/lib/assignee-validation.d.ts.map +1 -1
- package/dist/lib/plan-project-link-contract.d.ts +17 -0
- package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
- package/dist/lib/plan-project-link.d.ts +14 -0
- package/dist/lib/plan-project-link.d.ts.map +1 -0
- package/dist/lib/project-task-list-ensure.d.ts +19 -0
- package/dist/lib/project-task-list-ensure.d.ts.map +1 -0
- package/dist/mcp/index.js +2234 -31
- package/dist/mcp.js +7 -3
- package/dist/project-registration/sqlite.d.ts.map +1 -1
- package/dist/project-registration.js +1335 -60
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +150 -10
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +42 -0
- package/dist/sdk/v1.generated.d.ts +96 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +2419 -216
- package/dist/server/openapi.d.ts +705 -24
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +37 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage.js +870 -20
- package/dist/task-manifest/authority.d.ts +19 -0
- package/dist/task-manifest/authority.d.ts.map +1 -0
- package/dist/task-manifest/backend.d.ts +27 -0
- package/dist/task-manifest/backend.d.ts.map +1 -0
- package/dist/task-manifest/canonical.d.ts +4 -0
- package/dist/task-manifest/canonical.d.ts.map +1 -0
- package/dist/task-manifest/http.d.ts +16 -0
- package/dist/task-manifest/http.d.ts.map +1 -0
- package/dist/task-manifest/index.d.ts +10 -0
- package/dist/task-manifest/index.d.ts.map +1 -0
- package/dist/task-manifest/postgres.d.ts +18 -0
- package/dist/task-manifest/postgres.d.ts.map +1 -0
- package/dist/task-manifest/reference-guard.d.ts +27 -0
- package/dist/task-manifest/reference-guard.d.ts.map +1 -0
- package/dist/task-manifest/schema-sql.d.ts +3 -0
- package/dist/task-manifest/schema-sql.d.ts.map +1 -0
- package/dist/task-manifest/schema.d.ts +15 -0
- package/dist/task-manifest/schema.d.ts.map +1 -0
- package/dist/task-manifest/sqlite.d.ts +15 -0
- package/dist/task-manifest/sqlite.d.ts.map +1 -0
- package/dist/task-manifest/types.d.ts +166 -0
- package/dist/task-manifest/types.d.ts.map +1 -0
- package/dist/task-manifest.d.ts +2 -0
- package/dist/task-manifest.d.ts.map +1 -0
- package/dist/task-manifest.js +6816 -0
- package/dist/types/index.d.ts +89 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -3
package/dist/mcp/index.js
CHANGED
|
@@ -2038,6 +2038,29 @@ var init_migrations = __esm(() => {
|
|
|
2038
2038
|
${sqliteTodosProjectRegistrationSchemaSql()}
|
|
2039
2039
|
INSERT OR IGNORE INTO _migrations (id) VALUES (69);
|
|
2040
2040
|
COMMIT;
|
|
2041
|
+
`,
|
|
2042
|
+
`BEGIN;
|
|
2043
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
|
|
2044
|
+
receipt_id TEXT PRIMARY KEY,
|
|
2045
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
2046
|
+
plan_id TEXT NOT NULL,
|
|
2047
|
+
project_id TEXT NOT NULL,
|
|
2048
|
+
payload_hash TEXT NOT NULL,
|
|
2049
|
+
payload TEXT NOT NULL,
|
|
2050
|
+
created_at TEXT NOT NULL
|
|
2051
|
+
);
|
|
2052
|
+
CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
|
|
2053
|
+
ON plan_project_link_receipts(plan_id, created_at);
|
|
2054
|
+
|
|
2055
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
|
|
2056
|
+
rollback_receipt_id TEXT PRIMARY KEY,
|
|
2057
|
+
accepted_receipt_id TEXT NOT NULL UNIQUE
|
|
2058
|
+
REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
|
|
2059
|
+
payload TEXT NOT NULL,
|
|
2060
|
+
created_at TEXT NOT NULL
|
|
2061
|
+
);
|
|
2062
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
2063
|
+
COMMIT;
|
|
2041
2064
|
`
|
|
2042
2065
|
];
|
|
2043
2066
|
});
|
|
@@ -11646,6 +11669,40 @@ function deleteTaskList(id, db) {
|
|
|
11646
11669
|
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
11647
11670
|
})();
|
|
11648
11671
|
}
|
|
11672
|
+
function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
|
|
11673
|
+
const d = db || getDatabase();
|
|
11674
|
+
return d.transaction(() => {
|
|
11675
|
+
const current = getTaskList(id, d);
|
|
11676
|
+
if (!current) {
|
|
11677
|
+
return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
|
|
11678
|
+
}
|
|
11679
|
+
const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
|
|
11680
|
+
if (changed) {
|
|
11681
|
+
return { status: "changed", task_dependents: 0, plan_dependents: 0 };
|
|
11682
|
+
}
|
|
11683
|
+
const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
|
|
11684
|
+
const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
|
|
11685
|
+
if (taskDependents > 0 || planDependents > 0) {
|
|
11686
|
+
return {
|
|
11687
|
+
status: "has_dependents",
|
|
11688
|
+
task_dependents: taskDependents,
|
|
11689
|
+
plan_dependents: planDependents
|
|
11690
|
+
};
|
|
11691
|
+
}
|
|
11692
|
+
recordStorageTombstone({
|
|
11693
|
+
object_type: "task_lists",
|
|
11694
|
+
object_id: id,
|
|
11695
|
+
payload: current
|
|
11696
|
+
}, d);
|
|
11697
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
11698
|
+
const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
11699
|
+
return {
|
|
11700
|
+
status: deleted ? "deleted" : "not_found",
|
|
11701
|
+
task_dependents: 0,
|
|
11702
|
+
plan_dependents: 0
|
|
11703
|
+
};
|
|
11704
|
+
})();
|
|
11705
|
+
}
|
|
11649
11706
|
function ensureTaskList(name, slug, projectId, db) {
|
|
11650
11707
|
const d = db || getDatabase();
|
|
11651
11708
|
const existing = getTaskListBySlug(slug, projectId, d);
|
|
@@ -12365,6 +12422,14 @@ var init_checklists = __esm(() => {
|
|
|
12365
12422
|
init_database();
|
|
12366
12423
|
});
|
|
12367
12424
|
|
|
12425
|
+
// src/db/plan-row-serialization.ts
|
|
12426
|
+
function guardPlanRowsSqlite(planIds, db) {
|
|
12427
|
+
const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
|
|
12428
|
+
for (const id of ids) {
|
|
12429
|
+
db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
|
|
12430
|
+
}
|
|
12431
|
+
}
|
|
12432
|
+
|
|
12368
12433
|
// src/lib/creator-identity.ts
|
|
12369
12434
|
import { existsSync as existsSync6, rmSync } from "fs";
|
|
12370
12435
|
import { join as join5 } from "path";
|
|
@@ -13747,9 +13812,23 @@ function sanitizeUpdateTaskInput(input) {
|
|
|
13747
13812
|
metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
|
|
13748
13813
|
};
|
|
13749
13814
|
}
|
|
13750
|
-
function
|
|
13751
|
-
|
|
13752
|
-
|
|
13815
|
+
function linkedPlanProjectId(planId, db) {
|
|
13816
|
+
if (!planId)
|
|
13817
|
+
return null;
|
|
13818
|
+
const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
|
|
13819
|
+
return row?.project_id ?? null;
|
|
13820
|
+
}
|
|
13821
|
+
function resolveCreateProjectForPlan(input, db) {
|
|
13822
|
+
const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
|
|
13823
|
+
if (!linkedProjectId)
|
|
13824
|
+
return input.project_id || null;
|
|
13825
|
+
if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
|
|
13826
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
|
|
13827
|
+
}
|
|
13828
|
+
return linkedProjectId;
|
|
13829
|
+
}
|
|
13830
|
+
function createTaskStored(input, d) {
|
|
13831
|
+
const effectiveProjectId = resolveCreateProjectForPlan(input, d);
|
|
13753
13832
|
const timestamp2 = now();
|
|
13754
13833
|
const tags = input.tags || [];
|
|
13755
13834
|
const machineId = currentStorageMachineId(d);
|
|
@@ -13763,7 +13842,7 @@ function createTask(input, db) {
|
|
|
13763
13842
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
13764
13843
|
id,
|
|
13765
13844
|
null,
|
|
13766
|
-
|
|
13845
|
+
effectiveProjectId,
|
|
13767
13846
|
input.parent_id || null,
|
|
13768
13847
|
input.plan_id || null,
|
|
13769
13848
|
input.task_list_id || null,
|
|
@@ -13813,7 +13892,15 @@ function createTask(input, db) {
|
|
|
13813
13892
|
if (tags.length > 0) {
|
|
13814
13893
|
insertTaskTags(id, tags, d);
|
|
13815
13894
|
}
|
|
13816
|
-
|
|
13895
|
+
return getTask(id, d);
|
|
13896
|
+
}
|
|
13897
|
+
function createTask(input, db) {
|
|
13898
|
+
input = sanitizeCreateTaskInput(input);
|
|
13899
|
+
const d = db || getDatabase();
|
|
13900
|
+
const task = input.plan_id ? d.transaction(() => {
|
|
13901
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
13902
|
+
return createTaskStored(input, d);
|
|
13903
|
+
})() : createTaskStored(input, d);
|
|
13817
13904
|
const payload = taskEventData(task);
|
|
13818
13905
|
const databasePath = databasePathFromDatabase(d);
|
|
13819
13906
|
dispatchWebhook2("task.created", payload, d).catch(() => {});
|
|
@@ -13913,6 +14000,10 @@ function listTasks(filter = {}, db) {
|
|
|
13913
14000
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
13914
14001
|
params.push(filter.not_created_by);
|
|
13915
14002
|
}
|
|
14003
|
+
if (filter.updated_after) {
|
|
14004
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
14005
|
+
params.push(filter.updated_after);
|
|
14006
|
+
}
|
|
13916
14007
|
if (filter.session_id) {
|
|
13917
14008
|
conditions.push("session_id = ?");
|
|
13918
14009
|
params.push(filter.session_id);
|
|
@@ -14073,6 +14164,10 @@ function countTasks(filter = {}, db) {
|
|
|
14073
14164
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14074
14165
|
params.push(filter.not_created_by);
|
|
14075
14166
|
}
|
|
14167
|
+
if (filter.updated_after) {
|
|
14168
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
14169
|
+
params.push(filter.updated_after);
|
|
14170
|
+
}
|
|
14076
14171
|
if (filter.session_id) {
|
|
14077
14172
|
conditions.push("session_id = ?");
|
|
14078
14173
|
params.push(filter.session_id);
|
|
@@ -14112,7 +14207,7 @@ function countTasks(filter = {}, db) {
|
|
|
14112
14207
|
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
14113
14208
|
return row.count;
|
|
14114
14209
|
}
|
|
14115
|
-
function
|
|
14210
|
+
function updateTaskStored(id, input, db) {
|
|
14116
14211
|
const d = db || getDatabase();
|
|
14117
14212
|
const task = getTask(id, d);
|
|
14118
14213
|
if (!task)
|
|
@@ -14121,6 +14216,18 @@ function updateTask(id, input, db) {
|
|
|
14121
14216
|
throw new VersionConflictError(id, input.version, task.version);
|
|
14122
14217
|
}
|
|
14123
14218
|
input = sanitizeUpdateTaskInput(input);
|
|
14219
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
|
|
14220
|
+
const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
|
|
14221
|
+
if (linkedProjectId) {
|
|
14222
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
|
|
14223
|
+
if (effectiveProjectId !== linkedProjectId) {
|
|
14224
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
|
|
14225
|
+
input = { ...input, project_id: linkedProjectId };
|
|
14226
|
+
} else {
|
|
14227
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
|
|
14228
|
+
}
|
|
14229
|
+
}
|
|
14230
|
+
}
|
|
14124
14231
|
const timestamp2 = now();
|
|
14125
14232
|
const completionTimestamp = input.completed_at ?? timestamp2;
|
|
14126
14233
|
const sets = ["version = version + 1", "updated_at = ?"];
|
|
@@ -14328,6 +14435,21 @@ function updateTask(id, input, db) {
|
|
|
14328
14435
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
14329
14436
|
return updatedTask;
|
|
14330
14437
|
}
|
|
14438
|
+
function updateTask(id, input, db) {
|
|
14439
|
+
const d = db || getDatabase();
|
|
14440
|
+
const before = getTask(id, d);
|
|
14441
|
+
if (!before)
|
|
14442
|
+
throw new TaskNotFoundError(id);
|
|
14443
|
+
const guardedPlanIds = [before.plan_id, input.plan_id];
|
|
14444
|
+
if (!guardedPlanIds.some(Boolean))
|
|
14445
|
+
return updateTaskStored(id, input, d);
|
|
14446
|
+
return d.transaction(() => {
|
|
14447
|
+
guardPlanRowsSqlite(guardedPlanIds, d);
|
|
14448
|
+
const current = getTask(id, d);
|
|
14449
|
+
guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
|
|
14450
|
+
return updateTaskStored(id, input, d);
|
|
14451
|
+
})();
|
|
14452
|
+
}
|
|
14331
14453
|
function deleteTask(id, db) {
|
|
14332
14454
|
const d = db || getDatabase();
|
|
14333
14455
|
const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
@@ -15134,7 +15256,7 @@ function listPlans(projectId, db) {
|
|
|
15134
15256
|
}
|
|
15135
15257
|
return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
|
|
15136
15258
|
}
|
|
15137
|
-
function
|
|
15259
|
+
function updatePlanStored(id, input, db) {
|
|
15138
15260
|
const d = db || getDatabase();
|
|
15139
15261
|
const plan = getPlan(id, d);
|
|
15140
15262
|
if (!plan)
|
|
@@ -15179,6 +15301,13 @@ function updatePlan(id, input, db) {
|
|
|
15179
15301
|
});
|
|
15180
15302
|
return updated;
|
|
15181
15303
|
}
|
|
15304
|
+
function updatePlan(id, input, db) {
|
|
15305
|
+
const d = db || getDatabase();
|
|
15306
|
+
return d.transaction(() => {
|
|
15307
|
+
guardPlanRowsSqlite([id], d);
|
|
15308
|
+
return updatePlanStored(id, input, d);
|
|
15309
|
+
})();
|
|
15310
|
+
}
|
|
15182
15311
|
function deletePlan(id, db) {
|
|
15183
15312
|
const d = db || getDatabase();
|
|
15184
15313
|
const plan = getPlan(id, d);
|
|
@@ -20769,12 +20898,16 @@ function protectRemoteClient(client) {
|
|
|
20769
20898
|
function remoteAuthorityBase(client) {
|
|
20770
20899
|
return client.baseUrl.replace(/\/v1\/?$/, "");
|
|
20771
20900
|
}
|
|
20772
|
-
async function requiredRemoteRoute(client, route, request) {
|
|
20901
|
+
async function requiredRemoteRoute(client, route, request, recognized404Codes = []) {
|
|
20773
20902
|
try {
|
|
20774
20903
|
return await request();
|
|
20775
20904
|
} catch (error) {
|
|
20776
20905
|
const status = error && typeof error === "object" ? error.status : undefined;
|
|
20777
20906
|
if (status === 404) {
|
|
20907
|
+
const body = error && typeof error === "object" ? error.body : undefined;
|
|
20908
|
+
const code = body && typeof body === "object" && !Array.isArray(body) ? body.code : undefined;
|
|
20909
|
+
if (typeof code === "string" && recognized404Codes.includes(code))
|
|
20910
|
+
throw error;
|
|
20778
20911
|
throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} does not expose ${route}; ` + "deploy the @hasna/todos /v1 server contract before retrying; local SQLite fallback is disabled", { cause: error });
|
|
20779
20912
|
}
|
|
20780
20913
|
throw error;
|
|
@@ -35176,7 +35309,7 @@ var package_default;
|
|
|
35176
35309
|
var init_package = __esm(() => {
|
|
35177
35310
|
package_default = {
|
|
35178
35311
|
name: "@hasna/todos",
|
|
35179
|
-
version: "0.15.
|
|
35312
|
+
version: "0.15.10",
|
|
35180
35313
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
35181
35314
|
type: "module",
|
|
35182
35315
|
main: "dist/index.js",
|
|
@@ -35218,6 +35351,10 @@ var init_package = __esm(() => {
|
|
|
35218
35351
|
"./project-registration": {
|
|
35219
35352
|
types: "./dist/project-registration.d.ts",
|
|
35220
35353
|
import: "./dist/project-registration.js"
|
|
35354
|
+
},
|
|
35355
|
+
"./task-manifest": {
|
|
35356
|
+
types: "./dist/task-manifest.d.ts",
|
|
35357
|
+
import: "./dist/task-manifest.js"
|
|
35221
35358
|
}
|
|
35222
35359
|
},
|
|
35223
35360
|
workspaces: [
|
|
@@ -35230,8 +35367,8 @@ var init_package = __esm(() => {
|
|
|
35230
35367
|
"README.md"
|
|
35231
35368
|
],
|
|
35232
35369
|
scripts: {
|
|
35233
|
-
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",
|
|
35234
|
-
"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/*'",
|
|
35370
|
+
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",
|
|
35371
|
+
"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/*'",
|
|
35235
35372
|
migrate: "bun run src/server/index.ts migrate",
|
|
35236
35373
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
35237
35374
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
@@ -44552,6 +44689,243 @@ var init_cloud_client = __esm(() => {
|
|
|
44552
44689
|
init_config();
|
|
44553
44690
|
});
|
|
44554
44691
|
|
|
44692
|
+
// src/lib/plan-project-link-contract.ts
|
|
44693
|
+
import { createHash as createHash13 } from "crypto";
|
|
44694
|
+
function canonicalPlanProjectLinkJson(value) {
|
|
44695
|
+
if (value === null || typeof value !== "object")
|
|
44696
|
+
return JSON.stringify(value);
|
|
44697
|
+
if (Array.isArray(value))
|
|
44698
|
+
return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
|
|
44699
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
|
|
44700
|
+
}
|
|
44701
|
+
function planProjectLinkDigest(value) {
|
|
44702
|
+
return createHash13("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
|
|
44703
|
+
}
|
|
44704
|
+
function normalizePlanProjectLinkIdempotencyKey(value) {
|
|
44705
|
+
const key = value?.trim() ?? "";
|
|
44706
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
44707
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
44708
|
+
}
|
|
44709
|
+
return key;
|
|
44710
|
+
}
|
|
44711
|
+
function planProjectLinkReceiptId(idempotencyKey) {
|
|
44712
|
+
return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
44713
|
+
}
|
|
44714
|
+
function planProjectLinkRollbackReceiptId(receiptId) {
|
|
44715
|
+
return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
|
|
44716
|
+
}
|
|
44717
|
+
function planProjectLinkRequestHash(planId, projectId) {
|
|
44718
|
+
return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
|
|
44719
|
+
}
|
|
44720
|
+
function planProjectLinkResultDigest(plan, tasks) {
|
|
44721
|
+
return planProjectLinkDigest({
|
|
44722
|
+
plan_id: plan.id,
|
|
44723
|
+
plan_project_id: plan.project_id,
|
|
44724
|
+
tasks: tasks.map((task2) => ({ id: task2.id, plan_id: task2.plan_id, project_id: task2.project_id })).sort((left, right) => left.id.localeCompare(right.id))
|
|
44725
|
+
});
|
|
44726
|
+
}
|
|
44727
|
+
function assertPlanProjectLinkReceipt(value) {
|
|
44728
|
+
const receipt = value;
|
|
44729
|
+
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") {
|
|
44730
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
|
|
44731
|
+
}
|
|
44732
|
+
return receipt;
|
|
44733
|
+
}
|
|
44734
|
+
var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1", PlanProjectLinkError;
|
|
44735
|
+
var init_plan_project_link_contract = __esm(() => {
|
|
44736
|
+
PlanProjectLinkError = class PlanProjectLinkError extends Error {
|
|
44737
|
+
code;
|
|
44738
|
+
details;
|
|
44739
|
+
constructor(code, message, details = {}) {
|
|
44740
|
+
super(message);
|
|
44741
|
+
this.code = code;
|
|
44742
|
+
this.details = details;
|
|
44743
|
+
this.name = "PlanProjectLinkError";
|
|
44744
|
+
}
|
|
44745
|
+
};
|
|
44746
|
+
});
|
|
44747
|
+
|
|
44748
|
+
// src/db/plan-project-links.ts
|
|
44749
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
44750
|
+
const d = db || getDatabase();
|
|
44751
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
44752
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
44753
|
+
}
|
|
44754
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
44755
|
+
const d = db || getDatabase();
|
|
44756
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
44757
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
44758
|
+
}
|
|
44759
|
+
function exactTasks(planId, db) {
|
|
44760
|
+
return listTasks({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
44761
|
+
}
|
|
44762
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
44763
|
+
const plan = getPlan(planId, db);
|
|
44764
|
+
const project = getProject(projectId, db);
|
|
44765
|
+
if (!plan || !project) {
|
|
44766
|
+
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 });
|
|
44767
|
+
}
|
|
44768
|
+
const tasks = exactTasks(planId, db);
|
|
44769
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
44770
|
+
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 });
|
|
44771
|
+
}
|
|
44772
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
44773
|
+
}
|
|
44774
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
44775
|
+
const d = db || getDatabase();
|
|
44776
|
+
const mutate = d.transaction(() => {
|
|
44777
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
44778
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
44779
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
44780
|
+
if (existingRow) {
|
|
44781
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
44782
|
+
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 });
|
|
44783
|
+
}
|
|
44784
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
44785
|
+
if (rolledBack) {
|
|
44786
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
44787
|
+
}
|
|
44788
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
44789
|
+
}
|
|
44790
|
+
const plan = getPlan(input.plan_id, d);
|
|
44791
|
+
if (!plan) {
|
|
44792
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
44793
|
+
}
|
|
44794
|
+
const project = getProject(input.project_id, d);
|
|
44795
|
+
if (!project) {
|
|
44796
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
44797
|
+
}
|
|
44798
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
44799
|
+
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 });
|
|
44800
|
+
}
|
|
44801
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
44802
|
+
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 });
|
|
44803
|
+
}
|
|
44804
|
+
if (plan.slug) {
|
|
44805
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
44806
|
+
if (collision) {
|
|
44807
|
+
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 });
|
|
44808
|
+
}
|
|
44809
|
+
}
|
|
44810
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
44811
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task2) => task2.project_id === project.id);
|
|
44812
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task2) => [task2.id, task2.project_id]));
|
|
44813
|
+
const timestamp4 = input.created_at;
|
|
44814
|
+
if (plan.project_id !== project.id) {
|
|
44815
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
44816
|
+
project.id,
|
|
44817
|
+
timestamp4,
|
|
44818
|
+
plan.id,
|
|
44819
|
+
input.expected_plan_revision
|
|
44820
|
+
]);
|
|
44821
|
+
}
|
|
44822
|
+
for (const task2 of beforeTasks) {
|
|
44823
|
+
if (task2.project_id === project.id)
|
|
44824
|
+
continue;
|
|
44825
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp4, task2.id, plan.id]);
|
|
44826
|
+
}
|
|
44827
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
44828
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
44829
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task2) => task2.project_id !== project.id)) {
|
|
44830
|
+
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 });
|
|
44831
|
+
}
|
|
44832
|
+
const receipt = {
|
|
44833
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
44834
|
+
receipt_id: input.receipt_id,
|
|
44835
|
+
idempotency_key: input.idempotency_key,
|
|
44836
|
+
plan_id: plan.id,
|
|
44837
|
+
project_id: project.id,
|
|
44838
|
+
prior_plan_project_id: plan.project_id,
|
|
44839
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
44840
|
+
task_ids: beforeTasks.map((task2) => task2.id),
|
|
44841
|
+
task_count: beforeTasks.length,
|
|
44842
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
44843
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
44844
|
+
rollback_supported: true,
|
|
44845
|
+
created_at: input.created_at
|
|
44846
|
+
};
|
|
44847
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
44848
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
44849
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
44850
|
+
receipt.receipt_id,
|
|
44851
|
+
receipt.idempotency_key,
|
|
44852
|
+
receipt.plan_id,
|
|
44853
|
+
receipt.project_id,
|
|
44854
|
+
requestHash,
|
|
44855
|
+
JSON.stringify(receipt),
|
|
44856
|
+
receipt.created_at
|
|
44857
|
+
]);
|
|
44858
|
+
return {
|
|
44859
|
+
mode: "apply",
|
|
44860
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
44861
|
+
plan: linkedPlan,
|
|
44862
|
+
project,
|
|
44863
|
+
tasks: linkedTasks,
|
|
44864
|
+
receipt
|
|
44865
|
+
};
|
|
44866
|
+
});
|
|
44867
|
+
return mutate();
|
|
44868
|
+
}
|
|
44869
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
44870
|
+
const d = db || getDatabase();
|
|
44871
|
+
return d.transaction(() => {
|
|
44872
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
44873
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
44874
|
+
if (priorRollback)
|
|
44875
|
+
return JSON.parse(priorRollback.payload);
|
|
44876
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
44877
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
44878
|
+
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 });
|
|
44879
|
+
}
|
|
44880
|
+
const plan = getPlan(input.plan_id, d);
|
|
44881
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
44882
|
+
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 });
|
|
44883
|
+
}
|
|
44884
|
+
const tasks = exactTasks(plan.id, d);
|
|
44885
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task2, index) => task2.id !== receipt.task_ids[index])) {
|
|
44886
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
44887
|
+
}
|
|
44888
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
44889
|
+
receipt.prior_plan_project_id,
|
|
44890
|
+
input.restored_at,
|
|
44891
|
+
plan.id,
|
|
44892
|
+
input.expected_plan_revision
|
|
44893
|
+
]);
|
|
44894
|
+
for (const task2 of tasks) {
|
|
44895
|
+
const priorProjectId = receipt.prior_task_project_ids[task2.id];
|
|
44896
|
+
if (priorProjectId === undefined && !(task2.id in receipt.prior_task_project_ids)) {
|
|
44897
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Receipt does not contain the exact prior project for every member task", { task_id: task2.id, receipt_id: receipt.receipt_id });
|
|
44898
|
+
}
|
|
44899
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [priorProjectId, input.restored_at, task2.id, plan.id]);
|
|
44900
|
+
}
|
|
44901
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
44902
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
44903
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task2) => task2.project_id !== receipt.prior_task_project_ids[task2.id])) {
|
|
44904
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
44905
|
+
}
|
|
44906
|
+
const result = {
|
|
44907
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
44908
|
+
action: "restored",
|
|
44909
|
+
plan: restoredPlan,
|
|
44910
|
+
tasks: restoredTasks,
|
|
44911
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
44912
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
44913
|
+
restored_at: input.restored_at
|
|
44914
|
+
};
|
|
44915
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
44916
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
44917
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
44918
|
+
return result;
|
|
44919
|
+
})();
|
|
44920
|
+
}
|
|
44921
|
+
var init_plan_project_links = __esm(() => {
|
|
44922
|
+
init_plan_project_link_contract();
|
|
44923
|
+
init_database();
|
|
44924
|
+
init_plans();
|
|
44925
|
+
init_projects();
|
|
44926
|
+
init_tasks();
|
|
44927
|
+
});
|
|
44928
|
+
|
|
44555
44929
|
// src/storage/sqlite-snapshot.ts
|
|
44556
44930
|
function exportSqliteTodosStorageSnapshot(db) {
|
|
44557
44931
|
const d = db ?? getDatabase();
|
|
@@ -45058,6 +45432,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
45058
45432
|
update: (id, input) => updatePlan(id, input, database()),
|
|
45059
45433
|
delete: (id) => deletePlan(id, database())
|
|
45060
45434
|
},
|
|
45435
|
+
planProjectLinks: {
|
|
45436
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
45437
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
45438
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
45439
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
45440
|
+
},
|
|
45061
45441
|
agents: {
|
|
45062
45442
|
register: (input) => registerAgent(input, database()),
|
|
45063
45443
|
get: (id) => getAgent(id, database()),
|
|
@@ -45071,7 +45451,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
45071
45451
|
getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
|
|
45072
45452
|
list: (projectId) => listTaskLists(projectId, database()),
|
|
45073
45453
|
update: (id, input) => updateTaskList(id, input, database()),
|
|
45074
|
-
delete: (id) => deleteTaskList(id, database())
|
|
45454
|
+
delete: (id) => deleteTaskList(id, database()),
|
|
45455
|
+
deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
|
|
45075
45456
|
},
|
|
45076
45457
|
templates: {
|
|
45077
45458
|
create: (input) => createTemplate(input, database()),
|
|
@@ -45131,6 +45512,7 @@ var init_local_sqlite = __esm(() => {
|
|
|
45131
45512
|
init_comments();
|
|
45132
45513
|
init_database();
|
|
45133
45514
|
init_integrity2();
|
|
45515
|
+
init_plan_project_links();
|
|
45134
45516
|
init_sqlite_snapshot();
|
|
45135
45517
|
TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
45136
45518
|
});
|
|
@@ -45161,6 +45543,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
45161
45543
|
RETURNS text
|
|
45162
45544
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
45163
45545
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
45546
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
45547
|
+
RETURNS timestamptz
|
|
45548
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
45549
|
+
SET DateStyle TO 'ISO, YMD'
|
|
45550
|
+
AS $$
|
|
45551
|
+
BEGIN
|
|
45552
|
+
RETURN CASE
|
|
45553
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
45554
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
45555
|
+
END;
|
|
45556
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
45557
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
45558
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
45559
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
45560
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
45164
45561
|
`ALTER TABLE ${tableName}
|
|
45165
45562
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
45166
45563
|
GENERATED ALWAYS AS (
|
|
@@ -45880,7 +46277,7 @@ var init_shadow_runtime = __esm(() => {
|
|
|
45880
46277
|
});
|
|
45881
46278
|
|
|
45882
46279
|
// src/db/api-keys.ts
|
|
45883
|
-
import { createHash as
|
|
46280
|
+
import { createHash as createHash14, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
45884
46281
|
function rowToRecord(row) {
|
|
45885
46282
|
return {
|
|
45886
46283
|
id: row.id,
|
|
@@ -45894,7 +46291,7 @@ function rowToRecord(row) {
|
|
|
45894
46291
|
};
|
|
45895
46292
|
}
|
|
45896
46293
|
function hashApiKey(key) {
|
|
45897
|
-
return
|
|
46294
|
+
return createHash14("sha256").update(key).digest("hex");
|
|
45898
46295
|
}
|
|
45899
46296
|
function safeEqualHex(a, b) {
|
|
45900
46297
|
if (a.length !== b.length)
|
|
@@ -45902,8 +46299,8 @@ function safeEqualHex(a, b) {
|
|
|
45902
46299
|
return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
45903
46300
|
}
|
|
45904
46301
|
function safeEqualStrings(a, b) {
|
|
45905
|
-
const ah =
|
|
45906
|
-
const bh =
|
|
46302
|
+
const ah = createHash14("sha256").update(a, "utf8").digest();
|
|
46303
|
+
const bh = createHash14("sha256").update(b, "utf8").digest();
|
|
45907
46304
|
return timingSafeEqual3(ah, bh);
|
|
45908
46305
|
}
|
|
45909
46306
|
function hasActiveApiKeys(db) {
|
|
@@ -45995,6 +46392,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
45995
46392
|
update: (id, input) => updatePlan2(id, input, store),
|
|
45996
46393
|
delete: (id, context) => store.deletePlan(id, context)
|
|
45997
46394
|
},
|
|
46395
|
+
planProjectLinks: {
|
|
46396
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
46397
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
46398
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
46399
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
46400
|
+
},
|
|
45998
46401
|
agents: {
|
|
45999
46402
|
register: (input, context) => registerAgent2(input, store, context),
|
|
46000
46403
|
get: (id) => store.get("agents", id),
|
|
@@ -46177,6 +46580,9 @@ class PostgresJsonRecordStore {
|
|
|
46177
46580
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
46178
46581
|
if (filter.not_created_by !== undefined)
|
|
46179
46582
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
46583
|
+
if (filter.updated_after !== undefined) {
|
|
46584
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
46585
|
+
}
|
|
46180
46586
|
if (filter.session_id !== undefined)
|
|
46181
46587
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
46182
46588
|
if (filter.tags?.length) {
|
|
@@ -46361,6 +46767,115 @@ class PostgresJsonRecordStore {
|
|
|
46361
46767
|
}
|
|
46362
46768
|
return value;
|
|
46363
46769
|
}
|
|
46770
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
46771
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
46772
|
+
if (planIds.length === 0)
|
|
46773
|
+
return this.upsert("tasks", value, context);
|
|
46774
|
+
await this.ensureSchema();
|
|
46775
|
+
const updatedAt = value.updated_at;
|
|
46776
|
+
const targetPlanId = value.plan_id;
|
|
46777
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
46778
|
+
locked_plans AS MATERIALIZED (
|
|
46779
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
46780
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
46781
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
46782
|
+
ORDER BY object_id
|
|
46783
|
+
FOR UPDATE
|
|
46784
|
+
), validation AS (
|
|
46785
|
+
SELECT
|
|
46786
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
46787
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
46788
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
46789
|
+
), guarded AS (
|
|
46790
|
+
SELECT
|
|
46791
|
+
validation.*,
|
|
46792
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
46793
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
46794
|
+
CASE
|
|
46795
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
46796
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
46797
|
+
END AS payload
|
|
46798
|
+
FROM validation
|
|
46799
|
+
), stored AS (
|
|
46800
|
+
INSERT INTO ${this.tableName} (
|
|
46801
|
+
service, object_type, object_id, payload, updated_at,
|
|
46802
|
+
deleted_at, source_machine_id, version
|
|
46803
|
+
)
|
|
46804
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
46805
|
+
FROM guarded
|
|
46806
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
46807
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
46808
|
+
payload = EXCLUDED.payload,
|
|
46809
|
+
updated_at = EXCLUDED.updated_at,
|
|
46810
|
+
deleted_at = NULL,
|
|
46811
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
46812
|
+
version = EXCLUDED.version
|
|
46813
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
46814
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
46815
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
46816
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
46817
|
+
RETURNING payload
|
|
46818
|
+
)
|
|
46819
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
46820
|
+
(SELECT payload FROM stored) AS payload
|
|
46821
|
+
FROM guarded`, [
|
|
46822
|
+
this.service,
|
|
46823
|
+
value.id,
|
|
46824
|
+
jsonbParam(value),
|
|
46825
|
+
updatedAt,
|
|
46826
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
46827
|
+
numberValue3(value.version),
|
|
46828
|
+
jsonbParam(planIds),
|
|
46829
|
+
targetPlanId,
|
|
46830
|
+
explicitProject
|
|
46831
|
+
]);
|
|
46832
|
+
const row = result.rows[0];
|
|
46833
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
46834
|
+
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 });
|
|
46835
|
+
}
|
|
46836
|
+
if (row.project_conflict) {
|
|
46837
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
46838
|
+
}
|
|
46839
|
+
if (!row.payload) {
|
|
46840
|
+
return await requireRecord("tasks", value.id, this);
|
|
46841
|
+
}
|
|
46842
|
+
return payloadRecord2(row.payload);
|
|
46843
|
+
}
|
|
46844
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
46845
|
+
await this.ensureSchema();
|
|
46846
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
46847
|
+
SELECT payload FROM ${this.tableName}
|
|
46848
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
46849
|
+
FOR UPDATE
|
|
46850
|
+
), stored AS (
|
|
46851
|
+
UPDATE ${this.tableName} r SET
|
|
46852
|
+
payload = jsonb_set(
|
|
46853
|
+
$3::jsonb,
|
|
46854
|
+
'{project_id}',
|
|
46855
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
46856
|
+
true
|
|
46857
|
+
),
|
|
46858
|
+
updated_at = $4::timestamptz,
|
|
46859
|
+
deleted_at = NULL,
|
|
46860
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
46861
|
+
version = COALESCE(r.version, 0) + 1
|
|
46862
|
+
FROM locked_plan
|
|
46863
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
46864
|
+
RETURNING r.payload
|
|
46865
|
+
)
|
|
46866
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
46867
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
46868
|
+
this.service,
|
|
46869
|
+
value.id,
|
|
46870
|
+
jsonbParam(value),
|
|
46871
|
+
value.updated_at,
|
|
46872
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
46873
|
+
]);
|
|
46874
|
+
const row = result.rows[0];
|
|
46875
|
+
if (!row?.plan_found || !row.payload)
|
|
46876
|
+
throw new PlanNotFoundError(value.id);
|
|
46877
|
+
return payloadRecord2(row.payload);
|
|
46878
|
+
}
|
|
46364
46879
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
46365
46880
|
await this.ensureSchema();
|
|
46366
46881
|
const records = [
|
|
@@ -46599,6 +47114,333 @@ class PostgresJsonRecordStore {
|
|
|
46599
47114
|
version: numberValue3(existing["version"])
|
|
46600
47115
|
}, context);
|
|
46601
47116
|
}
|
|
47117
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
47118
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
47119
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
47120
|
+
}
|
|
47121
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
47122
|
+
await this.ensureSchema();
|
|
47123
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
47124
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
47125
|
+
AND payload->>'idempotency_key' = $2
|
|
47126
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
47127
|
+
if (result.rows.length > 1) {
|
|
47128
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
47129
|
+
}
|
|
47130
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
47131
|
+
}
|
|
47132
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
47133
|
+
const [plan, project, tasks] = await Promise.all([
|
|
47134
|
+
this.get("plans", receipt.plan_id),
|
|
47135
|
+
this.get("projects", receipt.project_id),
|
|
47136
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
47137
|
+
]);
|
|
47138
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47139
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
|
|
47140
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
47141
|
+
}
|
|
47142
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks3, receipt };
|
|
47143
|
+
}
|
|
47144
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
47145
|
+
await this.ensureSchema();
|
|
47146
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47147
|
+
if (existing) {
|
|
47148
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
47149
|
+
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 });
|
|
47150
|
+
}
|
|
47151
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
47152
|
+
if (rolledBack) {
|
|
47153
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
47154
|
+
}
|
|
47155
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
47156
|
+
}
|
|
47157
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
47158
|
+
this.get("plans", input.plan_id),
|
|
47159
|
+
this.get("projects", input.project_id),
|
|
47160
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
47161
|
+
this.list("plans")
|
|
47162
|
+
]);
|
|
47163
|
+
if (!plan)
|
|
47164
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
47165
|
+
if (!project)
|
|
47166
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
47167
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
47168
|
+
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 });
|
|
47169
|
+
}
|
|
47170
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
47171
|
+
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 });
|
|
47172
|
+
}
|
|
47173
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
47174
|
+
if (collision) {
|
|
47175
|
+
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 });
|
|
47176
|
+
}
|
|
47177
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47178
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
|
|
47179
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
47180
|
+
const projectedTasks = sortedTasks3.map((task2) => task2.project_id === project.id ? task2 : { ...task2, project_id: project.id, updated_at: input.created_at, version: task2.version + 1 });
|
|
47181
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks3.every((task2) => task2.project_id === project.id);
|
|
47182
|
+
const receipt = {
|
|
47183
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
47184
|
+
receipt_id: input.receipt_id,
|
|
47185
|
+
idempotency_key: input.idempotency_key,
|
|
47186
|
+
plan_id: plan.id,
|
|
47187
|
+
project_id: project.id,
|
|
47188
|
+
prior_plan_project_id: plan.project_id,
|
|
47189
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
47190
|
+
task_ids: sortedTasks3.map((task2) => task2.id),
|
|
47191
|
+
task_count: sortedTasks3.length,
|
|
47192
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
47193
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
47194
|
+
rollback_supported: true,
|
|
47195
|
+
created_at: input.created_at
|
|
47196
|
+
};
|
|
47197
|
+
let mutation;
|
|
47198
|
+
try {
|
|
47199
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
47200
|
+
target_plan AS MATERIALIZED (
|
|
47201
|
+
SELECT payload FROM ${this.tableName}
|
|
47202
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
47203
|
+
FOR UPDATE
|
|
47204
|
+
), target_project AS (
|
|
47205
|
+
SELECT payload FROM ${this.tableName}
|
|
47206
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
47207
|
+
FOR UPDATE
|
|
47208
|
+
), member_tasks AS MATERIALIZED (
|
|
47209
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
47210
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
47211
|
+
AND payload->>'plan_id' = $2
|
|
47212
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
47213
|
+
FOR UPDATE
|
|
47214
|
+
), existing AS (
|
|
47215
|
+
SELECT payload FROM ${this.tableName}
|
|
47216
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
47217
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
47218
|
+
FOR UPDATE
|
|
47219
|
+
), collision AS (
|
|
47220
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
47221
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
47222
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
47223
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
47224
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
47225
|
+
LIMIT 1
|
|
47226
|
+
), checks AS (
|
|
47227
|
+
SELECT
|
|
47228
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
47229
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
47230
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
47231
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
47232
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
47233
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
47234
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
47235
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
47236
|
+
), updated_plan AS (
|
|
47237
|
+
UPDATE ${this.tableName} r SET
|
|
47238
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
47239
|
+
updated_at = $10::timestamptz,
|
|
47240
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47241
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47242
|
+
FROM checks
|
|
47243
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
47244
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
47245
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
47246
|
+
RETURNING r.payload
|
|
47247
|
+
), updated_tasks AS (
|
|
47248
|
+
UPDATE ${this.tableName} r SET
|
|
47249
|
+
payload = r.payload || jsonb_build_object(
|
|
47250
|
+
'project_id', $3::text,
|
|
47251
|
+
'updated_at', $10::text,
|
|
47252
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
47253
|
+
),
|
|
47254
|
+
updated_at = $10::timestamptz,
|
|
47255
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47256
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47257
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
47258
|
+
AND r.payload->>'plan_id' = $2
|
|
47259
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
47260
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47261
|
+
RETURNING 1
|
|
47262
|
+
), task_gate AS (
|
|
47263
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
47264
|
+
), inserted AS (
|
|
47265
|
+
INSERT INTO ${this.tableName}
|
|
47266
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
47267
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
47268
|
+
FROM checks, task_gate
|
|
47269
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47270
|
+
RETURNING payload
|
|
47271
|
+
) SELECT
|
|
47272
|
+
checks.plan_found,
|
|
47273
|
+
checks.project_found,
|
|
47274
|
+
checks.plan_revision_ok,
|
|
47275
|
+
checks.project_revision_ok,
|
|
47276
|
+
checks.membership_ok,
|
|
47277
|
+
checks.collision,
|
|
47278
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
47279
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
47280
|
+
FROM checks`, [
|
|
47281
|
+
this.service,
|
|
47282
|
+
plan.id,
|
|
47283
|
+
project.id,
|
|
47284
|
+
input.expected_plan_revision,
|
|
47285
|
+
input.expected_project_revision,
|
|
47286
|
+
receipt.receipt_id,
|
|
47287
|
+
jsonbParam(receipt),
|
|
47288
|
+
jsonbParam(priorTaskProjectIds),
|
|
47289
|
+
jsonbParam(receipt.task_ids),
|
|
47290
|
+
input.created_at,
|
|
47291
|
+
this.machineId(context)
|
|
47292
|
+
]);
|
|
47293
|
+
} catch (error) {
|
|
47294
|
+
if (isPostgresUniqueViolation(error)) {
|
|
47295
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47296
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
47297
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
47298
|
+
}
|
|
47299
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
47300
|
+
}
|
|
47301
|
+
throw error;
|
|
47302
|
+
}
|
|
47303
|
+
const row = mutation.rows[0];
|
|
47304
|
+
if (!row?.plan_found)
|
|
47305
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
47306
|
+
if (!row.project_found)
|
|
47307
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
47308
|
+
if (!row.plan_revision_ok)
|
|
47309
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
47310
|
+
if (!row.project_revision_ok)
|
|
47311
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
47312
|
+
if (!row.membership_ok)
|
|
47313
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
47314
|
+
if (row.collision)
|
|
47315
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
47316
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
47317
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
47318
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
47319
|
+
}
|
|
47320
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
47321
|
+
}
|
|
47322
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
47323
|
+
await this.ensureSchema();
|
|
47324
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
47325
|
+
if (existingRollback)
|
|
47326
|
+
return existingRollback;
|
|
47327
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47328
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
47329
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
47330
|
+
}
|
|
47331
|
+
const [plan, tasks] = await Promise.all([
|
|
47332
|
+
this.get("plans", input.plan_id),
|
|
47333
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
47334
|
+
]);
|
|
47335
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47336
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
47337
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
47338
|
+
}
|
|
47339
|
+
if (planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
|
|
47340
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
47341
|
+
}
|
|
47342
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
47343
|
+
const projectedTasks = sortedTasks3.map((task2) => ({
|
|
47344
|
+
...task2,
|
|
47345
|
+
project_id: receipt.prior_task_project_ids[task2.id] ?? null,
|
|
47346
|
+
updated_at: input.restored_at,
|
|
47347
|
+
version: task2.version + 1
|
|
47348
|
+
}));
|
|
47349
|
+
const rollback = {
|
|
47350
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
47351
|
+
action: "restored",
|
|
47352
|
+
plan: projectedPlan,
|
|
47353
|
+
tasks: projectedTasks,
|
|
47354
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
47355
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
47356
|
+
restored_at: input.restored_at
|
|
47357
|
+
};
|
|
47358
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
|
|
47359
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
47360
|
+
target_plan AS MATERIALIZED (
|
|
47361
|
+
SELECT payload FROM ${this.tableName}
|
|
47362
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
47363
|
+
FOR UPDATE
|
|
47364
|
+
), member_tasks AS MATERIALIZED (
|
|
47365
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
47366
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
47367
|
+
AND payload->>'plan_id' = $2
|
|
47368
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
47369
|
+
FOR UPDATE
|
|
47370
|
+
), existing AS (
|
|
47371
|
+
SELECT payload FROM ${this.tableName}
|
|
47372
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
47373
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
47374
|
+
FOR UPDATE
|
|
47375
|
+
), checks AS (
|
|
47376
|
+
SELECT
|
|
47377
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
47378
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
47379
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
47380
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
47381
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
47382
|
+
), updated_plan AS (
|
|
47383
|
+
UPDATE ${this.tableName} r SET
|
|
47384
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
47385
|
+
updated_at = $10::timestamptz,
|
|
47386
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47387
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47388
|
+
FROM checks
|
|
47389
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
47390
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
47391
|
+
RETURNING 1
|
|
47392
|
+
), updated_tasks AS (
|
|
47393
|
+
UPDATE ${this.tableName} r SET
|
|
47394
|
+
payload = r.payload || jsonb_build_object(
|
|
47395
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
47396
|
+
'updated_at', $10::text,
|
|
47397
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
47398
|
+
),
|
|
47399
|
+
updated_at = $10::timestamptz,
|
|
47400
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47401
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47402
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
47403
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47404
|
+
RETURNING 1
|
|
47405
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
47406
|
+
INSERT INTO ${this.tableName}
|
|
47407
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
47408
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
47409
|
+
FROM checks, task_gate
|
|
47410
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47411
|
+
RETURNING payload
|
|
47412
|
+
) SELECT
|
|
47413
|
+
checks.plan_found,
|
|
47414
|
+
checks.plan_revision_ok,
|
|
47415
|
+
checks.membership_ok,
|
|
47416
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
47417
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
47418
|
+
FROM checks`, [
|
|
47419
|
+
this.service,
|
|
47420
|
+
input.plan_id,
|
|
47421
|
+
input.expected_plan_revision,
|
|
47422
|
+
input.receipt_id,
|
|
47423
|
+
input.rollback_receipt_id,
|
|
47424
|
+
jsonbParam(rollback),
|
|
47425
|
+
jsonbParam(currentTaskProjects),
|
|
47426
|
+
jsonbParam(receipt.task_ids),
|
|
47427
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
47428
|
+
input.restored_at,
|
|
47429
|
+
this.machineId(context),
|
|
47430
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
47431
|
+
]);
|
|
47432
|
+
const row = result.rows[0];
|
|
47433
|
+
if (!row?.plan_found)
|
|
47434
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
47435
|
+
if (!row.plan_revision_ok)
|
|
47436
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
47437
|
+
if (!row.membership_ok)
|
|
47438
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
47439
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
47440
|
+
if (!accepted)
|
|
47441
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
47442
|
+
return accepted;
|
|
47443
|
+
}
|
|
46602
47444
|
async deletePlan(id, context = {}) {
|
|
46603
47445
|
await this.ensureSchema();
|
|
46604
47446
|
const timestamp4 = new Date().toISOString();
|
|
@@ -46693,11 +47535,17 @@ class PostgresJsonRecordStore {
|
|
|
46693
47535
|
}
|
|
46694
47536
|
async function createTask3(input, store, context) {
|
|
46695
47537
|
const timestamp4 = new Date().toISOString();
|
|
46696
|
-
const
|
|
47538
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
47539
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
47540
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
47541
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
47542
|
+
}
|
|
47543
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
47544
|
+
const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
|
|
46697
47545
|
const task2 = {
|
|
46698
47546
|
id: randomUUID3(),
|
|
46699
47547
|
short_id: shortId,
|
|
46700
|
-
project_id:
|
|
47548
|
+
project_id: effectiveProjectId,
|
|
46701
47549
|
parent_id: input.parent_id ?? null,
|
|
46702
47550
|
plan_id: input.plan_id ?? null,
|
|
46703
47551
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -46751,15 +47599,27 @@ async function createTask3(input, store, context) {
|
|
|
46751
47599
|
synced_at: null,
|
|
46752
47600
|
archived_at: null
|
|
46753
47601
|
};
|
|
46754
|
-
await store.
|
|
46755
|
-
await logTaskChange2(
|
|
46756
|
-
return
|
|
47602
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, task2.plan_id ? [task2.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
47603
|
+
await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
47604
|
+
return storedTask;
|
|
46757
47605
|
}
|
|
46758
47606
|
async function updateTask2(id, input, store) {
|
|
46759
47607
|
const existing = await requireRecord("tasks", id, store);
|
|
46760
47608
|
if (existing.version !== input.version) {
|
|
46761
47609
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
46762
47610
|
}
|
|
47611
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
47612
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
47613
|
+
if (linkedPlan?.project_id) {
|
|
47614
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
47615
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
47616
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
47617
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
47618
|
+
} else {
|
|
47619
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
47620
|
+
}
|
|
47621
|
+
}
|
|
47622
|
+
}
|
|
46763
47623
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
46764
47624
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
46765
47625
|
const task2 = {
|
|
@@ -46775,8 +47635,7 @@ async function updateTask2(id, input, store) {
|
|
|
46775
47635
|
created_by: existing.created_by,
|
|
46776
47636
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
46777
47637
|
};
|
|
46778
|
-
|
|
46779
|
-
return task2;
|
|
47638
|
+
return store.upsertTaskWithPlanMembershipGuard(task2, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
46780
47639
|
}
|
|
46781
47640
|
async function startTask2(id, agentId, store) {
|
|
46782
47641
|
const task2 = await requireRecord("tasks", id, store);
|
|
@@ -46840,8 +47699,7 @@ async function patchTask(task2, patch, store) {
|
|
|
46840
47699
|
version: task2.version + 1,
|
|
46841
47700
|
updated_at: new Date().toISOString()
|
|
46842
47701
|
};
|
|
46843
|
-
|
|
46844
|
-
return updated;
|
|
47702
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task2.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
46845
47703
|
}
|
|
46846
47704
|
function sameCloudLockHolder(stored, incoming) {
|
|
46847
47705
|
if (!stored || !incoming)
|
|
@@ -47103,7 +47961,12 @@ async function updatePlan2(id, input, store) {
|
|
|
47103
47961
|
excludeId: id
|
|
47104
47962
|
});
|
|
47105
47963
|
}
|
|
47106
|
-
return store.
|
|
47964
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
47965
|
+
...plan,
|
|
47966
|
+
...patch,
|
|
47967
|
+
project_id: plan.project_id,
|
|
47968
|
+
updated_at: new Date().toISOString()
|
|
47969
|
+
});
|
|
47107
47970
|
}
|
|
47108
47971
|
function matchAgentByName(agents, name, options) {
|
|
47109
47972
|
const target = normalizeAgentNameInput(name);
|
|
@@ -47491,6 +48354,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
|
|
|
47491
48354
|
var init_postgres_adapter = __esm(() => {
|
|
47492
48355
|
init_types();
|
|
47493
48356
|
init_creator_identity();
|
|
48357
|
+
init_plan_project_link_contract();
|
|
47494
48358
|
init_postgres_sync();
|
|
47495
48359
|
init_integrity();
|
|
47496
48360
|
init_redaction();
|
|
@@ -48377,8 +49241,592 @@ var init_postgres2 = __esm(() => {
|
|
|
48377
49241
|
});
|
|
48378
49242
|
|
|
48379
49243
|
// src/project-registration/sqlite.ts
|
|
48380
|
-
|
|
49244
|
+
function sameSqliteValue(left, right) {
|
|
49245
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
49246
|
+
}
|
|
49247
|
+
function taskListFromRow(row) {
|
|
49248
|
+
return {
|
|
49249
|
+
...row,
|
|
49250
|
+
metadata: JSON.parse(row.metadata || "{}")
|
|
49251
|
+
};
|
|
49252
|
+
}
|
|
49253
|
+
function selectProject(db, id) {
|
|
49254
|
+
return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
|
|
49255
|
+
}
|
|
49256
|
+
function selectTaskList(db, id) {
|
|
49257
|
+
const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
|
|
49258
|
+
return row ? taskListFromRow(row) : null;
|
|
49259
|
+
}
|
|
49260
|
+
function selectProjectConflict(db, path, taskListSlug) {
|
|
49261
|
+
return db.query(`
|
|
49262
|
+
SELECT * FROM projects
|
|
49263
|
+
WHERE path = ? OR task_list_id = ?
|
|
49264
|
+
ORDER BY created_at ASC, id ASC
|
|
49265
|
+
LIMIT 1
|
|
49266
|
+
`).get(path, taskListSlug);
|
|
49267
|
+
}
|
|
49268
|
+
function selectTaskListConflict(db, projectId, slug) {
|
|
49269
|
+
const row = db.query(`
|
|
49270
|
+
SELECT * FROM task_lists
|
|
49271
|
+
WHERE project_id = ? AND slug = ?
|
|
49272
|
+
LIMIT 1
|
|
49273
|
+
`).get(projectId, slug);
|
|
49274
|
+
return row ? taskListFromRow(row) : null;
|
|
49275
|
+
}
|
|
49276
|
+
function quoteSqliteIdentifier(value) {
|
|
49277
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
49278
|
+
}
|
|
49279
|
+
function hasSqliteDependents(db, resourceKind, targetId) {
|
|
49280
|
+
const targetTable = resourceKind === "project" ? "projects" : "task_lists";
|
|
49281
|
+
const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
|
|
49282
|
+
const tables = db.query(`
|
|
49283
|
+
SELECT name FROM sqlite_schema
|
|
49284
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
49285
|
+
ORDER BY name
|
|
49286
|
+
`).all();
|
|
49287
|
+
for (const { name: tableName } of tables) {
|
|
49288
|
+
const quotedTable = quoteSqliteIdentifier(tableName);
|
|
49289
|
+
const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
|
|
49290
|
+
const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
|
|
49291
|
+
const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
|
|
49292
|
+
for (const columnName of referenceColumns) {
|
|
49293
|
+
const row = db.query(`
|
|
49294
|
+
SELECT 1 AS found
|
|
49295
|
+
FROM ${quotedTable}
|
|
49296
|
+
WHERE ${quoteSqliteIdentifier(columnName)} = ?
|
|
49297
|
+
LIMIT 1
|
|
49298
|
+
`).get(targetId);
|
|
49299
|
+
if (row)
|
|
49300
|
+
return true;
|
|
49301
|
+
}
|
|
49302
|
+
}
|
|
49303
|
+
return false;
|
|
49304
|
+
}
|
|
49305
|
+
function receiptFromRow2(row) {
|
|
49306
|
+
return {
|
|
49307
|
+
...row,
|
|
49308
|
+
authority: "todos",
|
|
49309
|
+
created_by_operation: Number(row["created_by_operation"]) === 1
|
|
49310
|
+
};
|
|
49311
|
+
}
|
|
49312
|
+
function bindingFromRow2(row) {
|
|
49313
|
+
return row;
|
|
49314
|
+
}
|
|
49315
|
+
|
|
49316
|
+
class SqliteTodosProjectRegistrationTransaction {
|
|
49317
|
+
db;
|
|
49318
|
+
storage;
|
|
49319
|
+
constructor(db) {
|
|
49320
|
+
this.db = db;
|
|
49321
|
+
this.storage = createLocalSqliteTodosStorageAdapter({ db });
|
|
49322
|
+
}
|
|
49323
|
+
async lockStep(_identity) {}
|
|
49324
|
+
async getReceiptForLookup(identity) {
|
|
49325
|
+
const row = this.db.query(`
|
|
49326
|
+
SELECT * FROM todos_project_registration_receipts
|
|
49327
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49328
|
+
AND operation_id = ? AND step_id = ? AND resource_kind = ?
|
|
49329
|
+
AND direction = ? AND idempotency_key = ? AND target_selector = ?
|
|
49330
|
+
ORDER BY CASE outcome
|
|
49331
|
+
WHEN 'terminal_nonacceptance' THEN 0
|
|
49332
|
+
WHEN 'duplicate_of_accepted' THEN 1
|
|
49333
|
+
ELSE 2
|
|
49334
|
+
END, created_at DESC, receipt_id DESC
|
|
49335
|
+
LIMIT 1
|
|
49336
|
+
`).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction, identity.idempotency_key, identity.target_selector);
|
|
49337
|
+
return row ? receiptFromRow2(row) : null;
|
|
49338
|
+
}
|
|
49339
|
+
async getReceiptById(receiptId) {
|
|
49340
|
+
const row = this.db.query("SELECT * FROM todos_project_registration_receipts WHERE receipt_id = ? LIMIT 1").get(receiptId);
|
|
49341
|
+
return row ? receiptFromRow2(row) : null;
|
|
49342
|
+
}
|
|
49343
|
+
async getAcceptedReceiptForStep(identity) {
|
|
49344
|
+
const row = this.db.query(`
|
|
49345
|
+
SELECT * FROM todos_project_registration_receipts
|
|
49346
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49347
|
+
AND operation_id = ? AND step_id = ? AND resource_kind = ?
|
|
49348
|
+
AND direction = ? AND outcome = 'accepted'
|
|
49349
|
+
ORDER BY created_at ASC, receipt_id ASC
|
|
49350
|
+
LIMIT 1
|
|
49351
|
+
`).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction);
|
|
49352
|
+
return row ? receiptFromRow2(row) : null;
|
|
49353
|
+
}
|
|
49354
|
+
async insertReceipt(receipt) {
|
|
49355
|
+
const result = this.db.query(`
|
|
49356
|
+
INSERT OR IGNORE INTO todos_project_registration_receipts (
|
|
49357
|
+
receipt_id, authority, route, package_version, authority_id, tenant_id,
|
|
49358
|
+
corpus_id, operation_id, step_id, resource_kind, direction,
|
|
49359
|
+
target_selector, idempotency_key, request_digest, precondition_digest,
|
|
49360
|
+
normalized_call_digest, outcome, reason, target_id, result_revision,
|
|
49361
|
+
result_digest, duplicate_of_receipt_id, accepted_receipt_id,
|
|
49362
|
+
created_by_operation, created_at
|
|
49363
|
+
) VALUES (
|
|
49364
|
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
49365
|
+
)
|
|
49366
|
+
`).run(receipt.receipt_id, receipt.authority, receipt.route, receipt.package_version, receipt.authority_id, receipt.tenant_id, receipt.corpus_id, receipt.operation_id, receipt.step_id, receipt.resource_kind, receipt.direction, receipt.target_selector, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.normalized_call_digest, receipt.outcome, receipt.reason, receipt.target_id, receipt.result_revision, receipt.result_digest, receipt.duplicate_of_receipt_id, receipt.accepted_receipt_id, receipt.created_by_operation ? 1 : 0, receipt.created_at);
|
|
49367
|
+
return result.changes === 1;
|
|
49368
|
+
}
|
|
49369
|
+
async getBinding(scope, resourceKind, targetSelector) {
|
|
49370
|
+
const row = this.db.query(`
|
|
49371
|
+
SELECT * FROM todos_project_registration_bindings
|
|
49372
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49373
|
+
AND resource_kind = ? AND target_selector = ?
|
|
49374
|
+
LIMIT 1
|
|
49375
|
+
`).get(scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49376
|
+
return row ? bindingFromRow2(row) : null;
|
|
49377
|
+
}
|
|
49378
|
+
async claimBinding(binding) {
|
|
49379
|
+
const result = this.db.query(`
|
|
49380
|
+
INSERT OR IGNORE INTO todos_project_registration_bindings (
|
|
49381
|
+
authority_id, tenant_id, corpus_id, resource_kind, target_selector,
|
|
49382
|
+
operation_id, step_id, direction, idempotency_key, request_digest,
|
|
49383
|
+
precondition_digest, normalized_call_digest, state, target_id,
|
|
49384
|
+
accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
|
|
49385
|
+
created_at, updated_at
|
|
49386
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
49387
|
+
`).run(binding.authority_id, binding.tenant_id, binding.corpus_id, binding.resource_kind, binding.target_selector, binding.operation_id, binding.step_id, binding.direction, binding.idempotency_key, binding.request_digest, binding.precondition_digest, binding.normalized_call_digest, binding.state, binding.target_id, binding.accepted_receipt_id, binding.result_revision, binding.result_digest, binding.removed_receipt_id, binding.created_at, binding.updated_at);
|
|
49388
|
+
return result.changes === 1;
|
|
49389
|
+
}
|
|
49390
|
+
async setBindingAccepted(scope, resourceKind, targetSelector, update) {
|
|
49391
|
+
const result = this.db.query(`
|
|
49392
|
+
UPDATE todos_project_registration_bindings
|
|
49393
|
+
SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
|
|
49394
|
+
result_revision = ?, result_digest = ?, updated_at = ?
|
|
49395
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49396
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
49397
|
+
`).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49398
|
+
if (result.changes !== 1) {
|
|
49399
|
+
throw new Error("Todos project registration binding was not pending at acceptance");
|
|
49400
|
+
}
|
|
49401
|
+
}
|
|
49402
|
+
async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
|
|
49403
|
+
this.db.query(`
|
|
49404
|
+
UPDATE todos_project_registration_bindings
|
|
49405
|
+
SET state = 'terminal_nonacceptance', updated_at = ?
|
|
49406
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49407
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
49408
|
+
`).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49409
|
+
}
|
|
49410
|
+
async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
|
|
49411
|
+
const result = this.db.query(`
|
|
49412
|
+
UPDATE todos_project_registration_bindings
|
|
49413
|
+
SET state = 'removed', removed_receipt_id = ?, updated_at = ?
|
|
49414
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49415
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
|
|
49416
|
+
`).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49417
|
+
if (result.changes !== 1) {
|
|
49418
|
+
throw new Error("Todos project registration binding was not accepted at removal");
|
|
49419
|
+
}
|
|
49420
|
+
}
|
|
49421
|
+
async findProjectConflict(path, taskListSlug) {
|
|
49422
|
+
const row = this.db.query(`
|
|
49423
|
+
SELECT * FROM projects
|
|
49424
|
+
WHERE path = ? OR task_list_id = ?
|
|
49425
|
+
ORDER BY created_at ASC, id ASC
|
|
49426
|
+
LIMIT 1
|
|
49427
|
+
`).get(path, taskListSlug);
|
|
49428
|
+
return row ?? null;
|
|
49429
|
+
}
|
|
49430
|
+
async findTaskListConflict(projectId, slug) {
|
|
49431
|
+
return await this.storage.taskLists.getBySlug(slug, projectId);
|
|
49432
|
+
}
|
|
49433
|
+
async createProject(input) {
|
|
49434
|
+
return await this.storage.projects.create(input);
|
|
49435
|
+
}
|
|
49436
|
+
async createTaskList(input) {
|
|
49437
|
+
return await this.storage.taskLists.create(input);
|
|
49438
|
+
}
|
|
49439
|
+
async getProject(id) {
|
|
49440
|
+
return await this.storage.projects.get(id);
|
|
49441
|
+
}
|
|
49442
|
+
async getTaskList(id) {
|
|
49443
|
+
return await this.storage.taskLists.get(id);
|
|
49444
|
+
}
|
|
49445
|
+
async lockCompensationWrites() {}
|
|
49446
|
+
async hasDependents(resourceKind, targetId) {
|
|
49447
|
+
return hasSqliteDependents(this.db, resourceKind, targetId);
|
|
49448
|
+
}
|
|
49449
|
+
async deleteProject(id) {
|
|
49450
|
+
return await this.storage.projects.delete(id);
|
|
49451
|
+
}
|
|
49452
|
+
async deleteTaskList(id) {
|
|
49453
|
+
return await this.storage.taskLists.delete(id);
|
|
49454
|
+
}
|
|
49455
|
+
}
|
|
49456
|
+
|
|
49457
|
+
class StagedSqliteTodosProjectRegistrationTransaction {
|
|
49458
|
+
db;
|
|
49459
|
+
direct;
|
|
49460
|
+
validators = [];
|
|
49461
|
+
mutations = [];
|
|
49462
|
+
receipts = new Map;
|
|
49463
|
+
bindings = new Map;
|
|
49464
|
+
projects = new Map;
|
|
49465
|
+
taskLists = new Map;
|
|
49466
|
+
constructor(db) {
|
|
49467
|
+
this.db = db;
|
|
49468
|
+
this.direct = new SqliteTodosProjectRegistrationTransaction(db);
|
|
49469
|
+
}
|
|
49470
|
+
commit() {
|
|
49471
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
49472
|
+
try {
|
|
49473
|
+
for (const validate of this.validators) {
|
|
49474
|
+
if (!validate()) {
|
|
49475
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
|
|
49476
|
+
}
|
|
49477
|
+
}
|
|
49478
|
+
for (const mutate of this.mutations)
|
|
49479
|
+
mutate();
|
|
49480
|
+
this.db.exec("COMMIT");
|
|
49481
|
+
} catch (error) {
|
|
49482
|
+
try {
|
|
49483
|
+
this.db.exec("ROLLBACK");
|
|
49484
|
+
} catch {}
|
|
49485
|
+
throw error;
|
|
49486
|
+
}
|
|
49487
|
+
}
|
|
49488
|
+
async lockStep(_identity) {}
|
|
49489
|
+
async getReceiptForLookup(identity) {
|
|
49490
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
|
|
49491
|
+
const stored = await this.direct.getReceiptForLookup(identity);
|
|
49492
|
+
if (stored)
|
|
49493
|
+
staged.push(stored);
|
|
49494
|
+
const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
|
|
49495
|
+
return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
|
|
49496
|
+
}
|
|
49497
|
+
async getReceiptById(receiptId) {
|
|
49498
|
+
return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
|
|
49499
|
+
}
|
|
49500
|
+
async getAcceptedReceiptForStep(identity) {
|
|
49501
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
|
|
49502
|
+
const stored = await this.direct.getAcceptedReceiptForStep(identity);
|
|
49503
|
+
if (stored)
|
|
49504
|
+
staged.push(stored);
|
|
49505
|
+
return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
|
|
49506
|
+
}
|
|
49507
|
+
async insertReceipt(receipt) {
|
|
49508
|
+
if (this.receipts.has(receipt.receipt_id))
|
|
49509
|
+
return false;
|
|
49510
|
+
if (await this.direct.getReceiptById(receipt.receipt_id))
|
|
49511
|
+
return false;
|
|
49512
|
+
const planned = { ...receipt };
|
|
49513
|
+
this.receipts.set(planned.receipt_id, planned);
|
|
49514
|
+
this.mutations.push(() => {
|
|
49515
|
+
try {
|
|
49516
|
+
const result = this.db.query(`
|
|
49517
|
+
INSERT OR IGNORE INTO todos_project_registration_receipts (
|
|
49518
|
+
receipt_id, authority, route, package_version, authority_id, tenant_id,
|
|
49519
|
+
corpus_id, operation_id, step_id, resource_kind, direction,
|
|
49520
|
+
target_selector, idempotency_key, request_digest, precondition_digest,
|
|
49521
|
+
normalized_call_digest, outcome, reason, target_id, result_revision,
|
|
49522
|
+
result_digest, duplicate_of_receipt_id, accepted_receipt_id,
|
|
49523
|
+
created_by_operation, created_at
|
|
49524
|
+
) VALUES (
|
|
49525
|
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
49526
|
+
)
|
|
49527
|
+
`).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
|
|
49528
|
+
if (result.changes !== 1) {
|
|
49529
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
|
|
49530
|
+
}
|
|
49531
|
+
} catch (error) {
|
|
49532
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
49533
|
+
throw error;
|
|
49534
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
|
|
49535
|
+
}
|
|
49536
|
+
});
|
|
49537
|
+
return true;
|
|
49538
|
+
}
|
|
49539
|
+
async getBinding(scope, resourceKind, targetSelector) {
|
|
49540
|
+
const key = this.bindingKey(scope, resourceKind, targetSelector);
|
|
49541
|
+
return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
|
|
49542
|
+
}
|
|
49543
|
+
async claimBinding(binding) {
|
|
49544
|
+
const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
|
|
49545
|
+
if (this.bindings.has(key))
|
|
49546
|
+
return false;
|
|
49547
|
+
if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
|
|
49548
|
+
return false;
|
|
49549
|
+
}
|
|
49550
|
+
const planned = { ...binding };
|
|
49551
|
+
this.bindings.set(key, planned);
|
|
49552
|
+
this.mutations.push(() => {
|
|
49553
|
+
try {
|
|
49554
|
+
const result = this.db.query(`
|
|
49555
|
+
INSERT OR IGNORE INTO todos_project_registration_bindings (
|
|
49556
|
+
authority_id, tenant_id, corpus_id, resource_kind, target_selector,
|
|
49557
|
+
operation_id, step_id, direction, idempotency_key, request_digest,
|
|
49558
|
+
precondition_digest, normalized_call_digest, state, target_id,
|
|
49559
|
+
accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
|
|
49560
|
+
created_at, updated_at
|
|
49561
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
49562
|
+
`).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
|
|
49563
|
+
if (result.changes !== 1) {
|
|
49564
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
|
|
49565
|
+
}
|
|
49566
|
+
} catch (error) {
|
|
49567
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
49568
|
+
throw error;
|
|
49569
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
|
|
49570
|
+
}
|
|
49571
|
+
});
|
|
49572
|
+
return true;
|
|
49573
|
+
}
|
|
49574
|
+
async setBindingAccepted(scope, resourceKind, targetSelector, update) {
|
|
49575
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
49576
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
49577
|
+
...binding,
|
|
49578
|
+
state: "accepted",
|
|
49579
|
+
target_id: update.target_id,
|
|
49580
|
+
accepted_receipt_id: update.accepted_receipt_id,
|
|
49581
|
+
result_revision: update.result_revision,
|
|
49582
|
+
result_digest: update.result_digest,
|
|
49583
|
+
updated_at: update.updated_at
|
|
49584
|
+
});
|
|
49585
|
+
this.mutations.push(() => {
|
|
49586
|
+
const result = this.db.query(`
|
|
49587
|
+
UPDATE todos_project_registration_bindings
|
|
49588
|
+
SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
|
|
49589
|
+
result_revision = ?, result_digest = ?, updated_at = ?
|
|
49590
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49591
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
49592
|
+
`).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49593
|
+
if (result.changes !== 1) {
|
|
49594
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
49595
|
+
}
|
|
49596
|
+
});
|
|
49597
|
+
}
|
|
49598
|
+
async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
|
|
49599
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
49600
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
49601
|
+
...binding,
|
|
49602
|
+
state: "terminal_nonacceptance",
|
|
49603
|
+
updated_at: updatedAt
|
|
49604
|
+
});
|
|
49605
|
+
this.mutations.push(() => {
|
|
49606
|
+
const result = this.db.query(`
|
|
49607
|
+
UPDATE todos_project_registration_bindings
|
|
49608
|
+
SET state = 'terminal_nonacceptance', updated_at = ?
|
|
49609
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49610
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
49611
|
+
`).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49612
|
+
if (result.changes !== 1) {
|
|
49613
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
49614
|
+
}
|
|
49615
|
+
});
|
|
49616
|
+
}
|
|
49617
|
+
async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
|
|
49618
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
|
|
49619
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
49620
|
+
...binding,
|
|
49621
|
+
state: "removed",
|
|
49622
|
+
removed_receipt_id: removedReceiptId,
|
|
49623
|
+
updated_at: updatedAt
|
|
49624
|
+
});
|
|
49625
|
+
this.mutations.push(() => {
|
|
49626
|
+
const result = this.db.query(`
|
|
49627
|
+
UPDATE todos_project_registration_bindings
|
|
49628
|
+
SET state = 'removed', removed_receipt_id = ?, updated_at = ?
|
|
49629
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
49630
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
|
|
49631
|
+
`).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
49632
|
+
if (result.changes !== 1) {
|
|
49633
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
|
|
49634
|
+
}
|
|
49635
|
+
});
|
|
49636
|
+
}
|
|
49637
|
+
async findProjectConflict(path, taskListSlug) {
|
|
49638
|
+
const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
|
|
49639
|
+
if (planned)
|
|
49640
|
+
return planned;
|
|
49641
|
+
const observed = selectProjectConflict(this.db, path, taskListSlug);
|
|
49642
|
+
this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
|
|
49643
|
+
return observed;
|
|
49644
|
+
}
|
|
49645
|
+
async findTaskListConflict(projectId, slug) {
|
|
49646
|
+
const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
|
|
49647
|
+
if (planned)
|
|
49648
|
+
return planned;
|
|
49649
|
+
const observed = selectTaskListConflict(this.db, projectId, slug);
|
|
49650
|
+
this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
|
|
49651
|
+
return observed;
|
|
49652
|
+
}
|
|
49653
|
+
async createProject(input) {
|
|
49654
|
+
const derivedSlug = normalizeSlug(input.name);
|
|
49655
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
|
|
49656
|
+
if (!derivedSlug || !taskListId) {
|
|
49657
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
49658
|
+
}
|
|
49659
|
+
const project = {
|
|
49660
|
+
id: uuid(),
|
|
49661
|
+
name: input.name,
|
|
49662
|
+
path: input.path,
|
|
49663
|
+
description: input.description || null,
|
|
49664
|
+
task_list_id: taskListId,
|
|
49665
|
+
task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
|
|
49666
|
+
task_counter: 0,
|
|
49667
|
+
created_at: now(),
|
|
49668
|
+
updated_at: now(),
|
|
49669
|
+
machine_id: currentStorageMachineId(this.db)
|
|
49670
|
+
};
|
|
49671
|
+
project.updated_at = project.created_at;
|
|
49672
|
+
this.projects.set(project.id, project);
|
|
49673
|
+
this.mutations.push(() => {
|
|
49674
|
+
try {
|
|
49675
|
+
const result = this.db.run(`INSERT INTO projects (
|
|
49676
|
+
id, name, path, description, task_list_id, task_prefix,
|
|
49677
|
+
task_counter, created_at, updated_at, machine_id
|
|
49678
|
+
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
|
|
49679
|
+
project.id,
|
|
49680
|
+
project.name,
|
|
49681
|
+
project.path,
|
|
49682
|
+
project.description,
|
|
49683
|
+
project.task_list_id,
|
|
49684
|
+
project.task_prefix,
|
|
49685
|
+
project.created_at,
|
|
49686
|
+
project.updated_at,
|
|
49687
|
+
project.machine_id ?? null
|
|
49688
|
+
]);
|
|
49689
|
+
if (result.changes < 1) {
|
|
49690
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
|
|
49691
|
+
}
|
|
49692
|
+
} catch (error) {
|
|
49693
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
49694
|
+
throw error;
|
|
49695
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
|
|
49696
|
+
}
|
|
49697
|
+
});
|
|
49698
|
+
return project;
|
|
49699
|
+
}
|
|
49700
|
+
async createTaskList(input) {
|
|
49701
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
49702
|
+
if (!slug)
|
|
49703
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
49704
|
+
const taskList = {
|
|
49705
|
+
id: uuid(),
|
|
49706
|
+
project_id: input.project_id || null,
|
|
49707
|
+
slug,
|
|
49708
|
+
name: input.name,
|
|
49709
|
+
description: input.description || null,
|
|
49710
|
+
metadata: input.metadata ?? {},
|
|
49711
|
+
created_at: now(),
|
|
49712
|
+
updated_at: now(),
|
|
49713
|
+
machine_id: currentStorageMachineId(this.db)
|
|
49714
|
+
};
|
|
49715
|
+
taskList.updated_at = taskList.created_at;
|
|
49716
|
+
this.taskLists.set(taskList.id, taskList);
|
|
49717
|
+
this.mutations.push(() => {
|
|
49718
|
+
try {
|
|
49719
|
+
const result = this.db.run(`INSERT INTO task_lists (
|
|
49720
|
+
id, project_id, slug, name, description, metadata,
|
|
49721
|
+
created_at, updated_at, machine_id
|
|
49722
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
49723
|
+
taskList.id,
|
|
49724
|
+
taskList.project_id,
|
|
49725
|
+
taskList.slug,
|
|
49726
|
+
taskList.name,
|
|
49727
|
+
taskList.description,
|
|
49728
|
+
JSON.stringify(taskList.metadata),
|
|
49729
|
+
taskList.created_at,
|
|
49730
|
+
taskList.updated_at,
|
|
49731
|
+
taskList.machine_id ?? null
|
|
49732
|
+
]);
|
|
49733
|
+
if (result.changes < 1) {
|
|
49734
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
|
|
49735
|
+
}
|
|
49736
|
+
} catch (error) {
|
|
49737
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
49738
|
+
throw error;
|
|
49739
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
|
|
49740
|
+
}
|
|
49741
|
+
});
|
|
49742
|
+
return taskList;
|
|
49743
|
+
}
|
|
49744
|
+
async getProject(id) {
|
|
49745
|
+
if (this.projects.has(id))
|
|
49746
|
+
return this.projects.get(id) ?? null;
|
|
49747
|
+
const observed = selectProject(this.db, id);
|
|
49748
|
+
this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
|
|
49749
|
+
return observed;
|
|
49750
|
+
}
|
|
49751
|
+
async getTaskList(id) {
|
|
49752
|
+
if (this.taskLists.has(id))
|
|
49753
|
+
return this.taskLists.get(id) ?? null;
|
|
49754
|
+
const observed = selectTaskList(this.db, id);
|
|
49755
|
+
this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
|
|
49756
|
+
return observed;
|
|
49757
|
+
}
|
|
49758
|
+
async lockCompensationWrites() {}
|
|
49759
|
+
async hasDependents(resourceKind, targetId) {
|
|
49760
|
+
const observed = hasSqliteDependents(this.db, resourceKind, targetId);
|
|
49761
|
+
this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
|
|
49762
|
+
return observed;
|
|
49763
|
+
}
|
|
49764
|
+
async deleteProject(id) {
|
|
49765
|
+
const project = await this.getProject(id);
|
|
49766
|
+
if (!project)
|
|
49767
|
+
return false;
|
|
49768
|
+
this.projects.set(id, null);
|
|
49769
|
+
this.mutations.push(() => {
|
|
49770
|
+
recordStorageTombstone({
|
|
49771
|
+
object_type: "projects",
|
|
49772
|
+
object_id: id,
|
|
49773
|
+
payload: project
|
|
49774
|
+
}, this.db);
|
|
49775
|
+
if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
|
|
49776
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
|
|
49777
|
+
}
|
|
49778
|
+
});
|
|
49779
|
+
return true;
|
|
49780
|
+
}
|
|
49781
|
+
async deleteTaskList(id) {
|
|
49782
|
+
const taskList = await this.getTaskList(id);
|
|
49783
|
+
if (!taskList)
|
|
49784
|
+
return false;
|
|
49785
|
+
this.taskLists.set(id, null);
|
|
49786
|
+
this.mutations.push(() => {
|
|
49787
|
+
recordStorageTombstone({
|
|
49788
|
+
object_type: "task_lists",
|
|
49789
|
+
object_id: id,
|
|
49790
|
+
payload: taskList
|
|
49791
|
+
}, this.db);
|
|
49792
|
+
if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
|
|
49793
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
|
|
49794
|
+
}
|
|
49795
|
+
});
|
|
49796
|
+
return true;
|
|
49797
|
+
}
|
|
49798
|
+
bindingKey(scope, resourceKind, targetSelector) {
|
|
49799
|
+
return JSON.stringify([
|
|
49800
|
+
scope.authority_id,
|
|
49801
|
+
scope.tenant_id,
|
|
49802
|
+
scope.corpus_id,
|
|
49803
|
+
resourceKind,
|
|
49804
|
+
targetSelector
|
|
49805
|
+
]);
|
|
49806
|
+
}
|
|
49807
|
+
async requireBinding(scope, resourceKind, targetSelector, state) {
|
|
49808
|
+
const binding = await this.getBinding(scope, resourceKind, targetSelector);
|
|
49809
|
+
if (!binding || binding.state !== state) {
|
|
49810
|
+
throw new Error(`Todos project registration binding was not ${state}`);
|
|
49811
|
+
}
|
|
49812
|
+
return binding;
|
|
49813
|
+
}
|
|
49814
|
+
availableProjectPrefix(name) {
|
|
49815
|
+
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
49816
|
+
const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
|
|
49817
|
+
let candidate = prefix;
|
|
49818
|
+
let suffix = 1;
|
|
49819
|
+
while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
|
|
49820
|
+
suffix += 1;
|
|
49821
|
+
candidate = `${prefix}${suffix}`;
|
|
49822
|
+
}
|
|
49823
|
+
return candidate;
|
|
49824
|
+
}
|
|
49825
|
+
}
|
|
49826
|
+
var sqliteTransactionTails, PROJECT_REFERENCE_COLUMNS, TASK_LIST_REFERENCE_COLUMNS, SqliteRegistrationOptimisticConflict;
|
|
48381
49827
|
var init_sqlite = __esm(() => {
|
|
49828
|
+
init_database();
|
|
49829
|
+
init_storage_tombstones();
|
|
48382
49830
|
init_local_sqlite();
|
|
48383
49831
|
sqliteTransactionTails = new WeakMap;
|
|
48384
49832
|
PROJECT_REFERENCE_COLUMNS = new Set([
|
|
@@ -48388,10 +49836,16 @@ var init_sqlite = __esm(() => {
|
|
|
48388
49836
|
"external_project_id"
|
|
48389
49837
|
]);
|
|
48390
49838
|
TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
|
|
49839
|
+
SqliteRegistrationOptimisticConflict = class SqliteRegistrationOptimisticConflict extends Error {
|
|
49840
|
+
constructor(message, options = {}) {
|
|
49841
|
+
super(message, options);
|
|
49842
|
+
this.name = "SqliteRegistrationOptimisticConflict";
|
|
49843
|
+
}
|
|
49844
|
+
};
|
|
48391
49845
|
});
|
|
48392
49846
|
|
|
48393
49847
|
// src/project-registration/authority.ts
|
|
48394
|
-
import { createHash as
|
|
49848
|
+
import { createHash as createHash15 } from "crypto";
|
|
48395
49849
|
function canonicalProjectRegistrationJson(value) {
|
|
48396
49850
|
return JSON.stringify(canonicalize2(value));
|
|
48397
49851
|
}
|
|
@@ -48409,7 +49863,7 @@ function canonicalize2(value) {
|
|
|
48409
49863
|
return out;
|
|
48410
49864
|
}
|
|
48411
49865
|
function digestProjectRegistrationValue(value) {
|
|
48412
|
-
return
|
|
49866
|
+
return createHash15("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
|
|
48413
49867
|
}
|
|
48414
49868
|
function deriveTodosProjectRegistrationIdempotencyKey(input) {
|
|
48415
49869
|
return `prk_${digestProjectRegistrationValue({
|
|
@@ -50892,8 +52346,14 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
50892
52346
|
Task: taskSchema,
|
|
50893
52347
|
Project: projectSchema,
|
|
50894
52348
|
TaskList: taskListSchema,
|
|
52349
|
+
ProjectTaskListEnsureReceipt: projectTaskListEnsureReceiptSchema,
|
|
52350
|
+
ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
|
|
52351
|
+
ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
|
|
50895
52352
|
TaskComment: taskCommentSchema,
|
|
50896
52353
|
Plan: planSchema,
|
|
52354
|
+
PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
|
|
52355
|
+
PlanProjectLinkResult: planProjectLinkResultSchema,
|
|
52356
|
+
PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
|
|
50897
52357
|
Template: templateSchema,
|
|
50898
52358
|
TemplateTask: templateTaskSchema,
|
|
50899
52359
|
TemplateVariable: templateVariableSchema,
|
|
@@ -50907,6 +52367,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
50907
52367
|
status: { type: "string" },
|
|
50908
52368
|
priority: { type: "string" },
|
|
50909
52369
|
project_id: { type: "string" },
|
|
52370
|
+
plan_id: { type: "string" },
|
|
50910
52371
|
assigned_to: { type: "string" },
|
|
50911
52372
|
agent_id: { type: "string" },
|
|
50912
52373
|
tags: { type: "array", items: { type: "string" } }
|
|
@@ -50921,6 +52382,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
50921
52382
|
priority: { type: "string" },
|
|
50922
52383
|
assigned_to: { type: "string" },
|
|
50923
52384
|
project_id: { type: "string", nullable: true },
|
|
52385
|
+
plan_id: { type: "string", nullable: true },
|
|
50924
52386
|
task_list_id: { type: "string", nullable: true },
|
|
50925
52387
|
version: { type: "number" }
|
|
50926
52388
|
}
|
|
@@ -50969,6 +52431,55 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
50969
52431
|
name: { type: "string", minLength: 1 }
|
|
50970
52432
|
}
|
|
50971
52433
|
},
|
|
52434
|
+
ProjectTaskListEnsureApplyInput: {
|
|
52435
|
+
type: "object",
|
|
52436
|
+
additionalProperties: false,
|
|
52437
|
+
required: ["expected_project_revision"],
|
|
52438
|
+
properties: {
|
|
52439
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
52440
|
+
idempotency_key: {
|
|
52441
|
+
type: "string",
|
|
52442
|
+
minLength: 8,
|
|
52443
|
+
maxLength: 128,
|
|
52444
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
52445
|
+
}
|
|
52446
|
+
}
|
|
52447
|
+
},
|
|
52448
|
+
ProjectTaskListRollbackInput: {
|
|
52449
|
+
type: "object",
|
|
52450
|
+
additionalProperties: false,
|
|
52451
|
+
required: ["receipt_id", "expected_task_list_revision"],
|
|
52452
|
+
properties: {
|
|
52453
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
52454
|
+
expected_task_list_revision: { type: "string", minLength: 1 }
|
|
52455
|
+
}
|
|
52456
|
+
},
|
|
52457
|
+
PlanProjectLinkApplyInput: {
|
|
52458
|
+
type: "object",
|
|
52459
|
+
additionalProperties: false,
|
|
52460
|
+
required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
|
|
52461
|
+
properties: {
|
|
52462
|
+
project_id: { type: "string", minLength: 1 },
|
|
52463
|
+
expected_plan_revision: { type: "string", minLength: 1 },
|
|
52464
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
52465
|
+
idempotency_key: {
|
|
52466
|
+
type: "string",
|
|
52467
|
+
minLength: 8,
|
|
52468
|
+
maxLength: 128,
|
|
52469
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
52470
|
+
}
|
|
52471
|
+
}
|
|
52472
|
+
},
|
|
52473
|
+
PlanProjectLinkRollbackInput: {
|
|
52474
|
+
type: "object",
|
|
52475
|
+
additionalProperties: false,
|
|
52476
|
+
required: ["project_id", "receipt_id", "expected_plan_revision"],
|
|
52477
|
+
properties: {
|
|
52478
|
+
project_id: { type: "string", minLength: 1 },
|
|
52479
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
52480
|
+
expected_plan_revision: { type: "string", minLength: 1 }
|
|
52481
|
+
}
|
|
52482
|
+
},
|
|
50972
52483
|
ErrorResponse: {
|
|
50973
52484
|
type: "object",
|
|
50974
52485
|
required: ["error"],
|
|
@@ -51852,6 +53363,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
51852
53363
|
{ name: "assigned_to", in: "query", schema: { type: "string" } },
|
|
51853
53364
|
{ name: "agent_id", in: "query", schema: { type: "string" } },
|
|
51854
53365
|
{ name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
|
|
53366
|
+
{
|
|
53367
|
+
name: "updated_after",
|
|
53368
|
+
in: "query",
|
|
53369
|
+
schema: { type: "string", format: "date-time" },
|
|
53370
|
+
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."
|
|
53371
|
+
},
|
|
51855
53372
|
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
|
|
51856
53373
|
{ name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
|
|
51857
53374
|
],
|
|
@@ -52071,6 +53588,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
52071
53588
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
52072
53589
|
}
|
|
52073
53590
|
},
|
|
53591
|
+
"/v1/projects/{id}/task-list/ensure": {
|
|
53592
|
+
get: {
|
|
53593
|
+
operationId: "planProjectTaskListEnsure",
|
|
53594
|
+
summary: "Plan a non-mutating repair of a project's declared task list",
|
|
53595
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53596
|
+
responses: {
|
|
53597
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
53598
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53599
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53600
|
+
}
|
|
53601
|
+
},
|
|
53602
|
+
post: {
|
|
53603
|
+
operationId: "ensureProjectTaskList",
|
|
53604
|
+
summary: "Idempotently create an existing project's declared task list",
|
|
53605
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53606
|
+
requestBody: {
|
|
53607
|
+
required: true,
|
|
53608
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureApplyInput" } } }
|
|
53609
|
+
},
|
|
53610
|
+
responses: {
|
|
53611
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
53612
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListEnsureResult" } } } },
|
|
53613
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53614
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53615
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53616
|
+
}
|
|
53617
|
+
}
|
|
53618
|
+
},
|
|
53619
|
+
"/v1/projects/{id}/task-list/rollback": {
|
|
53620
|
+
post: {
|
|
53621
|
+
operationId: "rollbackProjectTaskListEnsure",
|
|
53622
|
+
summary: "Conditionally remove an unchanged task list created by an accepted ensure receipt",
|
|
53623
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53624
|
+
requestBody: {
|
|
53625
|
+
required: true,
|
|
53626
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackInput" } } }
|
|
53627
|
+
},
|
|
53628
|
+
responses: {
|
|
53629
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectTaskListRollbackResult" } } } },
|
|
53630
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53631
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53632
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53633
|
+
}
|
|
53634
|
+
}
|
|
53635
|
+
},
|
|
52074
53636
|
"/v1/projects/{id}/rename": {
|
|
52075
53637
|
post: {
|
|
52076
53638
|
operationId: "renameProject",
|
|
@@ -52135,6 +53697,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
52135
53697
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
52136
53698
|
}
|
|
52137
53699
|
},
|
|
53700
|
+
"/v1/plans/{id}/project-link": {
|
|
53701
|
+
get: {
|
|
53702
|
+
operationId: "planPlanProjectLink",
|
|
53703
|
+
summary: "Plan atomic linkage of an existing plan and every current member task to a project",
|
|
53704
|
+
parameters: [
|
|
53705
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
53706
|
+
{ name: "project_id", in: "query", required: true, schema: { type: "string" } }
|
|
53707
|
+
],
|
|
53708
|
+
responses: {
|
|
53709
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53710
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53711
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53712
|
+
}
|
|
53713
|
+
},
|
|
53714
|
+
post: {
|
|
53715
|
+
operationId: "applyPlanProjectLink",
|
|
53716
|
+
summary: "Atomically and idempotently link an existing plan and every current member task to a project",
|
|
53717
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53718
|
+
requestBody: {
|
|
53719
|
+
required: true,
|
|
53720
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
|
|
53721
|
+
},
|
|
53722
|
+
responses: {
|
|
53723
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53724
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53725
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53726
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53727
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53728
|
+
}
|
|
53729
|
+
}
|
|
53730
|
+
},
|
|
53731
|
+
"/v1/plans/{id}/project-link/rollback": {
|
|
53732
|
+
post: {
|
|
53733
|
+
operationId: "rollbackPlanProjectLink",
|
|
53734
|
+
summary: "Conditionally restore every exact prior project link from an accepted receipt",
|
|
53735
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53736
|
+
requestBody: {
|
|
53737
|
+
required: true,
|
|
53738
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
|
|
53739
|
+
},
|
|
53740
|
+
responses: {
|
|
53741
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
|
|
53742
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53743
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53744
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53745
|
+
}
|
|
53746
|
+
}
|
|
53747
|
+
},
|
|
52138
53748
|
"/v1/templates": {
|
|
52139
53749
|
get: {
|
|
52140
53750
|
operationId: "listTemplates",
|
|
@@ -52352,7 +53962,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
52352
53962
|
}
|
|
52353
53963
|
};
|
|
52354
53964
|
}
|
|
52355
|
-
var taskSchema, projectSchema, taskListSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
53965
|
+
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
52356
53966
|
var init_openapi = __esm(() => {
|
|
52357
53967
|
init_package_version();
|
|
52358
53968
|
init_types();
|
|
@@ -52400,6 +54010,80 @@ var init_openapi = __esm(() => {
|
|
|
52400
54010
|
updated_at: { type: "string" }
|
|
52401
54011
|
}
|
|
52402
54012
|
};
|
|
54013
|
+
projectTaskListEnsureReceiptSchema = {
|
|
54014
|
+
type: "object",
|
|
54015
|
+
additionalProperties: false,
|
|
54016
|
+
required: [
|
|
54017
|
+
"schema_version",
|
|
54018
|
+
"receipt_id",
|
|
54019
|
+
"idempotency_key",
|
|
54020
|
+
"project_id",
|
|
54021
|
+
"task_list_id",
|
|
54022
|
+
"slug",
|
|
54023
|
+
"created_by_operation",
|
|
54024
|
+
"result_revision",
|
|
54025
|
+
"result_digest",
|
|
54026
|
+
"rollback_supported",
|
|
54027
|
+
"created_at"
|
|
54028
|
+
],
|
|
54029
|
+
properties: {
|
|
54030
|
+
schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
|
|
54031
|
+
receipt_id: { type: "string" },
|
|
54032
|
+
idempotency_key: { type: "string" },
|
|
54033
|
+
project_id: { type: "string" },
|
|
54034
|
+
task_list_id: { type: "string" },
|
|
54035
|
+
slug: { type: "string" },
|
|
54036
|
+
created_by_operation: { type: "boolean" },
|
|
54037
|
+
result_revision: { type: "string" },
|
|
54038
|
+
result_digest: { type: "string" },
|
|
54039
|
+
rollback_supported: { type: "boolean" },
|
|
54040
|
+
created_at: { type: "string", format: "date-time" }
|
|
54041
|
+
}
|
|
54042
|
+
};
|
|
54043
|
+
projectTaskListEnsureResultSchema = {
|
|
54044
|
+
type: "object",
|
|
54045
|
+
additionalProperties: false,
|
|
54046
|
+
required: ["mode", "action", "project", "task_list", "receipt"],
|
|
54047
|
+
properties: {
|
|
54048
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
54049
|
+
action: { type: "string", enum: ["would_create", "created", "already_present"] },
|
|
54050
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
54051
|
+
task_list: {
|
|
54052
|
+
oneOf: [
|
|
54053
|
+
{ $ref: "#/components/schemas/TaskList" },
|
|
54054
|
+
{ type: "null" }
|
|
54055
|
+
]
|
|
54056
|
+
},
|
|
54057
|
+
receipt: {
|
|
54058
|
+
oneOf: [
|
|
54059
|
+
{ $ref: "#/components/schemas/ProjectTaskListEnsureReceipt" },
|
|
54060
|
+
{ type: "null" }
|
|
54061
|
+
]
|
|
54062
|
+
}
|
|
54063
|
+
}
|
|
54064
|
+
};
|
|
54065
|
+
projectTaskListRollbackResultSchema = {
|
|
54066
|
+
type: "object",
|
|
54067
|
+
additionalProperties: false,
|
|
54068
|
+
required: [
|
|
54069
|
+
"schema_version",
|
|
54070
|
+
"action",
|
|
54071
|
+
"project_id",
|
|
54072
|
+
"task_list_id",
|
|
54073
|
+
"accepted_receipt_id",
|
|
54074
|
+
"rollback_receipt_id",
|
|
54075
|
+
"removed_at"
|
|
54076
|
+
],
|
|
54077
|
+
properties: {
|
|
54078
|
+
schema_version: { type: "string", enum: ["todos.project-task-list-ensure.v1"] },
|
|
54079
|
+
action: { type: "string", enum: ["removed"] },
|
|
54080
|
+
project_id: { type: "string" },
|
|
54081
|
+
task_list_id: { type: "string" },
|
|
54082
|
+
accepted_receipt_id: { type: "string" },
|
|
54083
|
+
rollback_receipt_id: { type: "string" },
|
|
54084
|
+
removed_at: { type: "string", format: "date-time" }
|
|
54085
|
+
}
|
|
54086
|
+
};
|
|
52403
54087
|
taskCommentSchema = {
|
|
52404
54088
|
type: "object",
|
|
52405
54089
|
required: ["id", "task_id", "agent_id", "session_id", "content", "type", "progress_pct", "created_at"],
|
|
@@ -52430,6 +54114,75 @@ var init_openapi = __esm(() => {
|
|
|
52430
54114
|
updated_at: { type: "string", format: "date-time" }
|
|
52431
54115
|
}
|
|
52432
54116
|
};
|
|
54117
|
+
planProjectLinkReceiptSchema = {
|
|
54118
|
+
type: "object",
|
|
54119
|
+
additionalProperties: false,
|
|
54120
|
+
required: [
|
|
54121
|
+
"schema_version",
|
|
54122
|
+
"receipt_id",
|
|
54123
|
+
"idempotency_key",
|
|
54124
|
+
"plan_id",
|
|
54125
|
+
"project_id",
|
|
54126
|
+
"prior_plan_project_id",
|
|
54127
|
+
"prior_task_project_ids",
|
|
54128
|
+
"task_ids",
|
|
54129
|
+
"task_count",
|
|
54130
|
+
"result_plan_revision",
|
|
54131
|
+
"result_digest",
|
|
54132
|
+
"rollback_supported",
|
|
54133
|
+
"created_at"
|
|
54134
|
+
],
|
|
54135
|
+
properties: {
|
|
54136
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
54137
|
+
receipt_id: { type: "string" },
|
|
54138
|
+
idempotency_key: { type: "string" },
|
|
54139
|
+
plan_id: { type: "string" },
|
|
54140
|
+
project_id: { type: "string" },
|
|
54141
|
+
prior_plan_project_id: { type: "string", nullable: true },
|
|
54142
|
+
prior_task_project_ids: {
|
|
54143
|
+
type: "object",
|
|
54144
|
+
additionalProperties: { type: "string", nullable: true }
|
|
54145
|
+
},
|
|
54146
|
+
task_ids: { type: "array", items: { type: "string" } },
|
|
54147
|
+
task_count: { type: "integer", minimum: 0 },
|
|
54148
|
+
result_plan_revision: { type: "string" },
|
|
54149
|
+
result_digest: { type: "string" },
|
|
54150
|
+
rollback_supported: { type: "boolean", enum: [true] },
|
|
54151
|
+
created_at: { type: "string", format: "date-time" }
|
|
54152
|
+
}
|
|
54153
|
+
};
|
|
54154
|
+
planProjectLinkResultSchema = {
|
|
54155
|
+
type: "object",
|
|
54156
|
+
additionalProperties: false,
|
|
54157
|
+
required: ["mode", "action", "plan", "project", "tasks", "receipt"],
|
|
54158
|
+
properties: {
|
|
54159
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
54160
|
+
action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
|
|
54161
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
54162
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
54163
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
54164
|
+
receipt: {
|
|
54165
|
+
oneOf: [
|
|
54166
|
+
{ $ref: "#/components/schemas/PlanProjectLinkReceipt" },
|
|
54167
|
+
{ type: "null" }
|
|
54168
|
+
]
|
|
54169
|
+
}
|
|
54170
|
+
}
|
|
54171
|
+
};
|
|
54172
|
+
planProjectLinkRollbackResultSchema = {
|
|
54173
|
+
type: "object",
|
|
54174
|
+
additionalProperties: false,
|
|
54175
|
+
required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
|
|
54176
|
+
properties: {
|
|
54177
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
54178
|
+
action: { type: "string", enum: ["restored"] },
|
|
54179
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
54180
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
54181
|
+
accepted_receipt_id: { type: "string" },
|
|
54182
|
+
rollback_receipt_id: { type: "string" },
|
|
54183
|
+
restored_at: { type: "string", format: "date-time" }
|
|
54184
|
+
}
|
|
54185
|
+
};
|
|
52433
54186
|
templateTaskSchema = {
|
|
52434
54187
|
type: "object",
|
|
52435
54188
|
required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
|
|
@@ -52633,6 +54386,330 @@ function decodeCommentCursor(value) {
|
|
|
52633
54386
|
}
|
|
52634
54387
|
var MAX_COMMENT_CURSOR_LENGTH = 1024;
|
|
52635
54388
|
|
|
54389
|
+
// src/lib/project-task-list-ensure.ts
|
|
54390
|
+
import { createHash as createHash16 } from "crypto";
|
|
54391
|
+
function canonicalJson(value) {
|
|
54392
|
+
if (value === null || typeof value !== "object")
|
|
54393
|
+
return JSON.stringify(value);
|
|
54394
|
+
if (Array.isArray(value))
|
|
54395
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
54396
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
54397
|
+
}
|
|
54398
|
+
function digest(value) {
|
|
54399
|
+
return createHash16("sha256").update(canonicalJson(value)).digest("hex");
|
|
54400
|
+
}
|
|
54401
|
+
function deriveIdempotencyKey(projectId, slug) {
|
|
54402
|
+
return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
|
|
54403
|
+
}
|
|
54404
|
+
function normalizeIdempotencyKey(value, projectId, slug) {
|
|
54405
|
+
const key = value?.trim() || deriveIdempotencyKey(projectId, slug);
|
|
54406
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
54407
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
54408
|
+
}
|
|
54409
|
+
return key;
|
|
54410
|
+
}
|
|
54411
|
+
function receiptId2(projectId, slug, idempotencyKey) {
|
|
54412
|
+
return `ptlr_${digest({ project_id: projectId, slug, idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
54413
|
+
}
|
|
54414
|
+
function semanticListDigest(list) {
|
|
54415
|
+
const metadata = { ...list.metadata ?? {} };
|
|
54416
|
+
delete metadata[RECEIPT_METADATA_KEY];
|
|
54417
|
+
return digest({
|
|
54418
|
+
project_id: list.project_id,
|
|
54419
|
+
slug: list.slug,
|
|
54420
|
+
name: list.name,
|
|
54421
|
+
description: list.description,
|
|
54422
|
+
metadata
|
|
54423
|
+
});
|
|
54424
|
+
}
|
|
54425
|
+
function storedMarker(list) {
|
|
54426
|
+
const value = list.metadata?.[RECEIPT_METADATA_KEY];
|
|
54427
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
54428
|
+
return null;
|
|
54429
|
+
const marker = value;
|
|
54430
|
+
if (marker.schema_version !== PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION || typeof marker.receipt_id !== "string" || typeof marker.idempotency_key !== "string" || typeof marker.project_id !== "string" || typeof marker.slug !== "string" || typeof marker.result_digest !== "string" || typeof marker.created_at !== "string")
|
|
54431
|
+
return null;
|
|
54432
|
+
return marker;
|
|
54433
|
+
}
|
|
54434
|
+
function receiptFor(store, project, list, idempotencyKey) {
|
|
54435
|
+
const marker = storedMarker(list);
|
|
54436
|
+
const owned = marker?.project_id === project.id && marker.slug === list.slug;
|
|
54437
|
+
if (owned && marker.idempotency_key !== idempotencyKey) {
|
|
54438
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_IDEMPOTENCY_CONFLICT", "The operation-owned task list was created under a different idempotency key", {
|
|
54439
|
+
project_id: project.id,
|
|
54440
|
+
task_list_id: list.id,
|
|
54441
|
+
receipt_id: marker.receipt_id
|
|
54442
|
+
});
|
|
54443
|
+
}
|
|
54444
|
+
return {
|
|
54445
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
54446
|
+
receipt_id: owned ? marker.receipt_id : `ptlr_existing_${digest({ project_id: project.id, task_list_id: list.id }).slice(0, 39)}`,
|
|
54447
|
+
idempotency_key: owned ? marker.idempotency_key : idempotencyKey,
|
|
54448
|
+
project_id: project.id,
|
|
54449
|
+
task_list_id: list.id,
|
|
54450
|
+
slug: list.slug,
|
|
54451
|
+
created_by_operation: owned,
|
|
54452
|
+
result_revision: list.updated_at,
|
|
54453
|
+
result_digest: owned ? marker.result_digest : semanticListDigest(list),
|
|
54454
|
+
rollback_supported: Boolean(owned && semanticListDigest(list) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused),
|
|
54455
|
+
created_at: owned ? marker.created_at : list.created_at
|
|
54456
|
+
};
|
|
54457
|
+
}
|
|
54458
|
+
async function exactProjectState(store, projectId) {
|
|
54459
|
+
const project = await store.projects.get(projectId);
|
|
54460
|
+
if (!project) {
|
|
54461
|
+
throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
54462
|
+
}
|
|
54463
|
+
const slug = project.task_list_id?.trim();
|
|
54464
|
+
if (!slug) {
|
|
54465
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_NOT_DECLARED", "Project does not declare a canonical task_list_id slug", { project_id: project.id });
|
|
54466
|
+
}
|
|
54467
|
+
const all = await store.taskLists.list();
|
|
54468
|
+
const scopedMatches = all.filter((list) => list.project_id === project.id && list.slug === slug);
|
|
54469
|
+
if (scopedMatches.length > 1) {
|
|
54470
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "More than one task list matches the project's exact id and declared slug", { project_id: project.id, slug, task_list_ids: scopedMatches.map((list) => list.id) });
|
|
54471
|
+
}
|
|
54472
|
+
const globalMatches = all.filter((list) => list.project_id === null && list.slug === slug);
|
|
54473
|
+
if (globalMatches.length > 0 && scopedMatches.length === 0) {
|
|
54474
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "A legacy global task list already owns the declared slug; refusing to create a second locator", { project_id: project.id, slug, task_list_ids: globalMatches.map((list) => list.id) });
|
|
54475
|
+
}
|
|
54476
|
+
return { project, scoped: scopedMatches[0] ?? null, globalCollision: globalMatches[0] ?? null };
|
|
54477
|
+
}
|
|
54478
|
+
async function planProjectTaskListEnsure(store, projectId) {
|
|
54479
|
+
const { project, scoped } = await exactProjectState(store, projectId);
|
|
54480
|
+
return {
|
|
54481
|
+
mode: "plan",
|
|
54482
|
+
action: scoped ? "already_present" : "would_create",
|
|
54483
|
+
project,
|
|
54484
|
+
task_list: scoped,
|
|
54485
|
+
receipt: null
|
|
54486
|
+
};
|
|
54487
|
+
}
|
|
54488
|
+
async function applyProjectTaskListEnsure(store, projectId, options) {
|
|
54489
|
+
const state = await exactProjectState(store, projectId);
|
|
54490
|
+
const { project } = state;
|
|
54491
|
+
if (project.updated_at !== options.expected_project_revision) {
|
|
54492
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed after the ensure plan; fetch a fresh plan before applying", {
|
|
54493
|
+
project_id: project.id,
|
|
54494
|
+
expected_project_revision: options.expected_project_revision,
|
|
54495
|
+
current_project_revision: project.updated_at
|
|
54496
|
+
});
|
|
54497
|
+
}
|
|
54498
|
+
const slug = project.task_list_id;
|
|
54499
|
+
const idempotencyKey = normalizeIdempotencyKey(options.idempotency_key, project.id, slug);
|
|
54500
|
+
if (state.scoped) {
|
|
54501
|
+
return {
|
|
54502
|
+
mode: "apply",
|
|
54503
|
+
action: "already_present",
|
|
54504
|
+
project,
|
|
54505
|
+
task_list: state.scoped,
|
|
54506
|
+
receipt: receiptFor(store, project, state.scoped, idempotencyKey)
|
|
54507
|
+
};
|
|
54508
|
+
}
|
|
54509
|
+
const marker = {
|
|
54510
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
54511
|
+
receipt_id: receiptId2(project.id, slug, idempotencyKey),
|
|
54512
|
+
idempotency_key: idempotencyKey,
|
|
54513
|
+
project_id: project.id,
|
|
54514
|
+
slug,
|
|
54515
|
+
result_digest: semanticListDigest({
|
|
54516
|
+
project_id: project.id,
|
|
54517
|
+
slug,
|
|
54518
|
+
name: project.name,
|
|
54519
|
+
description: null,
|
|
54520
|
+
metadata: {}
|
|
54521
|
+
}),
|
|
54522
|
+
created_at: new Date().toISOString()
|
|
54523
|
+
};
|
|
54524
|
+
let list;
|
|
54525
|
+
try {
|
|
54526
|
+
list = await store.taskLists.create({
|
|
54527
|
+
name: project.name,
|
|
54528
|
+
slug,
|
|
54529
|
+
project_id: project.id,
|
|
54530
|
+
metadata: { [RECEIPT_METADATA_KEY]: marker }
|
|
54531
|
+
});
|
|
54532
|
+
} catch (error) {
|
|
54533
|
+
if (!(error instanceof ResourceConflictError))
|
|
54534
|
+
throw error;
|
|
54535
|
+
const raced = await exactProjectState(store, projectId);
|
|
54536
|
+
if (!raced.scoped)
|
|
54537
|
+
throw error;
|
|
54538
|
+
if (raced.project.updated_at !== options.expected_project_revision || raced.project.task_list_id !== slug) {
|
|
54539
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", "Project changed while the task list was being created; fetch a fresh plan before retrying", {
|
|
54540
|
+
project_id: raced.project.id,
|
|
54541
|
+
expected_project_revision: options.expected_project_revision,
|
|
54542
|
+
current_project_revision: raced.project.updated_at
|
|
54543
|
+
});
|
|
54544
|
+
}
|
|
54545
|
+
return {
|
|
54546
|
+
mode: "apply",
|
|
54547
|
+
action: "already_present",
|
|
54548
|
+
project: raced.project,
|
|
54549
|
+
task_list: raced.scoped,
|
|
54550
|
+
receipt: receiptFor(store, raced.project, raced.scoped, idempotencyKey)
|
|
54551
|
+
};
|
|
54552
|
+
}
|
|
54553
|
+
const projectReadback = await store.projects.get(project.id);
|
|
54554
|
+
if (!projectReadback || projectReadback.updated_at !== options.expected_project_revision || projectReadback.task_list_id !== slug) {
|
|
54555
|
+
let compensated = false;
|
|
54556
|
+
const unchanged = await store.taskLists.get(list.id);
|
|
54557
|
+
const unchangedMarker = unchanged ? storedMarker(unchanged) : null;
|
|
54558
|
+
if (unchanged && unchangedMarker?.receipt_id === marker.receipt_id && semanticListDigest(unchanged) === marker.result_digest && store.taskLists.deleteIfUnchangedAndUnused) {
|
|
54559
|
+
const deletion = await store.taskLists.deleteIfUnchangedAndUnused(list.id, {
|
|
54560
|
+
project_id: unchanged.project_id,
|
|
54561
|
+
slug: unchanged.slug,
|
|
54562
|
+
name: unchanged.name,
|
|
54563
|
+
description: unchanged.description,
|
|
54564
|
+
metadata: unchanged.metadata,
|
|
54565
|
+
updated_at: unchanged.updated_at
|
|
54566
|
+
});
|
|
54567
|
+
compensated = deletion.status === "deleted";
|
|
54568
|
+
}
|
|
54569
|
+
throw new ProjectTaskListEnsureError("PROJECT_REVISION_CONFLICT", compensated ? "Project changed while the task list was being created; the new list was rolled back" : "Project changed while the task list was being created; the new list was retained because safe conditional rollback could not be proven", { project_id: project.id, task_list_id: list.id, compensated });
|
|
54570
|
+
}
|
|
54571
|
+
const readback = await store.taskLists.get(list.id);
|
|
54572
|
+
if (!readback || readback.project_id !== project.id || readback.slug !== slug) {
|
|
54573
|
+
throw new ProjectTaskListEnsureError("TASK_LIST_SCOPE_COLLISION", "Task-list create did not preserve the exact project id and declared slug", { project_id: project.id, task_list_id: list.id, slug });
|
|
54574
|
+
}
|
|
54575
|
+
return {
|
|
54576
|
+
mode: "apply",
|
|
54577
|
+
action: "created",
|
|
54578
|
+
project: projectReadback,
|
|
54579
|
+
task_list: readback,
|
|
54580
|
+
receipt: receiptFor(store, projectReadback, readback, idempotencyKey)
|
|
54581
|
+
};
|
|
54582
|
+
}
|
|
54583
|
+
async function rollbackProjectTaskListEnsure(store, projectId, options) {
|
|
54584
|
+
const conditionalDelete = store.taskLists.deleteIfUnchangedAndUnused;
|
|
54585
|
+
if (!conditionalDelete) {
|
|
54586
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "This storage backend cannot guarantee atomic conditional rollback; refusing to delete", { project_id: projectId, receipt_id: options.receipt_id });
|
|
54587
|
+
}
|
|
54588
|
+
const project = await store.projects.get(projectId);
|
|
54589
|
+
if (!project) {
|
|
54590
|
+
throw new ProjectTaskListEnsureError("PROJECT_NOT_FOUND", `Project not found: ${projectId}`);
|
|
54591
|
+
}
|
|
54592
|
+
const candidates = (await store.taskLists.list(project.id)).filter((list2) => storedMarker(list2)?.receipt_id === options.receipt_id);
|
|
54593
|
+
if (candidates.length !== 1) {
|
|
54594
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_RECEIPT_NOT_FOUND", "No exact operation-owned task list matches this rollback receipt", { project_id: project.id, receipt_id: options.receipt_id });
|
|
54595
|
+
}
|
|
54596
|
+
const list = candidates[0];
|
|
54597
|
+
const marker = storedMarker(list);
|
|
54598
|
+
if (marker.project_id !== project.id || marker.slug !== list.slug || list.project_id !== project.id || list.updated_at !== options.expected_task_list_revision || semanticListDigest(list) !== marker.result_digest) {
|
|
54599
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "The operation-owned task list drifted; refusing conditional rollback", { project_id: project.id, task_list_id: list.id, receipt_id: options.receipt_id });
|
|
54600
|
+
}
|
|
54601
|
+
const deletion = await conditionalDelete.call(store.taskLists, list.id, {
|
|
54602
|
+
project_id: list.project_id,
|
|
54603
|
+
slug: list.slug,
|
|
54604
|
+
name: list.name,
|
|
54605
|
+
description: list.description,
|
|
54606
|
+
metadata: list.metadata,
|
|
54607
|
+
updated_at: list.updated_at
|
|
54608
|
+
});
|
|
54609
|
+
if (deletion.status === "has_dependents") {
|
|
54610
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_HAS_DEPENDENTS", "The operation-owned task list has dependents; refusing conditional rollback", {
|
|
54611
|
+
task_list_id: list.id,
|
|
54612
|
+
task_dependents: deletion.task_dependents,
|
|
54613
|
+
plan_dependents: deletion.plan_dependents
|
|
54614
|
+
});
|
|
54615
|
+
}
|
|
54616
|
+
if (deletion.status !== "deleted" || await store.taskLists.get(list.id)) {
|
|
54617
|
+
throw new ProjectTaskListEnsureError("PROJECT_TASK_LIST_ROLLBACK_CONFLICT", "Conditional rollback did not remove the exact task list", { task_list_id: list.id });
|
|
54618
|
+
}
|
|
54619
|
+
return {
|
|
54620
|
+
schema_version: PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION,
|
|
54621
|
+
action: "removed",
|
|
54622
|
+
project_id: project.id,
|
|
54623
|
+
task_list_id: list.id,
|
|
54624
|
+
accepted_receipt_id: options.receipt_id,
|
|
54625
|
+
rollback_receipt_id: `ptlr_inverse_${digest({ accepted_receipt_id: options.receipt_id }).slice(0, 38)}`,
|
|
54626
|
+
removed_at: new Date().toISOString()
|
|
54627
|
+
};
|
|
54628
|
+
}
|
|
54629
|
+
var PROJECT_TASK_LIST_ENSURE_SCHEMA_VERSION = "todos.project-task-list-ensure.v1", RECEIPT_METADATA_KEY = "todos_project_task_list_ensure", ProjectTaskListEnsureError;
|
|
54630
|
+
var init_project_task_list_ensure = __esm(() => {
|
|
54631
|
+
init_types();
|
|
54632
|
+
ProjectTaskListEnsureError = class ProjectTaskListEnsureError extends Error {
|
|
54633
|
+
code;
|
|
54634
|
+
details;
|
|
54635
|
+
constructor(code, message, details = {}) {
|
|
54636
|
+
super(message);
|
|
54637
|
+
this.code = code;
|
|
54638
|
+
this.details = details;
|
|
54639
|
+
this.name = "ProjectTaskListEnsureError";
|
|
54640
|
+
}
|
|
54641
|
+
};
|
|
54642
|
+
});
|
|
54643
|
+
|
|
54644
|
+
// src/lib/plan-project-link.ts
|
|
54645
|
+
async function exactPlanProjectLinkState(store, planId, projectId) {
|
|
54646
|
+
const [plan, project] = await Promise.all([
|
|
54647
|
+
store.plans.get(planId),
|
|
54648
|
+
store.projects.get(projectId)
|
|
54649
|
+
]);
|
|
54650
|
+
if (!plan) {
|
|
54651
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
|
|
54652
|
+
}
|
|
54653
|
+
if (!project) {
|
|
54654
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
54655
|
+
}
|
|
54656
|
+
const [tasks, projectPlans] = await Promise.all([
|
|
54657
|
+
store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
|
|
54658
|
+
store.plans.list(project.id)
|
|
54659
|
+
]);
|
|
54660
|
+
const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
54661
|
+
if (collision) {
|
|
54662
|
+
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 });
|
|
54663
|
+
}
|
|
54664
|
+
return {
|
|
54665
|
+
plan,
|
|
54666
|
+
project,
|
|
54667
|
+
tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
|
|
54668
|
+
};
|
|
54669
|
+
}
|
|
54670
|
+
async function planPlanProjectLink(store, planId, projectId) {
|
|
54671
|
+
const state = await exactPlanProjectLinkState(store, planId, projectId);
|
|
54672
|
+
const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task2) => task2.project_id === state.project.id);
|
|
54673
|
+
return {
|
|
54674
|
+
mode: "plan",
|
|
54675
|
+
action: alreadyLinked ? "already_linked" : "would_link",
|
|
54676
|
+
...state,
|
|
54677
|
+
receipt: null
|
|
54678
|
+
};
|
|
54679
|
+
}
|
|
54680
|
+
async function applyPlanProjectLink(store, planId, projectId, options) {
|
|
54681
|
+
if (!store.planProjectLinks) {
|
|
54682
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
|
|
54683
|
+
}
|
|
54684
|
+
const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
|
|
54685
|
+
return store.planProjectLinks.apply({
|
|
54686
|
+
plan_id: planId,
|
|
54687
|
+
project_id: projectId,
|
|
54688
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
54689
|
+
expected_project_revision: options.expected_project_revision,
|
|
54690
|
+
idempotency_key: key,
|
|
54691
|
+
receipt_id: planProjectLinkReceiptId(key),
|
|
54692
|
+
created_at: new Date().toISOString()
|
|
54693
|
+
});
|
|
54694
|
+
}
|
|
54695
|
+
async function rollbackPlanProjectLink(store, planId, projectId, options) {
|
|
54696
|
+
if (!store.planProjectLinks) {
|
|
54697
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
|
|
54698
|
+
}
|
|
54699
|
+
return store.planProjectLinks.rollback({
|
|
54700
|
+
plan_id: planId,
|
|
54701
|
+
project_id: projectId,
|
|
54702
|
+
receipt_id: options.receipt_id,
|
|
54703
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
54704
|
+
rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
|
|
54705
|
+
restored_at: new Date().toISOString()
|
|
54706
|
+
});
|
|
54707
|
+
}
|
|
54708
|
+
var init_plan_project_link = __esm(() => {
|
|
54709
|
+
init_plan_project_link_contract();
|
|
54710
|
+
init_plan_project_link_contract();
|
|
54711
|
+
});
|
|
54712
|
+
|
|
52636
54713
|
// src/server/v1.ts
|
|
52637
54714
|
var exports_v1 = {};
|
|
52638
54715
|
__export(exports_v1, {
|
|
@@ -52655,6 +54732,21 @@ function enumQueryParam(url, name, vocabulary) {
|
|
|
52655
54732
|
return { ok: false, response: error(400, result.message) };
|
|
52656
54733
|
return { ok: true, value: collapseEnumValues(result.values) };
|
|
52657
54734
|
}
|
|
54735
|
+
function parseSinceCursor(raw) {
|
|
54736
|
+
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)}`;
|
|
54737
|
+
const match = RFC3339_DATE_TIME.exec(raw);
|
|
54738
|
+
if (!match)
|
|
54739
|
+
return { ok: false, message: shape };
|
|
54740
|
+
const parsed = Date.parse(raw);
|
|
54741
|
+
if (Number.isNaN(parsed))
|
|
54742
|
+
return { ok: false, message: shape };
|
|
54743
|
+
const [, year, month, day] = match;
|
|
54744
|
+
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
54745
|
+
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
|
|
54746
|
+
return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
|
|
54747
|
+
}
|
|
54748
|
+
return { ok: true, value: new Date(parsed).toISOString() };
|
|
54749
|
+
}
|
|
52658
54750
|
function validateTaskCompletion(value) {
|
|
52659
54751
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
52660
54752
|
return { ok: false, message: "completion body must be an object" };
|
|
@@ -53064,7 +55156,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
53064
55156
|
const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
|
|
53065
55157
|
if (!priorityParam.ok)
|
|
53066
55158
|
return priorityParam.response;
|
|
55159
|
+
const updatedAfterRaw = url.searchParams.get("updated_after");
|
|
55160
|
+
const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
|
|
55161
|
+
if (updatedAfter !== null && !updatedAfter.ok) {
|
|
55162
|
+
return error(400, updatedAfter.message);
|
|
55163
|
+
}
|
|
53067
55164
|
const filter = {
|
|
55165
|
+
...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
|
|
53068
55166
|
...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
|
|
53069
55167
|
...statusParam.value !== undefined ? { status: statusParam.value } : {},
|
|
53070
55168
|
...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
|
|
@@ -53420,6 +55518,52 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
53420
55518
|
}
|
|
53421
55519
|
return error(405, `method ${method} not allowed on /v1/projects`);
|
|
53422
55520
|
}
|
|
55521
|
+
if (action === "task-list" && subId === "ensure") {
|
|
55522
|
+
if (method === "GET") {
|
|
55523
|
+
return json4(await planProjectTaskListEnsure(store, id));
|
|
55524
|
+
}
|
|
55525
|
+
if (method !== "POST") {
|
|
55526
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/ensure`);
|
|
55527
|
+
}
|
|
55528
|
+
const body = await readJson3(req);
|
|
55529
|
+
if (!body)
|
|
55530
|
+
return error(400, "invalid JSON body");
|
|
55531
|
+
const unknown = Object.keys(body).find((key) => !["expected_project_revision", "idempotency_key"].includes(key));
|
|
55532
|
+
if (unknown)
|
|
55533
|
+
return error(400, `unknown task-list ensure field: ${unknown}`);
|
|
55534
|
+
if (typeof body.expected_project_revision !== "string" || !body.expected_project_revision.trim()) {
|
|
55535
|
+
return error(400, "expected_project_revision must be a non-empty string from a fresh ensure plan");
|
|
55536
|
+
}
|
|
55537
|
+
if (body.idempotency_key !== undefined && typeof body.idempotency_key !== "string") {
|
|
55538
|
+
return error(400, "idempotency_key must be a string");
|
|
55539
|
+
}
|
|
55540
|
+
const result = await applyProjectTaskListEnsure(store, id, {
|
|
55541
|
+
expected_project_revision: body.expected_project_revision,
|
|
55542
|
+
...typeof body.idempotency_key === "string" ? { idempotency_key: body.idempotency_key } : {}
|
|
55543
|
+
});
|
|
55544
|
+
return json4(result, result.action === "created" ? 201 : 200);
|
|
55545
|
+
}
|
|
55546
|
+
if (action === "task-list" && subId === "rollback") {
|
|
55547
|
+
if (method !== "POST") {
|
|
55548
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/task-list/rollback`);
|
|
55549
|
+
}
|
|
55550
|
+
const body = await readJson3(req);
|
|
55551
|
+
if (!body)
|
|
55552
|
+
return error(400, "invalid JSON body");
|
|
55553
|
+
const unknown = Object.keys(body).find((key) => !["receipt_id", "expected_task_list_revision"].includes(key));
|
|
55554
|
+
if (unknown)
|
|
55555
|
+
return error(400, `unknown task-list rollback field: ${unknown}`);
|
|
55556
|
+
if (typeof body.receipt_id !== "string" || !body.receipt_id.trim()) {
|
|
55557
|
+
return error(400, "receipt_id must be a non-empty string");
|
|
55558
|
+
}
|
|
55559
|
+
if (typeof body.expected_task_list_revision !== "string" || !body.expected_task_list_revision.trim()) {
|
|
55560
|
+
return error(400, "expected_task_list_revision must be a non-empty string from the accepted receipt");
|
|
55561
|
+
}
|
|
55562
|
+
return json4(await rollbackProjectTaskListEnsure(store, id, {
|
|
55563
|
+
receipt_id: body.receipt_id,
|
|
55564
|
+
expected_task_list_revision: body.expected_task_list_revision
|
|
55565
|
+
}));
|
|
55566
|
+
}
|
|
53423
55567
|
if (action === "rename") {
|
|
53424
55568
|
if (method !== "POST")
|
|
53425
55569
|
return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
|
|
@@ -53480,6 +55624,54 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
53480
55624
|
const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
|
|
53481
55625
|
return json4({ plan }, 201);
|
|
53482
55626
|
}
|
|
55627
|
+
if (id && action === "project-link" && !subId) {
|
|
55628
|
+
if (method === "GET") {
|
|
55629
|
+
const projectId = url.searchParams.get("project_id");
|
|
55630
|
+
if (!projectId?.trim())
|
|
55631
|
+
return error(400, "project_id query parameter is required");
|
|
55632
|
+
return json4(await planPlanProjectLink(store, id, projectId));
|
|
55633
|
+
}
|
|
55634
|
+
if (method !== "POST")
|
|
55635
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
|
|
55636
|
+
const body = await readJson3(req);
|
|
55637
|
+
if (!body)
|
|
55638
|
+
return error(400, "invalid JSON body");
|
|
55639
|
+
const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
|
|
55640
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
55641
|
+
if (unknown)
|
|
55642
|
+
return error(400, `unknown plan-project-link field: ${unknown}`);
|
|
55643
|
+
for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
|
|
55644
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
55645
|
+
return error(400, `${field} must be a non-empty string`);
|
|
55646
|
+
}
|
|
55647
|
+
}
|
|
55648
|
+
const result = await applyPlanProjectLink(store, id, body.project_id, {
|
|
55649
|
+
expected_plan_revision: body.expected_plan_revision,
|
|
55650
|
+
expected_project_revision: body.expected_project_revision,
|
|
55651
|
+
idempotency_key: body.idempotency_key
|
|
55652
|
+
});
|
|
55653
|
+
return json4(result, result.action === "linked" ? 201 : 200);
|
|
55654
|
+
}
|
|
55655
|
+
if (id && action === "project-link" && subId === "rollback") {
|
|
55656
|
+
if (method !== "POST")
|
|
55657
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
|
|
55658
|
+
const body = await readJson3(req);
|
|
55659
|
+
if (!body)
|
|
55660
|
+
return error(400, "invalid JSON body");
|
|
55661
|
+
const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
|
|
55662
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
55663
|
+
if (unknown)
|
|
55664
|
+
return error(400, `unknown plan-project-link rollback field: ${unknown}`);
|
|
55665
|
+
for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
|
|
55666
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
55667
|
+
return error(400, `${field} must be a non-empty string`);
|
|
55668
|
+
}
|
|
55669
|
+
}
|
|
55670
|
+
return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
|
|
55671
|
+
receipt_id: body.receipt_id,
|
|
55672
|
+
expected_plan_revision: body.expected_plan_revision
|
|
55673
|
+
}));
|
|
55674
|
+
}
|
|
53483
55675
|
if (id && method === "GET") {
|
|
53484
55676
|
const plan = await store.plans.get(id);
|
|
53485
55677
|
return plan ? json4({ plan }) : error(404, "plan not found");
|
|
@@ -53758,6 +55950,14 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
53758
55950
|
}
|
|
53759
55951
|
return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
53760
55952
|
} catch (e) {
|
|
55953
|
+
if (e instanceof PlanProjectLinkError) {
|
|
55954
|
+
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;
|
|
55955
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
55956
|
+
}
|
|
55957
|
+
if (e instanceof ProjectTaskListEnsureError) {
|
|
55958
|
+
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;
|
|
55959
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
55960
|
+
}
|
|
53761
55961
|
if (e instanceof TaskReferenceAmbiguousError) {
|
|
53762
55962
|
return error(409, e.message, {
|
|
53763
55963
|
code: TaskReferenceAmbiguousError.code,
|
|
@@ -53777,14 +55977,17 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
53777
55977
|
return error(500, e.message || "internal error");
|
|
53778
55978
|
}
|
|
53779
55979
|
}
|
|
53780
|
-
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
|
|
55980
|
+
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
|
|
53781
55981
|
var init_v1 = __esm(() => {
|
|
53782
55982
|
init_types();
|
|
53783
55983
|
init_cloud();
|
|
53784
55984
|
init_pr_groups();
|
|
53785
55985
|
init_project_registration();
|
|
53786
55986
|
init_redaction();
|
|
55987
|
+
init_project_task_list_ensure();
|
|
55988
|
+
init_plan_project_link();
|
|
53787
55989
|
JSON_HEADERS3 = { "Content-Type": "application/json" };
|
|
55990
|
+
RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
53788
55991
|
});
|
|
53789
55992
|
|
|
53790
55993
|
// src/pr-groups/sqlite.ts
|