@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
package/dist/storage.js
CHANGED
|
@@ -42,20 +42,16 @@ var __require = import.meta.require;
|
|
|
42
42
|
// src/storage/config.ts
|
|
43
43
|
var exports_config = {};
|
|
44
44
|
__export(exports_config, {
|
|
45
|
-
parseStorageMode: () => parseStorageMode,
|
|
46
45
|
parseStorageBackend: () => parseStorageBackend,
|
|
47
46
|
loadTodosStorageConfig: () => loadTodosStorageConfig,
|
|
48
47
|
loadStorageConfig: () => loadStorageConfig,
|
|
49
48
|
isTodosShadowEnabled: () => isTodosShadowEnabled,
|
|
50
|
-
isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
|
|
51
49
|
isTodosPostgresBackend: () => isTodosPostgresBackend,
|
|
52
50
|
getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
|
|
53
|
-
getTodosStorageMode: () => getTodosStorageMode,
|
|
54
51
|
getTodosStorageEnvName: () => getTodosStorageEnvName,
|
|
55
52
|
getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
|
|
56
53
|
getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
|
|
57
54
|
getTodosStorageBackend: () => getTodosStorageBackend,
|
|
58
|
-
getStorageMode: () => getStorageMode,
|
|
59
55
|
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
60
56
|
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
61
57
|
getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
|
|
@@ -65,7 +61,6 @@ __export(exports_config, {
|
|
|
65
61
|
TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
|
|
66
62
|
TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
|
|
67
63
|
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
68
|
-
REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
|
|
69
64
|
CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
|
|
70
65
|
CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
|
|
71
66
|
CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
|
|
@@ -120,9 +115,6 @@ function loadStorageConfig(env = process.env) {
|
|
|
120
115
|
function isTodosPostgresBackend(config) {
|
|
121
116
|
return config.mode === "postgres";
|
|
122
117
|
}
|
|
123
|
-
function isTodosRemoteStorageEnabled(config) {
|
|
124
|
-
return isTodosPostgresBackend(config);
|
|
125
|
-
}
|
|
126
118
|
function assertTodosRemoteStorageConfig(config) {
|
|
127
119
|
if (!isTodosPostgresBackend(config))
|
|
128
120
|
return;
|
|
@@ -138,32 +130,15 @@ function parseStorageBackend(value) {
|
|
|
138
130
|
return "sqlite";
|
|
139
131
|
if (normalized === "postgres" || normalized === "postgresql")
|
|
140
132
|
return "postgres";
|
|
141
|
-
if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
|
|
142
|
-
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.`);
|
|
143
|
-
}
|
|
144
133
|
throw new Error(`Storage backend must be sqlite or postgres`);
|
|
145
134
|
}
|
|
146
|
-
function parseStorageMode(value) {
|
|
147
|
-
return parseStorageBackend(value);
|
|
148
|
-
}
|
|
149
135
|
function getTodosStorageBackend(env = process.env) {
|
|
150
|
-
for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
|
|
151
|
-
if (Object.hasOwn(env, key) && env[key] !== undefined) {
|
|
152
|
-
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.`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
136
|
if (!getTodosStorageDatabaseUrl(env))
|
|
156
137
|
return "sqlite";
|
|
157
138
|
if (isTodosShadowEnabled(env))
|
|
158
139
|
return "sqlite";
|
|
159
140
|
return "postgres";
|
|
160
141
|
}
|
|
161
|
-
function getTodosStorageMode(env = process.env) {
|
|
162
|
-
return getTodosStorageBackend(env);
|
|
163
|
-
}
|
|
164
|
-
function getStorageMode(env = process.env) {
|
|
165
|
-
return getTodosStorageBackend(env);
|
|
166
|
-
}
|
|
167
142
|
function isTodosShadowEnabled(env = process.env) {
|
|
168
143
|
return parseBoolean(readStorageEnv(env, "shadow").value, false);
|
|
169
144
|
}
|
|
@@ -230,7 +205,7 @@ function parsePositiveInteger(value, fallback) {
|
|
|
230
205
|
}
|
|
231
206
|
return parsed;
|
|
232
207
|
}
|
|
233
|
-
var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV,
|
|
208
|
+
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";
|
|
234
209
|
var init_config = __esm(() => {
|
|
235
210
|
TODOS_STORAGE_TABLES = [
|
|
236
211
|
"todos_sync_records",
|
|
@@ -269,12 +244,6 @@ var init_config = __esm(() => {
|
|
|
269
244
|
syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
|
|
270
245
|
syncDryRun: "TODOS_SYNC_DRY_RUN"
|
|
271
246
|
};
|
|
272
|
-
REMOVED_STORAGE_MODE_ENV_KEYS = [
|
|
273
|
-
"HASNA_TODOS_STORAGE_MODE",
|
|
274
|
-
"HASNA_TODOS_MODE",
|
|
275
|
-
"TODOS_STORAGE_MODE",
|
|
276
|
-
"TODOS_MODE"
|
|
277
|
-
];
|
|
278
247
|
});
|
|
279
248
|
|
|
280
249
|
// src/types/index.ts
|
|
@@ -2335,6 +2304,10 @@ var init_migrations = __esm(() => {
|
|
|
2335
2304
|
);
|
|
2336
2305
|
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
2337
2306
|
COMMIT;
|
|
2307
|
+
`,
|
|
2308
|
+
`BEGIN;
|
|
2309
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (71);
|
|
2310
|
+
COMMIT;
|
|
2338
2311
|
`
|
|
2339
2312
|
];
|
|
2340
2313
|
});
|
|
@@ -3008,6 +2981,8 @@ function ensureSchema(db) {
|
|
|
3008
2981
|
ensureColumn("projects", "task_list_id", "TEXT");
|
|
3009
2982
|
ensureColumn("projects", "task_prefix", "TEXT");
|
|
3010
2983
|
ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
|
|
2984
|
+
ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
2985
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
|
|
3011
2986
|
ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
|
|
3012
2987
|
ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
|
|
3013
2988
|
ensureColumn("tasks", "short_id", "TEXT");
|
|
@@ -3121,6 +3096,20 @@ function ensureSchema(db) {
|
|
|
3121
3096
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON webhook_deliveries(event)");
|
|
3122
3097
|
ensureColumn("task_comments", "type", "TEXT DEFAULT 'comment'");
|
|
3123
3098
|
ensureColumn("task_comments", "progress_pct", "INTEGER");
|
|
3099
|
+
ensureTable("plan_comments", `
|
|
3100
|
+
CREATE TABLE plan_comments (
|
|
3101
|
+
id TEXT PRIMARY KEY,
|
|
3102
|
+
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
|
3103
|
+
agent_id TEXT,
|
|
3104
|
+
session_id TEXT,
|
|
3105
|
+
content TEXT NOT NULL,
|
|
3106
|
+
type TEXT DEFAULT 'comment' CHECK(type IN ('comment', 'progress', 'note')),
|
|
3107
|
+
progress_pct INTEGER CHECK(progress_pct IS NULL OR (progress_pct >= 0 AND progress_pct <= 100)),
|
|
3108
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3109
|
+
machine_id TEXT,
|
|
3110
|
+
synced_at TEXT
|
|
3111
|
+
)`);
|
|
3112
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_plan_comments_plan ON plan_comments(plan_id)");
|
|
3124
3113
|
ensureColumn("projects", "machine_id", "TEXT");
|
|
3125
3114
|
ensureColumn("projects", "synced_at", "TEXT");
|
|
3126
3115
|
ensureColumn("tasks", "machine_id", "TEXT");
|
|
@@ -4102,6 +4091,7 @@ var init_machines = __esm(() => {
|
|
|
4102
4091
|
"task_lists",
|
|
4103
4092
|
"plans",
|
|
4104
4093
|
"task_comments",
|
|
4094
|
+
"plan_comments",
|
|
4105
4095
|
"sessions",
|
|
4106
4096
|
"task_history",
|
|
4107
4097
|
"webhooks",
|
|
@@ -5045,17 +5035,24 @@ function createProject(input, db) {
|
|
|
5045
5035
|
const id = uuid();
|
|
5046
5036
|
const timestamp2 = now();
|
|
5047
5037
|
const derivedSlug = slugify(input.name);
|
|
5048
|
-
const taskListId = input.task_list_id === undefined ?
|
|
5038
|
+
const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
|
|
5049
5039
|
if (!derivedSlug || !taskListId)
|
|
5050
5040
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
5051
5041
|
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
5052
5042
|
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
5053
5043
|
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
5054
5044
|
}
|
|
5045
|
+
const parentId = input.parent_id ?? null;
|
|
5046
|
+
if (parentId !== null) {
|
|
5047
|
+
const parent = getProject(parentId, d);
|
|
5048
|
+
if (!parent)
|
|
5049
|
+
throw new ProjectNotFoundError(parentId);
|
|
5050
|
+
assertNotProjectAncestor(id, parentId, d);
|
|
5051
|
+
}
|
|
5055
5052
|
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
5056
5053
|
const machineId = currentStorageMachineId(d);
|
|
5057
|
-
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
5058
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
|
|
5054
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
|
|
5055
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
|
|
5059
5056
|
return getProject(id, d);
|
|
5060
5057
|
})();
|
|
5061
5058
|
}
|
|
@@ -5080,6 +5077,60 @@ function listProjects(db) {
|
|
|
5080
5077
|
const d = db || getDatabase();
|
|
5081
5078
|
return d.query("SELECT * FROM projects ORDER BY name").all();
|
|
5082
5079
|
}
|
|
5080
|
+
function orderProjectsParentFirst(projects) {
|
|
5081
|
+
const projectId = (project) => {
|
|
5082
|
+
const id = project.id;
|
|
5083
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
5084
|
+
};
|
|
5085
|
+
const parentId = (project) => {
|
|
5086
|
+
const parent = project.parent_id;
|
|
5087
|
+
return parent == null ? null : String(parent);
|
|
5088
|
+
};
|
|
5089
|
+
const byId = new Set;
|
|
5090
|
+
for (const project of projects) {
|
|
5091
|
+
const id = projectId(project);
|
|
5092
|
+
if (id !== null)
|
|
5093
|
+
byId.add(id);
|
|
5094
|
+
}
|
|
5095
|
+
const ordered = [];
|
|
5096
|
+
const emitted = new Set;
|
|
5097
|
+
let remaining = [...projects];
|
|
5098
|
+
let progress = true;
|
|
5099
|
+
while (progress && remaining.length > 0) {
|
|
5100
|
+
progress = false;
|
|
5101
|
+
const deferred = [];
|
|
5102
|
+
for (const project of remaining) {
|
|
5103
|
+
const parent = parentId(project);
|
|
5104
|
+
if (parent === null || emitted.has(parent) || !byId.has(parent)) {
|
|
5105
|
+
ordered.push(project);
|
|
5106
|
+
const id = projectId(project);
|
|
5107
|
+
if (id !== null)
|
|
5108
|
+
emitted.add(id);
|
|
5109
|
+
progress = true;
|
|
5110
|
+
} else {
|
|
5111
|
+
deferred.push(project);
|
|
5112
|
+
}
|
|
5113
|
+
}
|
|
5114
|
+
remaining = deferred;
|
|
5115
|
+
}
|
|
5116
|
+
ordered.push(...remaining);
|
|
5117
|
+
return ordered;
|
|
5118
|
+
}
|
|
5119
|
+
function assertNotProjectAncestor(projectId, ancestorId, db) {
|
|
5120
|
+
const d = db || getDatabase();
|
|
5121
|
+
let cursor = ancestorId;
|
|
5122
|
+
const seen = new Set;
|
|
5123
|
+
while (cursor !== null) {
|
|
5124
|
+
if (cursor === projectId) {
|
|
5125
|
+
throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
|
|
5126
|
+
}
|
|
5127
|
+
if (seen.has(cursor))
|
|
5128
|
+
break;
|
|
5129
|
+
seen.add(cursor);
|
|
5130
|
+
const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
|
|
5131
|
+
cursor = row?.parent_id ?? null;
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5083
5134
|
function updateProject(id, input, db) {
|
|
5084
5135
|
const d = db || getDatabase();
|
|
5085
5136
|
const project = getProject(id, d);
|
|
@@ -5102,6 +5153,16 @@ function updateProject(id, input, db) {
|
|
|
5102
5153
|
sets.push("path = ?");
|
|
5103
5154
|
params.push(input.path);
|
|
5104
5155
|
}
|
|
5156
|
+
if (input.parent_id !== undefined) {
|
|
5157
|
+
if (input.parent_id !== null) {
|
|
5158
|
+
const parent = getProject(input.parent_id, d);
|
|
5159
|
+
if (!parent)
|
|
5160
|
+
throw new ProjectNotFoundError(input.parent_id);
|
|
5161
|
+
assertNotProjectAncestor(id, input.parent_id, d);
|
|
5162
|
+
}
|
|
5163
|
+
sets.push("parent_id = ?");
|
|
5164
|
+
params.push(input.parent_id);
|
|
5165
|
+
}
|
|
5105
5166
|
params.push(id);
|
|
5106
5167
|
d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
5107
5168
|
return getProject(id, d);
|
|
@@ -6780,22 +6841,31 @@ function updateTaskList(id, input, db) {
|
|
|
6780
6841
|
throw new TaskListNotFoundError(id);
|
|
6781
6842
|
const sets = ["updated_at = ?"];
|
|
6782
6843
|
const params = [now()];
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6844
|
+
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? existing.project_id : input.project_id;
|
|
6845
|
+
const scopeChanged = projectId !== existing.project_id;
|
|
6846
|
+
const effectiveSlug = input.slug !== undefined ? slugify(input.slug) : existing.slug;
|
|
6847
|
+
if (input.slug !== undefined && !effectiveSlug) {
|
|
6848
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
6849
|
+
}
|
|
6850
|
+
if (scopeChanged || input.slug !== undefined && effectiveSlug !== existing.slug) {
|
|
6851
|
+
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);
|
|
6788
6852
|
if (duplicate) {
|
|
6789
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
6853
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
6790
6854
|
}
|
|
6791
|
-
if (
|
|
6855
|
+
if (scopeChanged || effectiveSlug !== existing.slug) {
|
|
6792
6856
|
releaseCanonicalSlugClaims("task_list", id, d);
|
|
6793
|
-
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(
|
|
6794
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
6857
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(projectId), effectiveSlug, id, d)) {
|
|
6858
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
6795
6859
|
}
|
|
6796
6860
|
}
|
|
6861
|
+
}
|
|
6862
|
+
if (input.slug !== undefined) {
|
|
6797
6863
|
sets.push("slug = ?");
|
|
6798
|
-
params.push(
|
|
6864
|
+
params.push(effectiveSlug);
|
|
6865
|
+
}
|
|
6866
|
+
if (projectId !== existing.project_id) {
|
|
6867
|
+
sets.push("project_id = ?");
|
|
6868
|
+
params.push(projectId);
|
|
6799
6869
|
}
|
|
6800
6870
|
if (input.name !== undefined) {
|
|
6801
6871
|
sets.push("name = ?");
|
|
@@ -9244,8 +9314,8 @@ function failTask(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 failTask(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
|
logTaskChange(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(() => {
|
|
@@ -12890,6 +12971,40 @@ init_tasks();
|
|
|
12890
12971
|
init_projects();
|
|
12891
12972
|
init_plans();
|
|
12892
12973
|
|
|
12974
|
+
// src/db/plan-comments.ts
|
|
12975
|
+
init_types();
|
|
12976
|
+
init_database();
|
|
12977
|
+
init_plans();
|
|
12978
|
+
init_prewrite_secrets();
|
|
12979
|
+
function addPlanComment(input, db) {
|
|
12980
|
+
const d = db || getDatabase();
|
|
12981
|
+
if (!getPlan(input.plan_id, d)) {
|
|
12982
|
+
throw new PlanNotFoundError(input.plan_id);
|
|
12983
|
+
}
|
|
12984
|
+
const id = uuid();
|
|
12985
|
+
const timestamp2 = now();
|
|
12986
|
+
d.run(`INSERT INTO plan_comments (id, plan_id, agent_id, session_id, content, type, progress_pct, created_at)
|
|
12987
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
12988
|
+
id,
|
|
12989
|
+
input.plan_id,
|
|
12990
|
+
input.agent_id || null,
|
|
12991
|
+
input.session_id || null,
|
|
12992
|
+
sanitizePreWriteText(input.content, "comment.content"),
|
|
12993
|
+
input.type || "comment",
|
|
12994
|
+
input.progress_pct ?? null,
|
|
12995
|
+
timestamp2
|
|
12996
|
+
]);
|
|
12997
|
+
return getPlanComment(id, d);
|
|
12998
|
+
}
|
|
12999
|
+
function getPlanComment(id, db) {
|
|
13000
|
+
const d = db || getDatabase();
|
|
13001
|
+
return d.query("SELECT * FROM plan_comments WHERE id = ?").get(id);
|
|
13002
|
+
}
|
|
13003
|
+
function listPlanComments(planId, db) {
|
|
13004
|
+
const d = db || getDatabase();
|
|
13005
|
+
return d.query("SELECT * FROM plan_comments WHERE plan_id = ? ORDER BY created_at, rowid").all(planId);
|
|
13006
|
+
}
|
|
13007
|
+
|
|
12893
13008
|
// src/db/agents.ts
|
|
12894
13009
|
init_types();
|
|
12895
13010
|
init_database();
|
|
@@ -13547,6 +13662,20 @@ init_task_lists();
|
|
|
13547
13662
|
init_templates();
|
|
13548
13663
|
init_audit();
|
|
13549
13664
|
init_comments();
|
|
13665
|
+
|
|
13666
|
+
// src/lib/comment-cursor.ts
|
|
13667
|
+
function isStrictlyOlder(comment, before) {
|
|
13668
|
+
return comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id;
|
|
13669
|
+
}
|
|
13670
|
+
function compareCommentKeyset(left, right) {
|
|
13671
|
+
if (isStrictlyOlder(left, right))
|
|
13672
|
+
return -1;
|
|
13673
|
+
if (isStrictlyOlder(right, left))
|
|
13674
|
+
return 1;
|
|
13675
|
+
return 0;
|
|
13676
|
+
}
|
|
13677
|
+
|
|
13678
|
+
// src/storage/local-sqlite.ts
|
|
13550
13679
|
init_database();
|
|
13551
13680
|
|
|
13552
13681
|
// src/db/integrity.ts
|
|
@@ -14091,6 +14220,7 @@ var PROJECT_COLUMNS = [
|
|
|
14091
14220
|
"task_list_id",
|
|
14092
14221
|
"task_prefix",
|
|
14093
14222
|
"task_counter",
|
|
14223
|
+
"parent_id",
|
|
14094
14224
|
"created_at",
|
|
14095
14225
|
"updated_at",
|
|
14096
14226
|
"machine_id",
|
|
@@ -14310,7 +14440,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14310
14440
|
}
|
|
14311
14441
|
}
|
|
14312
14442
|
};
|
|
14313
|
-
applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
|
|
14443
|
+
applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
|
|
14314
14444
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
14315
14445
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
14316
14446
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
@@ -14642,7 +14772,22 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14642
14772
|
list: (projectId) => listPlans(projectId, database()),
|
|
14643
14773
|
update: (id, input) => updatePlan(id, input, database()),
|
|
14644
14774
|
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
14645
|
-
delete: (id) => deletePlan(id, database())
|
|
14775
|
+
delete: (id) => deletePlan(id, database()),
|
|
14776
|
+
addComment: (input) => addPlanComment(input, database()),
|
|
14777
|
+
getComments: (planId) => listPlanComments(planId, database()),
|
|
14778
|
+
getCommentsPage: (planId, options2) => {
|
|
14779
|
+
if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
|
|
14780
|
+
throw new Error("Plan comment limit must be an integer between 1 and 1001");
|
|
14781
|
+
}
|
|
14782
|
+
let comments = listPlanComments(planId, database());
|
|
14783
|
+
comments = comments.sort(compareCommentKeyset);
|
|
14784
|
+
if (options2?.before) {
|
|
14785
|
+
const before = options2.before;
|
|
14786
|
+
comments = comments.filter((comment) => isStrictlyOlder(comment, before));
|
|
14787
|
+
}
|
|
14788
|
+
const limit = options2?.limit ?? 100;
|
|
14789
|
+
return comments.slice(-limit);
|
|
14790
|
+
}
|
|
14646
14791
|
},
|
|
14647
14792
|
planProjectLinks: {
|
|
14648
14793
|
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
@@ -14683,10 +14828,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14683
14828
|
throw new Error("Comment limit must be an integer between 1 and 1001");
|
|
14684
14829
|
}
|
|
14685
14830
|
let comments = listComments(taskId, database());
|
|
14686
|
-
comments = comments.sort(
|
|
14831
|
+
comments = comments.sort(compareCommentKeyset);
|
|
14687
14832
|
if (options2?.before) {
|
|
14688
14833
|
const before = options2.before;
|
|
14689
|
-
comments = comments.filter((comment) => comment
|
|
14834
|
+
comments = comments.filter((comment) => isStrictlyOlder(comment, before));
|
|
14690
14835
|
}
|
|
14691
14836
|
if (options2?.limit !== undefined)
|
|
14692
14837
|
comments = comments.slice(-options2.limit);
|
|
@@ -15216,7 +15361,26 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15216
15361
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
15217
15362
|
update: (id, input) => updatePlan2(id, input, store),
|
|
15218
15363
|
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
15219
|
-
delete: (id, context) => store.deletePlan(id, context)
|
|
15364
|
+
delete: (id, context) => store.deletePlan(id, context),
|
|
15365
|
+
addComment: (input, context) => addPlanComment2(input, store, context),
|
|
15366
|
+
getComments: async (planId) => {
|
|
15367
|
+
const pages = [];
|
|
15368
|
+
let before;
|
|
15369
|
+
while (true) {
|
|
15370
|
+
const page = await store.listPlanComments(planId, { limit: 1000, ...before ? { before } : {} });
|
|
15371
|
+
if (page.length === 0)
|
|
15372
|
+
break;
|
|
15373
|
+
pages.unshift(page);
|
|
15374
|
+
if (page.length < 1000)
|
|
15375
|
+
break;
|
|
15376
|
+
const oldest = page[0];
|
|
15377
|
+
before = { created_at: oldest.created_at, id: oldest.id };
|
|
15378
|
+
}
|
|
15379
|
+
return pages.flat().map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
15380
|
+
},
|
|
15381
|
+
getCommentsPage: async (planId, options2) => {
|
|
15382
|
+
return (await store.listPlanComments(planId, options2)).map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
15383
|
+
}
|
|
15220
15384
|
},
|
|
15221
15385
|
planProjectLinks: {
|
|
15222
15386
|
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
@@ -15434,6 +15598,27 @@ class PostgresJsonRecordStore {
|
|
|
15434
15598
|
LIMIT $${params.length}`, params);
|
|
15435
15599
|
return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
|
|
15436
15600
|
}
|
|
15601
|
+
async listPlanComments(planId, options = {}) {
|
|
15602
|
+
await this.ensureSchema();
|
|
15603
|
+
const limit = options.limit ?? 100;
|
|
15604
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
|
|
15605
|
+
throw new Error("Postgres plan comment limit must be an integer between 1 and 1001");
|
|
15606
|
+
}
|
|
15607
|
+
const params = [this.service, planId];
|
|
15608
|
+
let cursorPredicate = "";
|
|
15609
|
+
if (options.before) {
|
|
15610
|
+
params.push(options.before.created_at, options.before.id);
|
|
15611
|
+
cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
|
|
15612
|
+
}
|
|
15613
|
+
params.push(limit);
|
|
15614
|
+
const result = await this.options.client.query(`/* todos:list-plan-comments */ SELECT payload FROM ${this.tableName}
|
|
15615
|
+
WHERE service = $1 AND object_type = 'plan_comments' AND deleted_at IS NULL
|
|
15616
|
+
AND payload->>'plan_id' = $2
|
|
15617
|
+
${cursorPredicate}
|
|
15618
|
+
ORDER BY payload->>'created_at' DESC, object_id DESC
|
|
15619
|
+
LIMIT $${params.length}`, params);
|
|
15620
|
+
return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
|
|
15621
|
+
}
|
|
15437
15622
|
async listRecords(type) {
|
|
15438
15623
|
await this.ensureSchema();
|
|
15439
15624
|
const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
|
|
@@ -15765,8 +15950,12 @@ class PostgresJsonRecordStore {
|
|
|
15765
15950
|
OR ($11::text <> $2
|
|
15766
15951
|
AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
|
|
15767
15952
|
AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
|
|
15768
|
-
(SELECT
|
|
15769
|
-
|
|
15953
|
+
(COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
|
|
15954
|
+
IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
|
|
15955
|
+
(NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
|
|
15956
|
+
IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
|
|
15957
|
+
OR $8::text IS NULL
|
|
15958
|
+
OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
15770
15959
|
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
15771
15960
|
), guarded AS (
|
|
15772
15961
|
SELECT
|
|
@@ -15789,7 +15978,6 @@ class PostgresJsonRecordStore {
|
|
|
15789
15978
|
AND guarded.version_matches
|
|
15790
15979
|
AND guarded.parent_found
|
|
15791
15980
|
AND guarded.parent_acyclic
|
|
15792
|
-
AND guarded.all_plans_found
|
|
15793
15981
|
AND guarded.target_plan_found
|
|
15794
15982
|
AND NOT guarded.project_conflict
|
|
15795
15983
|
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
@@ -15805,7 +15993,7 @@ class PostgresJsonRecordStore {
|
|
|
15805
15993
|
RETURNING payload
|
|
15806
15994
|
)
|
|
15807
15995
|
SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
|
|
15808
|
-
guarded.
|
|
15996
|
+
guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
|
|
15809
15997
|
(SELECT payload FROM stored) AS payload,
|
|
15810
15998
|
(SELECT payload FROM locked_task) AS current_payload
|
|
15811
15999
|
FROM guarded`, [
|
|
@@ -15837,7 +16025,7 @@ class PostgresJsonRecordStore {
|
|
|
15837
16025
|
if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
|
|
15838
16026
|
throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
|
|
15839
16027
|
}
|
|
15840
|
-
if (!row?.
|
|
16028
|
+
if (!row?.target_plan_found) {
|
|
15841
16029
|
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 });
|
|
15842
16030
|
}
|
|
15843
16031
|
if (row.project_conflict) {
|
|
@@ -16592,6 +16780,15 @@ class PostgresJsonRecordStore {
|
|
|
16592
16780
|
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
16593
16781
|
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
|
|
16594
16782
|
RETURNING 1
|
|
16783
|
+
), deleted_plan_comments AS (
|
|
16784
|
+
UPDATE ${this.tableName} r SET
|
|
16785
|
+
deleted_at = $3::timestamptz,
|
|
16786
|
+
updated_at = $3::timestamptz,
|
|
16787
|
+
source_machine_id = COALESCE($4, r.source_machine_id),
|
|
16788
|
+
version = COALESCE(r.version, 0) + 1
|
|
16789
|
+
WHERE r.service = $1 AND r.object_type = 'plan_comments' AND r.deleted_at IS NULL
|
|
16790
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
|
|
16791
|
+
RETURNING 1
|
|
16595
16792
|
), deleted_plan AS (
|
|
16596
16793
|
UPDATE ${this.tableName} r SET
|
|
16597
16794
|
deleted_at = $3::timestamptz,
|
|
@@ -16767,7 +16964,8 @@ async function updateTask2(id, input, store, context) {
|
|
|
16767
16964
|
task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
|
|
16768
16965
|
parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
|
|
16769
16966
|
created_by: existing.created_by,
|
|
16770
|
-
completed_at:
|
|
16967
|
+
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,
|
|
16968
|
+
started_at: input.status === "in_progress" ? existing.started_at ?? new Date().toISOString() : existing.started_at
|
|
16771
16969
|
};
|
|
16772
16970
|
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
|
|
16773
16971
|
operation: "update",
|
|
@@ -16812,7 +17010,8 @@ async function failTask2(id, agentId, reason, options, store) {
|
|
|
16812
17010
|
status: "failed",
|
|
16813
17011
|
assigned_to: task.assigned_to ?? agentId ?? null,
|
|
16814
17012
|
reason: reason ?? task.reason,
|
|
16815
|
-
retry_after: options?.retry_after ?? task.retry_after
|
|
17013
|
+
retry_after: options?.retry_after ?? task.retry_after,
|
|
17014
|
+
completed_at: task.completed_at ?? new Date().toISOString()
|
|
16816
17015
|
}, store);
|
|
16817
17016
|
if (!options?.retry)
|
|
16818
17017
|
return { task: failed };
|
|
@@ -17049,17 +17248,26 @@ async function getChangedSince(since, filters, store) {
|
|
|
17049
17248
|
async function createProject2(input, store, context) {
|
|
17050
17249
|
const timestamp2 = new Date().toISOString();
|
|
17051
17250
|
const derivedSlug = slugifyRaw(input.name);
|
|
17052
|
-
const taskListId = input.task_list_id === undefined ?
|
|
17251
|
+
const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
|
|
17053
17252
|
if (!derivedSlug || !taskListId)
|
|
17054
17253
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
17254
|
+
const parentId = input.parent_id ?? null;
|
|
17255
|
+
const id = randomUUID3();
|
|
17256
|
+
if (parentId !== null) {
|
|
17257
|
+
const parent = await store.get("projects", parentId);
|
|
17258
|
+
if (!parent)
|
|
17259
|
+
throw new ProjectNotFoundError(parentId);
|
|
17260
|
+
await assertNotProjectAncestorPostgres(id, parentId, store);
|
|
17261
|
+
}
|
|
17055
17262
|
const project = {
|
|
17056
|
-
id
|
|
17263
|
+
id,
|
|
17057
17264
|
name: input.name,
|
|
17058
17265
|
path: input.path,
|
|
17059
17266
|
description: input.description ?? null,
|
|
17060
17267
|
task_list_id: taskListId,
|
|
17061
17268
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
17062
17269
|
task_counter: 0,
|
|
17270
|
+
parent_id: parentId,
|
|
17063
17271
|
created_at: timestamp2,
|
|
17064
17272
|
updated_at: timestamp2,
|
|
17065
17273
|
machine_id: store.machineId(context),
|
|
@@ -17067,12 +17275,38 @@ async function createProject2(input, store, context) {
|
|
|
17067
17275
|
};
|
|
17068
17276
|
return store.upsert("projects", project, context);
|
|
17069
17277
|
}
|
|
17278
|
+
async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
|
|
17279
|
+
const all = await store.list("projects");
|
|
17280
|
+
const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
|
|
17281
|
+
let cursor = candidateParentId;
|
|
17282
|
+
const seen = new Set;
|
|
17283
|
+
while (cursor !== null) {
|
|
17284
|
+
if (cursor === projectId) {
|
|
17285
|
+
throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
|
|
17286
|
+
}
|
|
17287
|
+
if (seen.has(cursor))
|
|
17288
|
+
break;
|
|
17289
|
+
seen.add(cursor);
|
|
17290
|
+
cursor = byId.get(cursor) ?? null;
|
|
17291
|
+
}
|
|
17292
|
+
}
|
|
17070
17293
|
async function updateProject2(id, input, store) {
|
|
17071
17294
|
if ("task_list_id" in input) {
|
|
17072
17295
|
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
17073
17296
|
}
|
|
17074
17297
|
const project = await requireRecord("projects", id, store);
|
|
17075
|
-
|
|
17298
|
+
if (input.parent_id !== undefined && input.parent_id !== null) {
|
|
17299
|
+
const parent = await store.get("projects", input.parent_id);
|
|
17300
|
+
if (!parent)
|
|
17301
|
+
throw new ProjectNotFoundError(input.parent_id);
|
|
17302
|
+
await assertNotProjectAncestorPostgres(id, input.parent_id, store);
|
|
17303
|
+
}
|
|
17304
|
+
const updated = {
|
|
17305
|
+
...project,
|
|
17306
|
+
...definedPatch(input),
|
|
17307
|
+
...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
|
|
17308
|
+
updated_at: new Date().toISOString()
|
|
17309
|
+
};
|
|
17076
17310
|
return store.upsert("projects", updated);
|
|
17077
17311
|
}
|
|
17078
17312
|
async function createPlan2(input, store, context) {
|
|
@@ -17225,16 +17459,22 @@ async function createTaskList2(input, store, context) {
|
|
|
17225
17459
|
async function updateTaskList2(id, input, store) {
|
|
17226
17460
|
const list = await requireRecord("task_lists", id, store);
|
|
17227
17461
|
const patch = definedPatch(input);
|
|
17462
|
+
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
|
|
17228
17463
|
if (input.slug !== undefined) {
|
|
17229
17464
|
const slug = slugifyRaw(input.slug);
|
|
17230
17465
|
if (!slug)
|
|
17231
17466
|
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
17232
|
-
|
|
17467
|
+
patch.slug = slug;
|
|
17468
|
+
}
|
|
17469
|
+
const effectiveSlug = patch.slug ?? list.slug;
|
|
17470
|
+
if (projectId !== list.project_id || effectiveSlug !== list.slug) {
|
|
17471
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === projectId && candidate.slug === effectiveSlug);
|
|
17233
17472
|
if (duplicate) {
|
|
17234
|
-
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${
|
|
17473
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
|
|
17235
17474
|
}
|
|
17236
|
-
patch.slug = slug;
|
|
17237
17475
|
}
|
|
17476
|
+
if (projectId !== list.project_id)
|
|
17477
|
+
patch.project_id = projectId;
|
|
17238
17478
|
return store.upsert("task_lists", {
|
|
17239
17479
|
...list,
|
|
17240
17480
|
...patch,
|
|
@@ -17327,6 +17567,22 @@ async function addComment2(input, store, context) {
|
|
|
17327
17567
|
function redactComment(comment) {
|
|
17328
17568
|
return { ...comment, content: redactEvidenceText(comment.content) };
|
|
17329
17569
|
}
|
|
17570
|
+
async function addPlanComment2(input, store, context) {
|
|
17571
|
+
const comment = {
|
|
17572
|
+
id: randomUUID3(),
|
|
17573
|
+
plan_id: input.plan_id,
|
|
17574
|
+
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
17575
|
+
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
17576
|
+
content: redactEvidenceText(input.content),
|
|
17577
|
+
type: input.type ?? "comment",
|
|
17578
|
+
progress_pct: input.progress_pct ?? null,
|
|
17579
|
+
created_at: new Date().toISOString()
|
|
17580
|
+
};
|
|
17581
|
+
return store.upsert("plan_comments", comment, context);
|
|
17582
|
+
}
|
|
17583
|
+
function redactPlanComment(comment) {
|
|
17584
|
+
return { ...comment, content: redactEvidenceText(comment.content) };
|
|
17585
|
+
}
|
|
17330
17586
|
async function exportSnapshot(store) {
|
|
17331
17587
|
return {
|
|
17332
17588
|
exportedAt: new Date().toISOString(),
|
|
@@ -18935,24 +19191,20 @@ export {
|
|
|
18935
19191
|
postgresTodosScopedSlugIndexStatusSql,
|
|
18936
19192
|
postgresTodosCommentCursorIndexSql,
|
|
18937
19193
|
planRunArtifactsS3Sync,
|
|
18938
|
-
parseStorageMode,
|
|
18939
19194
|
parseStorageBackend,
|
|
18940
19195
|
maybeInstallShadowCapture2 as maybeInstallShadowCapture,
|
|
18941
19196
|
loadTodosStorageConfig,
|
|
18942
19197
|
loadStorageConfig,
|
|
18943
19198
|
isTodosShadowEnabled,
|
|
18944
|
-
isTodosRemoteStorageEnabled,
|
|
18945
19199
|
isTodosPostgresBackend,
|
|
18946
19200
|
isCommentRedactionBackfillComplete,
|
|
18947
19201
|
installShadowOutboxSchema,
|
|
18948
19202
|
importSqliteTodosStorageSnapshot,
|
|
18949
19203
|
getTodosStorageShadowEnvName,
|
|
18950
|
-
getTodosStorageMode,
|
|
18951
19204
|
getTodosStorageEnvName,
|
|
18952
19205
|
getTodosStorageDatabaseUrl,
|
|
18953
19206
|
getTodosStorageDatabaseEnv,
|
|
18954
19207
|
getTodosStorageBackend,
|
|
18955
|
-
getStorageMode,
|
|
18956
19208
|
getStorageDatabaseUrl,
|
|
18957
19209
|
getStorageDatabaseEnv,
|
|
18958
19210
|
getRuntimeShadowOutbox,
|