@hasna/todos 0.15.9 → 0.15.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cloud-router.d.ts +14 -1
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/index.js +4831 -3259
- package/dist/contracts.js +109 -10
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plan-project-links.d.ts +8 -0
- package/dist/db/plan-project-links.d.ts.map +1 -0
- package/dist/db/plan-row-serialization.d.ts +8 -0
- package/dist/db/plan-row-serialization.d.ts.map +1 -0
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7747 -1275
- package/dist/lib/plan-project-link-contract.d.ts +32 -0
- package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
- package/dist/lib/plan-project-link.d.ts +14 -0
- package/dist/lib/plan-project-link.d.ts.map +1 -0
- package/dist/mcp/index.js +1175 -50
- package/dist/mcp.js +7 -3
- package/dist/project-registration.js +846 -26
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +116 -10
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.js +21 -0
- package/dist/sdk/v1.generated.d.ts +53 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1179 -54
- package/dist/server/openapi.d.ts +370 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +26 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage.js +835 -19
- package/dist/task-manifest/authority.d.ts +19 -0
- package/dist/task-manifest/authority.d.ts.map +1 -0
- package/dist/task-manifest/backend.d.ts +27 -0
- package/dist/task-manifest/backend.d.ts.map +1 -0
- package/dist/task-manifest/canonical.d.ts +4 -0
- package/dist/task-manifest/canonical.d.ts.map +1 -0
- package/dist/task-manifest/http.d.ts +16 -0
- package/dist/task-manifest/http.d.ts.map +1 -0
- package/dist/task-manifest/index.d.ts +10 -0
- package/dist/task-manifest/index.d.ts.map +1 -0
- package/dist/task-manifest/postgres.d.ts +18 -0
- package/dist/task-manifest/postgres.d.ts.map +1 -0
- package/dist/task-manifest/reference-guard.d.ts +27 -0
- package/dist/task-manifest/reference-guard.d.ts.map +1 -0
- package/dist/task-manifest/schema-sql.d.ts +3 -0
- package/dist/task-manifest/schema-sql.d.ts.map +1 -0
- package/dist/task-manifest/schema.d.ts +15 -0
- package/dist/task-manifest/schema.d.ts.map +1 -0
- package/dist/task-manifest/sqlite.d.ts +15 -0
- package/dist/task-manifest/sqlite.d.ts.map +1 -0
- package/dist/task-manifest/types.d.ts +166 -0
- package/dist/task-manifest/types.d.ts.map +1 -0
- package/dist/task-manifest.d.ts +2 -0
- package/dist/task-manifest.d.ts.map +1 -0
- package/dist/task-manifest.js +6816 -0
- package/dist/types/index.d.ts +60 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -3
package/dist/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
|
});
|
|
@@ -12399,6 +12422,14 @@ var init_checklists = __esm(() => {
|
|
|
12399
12422
|
init_database();
|
|
12400
12423
|
});
|
|
12401
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
|
+
|
|
12402
12433
|
// src/lib/creator-identity.ts
|
|
12403
12434
|
import { existsSync as existsSync6, rmSync } from "fs";
|
|
12404
12435
|
import { join as join5 } from "path";
|
|
@@ -13781,9 +13812,23 @@ function sanitizeUpdateTaskInput(input) {
|
|
|
13781
13812
|
metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
|
|
13782
13813
|
};
|
|
13783
13814
|
}
|
|
13784
|
-
function
|
|
13785
|
-
|
|
13786
|
-
|
|
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);
|
|
13787
13832
|
const timestamp2 = now();
|
|
13788
13833
|
const tags = input.tags || [];
|
|
13789
13834
|
const machineId = currentStorageMachineId(d);
|
|
@@ -13797,7 +13842,7 @@ function createTask(input, db) {
|
|
|
13797
13842
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
13798
13843
|
id,
|
|
13799
13844
|
null,
|
|
13800
|
-
|
|
13845
|
+
effectiveProjectId,
|
|
13801
13846
|
input.parent_id || null,
|
|
13802
13847
|
input.plan_id || null,
|
|
13803
13848
|
input.task_list_id || null,
|
|
@@ -13847,7 +13892,15 @@ function createTask(input, db) {
|
|
|
13847
13892
|
if (tags.length > 0) {
|
|
13848
13893
|
insertTaskTags(id, tags, d);
|
|
13849
13894
|
}
|
|
13850
|
-
|
|
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);
|
|
13851
13904
|
const payload = taskEventData(task);
|
|
13852
13905
|
const databasePath = databasePathFromDatabase(d);
|
|
13853
13906
|
dispatchWebhook2("task.created", payload, d).catch(() => {});
|
|
@@ -13947,6 +14000,10 @@ function listTasks(filter = {}, db) {
|
|
|
13947
14000
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
13948
14001
|
params.push(filter.not_created_by);
|
|
13949
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
|
+
}
|
|
13950
14007
|
if (filter.session_id) {
|
|
13951
14008
|
conditions.push("session_id = ?");
|
|
13952
14009
|
params.push(filter.session_id);
|
|
@@ -14107,6 +14164,10 @@ function countTasks(filter = {}, db) {
|
|
|
14107
14164
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
14108
14165
|
params.push(filter.not_created_by);
|
|
14109
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
|
+
}
|
|
14110
14171
|
if (filter.session_id) {
|
|
14111
14172
|
conditions.push("session_id = ?");
|
|
14112
14173
|
params.push(filter.session_id);
|
|
@@ -14146,7 +14207,7 @@ function countTasks(filter = {}, db) {
|
|
|
14146
14207
|
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
14147
14208
|
return row.count;
|
|
14148
14209
|
}
|
|
14149
|
-
function
|
|
14210
|
+
function updateTaskStored(id, input, db) {
|
|
14150
14211
|
const d = db || getDatabase();
|
|
14151
14212
|
const task = getTask(id, d);
|
|
14152
14213
|
if (!task)
|
|
@@ -14155,6 +14216,18 @@ function updateTask(id, input, db) {
|
|
|
14155
14216
|
throw new VersionConflictError(id, input.version, task.version);
|
|
14156
14217
|
}
|
|
14157
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
|
+
}
|
|
14158
14231
|
const timestamp2 = now();
|
|
14159
14232
|
const completionTimestamp = input.completed_at ?? timestamp2;
|
|
14160
14233
|
const sets = ["version = version + 1", "updated_at = ?"];
|
|
@@ -14362,6 +14435,21 @@ function updateTask(id, input, db) {
|
|
|
14362
14435
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
14363
14436
|
return updatedTask;
|
|
14364
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
|
+
}
|
|
14365
14453
|
function deleteTask(id, db) {
|
|
14366
14454
|
const d = db || getDatabase();
|
|
14367
14455
|
const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
@@ -15168,7 +15256,7 @@ function listPlans(projectId, db) {
|
|
|
15168
15256
|
}
|
|
15169
15257
|
return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
|
|
15170
15258
|
}
|
|
15171
|
-
function
|
|
15259
|
+
function updatePlanStored(id, input, db) {
|
|
15172
15260
|
const d = db || getDatabase();
|
|
15173
15261
|
const plan = getPlan(id, d);
|
|
15174
15262
|
if (!plan)
|
|
@@ -15213,6 +15301,13 @@ function updatePlan(id, input, db) {
|
|
|
15213
15301
|
});
|
|
15214
15302
|
return updated;
|
|
15215
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
|
+
}
|
|
15216
15311
|
function deletePlan(id, db) {
|
|
15217
15312
|
const d = db || getDatabase();
|
|
15218
15313
|
const plan = getPlan(id, d);
|
|
@@ -18348,6 +18443,63 @@ var init_assignee_context = __esm(() => {
|
|
|
18348
18443
|
init_assignee_validation();
|
|
18349
18444
|
});
|
|
18350
18445
|
|
|
18446
|
+
// src/lib/plan-project-link-contract.ts
|
|
18447
|
+
import { createHash as createHash3 } from "crypto";
|
|
18448
|
+
function canonicalPlanProjectLinkJson(value) {
|
|
18449
|
+
if (value === null || typeof value !== "object")
|
|
18450
|
+
return JSON.stringify(value);
|
|
18451
|
+
if (Array.isArray(value))
|
|
18452
|
+
return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
|
|
18453
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
|
|
18454
|
+
}
|
|
18455
|
+
function planProjectLinkDigest(value) {
|
|
18456
|
+
return createHash3("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
|
|
18457
|
+
}
|
|
18458
|
+
function normalizePlanProjectLinkIdempotencyKey(value) {
|
|
18459
|
+
const key = value?.trim() ?? "";
|
|
18460
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
18461
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
18462
|
+
}
|
|
18463
|
+
return key;
|
|
18464
|
+
}
|
|
18465
|
+
function planProjectLinkReceiptId(idempotencyKey) {
|
|
18466
|
+
return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
18467
|
+
}
|
|
18468
|
+
function planProjectLinkRollbackReceiptId(receiptId) {
|
|
18469
|
+
return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
|
|
18470
|
+
}
|
|
18471
|
+
function planProjectLinkRequestHash(planId, projectId) {
|
|
18472
|
+
return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
|
|
18473
|
+
}
|
|
18474
|
+
function planProjectLinkResultDigest(plan, tasks) {
|
|
18475
|
+
return planProjectLinkDigest({
|
|
18476
|
+
plan_id: plan.id,
|
|
18477
|
+
plan_project_id: plan.project_id,
|
|
18478
|
+
tasks: tasks.map((task) => ({ id: task.id, plan_id: task.plan_id, project_id: task.project_id })).sort((left, right) => left.id.localeCompare(right.id))
|
|
18479
|
+
});
|
|
18480
|
+
}
|
|
18481
|
+
function assertPlanProjectLinkReceipt(value) {
|
|
18482
|
+
const receipt = value;
|
|
18483
|
+
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") {
|
|
18484
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
|
|
18485
|
+
}
|
|
18486
|
+
return receipt;
|
|
18487
|
+
}
|
|
18488
|
+
var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1", PlanProjectLinkError;
|
|
18489
|
+
var init_plan_project_link_contract = __esm(() => {
|
|
18490
|
+
init_types();
|
|
18491
|
+
PlanProjectLinkError = class PlanProjectLinkError extends Error {
|
|
18492
|
+
code;
|
|
18493
|
+
details;
|
|
18494
|
+
constructor(code, message, details = {}) {
|
|
18495
|
+
super(message);
|
|
18496
|
+
this.code = code;
|
|
18497
|
+
this.details = details;
|
|
18498
|
+
this.name = "PlanProjectLinkError";
|
|
18499
|
+
}
|
|
18500
|
+
};
|
|
18501
|
+
});
|
|
18502
|
+
|
|
18351
18503
|
// src/pr-groups/types.ts
|
|
18352
18504
|
var PR_GROUP_LEDGER_SCHEMA_VERSION = 1, PR_GROUP_REPAIR_CYCLE_LIMIT = 2, PrGroupLedgerError;
|
|
18353
18505
|
var init_types3 = __esm(() => {
|
|
@@ -18364,9 +18516,9 @@ var init_types3 = __esm(() => {
|
|
|
18364
18516
|
});
|
|
18365
18517
|
|
|
18366
18518
|
// src/pr-groups/ledger.ts
|
|
18367
|
-
import { createHash as
|
|
18519
|
+
import { createHash as createHash4 } from "crypto";
|
|
18368
18520
|
function sha2562(value) {
|
|
18369
|
-
return
|
|
18521
|
+
return createHash4("sha256").update(value).digest("hex");
|
|
18370
18522
|
}
|
|
18371
18523
|
function stableValue(value) {
|
|
18372
18524
|
if (Array.isArray(value))
|
|
@@ -21108,6 +21260,7 @@ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, SERVER_MODE_CANDIDATES
|
|
|
21108
21260
|
var init_cloud_router = __esm(() => {
|
|
21109
21261
|
init_types();
|
|
21110
21262
|
init_redaction();
|
|
21263
|
+
init_plan_project_link_contract();
|
|
21111
21264
|
init_http_client();
|
|
21112
21265
|
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21113
21266
|
TRANSPORT_TOKENS = {
|
|
@@ -23513,12 +23666,12 @@ var init_local_notifications = __esm(() => {
|
|
|
23513
23666
|
});
|
|
23514
23667
|
|
|
23515
23668
|
// src/lib/local-encryption.ts
|
|
23516
|
-
import { createCipheriv, createDecipheriv, createHash as
|
|
23669
|
+
import { createCipheriv, createDecipheriv, createHash as createHash5, randomBytes, scryptSync, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
23517
23670
|
function now3() {
|
|
23518
23671
|
return new Date().toISOString();
|
|
23519
23672
|
}
|
|
23520
23673
|
function sha2563(value) {
|
|
23521
|
-
return
|
|
23674
|
+
return createHash5("sha256").update(value).digest("hex");
|
|
23522
23675
|
}
|
|
23523
23676
|
function normalizeProfileName(value) {
|
|
23524
23677
|
const name = (value || DEFAULT_ENCRYPTION_PROFILE).trim();
|
|
@@ -25656,7 +25809,7 @@ var init_capacity_forecasts = __esm(() => {
|
|
|
25656
25809
|
});
|
|
25657
25810
|
|
|
25658
25811
|
// src/lib/audit-ledger.ts
|
|
25659
|
-
import { createHash as
|
|
25812
|
+
import { createHash as createHash6 } from "crypto";
|
|
25660
25813
|
function canonicalize(value) {
|
|
25661
25814
|
if (value === null || typeof value !== "object")
|
|
25662
25815
|
return JSON.stringify(value);
|
|
@@ -25666,7 +25819,7 @@ function canonicalize(value) {
|
|
|
25666
25819
|
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`).join(",")}}`;
|
|
25667
25820
|
}
|
|
25668
25821
|
function hash(value) {
|
|
25669
|
-
return
|
|
25822
|
+
return createHash6("sha256").update(value).digest("hex");
|
|
25670
25823
|
}
|
|
25671
25824
|
function parsePayload2(value) {
|
|
25672
25825
|
if (!value)
|
|
@@ -35214,7 +35367,7 @@ var package_default;
|
|
|
35214
35367
|
var init_package = __esm(() => {
|
|
35215
35368
|
package_default = {
|
|
35216
35369
|
name: "@hasna/todos",
|
|
35217
|
-
version: "0.15.
|
|
35370
|
+
version: "0.15.11",
|
|
35218
35371
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
35219
35372
|
type: "module",
|
|
35220
35373
|
main: "dist/index.js",
|
|
@@ -35256,6 +35409,10 @@ var init_package = __esm(() => {
|
|
|
35256
35409
|
"./project-registration": {
|
|
35257
35410
|
types: "./dist/project-registration.d.ts",
|
|
35258
35411
|
import: "./dist/project-registration.js"
|
|
35412
|
+
},
|
|
35413
|
+
"./task-manifest": {
|
|
35414
|
+
types: "./dist/task-manifest.d.ts",
|
|
35415
|
+
import: "./dist/task-manifest.js"
|
|
35259
35416
|
}
|
|
35260
35417
|
},
|
|
35261
35418
|
workspaces: [
|
|
@@ -35268,8 +35425,8 @@ var init_package = __esm(() => {
|
|
|
35268
35425
|
"README.md"
|
|
35269
35426
|
],
|
|
35270
35427
|
scripts: {
|
|
35271
|
-
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",
|
|
35272
|
-
"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/*'",
|
|
35428
|
+
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",
|
|
35429
|
+
"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/*'",
|
|
35273
35430
|
migrate: "bun run src/server/index.ts migrate",
|
|
35274
35431
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
35275
35432
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
@@ -35924,7 +36081,7 @@ var init_local_bridge = __esm(() => {
|
|
|
35924
36081
|
});
|
|
35925
36082
|
|
|
35926
36083
|
// src/lib/local-backups.ts
|
|
35927
|
-
import { createHash as
|
|
36084
|
+
import { createHash as createHash7 } from "crypto";
|
|
35928
36085
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
35929
36086
|
import { dirname as dirname7, resolve as resolve12 } from "path";
|
|
35930
36087
|
import { mkdirSync as mkdirSync6 } from "fs";
|
|
@@ -35937,7 +36094,7 @@ function stableJson2(value) {
|
|
|
35937
36094
|
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson2(record[key])}`).join(",")}}`;
|
|
35938
36095
|
}
|
|
35939
36096
|
function sha2564(value) {
|
|
35940
|
-
return
|
|
36097
|
+
return createHash7("sha256").update(stableJson2(value)).digest("hex");
|
|
35941
36098
|
}
|
|
35942
36099
|
function sqliteIntegrity(db) {
|
|
35943
36100
|
let quick = "unknown";
|
|
@@ -36587,7 +36744,7 @@ var init_onboarding_fixtures = __esm(() => {
|
|
|
36587
36744
|
});
|
|
36588
36745
|
|
|
36589
36746
|
// src/lib/local-snapshots.ts
|
|
36590
|
-
import { createHash as
|
|
36747
|
+
import { createHash as createHash8 } from "crypto";
|
|
36591
36748
|
function source(version) {
|
|
36592
36749
|
return {
|
|
36593
36750
|
packageName: "@hasna/todos",
|
|
@@ -36611,7 +36768,7 @@ function stable(value) {
|
|
|
36611
36768
|
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stable(item)]));
|
|
36612
36769
|
}
|
|
36613
36770
|
function sha2565(value) {
|
|
36614
|
-
return
|
|
36771
|
+
return createHash8("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
|
36615
36772
|
}
|
|
36616
36773
|
function latestTimestamp2(items, fallback) {
|
|
36617
36774
|
const timestamps = [];
|
|
@@ -37177,7 +37334,7 @@ var init_retrospectives = __esm(() => {
|
|
|
37177
37334
|
});
|
|
37178
37335
|
|
|
37179
37336
|
// src/lib/agent-replay-simulator.ts
|
|
37180
|
-
import { createHash as
|
|
37337
|
+
import { createHash as createHash9 } from "crypto";
|
|
37181
37338
|
function isObject(value) {
|
|
37182
37339
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
37183
37340
|
}
|
|
@@ -37198,7 +37355,7 @@ function stable2(value) {
|
|
|
37198
37355
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable2(value[key])]));
|
|
37199
37356
|
}
|
|
37200
37357
|
function fingerprint2(value) {
|
|
37201
|
-
return
|
|
37358
|
+
return createHash9("sha256").update(JSON.stringify(stable2(value))).digest("hex");
|
|
37202
37359
|
}
|
|
37203
37360
|
function unpackFixture(input) {
|
|
37204
37361
|
if (!isObject(input))
|
|
@@ -37485,7 +37642,7 @@ __export(exports_local_extensions, {
|
|
|
37485
37642
|
getLocalExtension: () => getLocalExtension,
|
|
37486
37643
|
discoverLocalExtensions: () => discoverLocalExtensions
|
|
37487
37644
|
});
|
|
37488
|
-
import { createHash as
|
|
37645
|
+
import { createHash as createHash10, createVerify } from "crypto";
|
|
37489
37646
|
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
|
|
37490
37647
|
import { basename as basename5, join as join11, resolve as resolve13 } from "path";
|
|
37491
37648
|
function isObject2(value) {
|
|
@@ -37571,7 +37728,7 @@ function parseJson(path) {
|
|
|
37571
37728
|
return JSON.parse(readFileSync9(path, "utf8"));
|
|
37572
37729
|
}
|
|
37573
37730
|
function sha2566(bytes) {
|
|
37574
|
-
return `sha256:${
|
|
37731
|
+
return `sha256:${createHash10("sha256").update(bytes).digest("hex")}`;
|
|
37575
37732
|
}
|
|
37576
37733
|
function compareVersions(a, b) {
|
|
37577
37734
|
const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
@@ -38046,7 +38203,7 @@ function issueToTask(issue, opts) {
|
|
|
38046
38203
|
var init_github = () => {};
|
|
38047
38204
|
|
|
38048
38205
|
// src/db/inbox.ts
|
|
38049
|
-
import { createHash as
|
|
38206
|
+
import { createHash as createHash11 } from "crypto";
|
|
38050
38207
|
function parseMetadata3(value) {
|
|
38051
38208
|
if (!value)
|
|
38052
38209
|
return {};
|
|
@@ -38066,7 +38223,7 @@ function compactWhitespace(value) {
|
|
|
38066
38223
|
function fingerprintInboxInput(input) {
|
|
38067
38224
|
const sourceType = input.source_type || detectInboxSourceType(input.body, input.source_url);
|
|
38068
38225
|
const normalized = compactWhitespace(sanitizePreWriteText(input.body, "inbox.fingerprint")).slice(0, 8000);
|
|
38069
|
-
return
|
|
38226
|
+
return createHash11("sha256").update(`${sourceType}
|
|
38070
38227
|
${input.source_url || ""}
|
|
38071
38228
|
${normalized}`).digest("hex");
|
|
38072
38229
|
}
|
|
@@ -42266,10 +42423,10 @@ __export(exports_extract, {
|
|
|
42266
42423
|
EXTRACT_TAGS: () => EXTRACT_TAGS
|
|
42267
42424
|
});
|
|
42268
42425
|
import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync7 } from "fs";
|
|
42269
|
-
import { createHash as
|
|
42426
|
+
import { createHash as createHash12 } from "crypto";
|
|
42270
42427
|
import { relative as relative5, resolve as resolve14, join as join12 } from "path";
|
|
42271
42428
|
function stableHash(value) {
|
|
42272
|
-
return
|
|
42429
|
+
return createHash12("sha256").update(value).digest("hex");
|
|
42273
42430
|
}
|
|
42274
42431
|
function normalizePathForMatch(value) {
|
|
42275
42432
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -43993,13 +44150,13 @@ __export(exports_environment_snapshots, {
|
|
|
43993
44150
|
compareEnvironmentSnapshotFiles: () => compareEnvironmentSnapshotFiles,
|
|
43994
44151
|
captureEnvironmentSnapshot: () => captureEnvironmentSnapshot
|
|
43995
44152
|
});
|
|
43996
|
-
import { createHash as
|
|
44153
|
+
import { createHash as createHash13 } from "crypto";
|
|
43997
44154
|
import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
|
|
43998
44155
|
import { hostname as hostname2, platform, arch } from "os";
|
|
43999
44156
|
import { dirname as dirname8, join as join14, resolve as resolve15 } from "path";
|
|
44000
44157
|
import { tmpdir as tmpdir3 } from "os";
|
|
44001
44158
|
function sha2567(value) {
|
|
44002
|
-
return
|
|
44159
|
+
return createHash13("sha256").update(value).digest("hex");
|
|
44003
44160
|
}
|
|
44004
44161
|
function fileRecord(root, relativePath) {
|
|
44005
44162
|
const path = join14(root, relativePath);
|
|
@@ -44590,6 +44747,187 @@ var init_cloud_client = __esm(() => {
|
|
|
44590
44747
|
init_config();
|
|
44591
44748
|
});
|
|
44592
44749
|
|
|
44750
|
+
// src/db/plan-project-links.ts
|
|
44751
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
44752
|
+
const d = db || getDatabase();
|
|
44753
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
44754
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
44755
|
+
}
|
|
44756
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
44757
|
+
const d = db || getDatabase();
|
|
44758
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
44759
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
44760
|
+
}
|
|
44761
|
+
function exactTasks(planId, db) {
|
|
44762
|
+
return listTasks({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
44763
|
+
}
|
|
44764
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
44765
|
+
const plan = getPlan(planId, db);
|
|
44766
|
+
const project = getProject(projectId, db);
|
|
44767
|
+
if (!plan || !project) {
|
|
44768
|
+
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 });
|
|
44769
|
+
}
|
|
44770
|
+
const tasks = exactTasks(planId, db);
|
|
44771
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
44772
|
+
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 });
|
|
44773
|
+
}
|
|
44774
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
44775
|
+
}
|
|
44776
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
44777
|
+
const d = db || getDatabase();
|
|
44778
|
+
const mutate = d.transaction(() => {
|
|
44779
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
44780
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
44781
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
44782
|
+
if (existingRow) {
|
|
44783
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
44784
|
+
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 });
|
|
44785
|
+
}
|
|
44786
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
44787
|
+
if (rolledBack) {
|
|
44788
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
44789
|
+
}
|
|
44790
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
44791
|
+
}
|
|
44792
|
+
const plan = getPlan(input.plan_id, d);
|
|
44793
|
+
if (!plan) {
|
|
44794
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
44795
|
+
}
|
|
44796
|
+
const project = getProject(input.project_id, d);
|
|
44797
|
+
if (!project) {
|
|
44798
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
44799
|
+
}
|
|
44800
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
44801
|
+
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 });
|
|
44802
|
+
}
|
|
44803
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
44804
|
+
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 });
|
|
44805
|
+
}
|
|
44806
|
+
if (plan.slug) {
|
|
44807
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
44808
|
+
if (collision) {
|
|
44809
|
+
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 });
|
|
44810
|
+
}
|
|
44811
|
+
}
|
|
44812
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
44813
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task2) => task2.project_id === project.id);
|
|
44814
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task2) => [task2.id, task2.project_id]));
|
|
44815
|
+
const timestamp4 = input.created_at;
|
|
44816
|
+
if (plan.project_id !== project.id) {
|
|
44817
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
44818
|
+
project.id,
|
|
44819
|
+
timestamp4,
|
|
44820
|
+
plan.id,
|
|
44821
|
+
input.expected_plan_revision
|
|
44822
|
+
]);
|
|
44823
|
+
}
|
|
44824
|
+
for (const task2 of beforeTasks) {
|
|
44825
|
+
if (task2.project_id === project.id)
|
|
44826
|
+
continue;
|
|
44827
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp4, task2.id, plan.id]);
|
|
44828
|
+
}
|
|
44829
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
44830
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
44831
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task2) => task2.project_id !== project.id)) {
|
|
44832
|
+
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 });
|
|
44833
|
+
}
|
|
44834
|
+
const receipt = {
|
|
44835
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
44836
|
+
receipt_id: input.receipt_id,
|
|
44837
|
+
idempotency_key: input.idempotency_key,
|
|
44838
|
+
plan_id: plan.id,
|
|
44839
|
+
project_id: project.id,
|
|
44840
|
+
prior_plan_project_id: plan.project_id,
|
|
44841
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
44842
|
+
task_ids: beforeTasks.map((task2) => task2.id),
|
|
44843
|
+
task_count: beforeTasks.length,
|
|
44844
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
44845
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
44846
|
+
rollback_supported: true,
|
|
44847
|
+
created_at: input.created_at
|
|
44848
|
+
};
|
|
44849
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
44850
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
44851
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
44852
|
+
receipt.receipt_id,
|
|
44853
|
+
receipt.idempotency_key,
|
|
44854
|
+
receipt.plan_id,
|
|
44855
|
+
receipt.project_id,
|
|
44856
|
+
requestHash,
|
|
44857
|
+
JSON.stringify(receipt),
|
|
44858
|
+
receipt.created_at
|
|
44859
|
+
]);
|
|
44860
|
+
return {
|
|
44861
|
+
mode: "apply",
|
|
44862
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
44863
|
+
plan: linkedPlan,
|
|
44864
|
+
project,
|
|
44865
|
+
tasks: linkedTasks,
|
|
44866
|
+
receipt
|
|
44867
|
+
};
|
|
44868
|
+
});
|
|
44869
|
+
return mutate();
|
|
44870
|
+
}
|
|
44871
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
44872
|
+
const d = db || getDatabase();
|
|
44873
|
+
return d.transaction(() => {
|
|
44874
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
44875
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
44876
|
+
if (priorRollback)
|
|
44877
|
+
return JSON.parse(priorRollback.payload);
|
|
44878
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
44879
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
44880
|
+
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 });
|
|
44881
|
+
}
|
|
44882
|
+
const plan = getPlan(input.plan_id, d);
|
|
44883
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
44884
|
+
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 });
|
|
44885
|
+
}
|
|
44886
|
+
const tasks = exactTasks(plan.id, d);
|
|
44887
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task2, index) => task2.id !== receipt.task_ids[index])) {
|
|
44888
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
44889
|
+
}
|
|
44890
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
44891
|
+
receipt.prior_plan_project_id,
|
|
44892
|
+
input.restored_at,
|
|
44893
|
+
plan.id,
|
|
44894
|
+
input.expected_plan_revision
|
|
44895
|
+
]);
|
|
44896
|
+
for (const task2 of tasks) {
|
|
44897
|
+
const priorProjectId = receipt.prior_task_project_ids[task2.id];
|
|
44898
|
+
if (priorProjectId === undefined && !(task2.id in receipt.prior_task_project_ids)) {
|
|
44899
|
+
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 });
|
|
44900
|
+
}
|
|
44901
|
+
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]);
|
|
44902
|
+
}
|
|
44903
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
44904
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
44905
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task2) => task2.project_id !== receipt.prior_task_project_ids[task2.id])) {
|
|
44906
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
44907
|
+
}
|
|
44908
|
+
const result = {
|
|
44909
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
44910
|
+
action: "restored",
|
|
44911
|
+
plan: restoredPlan,
|
|
44912
|
+
tasks: restoredTasks,
|
|
44913
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
44914
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
44915
|
+
restored_at: input.restored_at
|
|
44916
|
+
};
|
|
44917
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
44918
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
44919
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
44920
|
+
return result;
|
|
44921
|
+
})();
|
|
44922
|
+
}
|
|
44923
|
+
var init_plan_project_links = __esm(() => {
|
|
44924
|
+
init_plan_project_link_contract();
|
|
44925
|
+
init_database();
|
|
44926
|
+
init_plans();
|
|
44927
|
+
init_projects();
|
|
44928
|
+
init_tasks();
|
|
44929
|
+
});
|
|
44930
|
+
|
|
44593
44931
|
// src/storage/sqlite-snapshot.ts
|
|
44594
44932
|
function exportSqliteTodosStorageSnapshot(db) {
|
|
44595
44933
|
const d = db ?? getDatabase();
|
|
@@ -45096,6 +45434,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
45096
45434
|
update: (id, input) => updatePlan(id, input, database()),
|
|
45097
45435
|
delete: (id) => deletePlan(id, database())
|
|
45098
45436
|
},
|
|
45437
|
+
planProjectLinks: {
|
|
45438
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
45439
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
45440
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
45441
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
45442
|
+
},
|
|
45099
45443
|
agents: {
|
|
45100
45444
|
register: (input) => registerAgent(input, database()),
|
|
45101
45445
|
get: (id) => getAgent(id, database()),
|
|
@@ -45170,6 +45514,7 @@ var init_local_sqlite = __esm(() => {
|
|
|
45170
45514
|
init_comments();
|
|
45171
45515
|
init_database();
|
|
45172
45516
|
init_integrity2();
|
|
45517
|
+
init_plan_project_links();
|
|
45173
45518
|
init_sqlite_snapshot();
|
|
45174
45519
|
TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
45175
45520
|
});
|
|
@@ -45200,6 +45545,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
45200
45545
|
RETURNS text
|
|
45201
45546
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
45202
45547
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
45548
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
45549
|
+
RETURNS timestamptz
|
|
45550
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
45551
|
+
SET DateStyle TO 'ISO, YMD'
|
|
45552
|
+
AS $$
|
|
45553
|
+
BEGIN
|
|
45554
|
+
RETURN CASE
|
|
45555
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
45556
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
45557
|
+
END;
|
|
45558
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
45559
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
45560
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
45561
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
45562
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
45203
45563
|
`ALTER TABLE ${tableName}
|
|
45204
45564
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
45205
45565
|
GENERATED ALWAYS AS (
|
|
@@ -45919,7 +46279,7 @@ var init_shadow_runtime = __esm(() => {
|
|
|
45919
46279
|
});
|
|
45920
46280
|
|
|
45921
46281
|
// src/db/api-keys.ts
|
|
45922
|
-
import { createHash as
|
|
46282
|
+
import { createHash as createHash14, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
45923
46283
|
function rowToRecord(row) {
|
|
45924
46284
|
return {
|
|
45925
46285
|
id: row.id,
|
|
@@ -45933,7 +46293,7 @@ function rowToRecord(row) {
|
|
|
45933
46293
|
};
|
|
45934
46294
|
}
|
|
45935
46295
|
function hashApiKey(key) {
|
|
45936
|
-
return
|
|
46296
|
+
return createHash14("sha256").update(key).digest("hex");
|
|
45937
46297
|
}
|
|
45938
46298
|
function safeEqualHex(a, b) {
|
|
45939
46299
|
if (a.length !== b.length)
|
|
@@ -45941,8 +46301,8 @@ function safeEqualHex(a, b) {
|
|
|
45941
46301
|
return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
45942
46302
|
}
|
|
45943
46303
|
function safeEqualStrings(a, b) {
|
|
45944
|
-
const ah =
|
|
45945
|
-
const bh =
|
|
46304
|
+
const ah = createHash14("sha256").update(a, "utf8").digest();
|
|
46305
|
+
const bh = createHash14("sha256").update(b, "utf8").digest();
|
|
45946
46306
|
return timingSafeEqual3(ah, bh);
|
|
45947
46307
|
}
|
|
45948
46308
|
function hasActiveApiKeys(db) {
|
|
@@ -46034,6 +46394,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
46034
46394
|
update: (id, input) => updatePlan2(id, input, store),
|
|
46035
46395
|
delete: (id, context) => store.deletePlan(id, context)
|
|
46036
46396
|
},
|
|
46397
|
+
planProjectLinks: {
|
|
46398
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
46399
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
46400
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
46401
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
46402
|
+
},
|
|
46037
46403
|
agents: {
|
|
46038
46404
|
register: (input, context) => registerAgent2(input, store, context),
|
|
46039
46405
|
get: (id) => store.get("agents", id),
|
|
@@ -46216,6 +46582,9 @@ class PostgresJsonRecordStore {
|
|
|
46216
46582
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
46217
46583
|
if (filter.not_created_by !== undefined)
|
|
46218
46584
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
46585
|
+
if (filter.updated_after !== undefined) {
|
|
46586
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
46587
|
+
}
|
|
46219
46588
|
if (filter.session_id !== undefined)
|
|
46220
46589
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
46221
46590
|
if (filter.tags?.length) {
|
|
@@ -46400,6 +46769,115 @@ class PostgresJsonRecordStore {
|
|
|
46400
46769
|
}
|
|
46401
46770
|
return value;
|
|
46402
46771
|
}
|
|
46772
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
46773
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
46774
|
+
if (planIds.length === 0)
|
|
46775
|
+
return this.upsert("tasks", value, context);
|
|
46776
|
+
await this.ensureSchema();
|
|
46777
|
+
const updatedAt = value.updated_at;
|
|
46778
|
+
const targetPlanId = value.plan_id;
|
|
46779
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
46780
|
+
locked_plans AS MATERIALIZED (
|
|
46781
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
46782
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
46783
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
46784
|
+
ORDER BY object_id
|
|
46785
|
+
FOR UPDATE
|
|
46786
|
+
), validation AS (
|
|
46787
|
+
SELECT
|
|
46788
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
46789
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
46790
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
46791
|
+
), guarded AS (
|
|
46792
|
+
SELECT
|
|
46793
|
+
validation.*,
|
|
46794
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
46795
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
46796
|
+
CASE
|
|
46797
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
46798
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
46799
|
+
END AS payload
|
|
46800
|
+
FROM validation
|
|
46801
|
+
), stored AS (
|
|
46802
|
+
INSERT INTO ${this.tableName} (
|
|
46803
|
+
service, object_type, object_id, payload, updated_at,
|
|
46804
|
+
deleted_at, source_machine_id, version
|
|
46805
|
+
)
|
|
46806
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
46807
|
+
FROM guarded
|
|
46808
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
46809
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
46810
|
+
payload = EXCLUDED.payload,
|
|
46811
|
+
updated_at = EXCLUDED.updated_at,
|
|
46812
|
+
deleted_at = NULL,
|
|
46813
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
46814
|
+
version = EXCLUDED.version
|
|
46815
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
46816
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
46817
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
46818
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
46819
|
+
RETURNING payload
|
|
46820
|
+
)
|
|
46821
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
46822
|
+
(SELECT payload FROM stored) AS payload
|
|
46823
|
+
FROM guarded`, [
|
|
46824
|
+
this.service,
|
|
46825
|
+
value.id,
|
|
46826
|
+
jsonbParam(value),
|
|
46827
|
+
updatedAt,
|
|
46828
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
46829
|
+
numberValue3(value.version),
|
|
46830
|
+
jsonbParam(planIds),
|
|
46831
|
+
targetPlanId,
|
|
46832
|
+
explicitProject
|
|
46833
|
+
]);
|
|
46834
|
+
const row = result.rows[0];
|
|
46835
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
46836
|
+
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 });
|
|
46837
|
+
}
|
|
46838
|
+
if (row.project_conflict) {
|
|
46839
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
46840
|
+
}
|
|
46841
|
+
if (!row.payload) {
|
|
46842
|
+
return await requireRecord("tasks", value.id, this);
|
|
46843
|
+
}
|
|
46844
|
+
return payloadRecord2(row.payload);
|
|
46845
|
+
}
|
|
46846
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
46847
|
+
await this.ensureSchema();
|
|
46848
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
46849
|
+
SELECT payload FROM ${this.tableName}
|
|
46850
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
46851
|
+
FOR UPDATE
|
|
46852
|
+
), stored AS (
|
|
46853
|
+
UPDATE ${this.tableName} r SET
|
|
46854
|
+
payload = jsonb_set(
|
|
46855
|
+
$3::jsonb,
|
|
46856
|
+
'{project_id}',
|
|
46857
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
46858
|
+
true
|
|
46859
|
+
),
|
|
46860
|
+
updated_at = $4::timestamptz,
|
|
46861
|
+
deleted_at = NULL,
|
|
46862
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
46863
|
+
version = COALESCE(r.version, 0) + 1
|
|
46864
|
+
FROM locked_plan
|
|
46865
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
46866
|
+
RETURNING r.payload
|
|
46867
|
+
)
|
|
46868
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
46869
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
46870
|
+
this.service,
|
|
46871
|
+
value.id,
|
|
46872
|
+
jsonbParam(value),
|
|
46873
|
+
value.updated_at,
|
|
46874
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
46875
|
+
]);
|
|
46876
|
+
const row = result.rows[0];
|
|
46877
|
+
if (!row?.plan_found || !row.payload)
|
|
46878
|
+
throw new PlanNotFoundError(value.id);
|
|
46879
|
+
return payloadRecord2(row.payload);
|
|
46880
|
+
}
|
|
46403
46881
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
46404
46882
|
await this.ensureSchema();
|
|
46405
46883
|
const records = [
|
|
@@ -46638,6 +47116,333 @@ class PostgresJsonRecordStore {
|
|
|
46638
47116
|
version: numberValue3(existing["version"])
|
|
46639
47117
|
}, context);
|
|
46640
47118
|
}
|
|
47119
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
47120
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
47121
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
47122
|
+
}
|
|
47123
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
47124
|
+
await this.ensureSchema();
|
|
47125
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
47126
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
47127
|
+
AND payload->>'idempotency_key' = $2
|
|
47128
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
47129
|
+
if (result.rows.length > 1) {
|
|
47130
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
47131
|
+
}
|
|
47132
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
47133
|
+
}
|
|
47134
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
47135
|
+
const [plan, project, tasks] = await Promise.all([
|
|
47136
|
+
this.get("plans", receipt.plan_id),
|
|
47137
|
+
this.get("projects", receipt.project_id),
|
|
47138
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
47139
|
+
]);
|
|
47140
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47141
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
|
|
47142
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
47143
|
+
}
|
|
47144
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks3, receipt };
|
|
47145
|
+
}
|
|
47146
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
47147
|
+
await this.ensureSchema();
|
|
47148
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47149
|
+
if (existing) {
|
|
47150
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
47151
|
+
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 });
|
|
47152
|
+
}
|
|
47153
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
47154
|
+
if (rolledBack) {
|
|
47155
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
47156
|
+
}
|
|
47157
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
47158
|
+
}
|
|
47159
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
47160
|
+
this.get("plans", input.plan_id),
|
|
47161
|
+
this.get("projects", input.project_id),
|
|
47162
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
47163
|
+
this.list("plans")
|
|
47164
|
+
]);
|
|
47165
|
+
if (!plan)
|
|
47166
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
47167
|
+
if (!project)
|
|
47168
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
47169
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
47170
|
+
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 });
|
|
47171
|
+
}
|
|
47172
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
47173
|
+
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 });
|
|
47174
|
+
}
|
|
47175
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
47176
|
+
if (collision) {
|
|
47177
|
+
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 });
|
|
47178
|
+
}
|
|
47179
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47180
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
|
|
47181
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
47182
|
+
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 });
|
|
47183
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks3.every((task2) => task2.project_id === project.id);
|
|
47184
|
+
const receipt = {
|
|
47185
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
47186
|
+
receipt_id: input.receipt_id,
|
|
47187
|
+
idempotency_key: input.idempotency_key,
|
|
47188
|
+
plan_id: plan.id,
|
|
47189
|
+
project_id: project.id,
|
|
47190
|
+
prior_plan_project_id: plan.project_id,
|
|
47191
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
47192
|
+
task_ids: sortedTasks3.map((task2) => task2.id),
|
|
47193
|
+
task_count: sortedTasks3.length,
|
|
47194
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
47195
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
47196
|
+
rollback_supported: true,
|
|
47197
|
+
created_at: input.created_at
|
|
47198
|
+
};
|
|
47199
|
+
let mutation;
|
|
47200
|
+
try {
|
|
47201
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
47202
|
+
target_plan AS MATERIALIZED (
|
|
47203
|
+
SELECT payload FROM ${this.tableName}
|
|
47204
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
47205
|
+
FOR UPDATE
|
|
47206
|
+
), target_project AS (
|
|
47207
|
+
SELECT payload FROM ${this.tableName}
|
|
47208
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
47209
|
+
FOR UPDATE
|
|
47210
|
+
), member_tasks AS MATERIALIZED (
|
|
47211
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
47212
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
47213
|
+
AND payload->>'plan_id' = $2
|
|
47214
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
47215
|
+
FOR UPDATE
|
|
47216
|
+
), existing AS (
|
|
47217
|
+
SELECT payload FROM ${this.tableName}
|
|
47218
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
47219
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
47220
|
+
FOR UPDATE
|
|
47221
|
+
), collision AS (
|
|
47222
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
47223
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
47224
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
47225
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
47226
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
47227
|
+
LIMIT 1
|
|
47228
|
+
), checks AS (
|
|
47229
|
+
SELECT
|
|
47230
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
47231
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
47232
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
47233
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
47234
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
47235
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
47236
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
47237
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
47238
|
+
), updated_plan AS (
|
|
47239
|
+
UPDATE ${this.tableName} r SET
|
|
47240
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
47241
|
+
updated_at = $10::timestamptz,
|
|
47242
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47243
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47244
|
+
FROM checks
|
|
47245
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
47246
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
47247
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
47248
|
+
RETURNING r.payload
|
|
47249
|
+
), updated_tasks AS (
|
|
47250
|
+
UPDATE ${this.tableName} r SET
|
|
47251
|
+
payload = r.payload || jsonb_build_object(
|
|
47252
|
+
'project_id', $3::text,
|
|
47253
|
+
'updated_at', $10::text,
|
|
47254
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
47255
|
+
),
|
|
47256
|
+
updated_at = $10::timestamptz,
|
|
47257
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47258
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47259
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
47260
|
+
AND r.payload->>'plan_id' = $2
|
|
47261
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
47262
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47263
|
+
RETURNING 1
|
|
47264
|
+
), task_gate AS (
|
|
47265
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
47266
|
+
), inserted AS (
|
|
47267
|
+
INSERT INTO ${this.tableName}
|
|
47268
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
47269
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
47270
|
+
FROM checks, task_gate
|
|
47271
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47272
|
+
RETURNING payload
|
|
47273
|
+
) SELECT
|
|
47274
|
+
checks.plan_found,
|
|
47275
|
+
checks.project_found,
|
|
47276
|
+
checks.plan_revision_ok,
|
|
47277
|
+
checks.project_revision_ok,
|
|
47278
|
+
checks.membership_ok,
|
|
47279
|
+
checks.collision,
|
|
47280
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
47281
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
47282
|
+
FROM checks`, [
|
|
47283
|
+
this.service,
|
|
47284
|
+
plan.id,
|
|
47285
|
+
project.id,
|
|
47286
|
+
input.expected_plan_revision,
|
|
47287
|
+
input.expected_project_revision,
|
|
47288
|
+
receipt.receipt_id,
|
|
47289
|
+
jsonbParam(receipt),
|
|
47290
|
+
jsonbParam(priorTaskProjectIds),
|
|
47291
|
+
jsonbParam(receipt.task_ids),
|
|
47292
|
+
input.created_at,
|
|
47293
|
+
this.machineId(context)
|
|
47294
|
+
]);
|
|
47295
|
+
} catch (error) {
|
|
47296
|
+
if (isPostgresUniqueViolation(error)) {
|
|
47297
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47298
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
47299
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
47300
|
+
}
|
|
47301
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
47302
|
+
}
|
|
47303
|
+
throw error;
|
|
47304
|
+
}
|
|
47305
|
+
const row = mutation.rows[0];
|
|
47306
|
+
if (!row?.plan_found)
|
|
47307
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
47308
|
+
if (!row.project_found)
|
|
47309
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
47310
|
+
if (!row.plan_revision_ok)
|
|
47311
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
47312
|
+
if (!row.project_revision_ok)
|
|
47313
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
47314
|
+
if (!row.membership_ok)
|
|
47315
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
47316
|
+
if (row.collision)
|
|
47317
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
47318
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
47319
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
47320
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
47321
|
+
}
|
|
47322
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
47323
|
+
}
|
|
47324
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
47325
|
+
await this.ensureSchema();
|
|
47326
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
47327
|
+
if (existingRollback)
|
|
47328
|
+
return existingRollback;
|
|
47329
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
47330
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
47331
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
47332
|
+
}
|
|
47333
|
+
const [plan, tasks] = await Promise.all([
|
|
47334
|
+
this.get("plans", input.plan_id),
|
|
47335
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
47336
|
+
]);
|
|
47337
|
+
const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
47338
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
47339
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
47340
|
+
}
|
|
47341
|
+
if (planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
|
|
47342
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
47343
|
+
}
|
|
47344
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
47345
|
+
const projectedTasks = sortedTasks3.map((task2) => ({
|
|
47346
|
+
...task2,
|
|
47347
|
+
project_id: receipt.prior_task_project_ids[task2.id] ?? null,
|
|
47348
|
+
updated_at: input.restored_at,
|
|
47349
|
+
version: task2.version + 1
|
|
47350
|
+
}));
|
|
47351
|
+
const rollback = {
|
|
47352
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
47353
|
+
action: "restored",
|
|
47354
|
+
plan: projectedPlan,
|
|
47355
|
+
tasks: projectedTasks,
|
|
47356
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
47357
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
47358
|
+
restored_at: input.restored_at
|
|
47359
|
+
};
|
|
47360
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
|
|
47361
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
47362
|
+
target_plan AS MATERIALIZED (
|
|
47363
|
+
SELECT payload FROM ${this.tableName}
|
|
47364
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
47365
|
+
FOR UPDATE
|
|
47366
|
+
), member_tasks AS MATERIALIZED (
|
|
47367
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
47368
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
47369
|
+
AND payload->>'plan_id' = $2
|
|
47370
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
47371
|
+
FOR UPDATE
|
|
47372
|
+
), existing AS (
|
|
47373
|
+
SELECT payload FROM ${this.tableName}
|
|
47374
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
47375
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
47376
|
+
FOR UPDATE
|
|
47377
|
+
), checks AS (
|
|
47378
|
+
SELECT
|
|
47379
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
47380
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
47381
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
47382
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
47383
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
47384
|
+
), updated_plan AS (
|
|
47385
|
+
UPDATE ${this.tableName} r SET
|
|
47386
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
47387
|
+
updated_at = $10::timestamptz,
|
|
47388
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47389
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47390
|
+
FROM checks
|
|
47391
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
47392
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
47393
|
+
RETURNING 1
|
|
47394
|
+
), updated_tasks AS (
|
|
47395
|
+
UPDATE ${this.tableName} r SET
|
|
47396
|
+
payload = r.payload || jsonb_build_object(
|
|
47397
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
47398
|
+
'updated_at', $10::text,
|
|
47399
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
47400
|
+
),
|
|
47401
|
+
updated_at = $10::timestamptz,
|
|
47402
|
+
version = COALESCE(r.version, 0) + 1,
|
|
47403
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
47404
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
47405
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47406
|
+
RETURNING 1
|
|
47407
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
47408
|
+
INSERT INTO ${this.tableName}
|
|
47409
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
47410
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
47411
|
+
FROM checks, task_gate
|
|
47412
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
47413
|
+
RETURNING payload
|
|
47414
|
+
) SELECT
|
|
47415
|
+
checks.plan_found,
|
|
47416
|
+
checks.plan_revision_ok,
|
|
47417
|
+
checks.membership_ok,
|
|
47418
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
47419
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
47420
|
+
FROM checks`, [
|
|
47421
|
+
this.service,
|
|
47422
|
+
input.plan_id,
|
|
47423
|
+
input.expected_plan_revision,
|
|
47424
|
+
input.receipt_id,
|
|
47425
|
+
input.rollback_receipt_id,
|
|
47426
|
+
jsonbParam(rollback),
|
|
47427
|
+
jsonbParam(currentTaskProjects),
|
|
47428
|
+
jsonbParam(receipt.task_ids),
|
|
47429
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
47430
|
+
input.restored_at,
|
|
47431
|
+
this.machineId(context),
|
|
47432
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
47433
|
+
]);
|
|
47434
|
+
const row = result.rows[0];
|
|
47435
|
+
if (!row?.plan_found)
|
|
47436
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
47437
|
+
if (!row.plan_revision_ok)
|
|
47438
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
47439
|
+
if (!row.membership_ok)
|
|
47440
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
47441
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
47442
|
+
if (!accepted)
|
|
47443
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
47444
|
+
return accepted;
|
|
47445
|
+
}
|
|
46641
47446
|
async deletePlan(id, context = {}) {
|
|
46642
47447
|
await this.ensureSchema();
|
|
46643
47448
|
const timestamp4 = new Date().toISOString();
|
|
@@ -46732,11 +47537,17 @@ class PostgresJsonRecordStore {
|
|
|
46732
47537
|
}
|
|
46733
47538
|
async function createTask3(input, store, context) {
|
|
46734
47539
|
const timestamp4 = new Date().toISOString();
|
|
46735
|
-
const
|
|
47540
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
47541
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
47542
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
47543
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
47544
|
+
}
|
|
47545
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
47546
|
+
const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
|
|
46736
47547
|
const task2 = {
|
|
46737
47548
|
id: randomUUID3(),
|
|
46738
47549
|
short_id: shortId,
|
|
46739
|
-
project_id:
|
|
47550
|
+
project_id: effectiveProjectId,
|
|
46740
47551
|
parent_id: input.parent_id ?? null,
|
|
46741
47552
|
plan_id: input.plan_id ?? null,
|
|
46742
47553
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -46790,15 +47601,27 @@ async function createTask3(input, store, context) {
|
|
|
46790
47601
|
synced_at: null,
|
|
46791
47602
|
archived_at: null
|
|
46792
47603
|
};
|
|
46793
|
-
await store.
|
|
46794
|
-
await logTaskChange2(
|
|
46795
|
-
return
|
|
47604
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, task2.plan_id ? [task2.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
47605
|
+
await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
47606
|
+
return storedTask;
|
|
46796
47607
|
}
|
|
46797
47608
|
async function updateTask2(id, input, store) {
|
|
46798
47609
|
const existing = await requireRecord("tasks", id, store);
|
|
46799
47610
|
if (existing.version !== input.version) {
|
|
46800
47611
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
46801
47612
|
}
|
|
47613
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
47614
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
47615
|
+
if (linkedPlan?.project_id) {
|
|
47616
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
47617
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
47618
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
47619
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
47620
|
+
} else {
|
|
47621
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
47622
|
+
}
|
|
47623
|
+
}
|
|
47624
|
+
}
|
|
46802
47625
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
46803
47626
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
46804
47627
|
const task2 = {
|
|
@@ -46814,8 +47637,7 @@ async function updateTask2(id, input, store) {
|
|
|
46814
47637
|
created_by: existing.created_by,
|
|
46815
47638
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
46816
47639
|
};
|
|
46817
|
-
|
|
46818
|
-
return task2;
|
|
47640
|
+
return store.upsertTaskWithPlanMembershipGuard(task2, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
46819
47641
|
}
|
|
46820
47642
|
async function startTask2(id, agentId, store) {
|
|
46821
47643
|
const task2 = await requireRecord("tasks", id, store);
|
|
@@ -46879,8 +47701,7 @@ async function patchTask(task2, patch, store) {
|
|
|
46879
47701
|
version: task2.version + 1,
|
|
46880
47702
|
updated_at: new Date().toISOString()
|
|
46881
47703
|
};
|
|
46882
|
-
|
|
46883
|
-
return updated;
|
|
47704
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task2.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
46884
47705
|
}
|
|
46885
47706
|
function sameCloudLockHolder(stored, incoming) {
|
|
46886
47707
|
if (!stored || !incoming)
|
|
@@ -47142,7 +47963,12 @@ async function updatePlan2(id, input, store) {
|
|
|
47142
47963
|
excludeId: id
|
|
47143
47964
|
});
|
|
47144
47965
|
}
|
|
47145
|
-
return store.
|
|
47966
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
47967
|
+
...plan,
|
|
47968
|
+
...patch,
|
|
47969
|
+
project_id: plan.project_id,
|
|
47970
|
+
updated_at: new Date().toISOString()
|
|
47971
|
+
});
|
|
47146
47972
|
}
|
|
47147
47973
|
function matchAgentByName(agents, name, options) {
|
|
47148
47974
|
const target = normalizeAgentNameInput(name);
|
|
@@ -47530,6 +48356,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
|
|
|
47530
48356
|
var init_postgres_adapter = __esm(() => {
|
|
47531
48357
|
init_types();
|
|
47532
48358
|
init_creator_identity();
|
|
48359
|
+
init_plan_project_link_contract();
|
|
47533
48360
|
init_postgres_sync();
|
|
47534
48361
|
init_integrity();
|
|
47535
48362
|
init_redaction();
|
|
@@ -49020,7 +49847,7 @@ var init_sqlite = __esm(() => {
|
|
|
49020
49847
|
});
|
|
49021
49848
|
|
|
49022
49849
|
// src/project-registration/authority.ts
|
|
49023
|
-
import { createHash as
|
|
49850
|
+
import { createHash as createHash15 } from "crypto";
|
|
49024
49851
|
function canonicalProjectRegistrationJson(value) {
|
|
49025
49852
|
return JSON.stringify(canonicalize2(value));
|
|
49026
49853
|
}
|
|
@@ -49038,7 +49865,7 @@ function canonicalize2(value) {
|
|
|
49038
49865
|
return out;
|
|
49039
49866
|
}
|
|
49040
49867
|
function digestProjectRegistrationValue(value) {
|
|
49041
|
-
return
|
|
49868
|
+
return createHash15("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
|
|
49042
49869
|
}
|
|
49043
49870
|
function deriveTodosProjectRegistrationIdempotencyKey(input) {
|
|
49044
49871
|
return `prk_${digestProjectRegistrationValue({
|
|
@@ -51526,6 +52353,9 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
51526
52353
|
ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
|
|
51527
52354
|
TaskComment: taskCommentSchema,
|
|
51528
52355
|
Plan: planSchema,
|
|
52356
|
+
PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
|
|
52357
|
+
PlanProjectLinkResult: planProjectLinkResultSchema,
|
|
52358
|
+
PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
|
|
51529
52359
|
Template: templateSchema,
|
|
51530
52360
|
TemplateTask: templateTaskSchema,
|
|
51531
52361
|
TemplateVariable: templateVariableSchema,
|
|
@@ -51539,6 +52369,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
51539
52369
|
status: { type: "string" },
|
|
51540
52370
|
priority: { type: "string" },
|
|
51541
52371
|
project_id: { type: "string" },
|
|
52372
|
+
plan_id: { type: "string" },
|
|
51542
52373
|
assigned_to: { type: "string" },
|
|
51543
52374
|
agent_id: { type: "string" },
|
|
51544
52375
|
tags: { type: "array", items: { type: "string" } }
|
|
@@ -51553,6 +52384,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
51553
52384
|
priority: { type: "string" },
|
|
51554
52385
|
assigned_to: { type: "string" },
|
|
51555
52386
|
project_id: { type: "string", nullable: true },
|
|
52387
|
+
plan_id: { type: "string", nullable: true },
|
|
51556
52388
|
task_list_id: { type: "string", nullable: true },
|
|
51557
52389
|
version: { type: "number" }
|
|
51558
52390
|
}
|
|
@@ -51624,6 +52456,32 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
51624
52456
|
expected_task_list_revision: { type: "string", minLength: 1 }
|
|
51625
52457
|
}
|
|
51626
52458
|
},
|
|
52459
|
+
PlanProjectLinkApplyInput: {
|
|
52460
|
+
type: "object",
|
|
52461
|
+
additionalProperties: false,
|
|
52462
|
+
required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
|
|
52463
|
+
properties: {
|
|
52464
|
+
project_id: { type: "string", minLength: 1 },
|
|
52465
|
+
expected_plan_revision: { type: "string", minLength: 1 },
|
|
52466
|
+
expected_project_revision: { type: "string", minLength: 1 },
|
|
52467
|
+
idempotency_key: {
|
|
52468
|
+
type: "string",
|
|
52469
|
+
minLength: 8,
|
|
52470
|
+
maxLength: 128,
|
|
52471
|
+
pattern: "^[A-Za-z0-9._:-]+$"
|
|
52472
|
+
}
|
|
52473
|
+
}
|
|
52474
|
+
},
|
|
52475
|
+
PlanProjectLinkRollbackInput: {
|
|
52476
|
+
type: "object",
|
|
52477
|
+
additionalProperties: false,
|
|
52478
|
+
required: ["project_id", "receipt_id", "expected_plan_revision"],
|
|
52479
|
+
properties: {
|
|
52480
|
+
project_id: { type: "string", minLength: 1 },
|
|
52481
|
+
receipt_id: { type: "string", minLength: 1 },
|
|
52482
|
+
expected_plan_revision: { type: "string", minLength: 1 }
|
|
52483
|
+
}
|
|
52484
|
+
},
|
|
51627
52485
|
ErrorResponse: {
|
|
51628
52486
|
type: "object",
|
|
51629
52487
|
required: ["error"],
|
|
@@ -52507,6 +53365,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
52507
53365
|
{ name: "assigned_to", in: "query", schema: { type: "string" } },
|
|
52508
53366
|
{ name: "agent_id", in: "query", schema: { type: "string" } },
|
|
52509
53367
|
{ name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
|
|
53368
|
+
{
|
|
53369
|
+
name: "updated_after",
|
|
53370
|
+
in: "query",
|
|
53371
|
+
schema: { type: "string", format: "date-time" },
|
|
53372
|
+
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."
|
|
53373
|
+
},
|
|
52510
53374
|
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
|
|
52511
53375
|
{ name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
|
|
52512
53376
|
],
|
|
@@ -52835,6 +53699,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
52835
53699
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
52836
53700
|
}
|
|
52837
53701
|
},
|
|
53702
|
+
"/v1/plans/{id}/project-link": {
|
|
53703
|
+
get: {
|
|
53704
|
+
operationId: "planPlanProjectLink",
|
|
53705
|
+
summary: "Plan atomic linkage of an existing plan and every current member task to a project",
|
|
53706
|
+
parameters: [
|
|
53707
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
53708
|
+
{ name: "project_id", in: "query", required: true, schema: { type: "string" } }
|
|
53709
|
+
],
|
|
53710
|
+
responses: {
|
|
53711
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53712
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53713
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53714
|
+
}
|
|
53715
|
+
},
|
|
53716
|
+
post: {
|
|
53717
|
+
operationId: "applyPlanProjectLink",
|
|
53718
|
+
summary: "Atomically and idempotently link an existing plan and every current member task to a project",
|
|
53719
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53720
|
+
requestBody: {
|
|
53721
|
+
required: true,
|
|
53722
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
|
|
53723
|
+
},
|
|
53724
|
+
responses: {
|
|
53725
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53726
|
+
"201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
|
|
53727
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53728
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53729
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53730
|
+
}
|
|
53731
|
+
}
|
|
53732
|
+
},
|
|
53733
|
+
"/v1/plans/{id}/project-link/rollback": {
|
|
53734
|
+
post: {
|
|
53735
|
+
operationId: "rollbackPlanProjectLink",
|
|
53736
|
+
summary: "Conditionally restore every exact prior project link from an accepted receipt",
|
|
53737
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
53738
|
+
requestBody: {
|
|
53739
|
+
required: true,
|
|
53740
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
|
|
53741
|
+
},
|
|
53742
|
+
responses: {
|
|
53743
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
|
|
53744
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53745
|
+
"404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
53746
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
53747
|
+
}
|
|
53748
|
+
}
|
|
53749
|
+
},
|
|
52838
53750
|
"/v1/templates": {
|
|
52839
53751
|
get: {
|
|
52840
53752
|
operationId: "listTemplates",
|
|
@@ -53052,7 +53964,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
53052
53964
|
}
|
|
53053
53965
|
};
|
|
53054
53966
|
}
|
|
53055
|
-
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
53967
|
+
var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
|
|
53056
53968
|
var init_openapi = __esm(() => {
|
|
53057
53969
|
init_package_version();
|
|
53058
53970
|
init_types();
|
|
@@ -53204,6 +54116,75 @@ var init_openapi = __esm(() => {
|
|
|
53204
54116
|
updated_at: { type: "string", format: "date-time" }
|
|
53205
54117
|
}
|
|
53206
54118
|
};
|
|
54119
|
+
planProjectLinkReceiptSchema = {
|
|
54120
|
+
type: "object",
|
|
54121
|
+
additionalProperties: false,
|
|
54122
|
+
required: [
|
|
54123
|
+
"schema_version",
|
|
54124
|
+
"receipt_id",
|
|
54125
|
+
"idempotency_key",
|
|
54126
|
+
"plan_id",
|
|
54127
|
+
"project_id",
|
|
54128
|
+
"prior_plan_project_id",
|
|
54129
|
+
"prior_task_project_ids",
|
|
54130
|
+
"task_ids",
|
|
54131
|
+
"task_count",
|
|
54132
|
+
"result_plan_revision",
|
|
54133
|
+
"result_digest",
|
|
54134
|
+
"rollback_supported",
|
|
54135
|
+
"created_at"
|
|
54136
|
+
],
|
|
54137
|
+
properties: {
|
|
54138
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
54139
|
+
receipt_id: { type: "string" },
|
|
54140
|
+
idempotency_key: { type: "string" },
|
|
54141
|
+
plan_id: { type: "string" },
|
|
54142
|
+
project_id: { type: "string" },
|
|
54143
|
+
prior_plan_project_id: { type: "string", nullable: true },
|
|
54144
|
+
prior_task_project_ids: {
|
|
54145
|
+
type: "object",
|
|
54146
|
+
additionalProperties: { type: "string", nullable: true }
|
|
54147
|
+
},
|
|
54148
|
+
task_ids: { type: "array", items: { type: "string" } },
|
|
54149
|
+
task_count: { type: "integer", minimum: 0 },
|
|
54150
|
+
result_plan_revision: { type: "string" },
|
|
54151
|
+
result_digest: { type: "string" },
|
|
54152
|
+
rollback_supported: { type: "boolean", enum: [true] },
|
|
54153
|
+
created_at: { type: "string", format: "date-time" }
|
|
54154
|
+
}
|
|
54155
|
+
};
|
|
54156
|
+
planProjectLinkResultSchema = {
|
|
54157
|
+
type: "object",
|
|
54158
|
+
additionalProperties: false,
|
|
54159
|
+
required: ["mode", "action", "plan", "project", "tasks", "receipt"],
|
|
54160
|
+
properties: {
|
|
54161
|
+
mode: { type: "string", enum: ["plan", "apply"] },
|
|
54162
|
+
action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
|
|
54163
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
54164
|
+
project: { $ref: "#/components/schemas/Project" },
|
|
54165
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
54166
|
+
receipt: {
|
|
54167
|
+
oneOf: [
|
|
54168
|
+
{ $ref: "#/components/schemas/PlanProjectLinkReceipt" },
|
|
54169
|
+
{ type: "null" }
|
|
54170
|
+
]
|
|
54171
|
+
}
|
|
54172
|
+
}
|
|
54173
|
+
};
|
|
54174
|
+
planProjectLinkRollbackResultSchema = {
|
|
54175
|
+
type: "object",
|
|
54176
|
+
additionalProperties: false,
|
|
54177
|
+
required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
|
|
54178
|
+
properties: {
|
|
54179
|
+
schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
|
|
54180
|
+
action: { type: "string", enum: ["restored"] },
|
|
54181
|
+
plan: { $ref: "#/components/schemas/Plan" },
|
|
54182
|
+
tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
|
|
54183
|
+
accepted_receipt_id: { type: "string" },
|
|
54184
|
+
rollback_receipt_id: { type: "string" },
|
|
54185
|
+
restored_at: { type: "string", format: "date-time" }
|
|
54186
|
+
}
|
|
54187
|
+
};
|
|
53207
54188
|
templateTaskSchema = {
|
|
53208
54189
|
type: "object",
|
|
53209
54190
|
required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
|
|
@@ -53408,7 +54389,7 @@ function decodeCommentCursor(value) {
|
|
|
53408
54389
|
var MAX_COMMENT_CURSOR_LENGTH = 1024;
|
|
53409
54390
|
|
|
53410
54391
|
// src/lib/project-task-list-ensure.ts
|
|
53411
|
-
import { createHash as
|
|
54392
|
+
import { createHash as createHash16 } from "crypto";
|
|
53412
54393
|
function canonicalJson(value) {
|
|
53413
54394
|
if (value === null || typeof value !== "object")
|
|
53414
54395
|
return JSON.stringify(value);
|
|
@@ -53417,7 +54398,7 @@ function canonicalJson(value) {
|
|
|
53417
54398
|
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
53418
54399
|
}
|
|
53419
54400
|
function digest(value) {
|
|
53420
|
-
return
|
|
54401
|
+
return createHash16("sha256").update(canonicalJson(value)).digest("hex");
|
|
53421
54402
|
}
|
|
53422
54403
|
function deriveIdempotencyKey(projectId, slug) {
|
|
53423
54404
|
return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
|
|
@@ -53662,6 +54643,75 @@ var init_project_task_list_ensure = __esm(() => {
|
|
|
53662
54643
|
};
|
|
53663
54644
|
});
|
|
53664
54645
|
|
|
54646
|
+
// src/lib/plan-project-link.ts
|
|
54647
|
+
async function exactPlanProjectLinkState(store, planId, projectId) {
|
|
54648
|
+
const [plan, project] = await Promise.all([
|
|
54649
|
+
store.plans.get(planId),
|
|
54650
|
+
store.projects.get(projectId)
|
|
54651
|
+
]);
|
|
54652
|
+
if (!plan) {
|
|
54653
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
|
|
54654
|
+
}
|
|
54655
|
+
if (!project) {
|
|
54656
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
|
|
54657
|
+
}
|
|
54658
|
+
const [tasks, projectPlans] = await Promise.all([
|
|
54659
|
+
store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
|
|
54660
|
+
store.plans.list(project.id)
|
|
54661
|
+
]);
|
|
54662
|
+
const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
54663
|
+
if (collision) {
|
|
54664
|
+
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 });
|
|
54665
|
+
}
|
|
54666
|
+
return {
|
|
54667
|
+
plan,
|
|
54668
|
+
project,
|
|
54669
|
+
tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
|
|
54670
|
+
};
|
|
54671
|
+
}
|
|
54672
|
+
async function planPlanProjectLink(store, planId, projectId) {
|
|
54673
|
+
const state = await exactPlanProjectLinkState(store, planId, projectId);
|
|
54674
|
+
const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task2) => task2.project_id === state.project.id);
|
|
54675
|
+
return {
|
|
54676
|
+
mode: "plan",
|
|
54677
|
+
action: alreadyLinked ? "already_linked" : "would_link",
|
|
54678
|
+
...state,
|
|
54679
|
+
receipt: null
|
|
54680
|
+
};
|
|
54681
|
+
}
|
|
54682
|
+
async function applyPlanProjectLink(store, planId, projectId, options) {
|
|
54683
|
+
if (!store.planProjectLinks) {
|
|
54684
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
|
|
54685
|
+
}
|
|
54686
|
+
const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
|
|
54687
|
+
return store.planProjectLinks.apply({
|
|
54688
|
+
plan_id: planId,
|
|
54689
|
+
project_id: projectId,
|
|
54690
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
54691
|
+
expected_project_revision: options.expected_project_revision,
|
|
54692
|
+
idempotency_key: key,
|
|
54693
|
+
receipt_id: planProjectLinkReceiptId(key),
|
|
54694
|
+
created_at: new Date().toISOString()
|
|
54695
|
+
});
|
|
54696
|
+
}
|
|
54697
|
+
async function rollbackPlanProjectLink(store, planId, projectId, options) {
|
|
54698
|
+
if (!store.planProjectLinks) {
|
|
54699
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
|
|
54700
|
+
}
|
|
54701
|
+
return store.planProjectLinks.rollback({
|
|
54702
|
+
plan_id: planId,
|
|
54703
|
+
project_id: projectId,
|
|
54704
|
+
receipt_id: options.receipt_id,
|
|
54705
|
+
expected_plan_revision: options.expected_plan_revision,
|
|
54706
|
+
rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
|
|
54707
|
+
restored_at: new Date().toISOString()
|
|
54708
|
+
});
|
|
54709
|
+
}
|
|
54710
|
+
var init_plan_project_link = __esm(() => {
|
|
54711
|
+
init_plan_project_link_contract();
|
|
54712
|
+
init_plan_project_link_contract();
|
|
54713
|
+
});
|
|
54714
|
+
|
|
53665
54715
|
// src/server/v1.ts
|
|
53666
54716
|
var exports_v1 = {};
|
|
53667
54717
|
__export(exports_v1, {
|
|
@@ -53684,6 +54734,21 @@ function enumQueryParam(url, name, vocabulary) {
|
|
|
53684
54734
|
return { ok: false, response: error(400, result.message) };
|
|
53685
54735
|
return { ok: true, value: collapseEnumValues(result.values) };
|
|
53686
54736
|
}
|
|
54737
|
+
function parseSinceCursor(raw) {
|
|
54738
|
+
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)}`;
|
|
54739
|
+
const match = RFC3339_DATE_TIME.exec(raw);
|
|
54740
|
+
if (!match)
|
|
54741
|
+
return { ok: false, message: shape };
|
|
54742
|
+
const parsed = Date.parse(raw);
|
|
54743
|
+
if (Number.isNaN(parsed))
|
|
54744
|
+
return { ok: false, message: shape };
|
|
54745
|
+
const [, year, month, day] = match;
|
|
54746
|
+
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
54747
|
+
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
|
|
54748
|
+
return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
|
|
54749
|
+
}
|
|
54750
|
+
return { ok: true, value: new Date(parsed).toISOString() };
|
|
54751
|
+
}
|
|
53687
54752
|
function validateTaskCompletion(value) {
|
|
53688
54753
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
53689
54754
|
return { ok: false, message: "completion body must be an object" };
|
|
@@ -54093,7 +55158,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
54093
55158
|
const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
|
|
54094
55159
|
if (!priorityParam.ok)
|
|
54095
55160
|
return priorityParam.response;
|
|
55161
|
+
const updatedAfterRaw = url.searchParams.get("updated_after");
|
|
55162
|
+
const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
|
|
55163
|
+
if (updatedAfter !== null && !updatedAfter.ok) {
|
|
55164
|
+
return error(400, updatedAfter.message);
|
|
55165
|
+
}
|
|
54096
55166
|
const filter = {
|
|
55167
|
+
...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
|
|
54097
55168
|
...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
|
|
54098
55169
|
...statusParam.value !== undefined ? { status: statusParam.value } : {},
|
|
54099
55170
|
...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
|
|
@@ -54555,6 +55626,54 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
54555
55626
|
const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
|
|
54556
55627
|
return json4({ plan }, 201);
|
|
54557
55628
|
}
|
|
55629
|
+
if (id && action === "project-link" && !subId) {
|
|
55630
|
+
if (method === "GET") {
|
|
55631
|
+
const projectId = url.searchParams.get("project_id");
|
|
55632
|
+
if (!projectId?.trim())
|
|
55633
|
+
return error(400, "project_id query parameter is required");
|
|
55634
|
+
return json4(await planPlanProjectLink(store, id, projectId));
|
|
55635
|
+
}
|
|
55636
|
+
if (method !== "POST")
|
|
55637
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
|
|
55638
|
+
const body = await readJson3(req);
|
|
55639
|
+
if (!body)
|
|
55640
|
+
return error(400, "invalid JSON body");
|
|
55641
|
+
const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
|
|
55642
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
55643
|
+
if (unknown)
|
|
55644
|
+
return error(400, `unknown plan-project-link field: ${unknown}`);
|
|
55645
|
+
for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
|
|
55646
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
55647
|
+
return error(400, `${field} must be a non-empty string`);
|
|
55648
|
+
}
|
|
55649
|
+
}
|
|
55650
|
+
const result = await applyPlanProjectLink(store, id, body.project_id, {
|
|
55651
|
+
expected_plan_revision: body.expected_plan_revision,
|
|
55652
|
+
expected_project_revision: body.expected_project_revision,
|
|
55653
|
+
idempotency_key: body.idempotency_key
|
|
55654
|
+
});
|
|
55655
|
+
return json4(result, result.action === "linked" ? 201 : 200);
|
|
55656
|
+
}
|
|
55657
|
+
if (id && action === "project-link" && subId === "rollback") {
|
|
55658
|
+
if (method !== "POST")
|
|
55659
|
+
return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
|
|
55660
|
+
const body = await readJson3(req);
|
|
55661
|
+
if (!body)
|
|
55662
|
+
return error(400, "invalid JSON body");
|
|
55663
|
+
const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
|
|
55664
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
55665
|
+
if (unknown)
|
|
55666
|
+
return error(400, `unknown plan-project-link rollback field: ${unknown}`);
|
|
55667
|
+
for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
|
|
55668
|
+
if (typeof body[field] !== "string" || !body[field].trim()) {
|
|
55669
|
+
return error(400, `${field} must be a non-empty string`);
|
|
55670
|
+
}
|
|
55671
|
+
}
|
|
55672
|
+
return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
|
|
55673
|
+
receipt_id: body.receipt_id,
|
|
55674
|
+
expected_plan_revision: body.expected_plan_revision
|
|
55675
|
+
}));
|
|
55676
|
+
}
|
|
54558
55677
|
if (id && method === "GET") {
|
|
54559
55678
|
const plan = await store.plans.get(id);
|
|
54560
55679
|
return plan ? json4({ plan }) : error(404, "plan not found");
|
|
@@ -54833,6 +55952,10 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
54833
55952
|
}
|
|
54834
55953
|
return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
54835
55954
|
} catch (e) {
|
|
55955
|
+
if (e instanceof PlanProjectLinkError) {
|
|
55956
|
+
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;
|
|
55957
|
+
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
55958
|
+
}
|
|
54836
55959
|
if (e instanceof ProjectTaskListEnsureError) {
|
|
54837
55960
|
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;
|
|
54838
55961
|
return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
|
|
@@ -54856,7 +55979,7 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
54856
55979
|
return error(500, e.message || "internal error");
|
|
54857
55980
|
}
|
|
54858
55981
|
}
|
|
54859
|
-
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
|
|
55982
|
+
var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
|
|
54860
55983
|
var init_v1 = __esm(() => {
|
|
54861
55984
|
init_types();
|
|
54862
55985
|
init_cloud();
|
|
@@ -54864,7 +55987,9 @@ var init_v1 = __esm(() => {
|
|
|
54864
55987
|
init_project_registration();
|
|
54865
55988
|
init_redaction();
|
|
54866
55989
|
init_project_task_list_ensure();
|
|
55990
|
+
init_plan_project_link();
|
|
54867
55991
|
JSON_HEADERS3 = { "Content-Type": "application/json" };
|
|
55992
|
+
RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
54868
55993
|
});
|
|
54869
55994
|
|
|
54870
55995
|
// src/pr-groups/sqlite.ts
|