@hasna/todos 0.15.18 → 0.15.19
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.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/index.js +457 -77
- package/dist/contracts.js +57 -2
- package/dist/db/plans.d.ts +4 -0
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/index.js +122 -8
- package/dist/mcp/index.js +254 -12
- package/dist/mcp.js +1 -1
- package/dist/project-registration.js +113 -8
- package/dist/registry.js +57 -2
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/v1.generated.d.ts +13 -1
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +254 -12
- package/dist/server/openapi.d.ts +50 -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 +10 -0
- 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/shadow.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.js +121 -7
- package/dist/task-manifest.js +14 -1
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -47,7 +47,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
47
47
|
function isTerminalStatus(status) {
|
|
48
48
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
49
49
|
}
|
|
50
|
-
var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
50
|
+
var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
51
51
|
var init_types = __esm(() => {
|
|
52
52
|
TASK_STATUSES = [
|
|
53
53
|
"pending",
|
|
@@ -134,6 +134,19 @@ var init_types = __esm(() => {
|
|
|
134
134
|
this.name = "ResourceConflictError";
|
|
135
135
|
}
|
|
136
136
|
};
|
|
137
|
+
PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
|
|
138
|
+
planId;
|
|
139
|
+
expectedUpdatedAt;
|
|
140
|
+
currentUpdatedAt;
|
|
141
|
+
static code = "PLAN_REVISION_CONFLICT";
|
|
142
|
+
constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
|
|
143
|
+
super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
|
|
144
|
+
this.planId = planId;
|
|
145
|
+
this.expectedUpdatedAt = expectedUpdatedAt;
|
|
146
|
+
this.currentUpdatedAt = currentUpdatedAt;
|
|
147
|
+
this.name = "PlanRevisionConflictError";
|
|
148
|
+
}
|
|
149
|
+
};
|
|
137
150
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
138
151
|
planId;
|
|
139
152
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -15309,6 +15322,48 @@ function updatePlan(id, input, db) {
|
|
|
15309
15322
|
return updatePlanStored(id, input, d);
|
|
15310
15323
|
})();
|
|
15311
15324
|
}
|
|
15325
|
+
function nextPlanCompletionTimestamp(expectedUpdatedAt) {
|
|
15326
|
+
const expected = Date.parse(expectedUpdatedAt);
|
|
15327
|
+
const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
|
|
15328
|
+
return new Date(Math.max(Date.now(), minimum)).toISOString();
|
|
15329
|
+
}
|
|
15330
|
+
function completePlanAtRevision(id, expectedUpdatedAt, db) {
|
|
15331
|
+
const d = db || getDatabase();
|
|
15332
|
+
return d.transaction(() => {
|
|
15333
|
+
guardPlanRowsSqlite([id], d);
|
|
15334
|
+
const plan = getPlan(id, d);
|
|
15335
|
+
if (!plan)
|
|
15336
|
+
throw new PlanNotFoundError(id);
|
|
15337
|
+
if (plan.updated_at !== expectedUpdatedAt) {
|
|
15338
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
|
|
15339
|
+
}
|
|
15340
|
+
if (plan.status === "completed")
|
|
15341
|
+
return { plan, applied: false };
|
|
15342
|
+
const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
|
|
15343
|
+
const result = d.run(`UPDATE plans
|
|
15344
|
+
SET status = 'completed', updated_at = ?
|
|
15345
|
+
WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
|
|
15346
|
+
if (result.changes !== 1) {
|
|
15347
|
+
const current = getPlan(id, d);
|
|
15348
|
+
if (!current)
|
|
15349
|
+
throw new PlanNotFoundError(id);
|
|
15350
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
15351
|
+
}
|
|
15352
|
+
const completed = getPlan(id, d);
|
|
15353
|
+
emitLocalEventHooksQuiet({
|
|
15354
|
+
type: "plan.updated",
|
|
15355
|
+
payload: {
|
|
15356
|
+
id,
|
|
15357
|
+
old_status: plan.status,
|
|
15358
|
+
new_status: completed.status,
|
|
15359
|
+
name: completed.name,
|
|
15360
|
+
project_id: completed.project_id
|
|
15361
|
+
},
|
|
15362
|
+
databasePath: databasePathFromDatabase(d)
|
|
15363
|
+
});
|
|
15364
|
+
return { plan: completed, applied: true };
|
|
15365
|
+
})();
|
|
15366
|
+
}
|
|
15312
15367
|
function deletePlan(id, db) {
|
|
15313
15368
|
const d = db || getDatabase();
|
|
15314
15369
|
const plan = getPlan(id, d);
|
|
@@ -21368,7 +21423,7 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
|
|
|
21368
21423
|
return input.toLowerCase();
|
|
21369
21424
|
return (await cloudResolveTaskList(client, ref, projectId)).id;
|
|
21370
21425
|
}
|
|
21371
|
-
var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
|
|
21426
|
+
var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
|
|
21372
21427
|
var init_cloud_router = __esm(() => {
|
|
21373
21428
|
init_types();
|
|
21374
21429
|
init_redaction();
|
|
@@ -21385,6 +21440,7 @@ var init_cloud_router = __esm(() => {
|
|
|
21385
21440
|
hybrid: "http"
|
|
21386
21441
|
};
|
|
21387
21442
|
completionCapabilityCache = new Map;
|
|
21443
|
+
retryCapabilityCache = new Map;
|
|
21388
21444
|
gitRefCapabilityCache = new Map;
|
|
21389
21445
|
SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
|
|
21390
21446
|
PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
@@ -35481,7 +35537,7 @@ var package_default;
|
|
|
35481
35537
|
var init_package = __esm(() => {
|
|
35482
35538
|
package_default = {
|
|
35483
35539
|
name: "@hasna/todos",
|
|
35484
|
-
version: "0.15.
|
|
35540
|
+
version: "0.15.19",
|
|
35485
35541
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
35486
35542
|
type: "module",
|
|
35487
35543
|
main: "dist/index.js",
|
|
@@ -45077,7 +45133,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
45077
45133
|
}
|
|
45078
45134
|
if (result.errors.length > 0)
|
|
45079
45135
|
return result;
|
|
45080
|
-
const applyRows = (objectType2, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
45136
|
+
const applyRows = (objectType2, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
45081
45137
|
for (const row of rows) {
|
|
45082
45138
|
try {
|
|
45083
45139
|
const record = asRecord2(row);
|
|
@@ -45086,7 +45142,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
45086
45142
|
result.skipped += 1;
|
|
45087
45143
|
continue;
|
|
45088
45144
|
}
|
|
45089
|
-
const state = upsertById(d, table, columns, record, updateClockColumn);
|
|
45145
|
+
const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
|
|
45090
45146
|
if (state === "inserted")
|
|
45091
45147
|
result.inserted += 1;
|
|
45092
45148
|
else if (state === "updated")
|
|
@@ -45103,10 +45159,10 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
45103
45159
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
45104
45160
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
45105
45161
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
45106
|
-
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
|
|
45162
|
+
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
|
|
45107
45163
|
applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
|
|
45108
45164
|
applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
|
|
45109
|
-
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", (row, changed) => {
|
|
45165
|
+
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", true, (row, changed) => {
|
|
45110
45166
|
if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
|
|
45111
45167
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
45112
45168
|
}
|
|
@@ -45115,7 +45171,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
45115
45171
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
45116
45172
|
return result;
|
|
45117
45173
|
}
|
|
45118
|
-
function upsertById(db, table, columns, row, updateClockColumn) {
|
|
45174
|
+
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
45119
45175
|
const id = row["id"];
|
|
45120
45176
|
if (typeof id !== "string" || !id)
|
|
45121
45177
|
throw new Error(`${table} row is missing id`);
|
|
@@ -45127,7 +45183,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
|
|
|
45127
45183
|
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
45128
45184
|
const updateColumns = presentColumns.filter((column) => column !== "id");
|
|
45129
45185
|
const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
|
|
45130
|
-
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
|
|
45186
|
+
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
|
|
45131
45187
|
const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})
|
|
45132
45188
|
ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})`;
|
|
45133
45189
|
const changes = db.run(sql, values).changes;
|
|
@@ -45546,6 +45602,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
45546
45602
|
get: (id) => getPlan(id, database()),
|
|
45547
45603
|
list: (projectId) => listPlans(projectId, database()),
|
|
45548
45604
|
update: (id, input) => updatePlan(id, input, database()),
|
|
45605
|
+
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
45549
45606
|
delete: (id) => deletePlan(id, database())
|
|
45550
45607
|
},
|
|
45551
45608
|
planProjectLinks: {
|
|
@@ -46506,6 +46563,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
46506
46563
|
get: (id) => store.get("plans", id),
|
|
46507
46564
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
46508
46565
|
update: (id, input) => updatePlan2(id, input, store),
|
|
46566
|
+
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
46509
46567
|
delete: (id, context) => store.deletePlan(id, context)
|
|
46510
46568
|
},
|
|
46511
46569
|
planProjectLinks: {
|
|
@@ -46993,6 +47051,54 @@ class PostgresJsonRecordStore {
|
|
|
46993
47051
|
throw new PlanNotFoundError(value.id);
|
|
46994
47052
|
return payloadRecord2(row.payload);
|
|
46995
47053
|
}
|
|
47054
|
+
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
47055
|
+
await this.ensureSchema();
|
|
47056
|
+
const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
|
|
47057
|
+
SELECT date_trunc(
|
|
47058
|
+
'milliseconds',
|
|
47059
|
+
GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
|
|
47060
|
+
) AS completed_at
|
|
47061
|
+
), stored AS (
|
|
47062
|
+
UPDATE ${this.tableName} AS record SET
|
|
47063
|
+
payload = record.payload || jsonb_build_object(
|
|
47064
|
+
'status', 'completed',
|
|
47065
|
+
'updated_at', to_char(
|
|
47066
|
+
next_clock.completed_at AT TIME ZONE 'UTC',
|
|
47067
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
47068
|
+
)
|
|
47069
|
+
),
|
|
47070
|
+
updated_at = next_clock.completed_at,
|
|
47071
|
+
deleted_at = NULL,
|
|
47072
|
+
source_machine_id = COALESCE($4, record.source_machine_id),
|
|
47073
|
+
version = COALESCE(record.version, 0) + 1
|
|
47074
|
+
FROM next_clock
|
|
47075
|
+
WHERE record.service = $1
|
|
47076
|
+
AND record.object_type = 'plans'
|
|
47077
|
+
AND record.object_id = $2
|
|
47078
|
+
AND record.deleted_at IS NULL
|
|
47079
|
+
AND record.payload->>'updated_at' = $3::text
|
|
47080
|
+
AND record.payload->>'status' IS DISTINCT FROM 'completed'
|
|
47081
|
+
RETURNING record.payload
|
|
47082
|
+
)
|
|
47083
|
+
SELECT payload FROM stored`, [
|
|
47084
|
+
this.service,
|
|
47085
|
+
id,
|
|
47086
|
+
expectedUpdatedAt,
|
|
47087
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
47088
|
+
]);
|
|
47089
|
+
const payload = result.rows[0]?.payload;
|
|
47090
|
+
if (payload)
|
|
47091
|
+
return { plan: payloadRecord2(payload), applied: true };
|
|
47092
|
+
const current = await this.get("plans", id);
|
|
47093
|
+
if (!current)
|
|
47094
|
+
throw new PlanNotFoundError(id);
|
|
47095
|
+
if (current.updated_at !== expectedUpdatedAt) {
|
|
47096
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
47097
|
+
}
|
|
47098
|
+
if (current.status === "completed")
|
|
47099
|
+
return { plan: current, applied: false };
|
|
47100
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
47101
|
+
}
|
|
46996
47102
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
46997
47103
|
await this.ensureSchema();
|
|
46998
47104
|
const records = [
|
|
@@ -55534,8 +55640,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
55534
55640
|
"/v1/import": {
|
|
55535
55641
|
post: {
|
|
55536
55642
|
operationId: "importSnapshot",
|
|
55537
|
-
summary: "Bulk-ingest a
|
|
55538
|
-
description: "Upserts every record
|
|
55643
|
+
summary: "Bulk-ingest a snapshot or atomically complete one observed plan",
|
|
55644
|
+
description: "Upserts every snapshot record by primary key, or accepts exactly one planCompletions operation that changes only plan status under an expected_updated_at CAS. Snapshot records and planCompletions are mutually exclusive. Requires the todos:write scope.",
|
|
55539
55645
|
requestBody: {
|
|
55540
55646
|
required: true,
|
|
55541
55647
|
content: {
|
|
@@ -55554,7 +55660,22 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
55554
55660
|
templates: { type: "array", items: { type: "object" } },
|
|
55555
55661
|
templateTasks: { type: "array", items: { $ref: "#/components/schemas/TemplateTask" } },
|
|
55556
55662
|
auditHistory: { type: "array", items: { type: "object" } },
|
|
55557
|
-
tombstones: { type: "array", items: { type: "object" } }
|
|
55663
|
+
tombstones: { type: "array", items: { type: "object" } },
|
|
55664
|
+
planCompletions: {
|
|
55665
|
+
type: "array",
|
|
55666
|
+
minItems: 1,
|
|
55667
|
+
maxItems: 1,
|
|
55668
|
+
items: {
|
|
55669
|
+
type: "object",
|
|
55670
|
+
additionalProperties: false,
|
|
55671
|
+
required: ["id", "expected_updated_at", "status"],
|
|
55672
|
+
properties: {
|
|
55673
|
+
id: { type: "string" },
|
|
55674
|
+
expected_updated_at: { type: "string", format: "date-time" },
|
|
55675
|
+
status: { type: "string", enum: ["completed"] }
|
|
55676
|
+
}
|
|
55677
|
+
}
|
|
55678
|
+
}
|
|
55558
55679
|
}
|
|
55559
55680
|
}
|
|
55560
55681
|
}
|
|
@@ -55577,6 +55698,26 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
55577
55698
|
skipped: { type: "number" },
|
|
55578
55699
|
errors: { type: "array", items: { type: "string" } }
|
|
55579
55700
|
}
|
|
55701
|
+
},
|
|
55702
|
+
planCompletions: {
|
|
55703
|
+
type: "array",
|
|
55704
|
+
items: {
|
|
55705
|
+
type: "object",
|
|
55706
|
+
required: [
|
|
55707
|
+
"id",
|
|
55708
|
+
"status",
|
|
55709
|
+
"expected_updated_at",
|
|
55710
|
+
"result_updated_at",
|
|
55711
|
+
"applied"
|
|
55712
|
+
],
|
|
55713
|
+
properties: {
|
|
55714
|
+
id: { type: "string" },
|
|
55715
|
+
status: { type: "string", enum: ["completed"] },
|
|
55716
|
+
expected_updated_at: { type: "string", format: "date-time" },
|
|
55717
|
+
result_updated_at: { type: "string", format: "date-time" },
|
|
55718
|
+
applied: { type: "boolean" }
|
|
55719
|
+
}
|
|
55720
|
+
}
|
|
55580
55721
|
}
|
|
55581
55722
|
}
|
|
55582
55723
|
}
|
|
@@ -56773,6 +56914,65 @@ function normalizeImportSnapshot(raw) {
|
|
|
56773
56914
|
function countSnapshotRecords(s) {
|
|
56774
56915
|
return s.tasks.length + s.projects.length + (s.projectMachinePaths?.length ?? 0) + s.plans.length + s.agents.length + s.taskLists.length + s.templates.length + s.templateTasks.length + s.auditHistory.length + (s.tombstones?.length ?? 0);
|
|
56775
56916
|
}
|
|
56917
|
+
function validatePlanCompletionImports(raw) {
|
|
56918
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
56919
|
+
return { present: false, operations: [] };
|
|
56920
|
+
}
|
|
56921
|
+
const body2 = raw;
|
|
56922
|
+
if (!Object.prototype.hasOwnProperty.call(body2, "planCompletions")) {
|
|
56923
|
+
return { present: false, operations: [] };
|
|
56924
|
+
}
|
|
56925
|
+
if (!Array.isArray(body2["planCompletions"]) || body2["planCompletions"].length !== 1) {
|
|
56926
|
+
return {
|
|
56927
|
+
present: true,
|
|
56928
|
+
operations: [],
|
|
56929
|
+
error: "planCompletions must contain exactly one completion operation"
|
|
56930
|
+
};
|
|
56931
|
+
}
|
|
56932
|
+
const operation = body2["planCompletions"][0];
|
|
56933
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
|
|
56934
|
+
return { present: true, operations: [], error: "plan completion must be an object" };
|
|
56935
|
+
}
|
|
56936
|
+
const record = operation;
|
|
56937
|
+
const allowed = new Set(["id", "expected_updated_at", "status"]);
|
|
56938
|
+
const unknown = Object.keys(record).find((key2) => !allowed.has(key2));
|
|
56939
|
+
if (unknown) {
|
|
56940
|
+
return { present: true, operations: [], error: `unknown plan completion field: ${unknown}` };
|
|
56941
|
+
}
|
|
56942
|
+
if (typeof record["id"] !== "string" || !record["id"].trim()) {
|
|
56943
|
+
return { present: true, operations: [], error: "plan completion id must be a non-empty string" };
|
|
56944
|
+
}
|
|
56945
|
+
if (record["status"] !== "completed") {
|
|
56946
|
+
return { present: true, operations: [], error: "plan completion status must be completed" };
|
|
56947
|
+
}
|
|
56948
|
+
const expectedUpdatedAt = typeof record["expected_updated_at"] === "string" ? record["expected_updated_at"] : "";
|
|
56949
|
+
const timestampMatch = RFC3339_DATE_TIME.exec(expectedUpdatedAt);
|
|
56950
|
+
const parsedTimestamp = Date.parse(expectedUpdatedAt);
|
|
56951
|
+
if (!timestampMatch || Number.isNaN(parsedTimestamp)) {
|
|
56952
|
+
return {
|
|
56953
|
+
present: true,
|
|
56954
|
+
operations: [],
|
|
56955
|
+
error: "plan completion expected_updated_at must be an RFC 3339 date-time with an explicit offset"
|
|
56956
|
+
};
|
|
56957
|
+
}
|
|
56958
|
+
const [, year, month, day] = timestampMatch;
|
|
56959
|
+
const calendarProbe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
56960
|
+
if (calendarProbe.getUTCFullYear() !== Number(year) || calendarProbe.getUTCMonth() !== Number(month) - 1 || calendarProbe.getUTCDate() !== Number(day)) {
|
|
56961
|
+
return {
|
|
56962
|
+
present: true,
|
|
56963
|
+
operations: [],
|
|
56964
|
+
error: "plan completion expected_updated_at names a date that does not exist"
|
|
56965
|
+
};
|
|
56966
|
+
}
|
|
56967
|
+
return {
|
|
56968
|
+
present: true,
|
|
56969
|
+
operations: [{
|
|
56970
|
+
id: record["id"],
|
|
56971
|
+
expected_updated_at: expectedUpdatedAt,
|
|
56972
|
+
status: "completed"
|
|
56973
|
+
}]
|
|
56974
|
+
};
|
|
56975
|
+
}
|
|
56776
56976
|
async function handleV1Request(req, url, dependencies = {}) {
|
|
56777
56977
|
const path = url.pathname;
|
|
56778
56978
|
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
@@ -57709,6 +57909,36 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
57709
57909
|
return error(400, "invalid JSON body");
|
|
57710
57910
|
const snapshot = normalizeImportSnapshot(raw);
|
|
57711
57911
|
const received = countSnapshotRecords(snapshot);
|
|
57912
|
+
const completionImports = validatePlanCompletionImports(raw);
|
|
57913
|
+
if (completionImports.present) {
|
|
57914
|
+
if (completionImports.error)
|
|
57915
|
+
return error(400, completionImports.error);
|
|
57916
|
+
if (received !== 0) {
|
|
57917
|
+
return error(400, "planCompletions cannot be combined with snapshot record arrays");
|
|
57918
|
+
}
|
|
57919
|
+
if (typeof store.plans.completeAtRevision !== "function") {
|
|
57920
|
+
return error(501, "atomic plan completion is not supported by this storage backend");
|
|
57921
|
+
}
|
|
57922
|
+
const operation = completionImports.operations[0];
|
|
57923
|
+
const completed = await store.plans.completeAtRevision(operation.id, operation.expected_updated_at, contextFromPrincipal(principal));
|
|
57924
|
+
return json5({
|
|
57925
|
+
result: {
|
|
57926
|
+
inserted: 0,
|
|
57927
|
+
updated: completed.applied ? 1 : 0,
|
|
57928
|
+
deleted: 0,
|
|
57929
|
+
skipped: completed.applied ? 0 : 1,
|
|
57930
|
+
errors: []
|
|
57931
|
+
},
|
|
57932
|
+
received: 1,
|
|
57933
|
+
planCompletions: [{
|
|
57934
|
+
id: operation.id,
|
|
57935
|
+
status: "completed",
|
|
57936
|
+
expected_updated_at: operation.expected_updated_at,
|
|
57937
|
+
result_updated_at: completed.plan.updated_at,
|
|
57938
|
+
applied: completed.applied
|
|
57939
|
+
}]
|
|
57940
|
+
});
|
|
57941
|
+
}
|
|
57712
57942
|
if (received === 0) {
|
|
57713
57943
|
return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
|
|
57714
57944
|
}
|
|
@@ -57735,6 +57965,18 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
57735
57965
|
if (e instanceof TaskNotFoundError) {
|
|
57736
57966
|
return error(404, e.message, { code: TaskNotFoundError.code });
|
|
57737
57967
|
}
|
|
57968
|
+
if (e instanceof PlanNotFoundError) {
|
|
57969
|
+
return error(404, e.message, { code: PlanNotFoundError.code });
|
|
57970
|
+
}
|
|
57971
|
+
if (e instanceof PlanRevisionConflictError) {
|
|
57972
|
+
return error(409, e.message, {
|
|
57973
|
+
code: PlanRevisionConflictError.code,
|
|
57974
|
+
conflict: true,
|
|
57975
|
+
plan_id: e.planId,
|
|
57976
|
+
expected_updated_at: e.expectedUpdatedAt,
|
|
57977
|
+
current_updated_at: e.currentUpdatedAt
|
|
57978
|
+
});
|
|
57979
|
+
}
|
|
57738
57980
|
if (e instanceof LockError)
|
|
57739
57981
|
return error(409, e.message, { code: LockError.code });
|
|
57740
57982
|
if (e instanceof TaskNotStartableError) {
|
package/dist/mcp.js
CHANGED
|
@@ -41,7 +41,7 @@ var __require = import.meta.require;
|
|
|
41
41
|
// package.json
|
|
42
42
|
var package_default = {
|
|
43
43
|
name: "@hasna/todos",
|
|
44
|
-
version: "0.15.
|
|
44
|
+
version: "0.15.19",
|
|
45
45
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
46
46
|
type: "module",
|
|
47
47
|
main: "dist/index.js",
|
|
@@ -117,7 +117,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
117
117
|
function isTerminalStatus(status) {
|
|
118
118
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
119
119
|
}
|
|
120
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
120
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
121
121
|
var init_types = __esm(() => {
|
|
122
122
|
TASK_STATUSES = [
|
|
123
123
|
"pending",
|
|
@@ -205,6 +205,19 @@ var init_types = __esm(() => {
|
|
|
205
205
|
this.name = "ResourceConflictError";
|
|
206
206
|
}
|
|
207
207
|
};
|
|
208
|
+
PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
|
|
209
|
+
planId;
|
|
210
|
+
expectedUpdatedAt;
|
|
211
|
+
currentUpdatedAt;
|
|
212
|
+
static code = "PLAN_REVISION_CONFLICT";
|
|
213
|
+
constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
|
|
214
|
+
super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
|
|
215
|
+
this.planId = planId;
|
|
216
|
+
this.expectedUpdatedAt = expectedUpdatedAt;
|
|
217
|
+
this.currentUpdatedAt = currentUpdatedAt;
|
|
218
|
+
this.name = "PlanRevisionConflictError";
|
|
219
|
+
}
|
|
220
|
+
};
|
|
208
221
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
209
222
|
planId;
|
|
210
223
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -10521,6 +10534,48 @@ function updatePlan2(id, input, db) {
|
|
|
10521
10534
|
return updatePlanStored(id, input, d);
|
|
10522
10535
|
})();
|
|
10523
10536
|
}
|
|
10537
|
+
function nextPlanCompletionTimestamp(expectedUpdatedAt) {
|
|
10538
|
+
const expected = Date.parse(expectedUpdatedAt);
|
|
10539
|
+
const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
|
|
10540
|
+
return new Date(Math.max(Date.now(), minimum)).toISOString();
|
|
10541
|
+
}
|
|
10542
|
+
function completePlanAtRevision(id, expectedUpdatedAt, db) {
|
|
10543
|
+
const d = db || getDatabase();
|
|
10544
|
+
return d.transaction(() => {
|
|
10545
|
+
guardPlanRowsSqlite([id], d);
|
|
10546
|
+
const plan = getPlan(id, d);
|
|
10547
|
+
if (!plan)
|
|
10548
|
+
throw new PlanNotFoundError(id);
|
|
10549
|
+
if (plan.updated_at !== expectedUpdatedAt) {
|
|
10550
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
|
|
10551
|
+
}
|
|
10552
|
+
if (plan.status === "completed")
|
|
10553
|
+
return { plan, applied: false };
|
|
10554
|
+
const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
|
|
10555
|
+
const result = d.run(`UPDATE plans
|
|
10556
|
+
SET status = 'completed', updated_at = ?
|
|
10557
|
+
WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
|
|
10558
|
+
if (result.changes !== 1) {
|
|
10559
|
+
const current = getPlan(id, d);
|
|
10560
|
+
if (!current)
|
|
10561
|
+
throw new PlanNotFoundError(id);
|
|
10562
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
10563
|
+
}
|
|
10564
|
+
const completed = getPlan(id, d);
|
|
10565
|
+
emitLocalEventHooksQuiet({
|
|
10566
|
+
type: "plan.updated",
|
|
10567
|
+
payload: {
|
|
10568
|
+
id,
|
|
10569
|
+
old_status: plan.status,
|
|
10570
|
+
new_status: completed.status,
|
|
10571
|
+
name: completed.name,
|
|
10572
|
+
project_id: completed.project_id
|
|
10573
|
+
},
|
|
10574
|
+
databasePath: databasePathFromDatabase(d)
|
|
10575
|
+
});
|
|
10576
|
+
return { plan: completed, applied: true };
|
|
10577
|
+
})();
|
|
10578
|
+
}
|
|
10524
10579
|
function deletePlan(id, db) {
|
|
10525
10580
|
const d = db || getDatabase();
|
|
10526
10581
|
const plan = getPlan(id, d);
|
|
@@ -12383,7 +12438,7 @@ import { createHash as createHash5 } from "crypto";
|
|
|
12383
12438
|
// package.json
|
|
12384
12439
|
var package_default = {
|
|
12385
12440
|
name: "@hasna/todos",
|
|
12386
|
-
version: "0.15.
|
|
12441
|
+
version: "0.15.19",
|
|
12387
12442
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12388
12443
|
type: "module",
|
|
12389
12444
|
main: "dist/index.js",
|
|
@@ -13288,6 +13343,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
13288
13343
|
get: (id) => store.get("plans", id),
|
|
13289
13344
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
13290
13345
|
update: (id, input) => updatePlan(id, input, store),
|
|
13346
|
+
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
13291
13347
|
delete: (id, context) => store.deletePlan(id, context)
|
|
13292
13348
|
},
|
|
13293
13349
|
planProjectLinks: {
|
|
@@ -13775,6 +13831,54 @@ class PostgresJsonRecordStore {
|
|
|
13775
13831
|
throw new PlanNotFoundError(value.id);
|
|
13776
13832
|
return payloadRecord2(row.payload);
|
|
13777
13833
|
}
|
|
13834
|
+
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
13835
|
+
await this.ensureSchema();
|
|
13836
|
+
const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
|
|
13837
|
+
SELECT date_trunc(
|
|
13838
|
+
'milliseconds',
|
|
13839
|
+
GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
|
|
13840
|
+
) AS completed_at
|
|
13841
|
+
), stored AS (
|
|
13842
|
+
UPDATE ${this.tableName} AS record SET
|
|
13843
|
+
payload = record.payload || jsonb_build_object(
|
|
13844
|
+
'status', 'completed',
|
|
13845
|
+
'updated_at', to_char(
|
|
13846
|
+
next_clock.completed_at AT TIME ZONE 'UTC',
|
|
13847
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
13848
|
+
)
|
|
13849
|
+
),
|
|
13850
|
+
updated_at = next_clock.completed_at,
|
|
13851
|
+
deleted_at = NULL,
|
|
13852
|
+
source_machine_id = COALESCE($4, record.source_machine_id),
|
|
13853
|
+
version = COALESCE(record.version, 0) + 1
|
|
13854
|
+
FROM next_clock
|
|
13855
|
+
WHERE record.service = $1
|
|
13856
|
+
AND record.object_type = 'plans'
|
|
13857
|
+
AND record.object_id = $2
|
|
13858
|
+
AND record.deleted_at IS NULL
|
|
13859
|
+
AND record.payload->>'updated_at' = $3::text
|
|
13860
|
+
AND record.payload->>'status' IS DISTINCT FROM 'completed'
|
|
13861
|
+
RETURNING record.payload
|
|
13862
|
+
)
|
|
13863
|
+
SELECT payload FROM stored`, [
|
|
13864
|
+
this.service,
|
|
13865
|
+
id,
|
|
13866
|
+
expectedUpdatedAt,
|
|
13867
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
13868
|
+
]);
|
|
13869
|
+
const payload = result.rows[0]?.payload;
|
|
13870
|
+
if (payload)
|
|
13871
|
+
return { plan: payloadRecord2(payload), applied: true };
|
|
13872
|
+
const current = await this.get("plans", id);
|
|
13873
|
+
if (!current)
|
|
13874
|
+
throw new PlanNotFoundError(id);
|
|
13875
|
+
if (current.updated_at !== expectedUpdatedAt) {
|
|
13876
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
13877
|
+
}
|
|
13878
|
+
if (current.status === "completed")
|
|
13879
|
+
return { plan: current, applied: false };
|
|
13880
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
13881
|
+
}
|
|
13778
13882
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
13779
13883
|
await this.ensureSchema();
|
|
13780
13884
|
const records = [
|
|
@@ -16879,7 +16983,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
16879
16983
|
}
|
|
16880
16984
|
if (result.errors.length > 0)
|
|
16881
16985
|
return result;
|
|
16882
|
-
const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
16986
|
+
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
16883
16987
|
for (const row of rows) {
|
|
16884
16988
|
try {
|
|
16885
16989
|
const record = asRecord(row);
|
|
@@ -16888,7 +16992,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
16888
16992
|
result.skipped += 1;
|
|
16889
16993
|
continue;
|
|
16890
16994
|
}
|
|
16891
|
-
const state = upsertById(d, table, columns, record, updateClockColumn);
|
|
16995
|
+
const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
|
|
16892
16996
|
if (state === "inserted")
|
|
16893
16997
|
result.inserted += 1;
|
|
16894
16998
|
else if (state === "updated")
|
|
@@ -16905,10 +17009,10 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
16905
17009
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
16906
17010
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
16907
17011
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
16908
|
-
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
|
|
17012
|
+
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
|
|
16909
17013
|
applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
|
|
16910
17014
|
applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
|
|
16911
|
-
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", (row, changed) => {
|
|
17015
|
+
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", true, (row, changed) => {
|
|
16912
17016
|
if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
|
|
16913
17017
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
16914
17018
|
}
|
|
@@ -16917,7 +17021,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
16917
17021
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
16918
17022
|
return result;
|
|
16919
17023
|
}
|
|
16920
|
-
function upsertById(db, table, columns, row, updateClockColumn) {
|
|
17024
|
+
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
16921
17025
|
const id = row["id"];
|
|
16922
17026
|
if (typeof id !== "string" || !id)
|
|
16923
17027
|
throw new Error(`${table} row is missing id`);
|
|
@@ -16929,7 +17033,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
|
|
|
16929
17033
|
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
16930
17034
|
const updateColumns = presentColumns.filter((column) => column !== "id");
|
|
16931
17035
|
const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
|
|
16932
|
-
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
|
|
17036
|
+
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
|
|
16933
17037
|
const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
|
|
16934
17038
|
ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
|
|
16935
17039
|
const changes = db.run(sql, values).changes;
|
|
@@ -17169,6 +17273,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
17169
17273
|
get: (id) => getPlan(id, database()),
|
|
17170
17274
|
list: (projectId) => listPlans(projectId, database()),
|
|
17171
17275
|
update: (id, input) => updatePlan2(id, input, database()),
|
|
17276
|
+
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
17172
17277
|
delete: (id) => deletePlan(id, database())
|
|
17173
17278
|
},
|
|
17174
17279
|
planProjectLinks: {
|