@hasna/todos 0.15.34 → 0.15.41
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 +53 -19
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-subtree-transfer-commands.d.ts +3 -0
- package/dist/cli/commands/task-subtree-transfer-commands.d.ts.map +1 -0
- package/dist/cli/index.js +3551 -836
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/contracts.js +147 -57
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plan-comments.d.ts +14 -0
- package/dist/db/plan-comments.d.ts.map +1 -0
- package/dist/db/projects.d.ts +23 -0
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/task-lifecycle.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/db/task-relations.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1994 -346
- package/dist/json-contracts.d.ts.map +1 -1
- package/dist/lib/comment-cursor.d.ts +19 -0
- package/dist/lib/comment-cursor.d.ts.map +1 -1
- package/dist/lib/import-export-bridge.d.ts.map +1 -1
- package/dist/lib/onboarding-fixtures.d.ts.map +1 -1
- package/dist/mcp/index.js +2491 -507
- package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
- package/dist/mcp.js +11 -4
- package/dist/project-registration/authority.d.ts.map +1 -1
- package/dist/project-registration/sqlite.d.ts.map +1 -1
- package/dist/project-registration.js +362 -79
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +154 -57
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +35 -0
- package/dist/sdk/v1.generated.d.ts +144 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +12 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +5247 -8429
- package/dist/server/openapi.d.ts +2104 -1314
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts +2 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/config.d.ts +12 -30
- package/dist/storage/config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +13 -2
- 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/timestamp-backfill.d.ts +62 -0
- package/dist/storage/timestamp-backfill.d.ts.map +1 -0
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +327 -75
- package/dist/task-subtree-transfer/authority.d.ts +27 -0
- package/dist/task-subtree-transfer/authority.d.ts.map +1 -0
- package/dist/task-subtree-transfer/backend.d.ts +9 -0
- package/dist/task-subtree-transfer/backend.d.ts.map +1 -0
- package/dist/task-subtree-transfer/canonical.d.ts +14 -0
- package/dist/task-subtree-transfer/canonical.d.ts.map +1 -0
- package/dist/task-subtree-transfer/http.d.ts +16 -0
- package/dist/task-subtree-transfer/http.d.ts.map +1 -0
- package/dist/task-subtree-transfer/index.d.ts +6 -0
- package/dist/task-subtree-transfer/index.d.ts.map +1 -0
- package/dist/task-subtree-transfer/planner.d.ts +24 -0
- package/dist/task-subtree-transfer/planner.d.ts.map +1 -0
- package/dist/task-subtree-transfer/postgres.d.ts +19 -0
- package/dist/task-subtree-transfer/postgres.d.ts.map +1 -0
- package/dist/task-subtree-transfer/schema-sql.d.ts +5 -0
- package/dist/task-subtree-transfer/schema-sql.d.ts.map +1 -0
- package/dist/task-subtree-transfer/sqlite.d.ts +16 -0
- package/dist/task-subtree-transfer/sqlite.d.ts.map +1 -0
- package/dist/task-subtree-transfer/types.d.ts +148 -0
- package/dist/task-subtree-transfer/types.d.ts.map +1 -0
- package/dist/task-subtree-transfer.d.ts +2 -0
- package/dist/task-subtree-transfer.d.ts.map +1 -0
- package/dist/task-subtree-transfer.js +1869 -0
- package/dist/testing.d.ts +41 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +44 -1
- package/dist/types/index.d.ts +29 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +11 -4
|
@@ -2900,6 +2900,10 @@ var init_migrations = __esm(() => {
|
|
|
2900
2900
|
);
|
|
2901
2901
|
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
2902
2902
|
COMMIT;
|
|
2903
|
+
`,
|
|
2904
|
+
`BEGIN;
|
|
2905
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (71);
|
|
2906
|
+
COMMIT;
|
|
2903
2907
|
`
|
|
2904
2908
|
];
|
|
2905
2909
|
});
|
|
@@ -3573,6 +3577,8 @@ function ensureSchema(db) {
|
|
|
3573
3577
|
ensureColumn("projects", "task_list_id", "TEXT");
|
|
3574
3578
|
ensureColumn("projects", "task_prefix", "TEXT");
|
|
3575
3579
|
ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
|
|
3580
|
+
ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
3581
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
|
|
3576
3582
|
ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
|
|
3577
3583
|
ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
|
|
3578
3584
|
ensureColumn("tasks", "short_id", "TEXT");
|
|
@@ -3686,6 +3692,20 @@ function ensureSchema(db) {
|
|
|
3686
3692
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON webhook_deliveries(event)");
|
|
3687
3693
|
ensureColumn("task_comments", "type", "TEXT DEFAULT 'comment'");
|
|
3688
3694
|
ensureColumn("task_comments", "progress_pct", "INTEGER");
|
|
3695
|
+
ensureTable("plan_comments", `
|
|
3696
|
+
CREATE TABLE plan_comments (
|
|
3697
|
+
id TEXT PRIMARY KEY,
|
|
3698
|
+
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
|
3699
|
+
agent_id TEXT,
|
|
3700
|
+
session_id TEXT,
|
|
3701
|
+
content TEXT NOT NULL,
|
|
3702
|
+
type TEXT DEFAULT 'comment' CHECK(type IN ('comment', 'progress', 'note')),
|
|
3703
|
+
progress_pct INTEGER CHECK(progress_pct IS NULL OR (progress_pct >= 0 AND progress_pct <= 100)),
|
|
3704
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3705
|
+
machine_id TEXT,
|
|
3706
|
+
synced_at TEXT
|
|
3707
|
+
)`);
|
|
3708
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_plan_comments_plan ON plan_comments(plan_id)");
|
|
3689
3709
|
ensureColumn("projects", "machine_id", "TEXT");
|
|
3690
3710
|
ensureColumn("projects", "synced_at", "TEXT");
|
|
3691
3711
|
ensureColumn("tasks", "machine_id", "TEXT");
|
|
@@ -4667,6 +4687,7 @@ var init_machines = __esm(() => {
|
|
|
4667
4687
|
"task_lists",
|
|
4668
4688
|
"plans",
|
|
4669
4689
|
"task_comments",
|
|
4690
|
+
"plan_comments",
|
|
4670
4691
|
"sessions",
|
|
4671
4692
|
"task_history",
|
|
4672
4693
|
"webhooks",
|
|
@@ -4860,20 +4881,16 @@ var init_identity_mapping = __esm(() => {
|
|
|
4860
4881
|
// src/storage/config.ts
|
|
4861
4882
|
var exports_config = {};
|
|
4862
4883
|
__export(exports_config, {
|
|
4863
|
-
parseStorageMode: () => parseStorageMode,
|
|
4864
4884
|
parseStorageBackend: () => parseStorageBackend,
|
|
4865
4885
|
loadTodosStorageConfig: () => loadTodosStorageConfig,
|
|
4866
4886
|
loadStorageConfig: () => loadStorageConfig,
|
|
4867
4887
|
isTodosShadowEnabled: () => isTodosShadowEnabled,
|
|
4868
|
-
isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
|
|
4869
4888
|
isTodosPostgresBackend: () => isTodosPostgresBackend,
|
|
4870
4889
|
getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
|
|
4871
|
-
getTodosStorageMode: () => getTodosStorageMode,
|
|
4872
4890
|
getTodosStorageEnvName: () => getTodosStorageEnvName,
|
|
4873
4891
|
getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
|
|
4874
4892
|
getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
|
|
4875
4893
|
getTodosStorageBackend: () => getTodosStorageBackend,
|
|
4876
|
-
getStorageMode: () => getStorageMode,
|
|
4877
4894
|
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
4878
4895
|
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
4879
4896
|
getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
|
|
@@ -4883,7 +4900,6 @@ __export(exports_config, {
|
|
|
4883
4900
|
TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
|
|
4884
4901
|
TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
|
|
4885
4902
|
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
4886
|
-
REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
|
|
4887
4903
|
CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
|
|
4888
4904
|
CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
|
|
4889
4905
|
CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
|
|
@@ -4938,9 +4954,6 @@ function loadStorageConfig(env = process.env) {
|
|
|
4938
4954
|
function isTodosPostgresBackend(config) {
|
|
4939
4955
|
return config.mode === "postgres";
|
|
4940
4956
|
}
|
|
4941
|
-
function isTodosRemoteStorageEnabled(config) {
|
|
4942
|
-
return isTodosPostgresBackend(config);
|
|
4943
|
-
}
|
|
4944
4957
|
function assertTodosRemoteStorageConfig(config) {
|
|
4945
4958
|
if (!isTodosPostgresBackend(config))
|
|
4946
4959
|
return;
|
|
@@ -4956,32 +4969,15 @@ function parseStorageBackend(value) {
|
|
|
4956
4969
|
return "sqlite";
|
|
4957
4970
|
if (normalized === "postgres" || normalized === "postgresql")
|
|
4958
4971
|
return "postgres";
|
|
4959
|
-
if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
|
|
4960
|
-
throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} selects the backend. Deployment modes no longer exist: ` + `delete the storage-mode variable and set ${TODOS_STORAGE_ENV.databaseUrl} to select the ` + `postgresql backend, or leave it unset for sqlite.`);
|
|
4961
|
-
}
|
|
4962
4972
|
throw new Error(`Storage backend must be sqlite or postgres`);
|
|
4963
4973
|
}
|
|
4964
|
-
function parseStorageMode(value) {
|
|
4965
|
-
return parseStorageBackend(value);
|
|
4966
|
-
}
|
|
4967
4974
|
function getTodosStorageBackend(env = process.env) {
|
|
4968
|
-
for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
|
|
4969
|
-
if (Object.hasOwn(env, key) && env[key] !== undefined) {
|
|
4970
|
-
throw new Error(`${key} was removed. Deployment modes no longer exist: delete the storage-mode variable. ` + `Set ${TODOS_STORAGE_ENV.databaseUrl} to select the postgresql backend, ` + `or leave it unset for sqlite.`);
|
|
4971
|
-
}
|
|
4972
|
-
}
|
|
4973
4975
|
if (!getTodosStorageDatabaseUrl(env))
|
|
4974
4976
|
return "sqlite";
|
|
4975
4977
|
if (isTodosShadowEnabled(env))
|
|
4976
4978
|
return "sqlite";
|
|
4977
4979
|
return "postgres";
|
|
4978
4980
|
}
|
|
4979
|
-
function getTodosStorageMode(env = process.env) {
|
|
4980
|
-
return getTodosStorageBackend(env);
|
|
4981
|
-
}
|
|
4982
|
-
function getStorageMode(env = process.env) {
|
|
4983
|
-
return getTodosStorageBackend(env);
|
|
4984
|
-
}
|
|
4985
4981
|
function isTodosShadowEnabled(env = process.env) {
|
|
4986
4982
|
return parseBoolean(readStorageEnv(env, "shadow").value, false);
|
|
4987
4983
|
}
|
|
@@ -5048,7 +5044,7 @@ function parsePositiveInteger(value, fallback) {
|
|
|
5048
5044
|
}
|
|
5049
5045
|
return parsed;
|
|
5050
5046
|
}
|
|
5051
|
-
var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV,
|
|
5047
|
+
var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos";
|
|
5052
5048
|
var init_config2 = __esm(() => {
|
|
5053
5049
|
TODOS_STORAGE_TABLES = [
|
|
5054
5050
|
"todos_sync_records",
|
|
@@ -5087,12 +5083,6 @@ var init_config2 = __esm(() => {
|
|
|
5087
5083
|
syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
|
|
5088
5084
|
syncDryRun: "TODOS_SYNC_DRY_RUN"
|
|
5089
5085
|
};
|
|
5090
|
-
REMOVED_STORAGE_MODE_ENV_KEYS = [
|
|
5091
|
-
"HASNA_TODOS_STORAGE_MODE",
|
|
5092
|
-
"HASNA_TODOS_MODE",
|
|
5093
|
-
"TODOS_STORAGE_MODE",
|
|
5094
|
-
"TODOS_MODE"
|
|
5095
|
-
];
|
|
5096
5086
|
});
|
|
5097
5087
|
|
|
5098
5088
|
// src/storage/shadow-outbox-schema.ts
|
|
@@ -5574,17 +5564,24 @@ function createProject2(input, db) {
|
|
|
5574
5564
|
const id = uuid();
|
|
5575
5565
|
const timestamp2 = now();
|
|
5576
5566
|
const derivedSlug = slugify(input.name);
|
|
5577
|
-
const taskListId = input.task_list_id === undefined ?
|
|
5567
|
+
const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
|
|
5578
5568
|
if (!derivedSlug || !taskListId)
|
|
5579
5569
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
5580
5570
|
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
5581
5571
|
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
5582
5572
|
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
5583
5573
|
}
|
|
5574
|
+
const parentId = input.parent_id ?? null;
|
|
5575
|
+
if (parentId !== null) {
|
|
5576
|
+
const parent = getProject(parentId, d);
|
|
5577
|
+
if (!parent)
|
|
5578
|
+
throw new ProjectNotFoundError(parentId);
|
|
5579
|
+
assertNotProjectAncestor(id, parentId, d);
|
|
5580
|
+
}
|
|
5584
5581
|
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
5585
5582
|
const machineId = currentStorageMachineId(d);
|
|
5586
|
-
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
5587
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
|
|
5583
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
|
|
5584
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
|
|
5588
5585
|
return getProject(id, d);
|
|
5589
5586
|
})();
|
|
5590
5587
|
}
|
|
@@ -5609,6 +5606,60 @@ function listProjects(db) {
|
|
|
5609
5606
|
const d = db || getDatabase();
|
|
5610
5607
|
return d.query("SELECT * FROM projects ORDER BY name").all();
|
|
5611
5608
|
}
|
|
5609
|
+
function orderProjectsParentFirst(projects) {
|
|
5610
|
+
const projectId = (project) => {
|
|
5611
|
+
const id = project.id;
|
|
5612
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
5613
|
+
};
|
|
5614
|
+
const parentId = (project) => {
|
|
5615
|
+
const parent = project.parent_id;
|
|
5616
|
+
return parent == null ? null : String(parent);
|
|
5617
|
+
};
|
|
5618
|
+
const byId = new Set;
|
|
5619
|
+
for (const project of projects) {
|
|
5620
|
+
const id = projectId(project);
|
|
5621
|
+
if (id !== null)
|
|
5622
|
+
byId.add(id);
|
|
5623
|
+
}
|
|
5624
|
+
const ordered = [];
|
|
5625
|
+
const emitted = new Set;
|
|
5626
|
+
let remaining = [...projects];
|
|
5627
|
+
let progress = true;
|
|
5628
|
+
while (progress && remaining.length > 0) {
|
|
5629
|
+
progress = false;
|
|
5630
|
+
const deferred = [];
|
|
5631
|
+
for (const project of remaining) {
|
|
5632
|
+
const parent = parentId(project);
|
|
5633
|
+
if (parent === null || emitted.has(parent) || !byId.has(parent)) {
|
|
5634
|
+
ordered.push(project);
|
|
5635
|
+
const id = projectId(project);
|
|
5636
|
+
if (id !== null)
|
|
5637
|
+
emitted.add(id);
|
|
5638
|
+
progress = true;
|
|
5639
|
+
} else {
|
|
5640
|
+
deferred.push(project);
|
|
5641
|
+
}
|
|
5642
|
+
}
|
|
5643
|
+
remaining = deferred;
|
|
5644
|
+
}
|
|
5645
|
+
ordered.push(...remaining);
|
|
5646
|
+
return ordered;
|
|
5647
|
+
}
|
|
5648
|
+
function assertNotProjectAncestor(projectId, ancestorId, db) {
|
|
5649
|
+
const d = db || getDatabase();
|
|
5650
|
+
let cursor = ancestorId;
|
|
5651
|
+
const seen = new Set;
|
|
5652
|
+
while (cursor !== null) {
|
|
5653
|
+
if (cursor === projectId) {
|
|
5654
|
+
throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
|
|
5655
|
+
}
|
|
5656
|
+
if (seen.has(cursor))
|
|
5657
|
+
break;
|
|
5658
|
+
seen.add(cursor);
|
|
5659
|
+
const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
|
|
5660
|
+
cursor = row?.parent_id ?? null;
|
|
5661
|
+
}
|
|
5662
|
+
}
|
|
5612
5663
|
function updateProject2(id, input, db) {
|
|
5613
5664
|
const d = db || getDatabase();
|
|
5614
5665
|
const project = getProject(id, d);
|
|
@@ -5631,6 +5682,16 @@ function updateProject2(id, input, db) {
|
|
|
5631
5682
|
sets.push("path = ?");
|
|
5632
5683
|
params.push(input.path);
|
|
5633
5684
|
}
|
|
5685
|
+
if (input.parent_id !== undefined) {
|
|
5686
|
+
if (input.parent_id !== null) {
|
|
5687
|
+
const parent = getProject(input.parent_id, d);
|
|
5688
|
+
if (!parent)
|
|
5689
|
+
throw new ProjectNotFoundError(input.parent_id);
|
|
5690
|
+
assertNotProjectAncestor(id, input.parent_id, d);
|
|
5691
|
+
}
|
|
5692
|
+
sets.push("parent_id = ?");
|
|
5693
|
+
params.push(input.parent_id);
|
|
5694
|
+
}
|
|
5634
5695
|
params.push(id);
|
|
5635
5696
|
d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
5636
5697
|
return getProject(id, d);
|
|
@@ -7193,22 +7254,31 @@ function updateTaskList2(id, input, db) {
|
|
|
7193
7254
|
throw new TaskListNotFoundError(id);
|
|
7194
7255
|
const sets = ["updated_at = ?"];
|
|
7195
7256
|
const params = [now()];
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7257
|
+
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? existing.project_id : input.project_id;
|
|
7258
|
+
const scopeChanged = projectId !== existing.project_id;
|
|
7259
|
+
const effectiveSlug = input.slug !== undefined ? slugify(input.slug) : existing.slug;
|
|
7260
|
+
if (input.slug !== undefined && !effectiveSlug) {
|
|
7261
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
7262
|
+
}
|
|
7263
|
+
if (scopeChanged || input.slug !== undefined && effectiveSlug !== existing.slug) {
|
|
7264
|
+
const duplicate = projectId ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND id != ?").get(projectId, effectiveSlug, id) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ? AND id != ?").get(effectiveSlug, id);
|
|
7201
7265
|
if (duplicate) {
|
|
7202
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
7266
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
7203
7267
|
}
|
|
7204
|
-
if (
|
|
7268
|
+
if (scopeChanged || effectiveSlug !== existing.slug) {
|
|
7205
7269
|
releaseCanonicalSlugClaims("task_list", id, d);
|
|
7206
|
-
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(
|
|
7207
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
7270
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(projectId), effectiveSlug, id, d)) {
|
|
7271
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
7208
7272
|
}
|
|
7209
7273
|
}
|
|
7274
|
+
}
|
|
7275
|
+
if (input.slug !== undefined) {
|
|
7210
7276
|
sets.push("slug = ?");
|
|
7211
|
-
params.push(
|
|
7277
|
+
params.push(effectiveSlug);
|
|
7278
|
+
}
|
|
7279
|
+
if (projectId !== existing.project_id) {
|
|
7280
|
+
sets.push("project_id = ?");
|
|
7281
|
+
params.push(projectId);
|
|
7212
7282
|
}
|
|
7213
7283
|
if (input.name !== undefined) {
|
|
7214
7284
|
sets.push("name = ?");
|
|
@@ -9244,8 +9314,8 @@ function failTask2(id, agentId, reason, options, db) {
|
|
|
9244
9314
|
const safeMeta = sanitizePreWriteValue(meta, "task.failure.metadata");
|
|
9245
9315
|
const timestamp2 = now();
|
|
9246
9316
|
const failTx = d.transaction(() => {
|
|
9247
|
-
const res = d.run(`UPDATE tasks SET status = 'failed', reason = ?, locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
|
|
9248
|
-
WHERE id = ? AND version = ?`, [safeReason, JSON.stringify(safeMeta), timestamp2, id, task.version]);
|
|
9317
|
+
const res = d.run(`UPDATE tasks SET status = 'failed', reason = ?, locked_by = NULL, locked_at = NULL, completed_at = COALESCE(completed_at, ?), metadata = ?, version = version + 1, updated_at = ?
|
|
9318
|
+
WHERE id = ? AND version = ?`, [safeReason, timestamp2, JSON.stringify(safeMeta), timestamp2, id, task.version]);
|
|
9249
9319
|
if (res.changes === 0) {
|
|
9250
9320
|
const current = getTask(id, d);
|
|
9251
9321
|
throw new VersionConflictError(id, task.version, current?.version ?? -1);
|
|
@@ -9257,6 +9327,7 @@ function failTask2(id, agentId, reason, options, db) {
|
|
|
9257
9327
|
status: "failed",
|
|
9258
9328
|
locked_by: null,
|
|
9259
9329
|
locked_at: null,
|
|
9330
|
+
completed_at: task.completed_at ?? timestamp2,
|
|
9260
9331
|
reason: safeReason,
|
|
9261
9332
|
metadata: safeMeta,
|
|
9262
9333
|
version: task.version + 1,
|
|
@@ -9905,13 +9976,20 @@ function updateTaskStored(id, input, db) {
|
|
|
9905
9976
|
if (isTerminalStatus(input.status)) {
|
|
9906
9977
|
sets.push("locked_by = NULL");
|
|
9907
9978
|
sets.push("locked_at = NULL");
|
|
9908
|
-
|
|
9909
|
-
|
|
9910
|
-
|
|
9911
|
-
|
|
9979
|
+
if (input.status === "completed") {
|
|
9980
|
+
sets.push("completed_at = ?");
|
|
9981
|
+
params.push(completionTimestamp);
|
|
9982
|
+
} else {
|
|
9983
|
+
sets.push("completed_at = COALESCE(completed_at, ?)");
|
|
9984
|
+
params.push(completionTimestamp);
|
|
9985
|
+
}
|
|
9912
9986
|
} else if (task.status === "completed" && input.completed_at === undefined) {
|
|
9913
9987
|
sets.push("completed_at = NULL");
|
|
9914
9988
|
}
|
|
9989
|
+
if (input.status === "in_progress") {
|
|
9990
|
+
sets.push("started_at = COALESCE(started_at, ?)");
|
|
9991
|
+
params.push(timestamp2);
|
|
9992
|
+
}
|
|
9915
9993
|
}
|
|
9916
9994
|
if (input.priority !== undefined) {
|
|
9917
9995
|
sets.push("priority = ?");
|
|
@@ -10049,7 +10127,6 @@ function updateTaskStored(id, input, db) {
|
|
|
10049
10127
|
if (input.approved_by !== undefined)
|
|
10050
10128
|
logTaskChange2(id, "approve", "approved_by", null, input.approved_by, agentId, d);
|
|
10051
10129
|
const reopened = input.status !== undefined && input.status !== "completed" && task.status === "completed" && input.completed_at === undefined;
|
|
10052
|
-
const completedNow = input.status === "completed";
|
|
10053
10130
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
10054
10131
|
const updatedTask = {
|
|
10055
10132
|
...task,
|
|
@@ -10060,7 +10137,8 @@ function updateTaskStored(id, input, db) {
|
|
|
10060
10137
|
updated_at: timestamp2,
|
|
10061
10138
|
locked_by: terminalNow ? null : task.locked_by,
|
|
10062
10139
|
locked_at: terminalNow ? null : task.locked_at,
|
|
10063
|
-
completed_at:
|
|
10140
|
+
completed_at: input.status === "completed" ? completionTimestamp : input.completed_at !== undefined ? input.completed_at : terminalNow ? task.completed_at ?? completionTimestamp : reopened ? null : task.completed_at,
|
|
10141
|
+
started_at: input.status === "in_progress" ? task.started_at ?? timestamp2 : task.started_at,
|
|
10064
10142
|
sla_minutes: input.sla_minutes !== undefined ? input.sla_minutes : task.sla_minutes,
|
|
10065
10143
|
actual_minutes: input.actual_minutes ?? task.actual_minutes,
|
|
10066
10144
|
confidence: input.confidence !== undefined ? input.confidence : task.confidence,
|
|
@@ -10121,6 +10199,9 @@ function deleteTask(id, db) {
|
|
|
10121
10199
|
version: row.version
|
|
10122
10200
|
}, d);
|
|
10123
10201
|
const result = d.run("DELETE FROM tasks WHERE id = ?", [id]);
|
|
10202
|
+
if (result.changes > 0) {
|
|
10203
|
+
d.run("DELETE FROM task_dependencies WHERE task_id = ? OR depends_on = ?", [id, id]);
|
|
10204
|
+
}
|
|
10124
10205
|
return result.changes > 0;
|
|
10125
10206
|
}
|
|
10126
10207
|
var init_task_crud = __esm(() => {
|
|
@@ -12731,7 +12812,7 @@ import { createHash as createHash7 } from "crypto";
|
|
|
12731
12812
|
// package.json
|
|
12732
12813
|
var package_default = {
|
|
12733
12814
|
name: "@hasna/todos",
|
|
12734
|
-
version: "0.15.
|
|
12815
|
+
version: "0.15.41",
|
|
12735
12816
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12736
12817
|
type: "module",
|
|
12737
12818
|
main: "dist/index.js",
|
|
@@ -12777,6 +12858,10 @@ var package_default = {
|
|
|
12777
12858
|
"./task-manifest": {
|
|
12778
12859
|
types: "./dist/task-manifest.d.ts",
|
|
12779
12860
|
import: "./dist/task-manifest.js"
|
|
12861
|
+
},
|
|
12862
|
+
"./task-subtree-transfer": {
|
|
12863
|
+
types: "./dist/task-subtree-transfer.d.ts",
|
|
12864
|
+
import: "./dist/task-subtree-transfer.js"
|
|
12780
12865
|
}
|
|
12781
12866
|
},
|
|
12782
12867
|
workspaces: [
|
|
@@ -12790,8 +12875,8 @@ var package_default = {
|
|
|
12790
12875
|
"README.md"
|
|
12791
12876
|
],
|
|
12792
12877
|
scripts: {
|
|
12793
|
-
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",
|
|
12794
|
-
"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/*'",
|
|
12878
|
+
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 src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
12879
|
+
"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 src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
12795
12880
|
migrate: "bun run src/server/index.ts migrate",
|
|
12796
12881
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
12797
12882
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
@@ -12806,6 +12891,9 @@ var package_default = {
|
|
|
12806
12891
|
"verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
|
|
12807
12892
|
"verify:attested-container-candidate": "bun run scripts/attested-container-candidate.ts verify",
|
|
12808
12893
|
"test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
|
|
12894
|
+
"emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
|
|
12895
|
+
"verify:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts verify",
|
|
12896
|
+
"test:attested-container-compatibility-vector": "bun test scripts/attested-container-compatibility-vector.test.ts",
|
|
12809
12897
|
"issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
|
|
12810
12898
|
prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
|
|
12811
12899
|
postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
|
|
@@ -12842,7 +12930,7 @@ var package_default = {
|
|
|
12842
12930
|
author: "Andrei Hasna <andrei@hasna.com>",
|
|
12843
12931
|
license: "Apache-2.0",
|
|
12844
12932
|
dependencies: {
|
|
12845
|
-
"@hasna/contracts": "0.
|
|
12933
|
+
"@hasna/contracts": "0.13.3",
|
|
12846
12934
|
"@hasna/events": "^0.1.11",
|
|
12847
12935
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
12848
12936
|
chalk: "^5.4.1",
|
|
@@ -13703,7 +13791,26 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
13703
13791
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
13704
13792
|
update: (id, input) => updatePlan(id, input, store),
|
|
13705
13793
|
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
13706
|
-
delete: (id, context) => store.deletePlan(id, context)
|
|
13794
|
+
delete: (id, context) => store.deletePlan(id, context),
|
|
13795
|
+
addComment: (input, context) => addPlanComment(input, store, context),
|
|
13796
|
+
getComments: async (planId) => {
|
|
13797
|
+
const pages = [];
|
|
13798
|
+
let before;
|
|
13799
|
+
while (true) {
|
|
13800
|
+
const page = await store.listPlanComments(planId, { limit: 1000, ...before ? { before } : {} });
|
|
13801
|
+
if (page.length === 0)
|
|
13802
|
+
break;
|
|
13803
|
+
pages.unshift(page);
|
|
13804
|
+
if (page.length < 1000)
|
|
13805
|
+
break;
|
|
13806
|
+
const oldest = page[0];
|
|
13807
|
+
before = { created_at: oldest.created_at, id: oldest.id };
|
|
13808
|
+
}
|
|
13809
|
+
return pages.flat().map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
13810
|
+
},
|
|
13811
|
+
getCommentsPage: async (planId, options2) => {
|
|
13812
|
+
return (await store.listPlanComments(planId, options2)).map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
13813
|
+
}
|
|
13707
13814
|
},
|
|
13708
13815
|
planProjectLinks: {
|
|
13709
13816
|
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
@@ -13921,6 +14028,27 @@ class PostgresJsonRecordStore {
|
|
|
13921
14028
|
LIMIT $${params.length}`, params);
|
|
13922
14029
|
return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
|
|
13923
14030
|
}
|
|
14031
|
+
async listPlanComments(planId, options = {}) {
|
|
14032
|
+
await this.ensureSchema();
|
|
14033
|
+
const limit = options.limit ?? 100;
|
|
14034
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
|
|
14035
|
+
throw new Error("Postgres plan comment limit must be an integer between 1 and 1001");
|
|
14036
|
+
}
|
|
14037
|
+
const params = [this.service, planId];
|
|
14038
|
+
let cursorPredicate = "";
|
|
14039
|
+
if (options.before) {
|
|
14040
|
+
params.push(options.before.created_at, options.before.id);
|
|
14041
|
+
cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
|
|
14042
|
+
}
|
|
14043
|
+
params.push(limit);
|
|
14044
|
+
const result = await this.options.client.query(`/* todos:list-plan-comments */ SELECT payload FROM ${this.tableName}
|
|
14045
|
+
WHERE service = $1 AND object_type = 'plan_comments' AND deleted_at IS NULL
|
|
14046
|
+
AND payload->>'plan_id' = $2
|
|
14047
|
+
${cursorPredicate}
|
|
14048
|
+
ORDER BY payload->>'created_at' DESC, object_id DESC
|
|
14049
|
+
LIMIT $${params.length}`, params);
|
|
14050
|
+
return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
|
|
14051
|
+
}
|
|
13924
14052
|
async listRecords(type) {
|
|
13925
14053
|
await this.ensureSchema();
|
|
13926
14054
|
const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
|
|
@@ -14252,8 +14380,12 @@ class PostgresJsonRecordStore {
|
|
|
14252
14380
|
OR ($11::text <> $2
|
|
14253
14381
|
AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
|
|
14254
14382
|
AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
|
|
14255
|
-
(SELECT
|
|
14256
|
-
|
|
14383
|
+
(COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
|
|
14384
|
+
IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
|
|
14385
|
+
(NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
|
|
14386
|
+
IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
|
|
14387
|
+
OR $8::text IS NULL
|
|
14388
|
+
OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
14257
14389
|
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
14258
14390
|
), guarded AS (
|
|
14259
14391
|
SELECT
|
|
@@ -14276,7 +14408,6 @@ class PostgresJsonRecordStore {
|
|
|
14276
14408
|
AND guarded.version_matches
|
|
14277
14409
|
AND guarded.parent_found
|
|
14278
14410
|
AND guarded.parent_acyclic
|
|
14279
|
-
AND guarded.all_plans_found
|
|
14280
14411
|
AND guarded.target_plan_found
|
|
14281
14412
|
AND NOT guarded.project_conflict
|
|
14282
14413
|
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
@@ -14292,7 +14423,7 @@ class PostgresJsonRecordStore {
|
|
|
14292
14423
|
RETURNING payload
|
|
14293
14424
|
)
|
|
14294
14425
|
SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
|
|
14295
|
-
guarded.
|
|
14426
|
+
guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
|
|
14296
14427
|
(SELECT payload FROM stored) AS payload,
|
|
14297
14428
|
(SELECT payload FROM locked_task) AS current_payload
|
|
14298
14429
|
FROM guarded`, [
|
|
@@ -14324,7 +14455,7 @@ class PostgresJsonRecordStore {
|
|
|
14324
14455
|
if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
|
|
14325
14456
|
throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
|
|
14326
14457
|
}
|
|
14327
|
-
if (!row?.
|
|
14458
|
+
if (!row?.target_plan_found) {
|
|
14328
14459
|
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 });
|
|
14329
14460
|
}
|
|
14330
14461
|
if (row.project_conflict) {
|
|
@@ -15079,6 +15210,15 @@ class PostgresJsonRecordStore {
|
|
|
15079
15210
|
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
15080
15211
|
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
|
|
15081
15212
|
RETURNING 1
|
|
15213
|
+
), deleted_plan_comments AS (
|
|
15214
|
+
UPDATE ${this.tableName} r SET
|
|
15215
|
+
deleted_at = $3::timestamptz,
|
|
15216
|
+
updated_at = $3::timestamptz,
|
|
15217
|
+
source_machine_id = COALESCE($4, r.source_machine_id),
|
|
15218
|
+
version = COALESCE(r.version, 0) + 1
|
|
15219
|
+
WHERE r.service = $1 AND r.object_type = 'plan_comments' AND r.deleted_at IS NULL
|
|
15220
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
|
|
15221
|
+
RETURNING 1
|
|
15082
15222
|
), deleted_plan AS (
|
|
15083
15223
|
UPDATE ${this.tableName} r SET
|
|
15084
15224
|
deleted_at = $3::timestamptz,
|
|
@@ -15254,7 +15394,8 @@ async function updateTask(id, input, store, context) {
|
|
|
15254
15394
|
task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
|
|
15255
15395
|
parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
|
|
15256
15396
|
created_by: existing.created_by,
|
|
15257
|
-
completed_at:
|
|
15397
|
+
completed_at: input.status === "completed" ? input.completed_at ?? new Date().toISOString() : input.completed_at !== undefined ? input.completed_at : input.status !== undefined && isTerminalStatus(input.status) ? existing.completed_at ?? new Date().toISOString() : reopened ? null : existing.completed_at,
|
|
15398
|
+
started_at: input.status === "in_progress" ? existing.started_at ?? new Date().toISOString() : existing.started_at
|
|
15258
15399
|
};
|
|
15259
15400
|
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
|
|
15260
15401
|
operation: "update",
|
|
@@ -15299,7 +15440,8 @@ async function failTask(id, agentId, reason, options, store) {
|
|
|
15299
15440
|
status: "failed",
|
|
15300
15441
|
assigned_to: task.assigned_to ?? agentId ?? null,
|
|
15301
15442
|
reason: reason ?? task.reason,
|
|
15302
|
-
retry_after: options?.retry_after ?? task.retry_after
|
|
15443
|
+
retry_after: options?.retry_after ?? task.retry_after,
|
|
15444
|
+
completed_at: task.completed_at ?? new Date().toISOString()
|
|
15303
15445
|
}, store);
|
|
15304
15446
|
if (!options?.retry)
|
|
15305
15447
|
return { task: failed };
|
|
@@ -15536,17 +15678,26 @@ async function getChangedSince(since, filters, store) {
|
|
|
15536
15678
|
async function createProject(input, store, context) {
|
|
15537
15679
|
const timestamp = new Date().toISOString();
|
|
15538
15680
|
const derivedSlug = slugifyRaw(input.name);
|
|
15539
|
-
const taskListId = input.task_list_id === undefined ?
|
|
15681
|
+
const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
|
|
15540
15682
|
if (!derivedSlug || !taskListId)
|
|
15541
15683
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
15684
|
+
const parentId = input.parent_id ?? null;
|
|
15685
|
+
const id = randomUUID();
|
|
15686
|
+
if (parentId !== null) {
|
|
15687
|
+
const parent = await store.get("projects", parentId);
|
|
15688
|
+
if (!parent)
|
|
15689
|
+
throw new ProjectNotFoundError(parentId);
|
|
15690
|
+
await assertNotProjectAncestorPostgres(id, parentId, store);
|
|
15691
|
+
}
|
|
15542
15692
|
const project = {
|
|
15543
|
-
id
|
|
15693
|
+
id,
|
|
15544
15694
|
name: input.name,
|
|
15545
15695
|
path: input.path,
|
|
15546
15696
|
description: input.description ?? null,
|
|
15547
15697
|
task_list_id: taskListId,
|
|
15548
15698
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
15549
15699
|
task_counter: 0,
|
|
15700
|
+
parent_id: parentId,
|
|
15550
15701
|
created_at: timestamp,
|
|
15551
15702
|
updated_at: timestamp,
|
|
15552
15703
|
machine_id: store.machineId(context),
|
|
@@ -15554,12 +15705,38 @@ async function createProject(input, store, context) {
|
|
|
15554
15705
|
};
|
|
15555
15706
|
return store.upsert("projects", project, context);
|
|
15556
15707
|
}
|
|
15708
|
+
async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
|
|
15709
|
+
const all = await store.list("projects");
|
|
15710
|
+
const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
|
|
15711
|
+
let cursor = candidateParentId;
|
|
15712
|
+
const seen = new Set;
|
|
15713
|
+
while (cursor !== null) {
|
|
15714
|
+
if (cursor === projectId) {
|
|
15715
|
+
throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
|
|
15716
|
+
}
|
|
15717
|
+
if (seen.has(cursor))
|
|
15718
|
+
break;
|
|
15719
|
+
seen.add(cursor);
|
|
15720
|
+
cursor = byId.get(cursor) ?? null;
|
|
15721
|
+
}
|
|
15722
|
+
}
|
|
15557
15723
|
async function updateProject(id, input, store) {
|
|
15558
15724
|
if ("task_list_id" in input) {
|
|
15559
15725
|
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
15560
15726
|
}
|
|
15561
15727
|
const project = await requireRecord("projects", id, store);
|
|
15562
|
-
|
|
15728
|
+
if (input.parent_id !== undefined && input.parent_id !== null) {
|
|
15729
|
+
const parent = await store.get("projects", input.parent_id);
|
|
15730
|
+
if (!parent)
|
|
15731
|
+
throw new ProjectNotFoundError(input.parent_id);
|
|
15732
|
+
await assertNotProjectAncestorPostgres(id, input.parent_id, store);
|
|
15733
|
+
}
|
|
15734
|
+
const updated = {
|
|
15735
|
+
...project,
|
|
15736
|
+
...definedPatch(input),
|
|
15737
|
+
...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
|
|
15738
|
+
updated_at: new Date().toISOString()
|
|
15739
|
+
};
|
|
15563
15740
|
return store.upsert("projects", updated);
|
|
15564
15741
|
}
|
|
15565
15742
|
async function createPlan(input, store, context) {
|
|
@@ -15712,16 +15889,22 @@ async function createTaskList(input, store, context) {
|
|
|
15712
15889
|
async function updateTaskList(id, input, store) {
|
|
15713
15890
|
const list = await requireRecord("task_lists", id, store);
|
|
15714
15891
|
const patch = definedPatch(input);
|
|
15892
|
+
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
|
|
15715
15893
|
if (input.slug !== undefined) {
|
|
15716
15894
|
const slug = slugifyRaw(input.slug);
|
|
15717
15895
|
if (!slug)
|
|
15718
15896
|
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
15719
|
-
|
|
15897
|
+
patch.slug = slug;
|
|
15898
|
+
}
|
|
15899
|
+
const effectiveSlug = patch.slug ?? list.slug;
|
|
15900
|
+
if (projectId !== list.project_id || effectiveSlug !== list.slug) {
|
|
15901
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === projectId && candidate.slug === effectiveSlug);
|
|
15720
15902
|
if (duplicate) {
|
|
15721
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
15903
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
15722
15904
|
}
|
|
15723
|
-
patch.slug = slug;
|
|
15724
15905
|
}
|
|
15906
|
+
if (projectId !== list.project_id)
|
|
15907
|
+
patch.project_id = projectId;
|
|
15725
15908
|
return store.upsert("task_lists", {
|
|
15726
15909
|
...list,
|
|
15727
15910
|
...patch,
|
|
@@ -15814,6 +15997,22 @@ async function addComment(input, store, context) {
|
|
|
15814
15997
|
function redactComment(comment) {
|
|
15815
15998
|
return { ...comment, content: redactEvidenceText(comment.content) };
|
|
15816
15999
|
}
|
|
16000
|
+
async function addPlanComment(input, store, context) {
|
|
16001
|
+
const comment = {
|
|
16002
|
+
id: randomUUID(),
|
|
16003
|
+
plan_id: input.plan_id,
|
|
16004
|
+
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
16005
|
+
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
16006
|
+
content: redactEvidenceText(input.content),
|
|
16007
|
+
type: input.type ?? "comment",
|
|
16008
|
+
progress_pct: input.progress_pct ?? null,
|
|
16009
|
+
created_at: new Date().toISOString()
|
|
16010
|
+
};
|
|
16011
|
+
return store.upsert("plan_comments", comment, context);
|
|
16012
|
+
}
|
|
16013
|
+
function redactPlanComment(comment) {
|
|
16014
|
+
return { ...comment, content: redactEvidenceText(comment.content) };
|
|
16015
|
+
}
|
|
15817
16016
|
async function exportSnapshot(store) {
|
|
15818
16017
|
return {
|
|
15819
16018
|
exportedAt: new Date().toISOString(),
|
|
@@ -16699,6 +16898,40 @@ init_tasks();
|
|
|
16699
16898
|
init_projects();
|
|
16700
16899
|
init_plans();
|
|
16701
16900
|
|
|
16901
|
+
// src/db/plan-comments.ts
|
|
16902
|
+
init_types();
|
|
16903
|
+
init_database();
|
|
16904
|
+
init_plans();
|
|
16905
|
+
init_prewrite_secrets();
|
|
16906
|
+
function addPlanComment2(input, db) {
|
|
16907
|
+
const d = db || getDatabase();
|
|
16908
|
+
if (!getPlan(input.plan_id, d)) {
|
|
16909
|
+
throw new PlanNotFoundError(input.plan_id);
|
|
16910
|
+
}
|
|
16911
|
+
const id = uuid();
|
|
16912
|
+
const timestamp2 = now();
|
|
16913
|
+
d.run(`INSERT INTO plan_comments (id, plan_id, agent_id, session_id, content, type, progress_pct, created_at)
|
|
16914
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
16915
|
+
id,
|
|
16916
|
+
input.plan_id,
|
|
16917
|
+
input.agent_id || null,
|
|
16918
|
+
input.session_id || null,
|
|
16919
|
+
sanitizePreWriteText(input.content, "comment.content"),
|
|
16920
|
+
input.type || "comment",
|
|
16921
|
+
input.progress_pct ?? null,
|
|
16922
|
+
timestamp2
|
|
16923
|
+
]);
|
|
16924
|
+
return getPlanComment(id, d);
|
|
16925
|
+
}
|
|
16926
|
+
function getPlanComment(id, db) {
|
|
16927
|
+
const d = db || getDatabase();
|
|
16928
|
+
return d.query("SELECT * FROM plan_comments WHERE id = ?").get(id);
|
|
16929
|
+
}
|
|
16930
|
+
function listPlanComments(planId, db) {
|
|
16931
|
+
const d = db || getDatabase();
|
|
16932
|
+
return d.query("SELECT * FROM plan_comments WHERE plan_id = ? ORDER BY created_at, rowid").all(planId);
|
|
16933
|
+
}
|
|
16934
|
+
|
|
16702
16935
|
// src/db/agents.ts
|
|
16703
16936
|
init_types();
|
|
16704
16937
|
init_database();
|
|
@@ -17351,6 +17584,20 @@ init_task_lists();
|
|
|
17351
17584
|
init_templates();
|
|
17352
17585
|
init_audit();
|
|
17353
17586
|
init_comments();
|
|
17587
|
+
|
|
17588
|
+
// src/lib/comment-cursor.ts
|
|
17589
|
+
function isStrictlyOlder(comment, before) {
|
|
17590
|
+
return comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id;
|
|
17591
|
+
}
|
|
17592
|
+
function compareCommentKeyset(left, right) {
|
|
17593
|
+
if (isStrictlyOlder(left, right))
|
|
17594
|
+
return -1;
|
|
17595
|
+
if (isStrictlyOlder(right, left))
|
|
17596
|
+
return 1;
|
|
17597
|
+
return 0;
|
|
17598
|
+
}
|
|
17599
|
+
|
|
17600
|
+
// src/storage/local-sqlite.ts
|
|
17354
17601
|
init_database();
|
|
17355
17602
|
|
|
17356
17603
|
// src/db/integrity.ts
|
|
@@ -17570,6 +17817,7 @@ var PROJECT_COLUMNS = [
|
|
|
17570
17817
|
"task_list_id",
|
|
17571
17818
|
"task_prefix",
|
|
17572
17819
|
"task_counter",
|
|
17820
|
+
"parent_id",
|
|
17573
17821
|
"created_at",
|
|
17574
17822
|
"updated_at",
|
|
17575
17823
|
"machine_id",
|
|
@@ -17789,7 +18037,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
17789
18037
|
}
|
|
17790
18038
|
}
|
|
17791
18039
|
};
|
|
17792
|
-
applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
|
|
18040
|
+
applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
|
|
17793
18041
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
17794
18042
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
17795
18043
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
@@ -18121,7 +18369,22 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
18121
18369
|
list: (projectId) => listPlans(projectId, database()),
|
|
18122
18370
|
update: (id, input) => updatePlan2(id, input, database()),
|
|
18123
18371
|
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
18124
|
-
delete: (id) => deletePlan(id, database())
|
|
18372
|
+
delete: (id) => deletePlan(id, database()),
|
|
18373
|
+
addComment: (input) => addPlanComment2(input, database()),
|
|
18374
|
+
getComments: (planId) => listPlanComments(planId, database()),
|
|
18375
|
+
getCommentsPage: (planId, options2) => {
|
|
18376
|
+
if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
|
|
18377
|
+
throw new Error("Plan comment limit must be an integer between 1 and 1001");
|
|
18378
|
+
}
|
|
18379
|
+
let comments = listPlanComments(planId, database());
|
|
18380
|
+
comments = comments.sort(compareCommentKeyset);
|
|
18381
|
+
if (options2?.before) {
|
|
18382
|
+
const before = options2.before;
|
|
18383
|
+
comments = comments.filter((comment) => isStrictlyOlder(comment, before));
|
|
18384
|
+
}
|
|
18385
|
+
const limit = options2?.limit ?? 100;
|
|
18386
|
+
return comments.slice(-limit);
|
|
18387
|
+
}
|
|
18125
18388
|
},
|
|
18126
18389
|
planProjectLinks: {
|
|
18127
18390
|
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
@@ -18162,10 +18425,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
18162
18425
|
throw new Error("Comment limit must be an integer between 1 and 1001");
|
|
18163
18426
|
}
|
|
18164
18427
|
let comments = listComments(taskId, database());
|
|
18165
|
-
comments = comments.sort(
|
|
18428
|
+
comments = comments.sort(compareCommentKeyset);
|
|
18166
18429
|
if (options2?.before) {
|
|
18167
18430
|
const before = options2.before;
|
|
18168
|
-
comments = comments.filter((comment) => comment
|
|
18431
|
+
comments = comments.filter((comment) => isStrictlyOlder(comment, before));
|
|
18169
18432
|
}
|
|
18170
18433
|
if (options2?.limit !== undefined)
|
|
18171
18434
|
comments = comments.slice(-options2.limit);
|
|
@@ -18619,7 +18882,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
|
|
|
18619
18882
|
}
|
|
18620
18883
|
async createProject(input) {
|
|
18621
18884
|
const derivedSlug = normalizeSlug(input.name);
|
|
18622
|
-
const taskListId = input.task_list_id === undefined ?
|
|
18885
|
+
const taskListId = input.task_list_id === undefined ? derivedSlug : normalizeSlug(input.task_list_id);
|
|
18623
18886
|
if (!derivedSlug || !taskListId) {
|
|
18624
18887
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
18625
18888
|
}
|
|
@@ -18631,6 +18894,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
|
|
|
18631
18894
|
task_list_id: taskListId,
|
|
18632
18895
|
task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
|
|
18633
18896
|
task_counter: 0,
|
|
18897
|
+
parent_id: input.parent_id ?? null,
|
|
18634
18898
|
created_at: now(),
|
|
18635
18899
|
updated_at: now(),
|
|
18636
18900
|
machine_id: currentStorageMachineId(this.db)
|
|
@@ -18641,14 +18905,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
|
|
|
18641
18905
|
try {
|
|
18642
18906
|
const result = this.db.run(`INSERT INTO projects (
|
|
18643
18907
|
id, name, path, description, task_list_id, task_prefix,
|
|
18644
|
-
task_counter, created_at, updated_at, machine_id
|
|
18645
|
-
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
|
|
18908
|
+
task_counter, parent_id, created_at, updated_at, machine_id
|
|
18909
|
+
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
|
|
18646
18910
|
project.id,
|
|
18647
18911
|
project.name,
|
|
18648
18912
|
project.path,
|
|
18649
18913
|
project.description,
|
|
18650
18914
|
project.task_list_id,
|
|
18651
18915
|
project.task_prefix,
|
|
18916
|
+
project.parent_id,
|
|
18652
18917
|
project.created_at,
|
|
18653
18918
|
project.updated_at,
|
|
18654
18919
|
project.machine_id ?? null
|
|
@@ -19044,7 +19309,10 @@ function taskListSlug(projectSlug) {
|
|
|
19044
19309
|
if (!slug || slug !== projectSlug) {
|
|
19045
19310
|
throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
|
|
19046
19311
|
}
|
|
19047
|
-
return
|
|
19312
|
+
return slug;
|
|
19313
|
+
}
|
|
19314
|
+
function legacyTaskListSlug(projectSlug) {
|
|
19315
|
+
return `todos-${normalizeSlug(projectSlug)}`;
|
|
19048
19316
|
}
|
|
19049
19317
|
function deterministicTaskPrefix(projectSlug) {
|
|
19050
19318
|
const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
|
|
@@ -19563,6 +19831,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
|
|
|
19563
19831
|
created_by_operation: false
|
|
19564
19832
|
};
|
|
19565
19833
|
}
|
|
19834
|
+
if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === legacyTaskListSlug(request.project_slug)) {
|
|
19835
|
+
return {
|
|
19836
|
+
record: boundExistingProjectRecord(conflict2),
|
|
19837
|
+
created_by_operation: false
|
|
19838
|
+
};
|
|
19839
|
+
}
|
|
19566
19840
|
return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
|
|
19567
19841
|
}
|
|
19568
19842
|
await this.fault("before_object_write", request);
|
|
@@ -19599,6 +19873,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
|
|
|
19599
19873
|
}
|
|
19600
19874
|
return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
|
|
19601
19875
|
}
|
|
19876
|
+
if (request.bind_existing === true) {
|
|
19877
|
+
const legacy = await transaction.findTaskListConflict(todosProjectId, legacyTaskListSlug(request.project_slug));
|
|
19878
|
+
if (legacy && legacy.project_id === todosProjectId) {
|
|
19879
|
+
return {
|
|
19880
|
+
record: boundExistingTaskListRecord(legacy),
|
|
19881
|
+
created_by_operation: false
|
|
19882
|
+
};
|
|
19883
|
+
}
|
|
19884
|
+
}
|
|
19602
19885
|
await this.fault("before_object_write", request);
|
|
19603
19886
|
const taskList = await transaction.createTaskList({
|
|
19604
19887
|
name: request.project_name,
|