handmux 0.24.0 → 0.25.0
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/README.md +29 -6
- package/README.zh-CN.md +29 -6
- package/bin/handmux.js +15 -3
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +200 -35
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +45 -3
- package/dist/hooks/handmux-write.cjs +175 -10
- package/dist/package.json +10 -9
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +22 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +52 -7
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +27 -9
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +17 -3
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +11 -2
- package/dist/src/claudeEvents.js +35 -9
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +49 -3
- package/dist/src/cli/codexManaged.js +6 -3
- package/dist/src/cli/hookScaffold.js +4 -1
- package/dist/src/cli/i18n/en.js +30 -4
- package/dist/src/cli/i18n/zh.js +30 -4
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/service.js +19 -9
- package/dist/src/cli/state.js +3 -0
- package/dist/src/cli/statusLine.js +19 -11
- package/dist/src/codexAppServer.js +1296 -159
- package/dist/src/codexQueueProtocol.js +41 -0
- package/dist/src/codexTranscriptParse.js +256 -145
- package/dist/src/codexUsageSnapshot.js +29 -6
- package/dist/src/git.js +13 -1
- package/dist/src/httpApi.js +35 -7
- package/dist/src/orphans.js +13 -3
- package/dist/src/paneInput.js +42 -0
- package/dist/src/privateStateStore.js +35 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/git.js +11 -0
- package/dist/src/routes/sessions.js +16 -4
- package/dist/src/routes/system.js +69 -5
- package/dist/src/routes/terminal.js +22 -16
- package/dist/src/server.js +109 -6
- package/dist/src/tmux/commands.js +29 -1
- package/dist/src/transcriptParse.js +22 -1
- package/dist/src/transcriptReader.js +100 -4
- package/dist/src/uploadTypes.js +2 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +103 -6
- package/dist/src/workspace/restore.js +51 -2
- package/dist/src/workspace/tmuxAdapter.js +10 -0
- package/package.json +6 -5
- package/dist/public/assets/index-CicdY9E9.css +0 -32
- package/dist/public/assets/index-D4VP5t8h.js +0 -337
- package/dist/src/routes/codex.js +0 -606
- package/dist/src/routes/transcript.js +0 -197
- package/dist/src/usage.js +0 -260
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import { ProjectTaskError, requireExpectedVersion, requireTrimmedString, } from './schema.js';
|
|
4
|
+
function projectFromRow(row) {
|
|
5
|
+
return {
|
|
6
|
+
id: row.id,
|
|
7
|
+
name: row.name,
|
|
8
|
+
rootPath: row.root_path,
|
|
9
|
+
repositoryRoot: row.repository_root,
|
|
10
|
+
defaultAgent: row.default_agent,
|
|
11
|
+
executionMode: row.execution_mode,
|
|
12
|
+
version: row.version,
|
|
13
|
+
archivedAt: row.archived_at,
|
|
14
|
+
createdAt: row.created_at,
|
|
15
|
+
updatedAt: row.updated_at,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function rowOf(value) {
|
|
19
|
+
return value && typeof value === 'object' ? value : null;
|
|
20
|
+
}
|
|
21
|
+
function taskFromRow(row) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
projectId: row.project_id,
|
|
25
|
+
title: row.title,
|
|
26
|
+
objective: row.objective,
|
|
27
|
+
acceptanceCriteria: JSON.parse(row.acceptance_criteria_json),
|
|
28
|
+
scope: row.scope,
|
|
29
|
+
constraints: row.constraints,
|
|
30
|
+
references: JSON.parse(row.references_json),
|
|
31
|
+
status: row.status,
|
|
32
|
+
priority: row.priority,
|
|
33
|
+
briefVersion: row.brief_version,
|
|
34
|
+
version: row.version,
|
|
35
|
+
archivedAt: row.archived_at,
|
|
36
|
+
createdAt: row.created_at,
|
|
37
|
+
updatedAt: row.updated_at,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function taskEventFromRow(row) {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
taskId: row.task_id,
|
|
44
|
+
type: row.type,
|
|
45
|
+
actor: row.actor,
|
|
46
|
+
taskVersion: row.task_version,
|
|
47
|
+
briefVersion: row.brief_version,
|
|
48
|
+
payload: JSON.parse(row.payload_json),
|
|
49
|
+
createdAt: row.created_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const TASK_PRIORITIES = new Set(['none', 'high', 'medium', 'low']);
|
|
53
|
+
const BRIEF_FIELDS = ['title', 'objective', 'acceptanceCriteria', 'scope', 'constraints', 'references'];
|
|
54
|
+
function optionalText(value, field) {
|
|
55
|
+
if (value == null || value === '')
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof value !== 'string') {
|
|
58
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, `${field} must be text or null`);
|
|
59
|
+
}
|
|
60
|
+
return value.trim() || null;
|
|
61
|
+
}
|
|
62
|
+
function objective(value) {
|
|
63
|
+
if (value == null)
|
|
64
|
+
return '';
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'objective must be text');
|
|
67
|
+
}
|
|
68
|
+
return value.trim();
|
|
69
|
+
}
|
|
70
|
+
function acceptanceCriteria(value) {
|
|
71
|
+
if (value == null)
|
|
72
|
+
return [];
|
|
73
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
74
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'acceptanceCriteria must be a list of text items');
|
|
75
|
+
}
|
|
76
|
+
return value.map((item) => item.trim()).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
function references(value) {
|
|
79
|
+
if (value == null)
|
|
80
|
+
return [];
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'references must be a list');
|
|
83
|
+
}
|
|
84
|
+
return value.map((item) => {
|
|
85
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
86
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'each reference must be an object');
|
|
87
|
+
}
|
|
88
|
+
const record = item;
|
|
89
|
+
if (record.kind !== 'url' && record.kind !== 'path') {
|
|
90
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference kind must be url or path');
|
|
91
|
+
}
|
|
92
|
+
if (typeof record.value !== 'string' || !record.value.trim()) {
|
|
93
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference value is required');
|
|
94
|
+
}
|
|
95
|
+
if (record.label != null && typeof record.label !== 'string') {
|
|
96
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference label must be text or null');
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: record.kind,
|
|
100
|
+
value: record.value.trim(),
|
|
101
|
+
label: typeof record.label === 'string' ? record.label.trim() || null : null,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function priority(value) {
|
|
106
|
+
const result = value ?? 'none';
|
|
107
|
+
if (typeof result !== 'string' || !TASK_PRIORITIES.has(result)) {
|
|
108
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'priority is invalid');
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
function status(value) {
|
|
113
|
+
if (value !== 'draft' && value !== 'ready') {
|
|
114
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'status must be draft or ready');
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function validateReady(task) {
|
|
119
|
+
if (!task.objective) {
|
|
120
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'A task needs a goal');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function sameJson(left, right) {
|
|
124
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
125
|
+
}
|
|
126
|
+
export function createProjectTaskStore(db, { realpath = fsp.realpath, isDirectory = async (target) => (await fsp.stat(target)).isDirectory(), resolveRepositoryRoot = async () => null, randomUUID = crypto.randomUUID, now = () => new Date(), onSuccessfulWrite = () => { }, } = {}) {
|
|
127
|
+
const selectProject = db.prepare(`
|
|
128
|
+
SELECT id, name, root_path, repository_root, default_agent, execution_mode,
|
|
129
|
+
version, archived_at, created_at, updated_at
|
|
130
|
+
FROM projects WHERE id = ?
|
|
131
|
+
`);
|
|
132
|
+
const selectTask = db.prepare(`
|
|
133
|
+
SELECT id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
134
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
135
|
+
FROM tasks WHERE id = ?
|
|
136
|
+
`);
|
|
137
|
+
const selectActiveProjectRoot = db.prepare(`
|
|
138
|
+
SELECT id FROM projects WHERE root_path = ? AND archived_at IS NULL
|
|
139
|
+
`);
|
|
140
|
+
const insertTaskEvent = db.prepare(`
|
|
141
|
+
INSERT INTO task_events (
|
|
142
|
+
id, task_id, type, actor, task_version, brief_version, payload_json, created_at
|
|
143
|
+
) VALUES (?, ?, ?, 'user', ?, ?, ?, ?)
|
|
144
|
+
`);
|
|
145
|
+
const notifyWrite = () => {
|
|
146
|
+
Promise.resolve(onSuccessfulWrite()).catch(() => { });
|
|
147
|
+
};
|
|
148
|
+
const requireProject = (id) => {
|
|
149
|
+
const row = rowOf(selectProject.get(id));
|
|
150
|
+
if (!row)
|
|
151
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project no longer exists');
|
|
152
|
+
return projectFromRow(row);
|
|
153
|
+
};
|
|
154
|
+
const requireTask = (id) => {
|
|
155
|
+
const row = selectTask.get(id);
|
|
156
|
+
if (!row)
|
|
157
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task no longer exists');
|
|
158
|
+
return taskFromRow(row);
|
|
159
|
+
};
|
|
160
|
+
const assertTaskProjectActive = (task) => {
|
|
161
|
+
if (requireProject(task.projectId).archivedAt) {
|
|
162
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const transaction = (operation) => {
|
|
166
|
+
db.exec('BEGIN IMMEDIATE');
|
|
167
|
+
try {
|
|
168
|
+
const result = operation();
|
|
169
|
+
db.exec('COMMIT');
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
db.exec('ROLLBACK');
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const appendTaskEvent = (task, type, payload) => {
|
|
181
|
+
insertTaskEvent.run(randomUUID(), task.id, type, task.version, task.briefVersion, JSON.stringify(payload), task.updatedAt);
|
|
182
|
+
};
|
|
183
|
+
const assertTaskVersion = (task, rawVersion) => {
|
|
184
|
+
const expectedVersion = requireExpectedVersion(rawVersion, 'TASK_VALIDATION');
|
|
185
|
+
if (task.version !== expectedVersion) {
|
|
186
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Task changed; refresh before saving again');
|
|
187
|
+
}
|
|
188
|
+
return expectedVersion;
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
async listProjects({ archived = false } = {}) {
|
|
192
|
+
const rows = db.prepare(`
|
|
193
|
+
SELECT id, name, root_path, repository_root, default_agent, execution_mode,
|
|
194
|
+
version, archived_at, created_at, updated_at
|
|
195
|
+
FROM projects
|
|
196
|
+
WHERE ${archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'}
|
|
197
|
+
ORDER BY updated_at DESC, id
|
|
198
|
+
`).all();
|
|
199
|
+
return rows.map(projectFromRow);
|
|
200
|
+
},
|
|
201
|
+
async getProject(id) {
|
|
202
|
+
return requireProject(id);
|
|
203
|
+
},
|
|
204
|
+
async createProject(input) {
|
|
205
|
+
const name = requireTrimmedString(input.name, 'name', 120, 'PROJECT_VALIDATION');
|
|
206
|
+
if (typeof input.rootPath !== 'string' || !input.rootPath) {
|
|
207
|
+
throw new ProjectTaskError('PROJECT_VALIDATION', 400, 'rootPath must be an absolute directory');
|
|
208
|
+
}
|
|
209
|
+
let rootPath;
|
|
210
|
+
try {
|
|
211
|
+
rootPath = await realpath(input.rootPath);
|
|
212
|
+
if (!await isDirectory(rootPath))
|
|
213
|
+
throw new Error('not a directory');
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new ProjectTaskError('PROJECT_VALIDATION', 400, 'rootPath must be an existing directory');
|
|
217
|
+
}
|
|
218
|
+
const existing = selectActiveProjectRoot.get(rootPath);
|
|
219
|
+
if (existing) {
|
|
220
|
+
throw new ProjectTaskError('PROJECT_ROOT_EXISTS', 409, 'This directory is already a Project');
|
|
221
|
+
}
|
|
222
|
+
const id = randomUUID();
|
|
223
|
+
const timestamp = now().toISOString();
|
|
224
|
+
const repositoryRoot = await resolveRepositoryRoot(rootPath);
|
|
225
|
+
// Repository discovery is async. Recheck at the synchronous write boundary so two concurrent
|
|
226
|
+
// Add Project requests cannot both pass the first lookup and leak a raw SQLite constraint error.
|
|
227
|
+
if (selectActiveProjectRoot.get(rootPath)) {
|
|
228
|
+
throw new ProjectTaskError('PROJECT_ROOT_EXISTS', 409, 'This directory is already a Project');
|
|
229
|
+
}
|
|
230
|
+
db.prepare(`
|
|
231
|
+
INSERT INTO projects (
|
|
232
|
+
id, name, root_path, repository_root, default_agent, execution_mode,
|
|
233
|
+
version, archived_at, created_at, updated_at
|
|
234
|
+
) VALUES (?, ?, ?, ?, NULL, 'project-root', 1, NULL, ?, ?)
|
|
235
|
+
`).run(id, name, rootPath, repositoryRoot, timestamp, timestamp);
|
|
236
|
+
notifyWrite();
|
|
237
|
+
return requireProject(id);
|
|
238
|
+
},
|
|
239
|
+
async updateProject(id, input) {
|
|
240
|
+
const name = requireTrimmedString(input.name, 'name', 120, 'PROJECT_VALIDATION');
|
|
241
|
+
const expectedVersion = requireExpectedVersion(input.expectedVersion);
|
|
242
|
+
const timestamp = now().toISOString();
|
|
243
|
+
const result = db.prepare(`
|
|
244
|
+
UPDATE projects
|
|
245
|
+
SET name = ?, version = version + 1, updated_at = ?
|
|
246
|
+
WHERE id = ? AND version = ? AND archived_at IS NULL
|
|
247
|
+
`).run(name, timestamp, id, expectedVersion);
|
|
248
|
+
if (result.changes === 0) {
|
|
249
|
+
const current = requireProject(id);
|
|
250
|
+
if (current.archivedAt) {
|
|
251
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
252
|
+
}
|
|
253
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Project changed; refresh before saving again');
|
|
254
|
+
}
|
|
255
|
+
notifyWrite();
|
|
256
|
+
return requireProject(id);
|
|
257
|
+
},
|
|
258
|
+
async archiveProject(id, input) {
|
|
259
|
+
const expectedVersion = requireExpectedVersion(input.expectedVersion);
|
|
260
|
+
const timestamp = now().toISOString();
|
|
261
|
+
const result = db.prepare(`
|
|
262
|
+
UPDATE projects
|
|
263
|
+
SET archived_at = ?, version = version + 1, updated_at = ?
|
|
264
|
+
WHERE id = ? AND version = ? AND archived_at IS NULL
|
|
265
|
+
`).run(timestamp, timestamp, id, expectedVersion);
|
|
266
|
+
if (result.changes === 0) {
|
|
267
|
+
const current = requireProject(id);
|
|
268
|
+
if (current.archivedAt)
|
|
269
|
+
return current;
|
|
270
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Project changed; refresh before archiving');
|
|
271
|
+
}
|
|
272
|
+
notifyWrite();
|
|
273
|
+
return requireProject(id);
|
|
274
|
+
},
|
|
275
|
+
async listTasks({ projectId, bucket }) {
|
|
276
|
+
if (typeof projectId !== 'string' || !projectId) {
|
|
277
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'projectId is required');
|
|
278
|
+
}
|
|
279
|
+
requireProject(projectId);
|
|
280
|
+
const clauses = {
|
|
281
|
+
tasks: "archived_at IS NULL AND status = 'ready'",
|
|
282
|
+
drafts: "archived_at IS NULL AND status = 'draft'",
|
|
283
|
+
canceled: "archived_at IS NULL AND status = 'canceled'",
|
|
284
|
+
archived: 'archived_at IS NOT NULL',
|
|
285
|
+
};
|
|
286
|
+
if (typeof bucket !== 'string' || !Object.hasOwn(clauses, bucket)) {
|
|
287
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'bucket must be tasks, drafts, canceled, or archived');
|
|
288
|
+
}
|
|
289
|
+
const rows = db.prepare(`
|
|
290
|
+
SELECT id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
291
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
292
|
+
FROM tasks
|
|
293
|
+
WHERE project_id = ? AND ${clauses[bucket]}
|
|
294
|
+
ORDER BY updated_at DESC, id
|
|
295
|
+
`).all(projectId);
|
|
296
|
+
return rows.map(taskFromRow);
|
|
297
|
+
},
|
|
298
|
+
async getTask(id) {
|
|
299
|
+
return requireTask(id);
|
|
300
|
+
},
|
|
301
|
+
async createTask(input) {
|
|
302
|
+
if (typeof input.projectId !== 'string' || !input.projectId) {
|
|
303
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'projectId is required');
|
|
304
|
+
}
|
|
305
|
+
const project = requireProject(input.projectId);
|
|
306
|
+
if (project.archivedAt)
|
|
307
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
308
|
+
const taskStatus = status(input.status);
|
|
309
|
+
const task = {
|
|
310
|
+
id: randomUUID(),
|
|
311
|
+
projectId: project.id,
|
|
312
|
+
title: requireTrimmedString(input.title, 'title', 240, 'TASK_VALIDATION'),
|
|
313
|
+
objective: objective(input.objective),
|
|
314
|
+
acceptanceCriteria: acceptanceCriteria(input.acceptanceCriteria),
|
|
315
|
+
scope: optionalText(input.scope, 'scope'),
|
|
316
|
+
constraints: optionalText(input.constraints, 'constraints'),
|
|
317
|
+
references: references(input.references),
|
|
318
|
+
status: taskStatus,
|
|
319
|
+
priority: priority(input.priority),
|
|
320
|
+
briefVersion: 1,
|
|
321
|
+
version: 1,
|
|
322
|
+
archivedAt: null,
|
|
323
|
+
createdAt: now().toISOString(),
|
|
324
|
+
updatedAt: '',
|
|
325
|
+
};
|
|
326
|
+
task.updatedAt = task.createdAt;
|
|
327
|
+
if (task.status === 'ready')
|
|
328
|
+
validateReady(task);
|
|
329
|
+
transaction(() => {
|
|
330
|
+
db.prepare(`
|
|
331
|
+
INSERT INTO tasks (
|
|
332
|
+
id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
333
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, NULL, ?, ?)
|
|
335
|
+
`).run(task.id, task.projectId, task.title, task.objective, JSON.stringify(task.acceptanceCriteria), task.scope, task.constraints, JSON.stringify(task.references), task.status, task.priority, task.createdAt, task.updatedAt);
|
|
336
|
+
appendTaskEvent(task, 'task.created', { status: task.status });
|
|
337
|
+
});
|
|
338
|
+
notifyWrite();
|
|
339
|
+
return requireTask(task.id);
|
|
340
|
+
},
|
|
341
|
+
async updateTask(id, input) {
|
|
342
|
+
const current = requireTask(id);
|
|
343
|
+
assertTaskProjectActive(current);
|
|
344
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
345
|
+
if (current.archivedAt)
|
|
346
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
347
|
+
if (current.status === 'canceled') {
|
|
348
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Canceled tasks are read-only');
|
|
349
|
+
}
|
|
350
|
+
const next = {
|
|
351
|
+
...current,
|
|
352
|
+
title: Object.hasOwn(input, 'title')
|
|
353
|
+
? requireTrimmedString(input.title, 'title', 240, 'TASK_VALIDATION') : current.title,
|
|
354
|
+
objective: Object.hasOwn(input, 'objective') ? objective(input.objective) : current.objective,
|
|
355
|
+
acceptanceCriteria: Object.hasOwn(input, 'acceptanceCriteria')
|
|
356
|
+
? acceptanceCriteria(input.acceptanceCriteria) : current.acceptanceCriteria,
|
|
357
|
+
scope: Object.hasOwn(input, 'scope') ? optionalText(input.scope, 'scope') : current.scope,
|
|
358
|
+
constraints: Object.hasOwn(input, 'constraints')
|
|
359
|
+
? optionalText(input.constraints, 'constraints') : current.constraints,
|
|
360
|
+
references: Object.hasOwn(input, 'references') ? references(input.references) : current.references,
|
|
361
|
+
priority: Object.hasOwn(input, 'priority') ? priority(input.priority) : current.priority,
|
|
362
|
+
};
|
|
363
|
+
if (next.status === 'ready')
|
|
364
|
+
validateReady(next);
|
|
365
|
+
const changedFields = [
|
|
366
|
+
...BRIEF_FIELDS.filter((field) => !sameJson(current[field], next[field])),
|
|
367
|
+
...(current.priority === next.priority ? [] : ['priority']),
|
|
368
|
+
];
|
|
369
|
+
if (changedFields.length === 0)
|
|
370
|
+
return current;
|
|
371
|
+
const briefChanged = changedFields.some((field) => BRIEF_FIELDS.includes(field));
|
|
372
|
+
next.version += 1;
|
|
373
|
+
if (briefChanged)
|
|
374
|
+
next.briefVersion += 1;
|
|
375
|
+
next.updatedAt = now().toISOString();
|
|
376
|
+
transaction(() => {
|
|
377
|
+
db.prepare(`
|
|
378
|
+
UPDATE tasks SET
|
|
379
|
+
title = ?, objective = ?, acceptance_criteria_json = ?, scope = ?, constraints = ?,
|
|
380
|
+
references_json = ?, priority = ?, brief_version = ?, version = ?, updated_at = ?
|
|
381
|
+
WHERE id = ? AND version = ?
|
|
382
|
+
`).run(next.title, next.objective, JSON.stringify(next.acceptanceCriteria), next.scope, next.constraints, JSON.stringify(next.references), next.priority, next.briefVersion, next.version, next.updatedAt, next.id, current.version);
|
|
383
|
+
appendTaskEvent(next, 'task.updated', { changedFields });
|
|
384
|
+
});
|
|
385
|
+
notifyWrite();
|
|
386
|
+
return requireTask(id);
|
|
387
|
+
},
|
|
388
|
+
async promoteTask(id, input) {
|
|
389
|
+
const current = requireTask(id);
|
|
390
|
+
assertTaskProjectActive(current);
|
|
391
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
392
|
+
if (current.archivedAt)
|
|
393
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
394
|
+
if (current.status !== 'draft') {
|
|
395
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Only a draft can become a task');
|
|
396
|
+
}
|
|
397
|
+
validateReady(current);
|
|
398
|
+
const next = { ...current, status: 'ready', version: current.version + 1, updatedAt: now().toISOString() };
|
|
399
|
+
transaction(() => {
|
|
400
|
+
db.prepare(`
|
|
401
|
+
UPDATE tasks SET status = 'ready', version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
402
|
+
`).run(next.version, next.updatedAt, id, current.version);
|
|
403
|
+
appendTaskEvent(next, 'task.promoted', { from: 'draft', to: 'ready' });
|
|
404
|
+
});
|
|
405
|
+
notifyWrite();
|
|
406
|
+
return requireTask(id);
|
|
407
|
+
},
|
|
408
|
+
async cancelTask(id, input) {
|
|
409
|
+
const current = requireTask(id);
|
|
410
|
+
assertTaskProjectActive(current);
|
|
411
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
412
|
+
if (current.archivedAt)
|
|
413
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
414
|
+
if (current.status === 'canceled') {
|
|
415
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Task is already canceled');
|
|
416
|
+
}
|
|
417
|
+
const next = { ...current, status: 'canceled', version: current.version + 1, updatedAt: now().toISOString() };
|
|
418
|
+
transaction(() => {
|
|
419
|
+
db.prepare(`
|
|
420
|
+
UPDATE tasks SET status = 'canceled', version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
421
|
+
`).run(next.version, next.updatedAt, id, current.version);
|
|
422
|
+
appendTaskEvent(next, 'task.canceled', { from: current.status, to: 'canceled' });
|
|
423
|
+
});
|
|
424
|
+
notifyWrite();
|
|
425
|
+
return requireTask(id);
|
|
426
|
+
},
|
|
427
|
+
async archiveTask(id, input) {
|
|
428
|
+
const current = requireTask(id);
|
|
429
|
+
assertTaskProjectActive(current);
|
|
430
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
431
|
+
if (current.archivedAt)
|
|
432
|
+
return current;
|
|
433
|
+
const next = {
|
|
434
|
+
...current,
|
|
435
|
+
version: current.version + 1,
|
|
436
|
+
archivedAt: now().toISOString(),
|
|
437
|
+
updatedAt: now().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
transaction(() => {
|
|
440
|
+
db.prepare(`
|
|
441
|
+
UPDATE tasks SET archived_at = ?, version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
442
|
+
`).run(next.archivedAt, next.version, next.updatedAt, id, current.version);
|
|
443
|
+
appendTaskEvent(next, 'task.archived', { archived: true });
|
|
444
|
+
});
|
|
445
|
+
notifyWrite();
|
|
446
|
+
return requireTask(id);
|
|
447
|
+
},
|
|
448
|
+
async listTaskEvents(id) {
|
|
449
|
+
requireTask(id);
|
|
450
|
+
const rows = db.prepare(`
|
|
451
|
+
SELECT id, task_id, type, actor, task_version, brief_version, payload_json, created_at
|
|
452
|
+
FROM task_events WHERE task_id = ? ORDER BY task_version, created_at, id
|
|
453
|
+
`).all(id);
|
|
454
|
+
return rows.map(taskEventFromRow);
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|