@plandesk/api 0.8.0 → 0.10.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/dist/events.d.ts +12 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/routes/agent-runs.d.ts +1 -1
- package/dist/routes/agent-runs.js +20 -0
- package/dist/routes/comments.js +39 -31
- package/dist/routes/goals.d.ts +4 -0
- package/dist/routes/goals.js +138 -0
- package/dist/routes/projects.d.ts +1 -1
- package/dist/routes/projects.js +12 -1
- package/dist/routes/submissions.d.ts +5 -0
- package/dist/routes/submissions.js +58 -0
- package/dist/serialize.d.ts +26 -3
- package/dist/serialize.js +38 -1
- package/dist/server.js +5 -1
- package/dist/services/canvas.d.ts +2 -0
- package/dist/services/canvas.js +2 -1
- package/dist/services/comments.d.ts +10 -2
- package/dist/services/comments.js +71 -34
- package/dist/services/documents.js +2 -2
- package/dist/services/goals.d.ts +239 -0
- package/dist/services/goals.js +323 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +3 -0
- package/dist/services/notes.js +2 -1
- package/dist/services/projects.js +4 -1
- package/dist/services/sync.d.ts +5 -3
- package/dist/services/sync.js +45 -2
- package/dist/services/tasks.d.ts +10 -1
- package/dist/services/tasks.js +34 -5
- package/package.json +2 -2
- package/web/assets/index-DMKmd5gS.js +318 -0
- package/web/assets/index-DcD7-VGi.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-CUxSE_WZ.css +0 -1
- package/web/assets/index-DSmNB4RA.js +0 -235
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { createGoal, createTask, getGoal, getProject, InvalidGoalStatusError, isGoalStatus, listGoals, listTagsByTaskForProject, listTasks, updateGoal, updateGoalStatus, updateTask, } from '@plandesk/db';
|
|
2
|
+
import { serializeGoal, serializeTask } from '../serialize.js';
|
|
3
|
+
export class InvalidGoalTransitionError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'InvalidGoalTransitionError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class GoalCompletionBlockedError extends Error {
|
|
10
|
+
incompleteTaskIds;
|
|
11
|
+
constructor(incompleteTaskIds) {
|
|
12
|
+
super('Goal cannot be completed until all cycle-tasks are done');
|
|
13
|
+
this.name = 'GoalCompletionBlockedError';
|
|
14
|
+
this.incompleteTaskIds = incompleteTaskIds;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class InvalidVerificationSurfaceError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'InvalidVerificationSurfaceError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class GoalVerificationRequiredError extends Error {
|
|
24
|
+
requiredKind;
|
|
25
|
+
constructor(requiredKind) {
|
|
26
|
+
super(`Verification evidence required for surface kind: ${requiredKind}`);
|
|
27
|
+
this.name = 'GoalVerificationRequiredError';
|
|
28
|
+
this.requiredKind = requiredKind;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function isNonEmptyString(value) {
|
|
35
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
36
|
+
}
|
|
37
|
+
export function parseVerificationSurface(raw) {
|
|
38
|
+
if (raw === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new InvalidVerificationSurfaceError('verification_surface must be valid JSON');
|
|
47
|
+
}
|
|
48
|
+
if (!isRecord(parsed) || typeof parsed.kind !== 'string') {
|
|
49
|
+
throw new InvalidVerificationSurfaceError('verification_surface must include a kind');
|
|
50
|
+
}
|
|
51
|
+
switch (parsed.kind) {
|
|
52
|
+
case 'gate_command': {
|
|
53
|
+
if (!isNonEmptyString(parsed.command)) {
|
|
54
|
+
throw new InvalidVerificationSurfaceError('gate_command requires a command');
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'gate_command', command: parsed.command };
|
|
57
|
+
}
|
|
58
|
+
case 'acceptance_checklist': {
|
|
59
|
+
if (!Array.isArray(parsed.items) || parsed.items.length === 0) {
|
|
60
|
+
throw new InvalidVerificationSurfaceError('acceptance_checklist requires non-empty items');
|
|
61
|
+
}
|
|
62
|
+
const items = [];
|
|
63
|
+
for (const item of parsed.items) {
|
|
64
|
+
if (!isRecord(item) || !isNonEmptyString(item.criterion)) {
|
|
65
|
+
throw new InvalidVerificationSurfaceError('acceptance_checklist items require a criterion');
|
|
66
|
+
}
|
|
67
|
+
items.push({ criterion: item.criterion });
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'acceptance_checklist', items };
|
|
70
|
+
}
|
|
71
|
+
case 'human_sign_off':
|
|
72
|
+
return { kind: 'human_sign_off' };
|
|
73
|
+
default:
|
|
74
|
+
throw new InvalidVerificationSurfaceError(`Unknown verification_surface kind: ${parsed.kind}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function evaluateEvidence(surface, evidence) {
|
|
78
|
+
if (surface.kind !== evidence.kind) {
|
|
79
|
+
return { green: false, detail: `Expected evidence kind ${surface.kind}` };
|
|
80
|
+
}
|
|
81
|
+
if (surface.kind === 'gate_command' && evidence.kind === 'gate_command') {
|
|
82
|
+
if (evidence.exit_code === 0) {
|
|
83
|
+
return { green: true };
|
|
84
|
+
}
|
|
85
|
+
const detail = evidence.detail ??
|
|
86
|
+
`exit_code ${String(evidence.exit_code)}${evidence.command ? ` for \`${evidence.command}\`` : ''}`;
|
|
87
|
+
return { green: false, detail };
|
|
88
|
+
}
|
|
89
|
+
if (surface.kind === 'acceptance_checklist' && evidence.kind === 'acceptance_checklist') {
|
|
90
|
+
const missing = surface.items
|
|
91
|
+
.map((item) => item.criterion)
|
|
92
|
+
.filter((criterion) => !evidence.checked.includes(criterion));
|
|
93
|
+
if (missing.length === 0) {
|
|
94
|
+
return { green: true };
|
|
95
|
+
}
|
|
96
|
+
return { green: false, detail: `Missing criteria: ${missing.join(', ')}` };
|
|
97
|
+
}
|
|
98
|
+
if (surface.kind === 'human_sign_off' && evidence.kind === 'human_sign_off') {
|
|
99
|
+
if (isNonEmptyString(evidence.approved_by)) {
|
|
100
|
+
return { green: true };
|
|
101
|
+
}
|
|
102
|
+
return { green: false, detail: 'approved_by is required' };
|
|
103
|
+
}
|
|
104
|
+
return { green: false, detail: `Expected evidence kind ${surface.kind}` };
|
|
105
|
+
}
|
|
106
|
+
function validateVerificationSurfaceInput(raw) {
|
|
107
|
+
if (raw === undefined) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
parseVerificationSurface(raw);
|
|
111
|
+
}
|
|
112
|
+
function acceptanceBlockMarker(goalId) {
|
|
113
|
+
return `Acceptance-block-for: ${goalId}`;
|
|
114
|
+
}
|
|
115
|
+
function hasUnresolvedBlockingTask(db, projectId, goalId) {
|
|
116
|
+
const marker = acceptanceBlockMarker(goalId);
|
|
117
|
+
return listTasks(db, projectId).some((task) => task.goalId === goalId && task.status === 'scope' && task.description?.includes(marker));
|
|
118
|
+
}
|
|
119
|
+
// When acceptance finally passes, the remediation task filed on the earlier red
|
|
120
|
+
// result is obsolete — resolve it so a completed goal never shows open work.
|
|
121
|
+
function resolveBlockingTasks(db, projectId, goalId) {
|
|
122
|
+
const marker = acceptanceBlockMarker(goalId);
|
|
123
|
+
for (const task of listTasks(db, projectId)) {
|
|
124
|
+
if (task.goalId === goalId && task.status !== 'done' && task.description?.includes(marker)) {
|
|
125
|
+
updateTask(db, task.id, { status: 'done' });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function truncateObjective(objective, maxLength = 80) {
|
|
130
|
+
if (objective.length <= maxLength) {
|
|
131
|
+
return objective;
|
|
132
|
+
}
|
|
133
|
+
return `${objective.slice(0, maxLength - 1)}…`;
|
|
134
|
+
}
|
|
135
|
+
function buildBlockingTaskDescription(goalId, surfaceKind, detail) {
|
|
136
|
+
const lines = [acceptanceBlockMarker(goalId), '', `Surface kind: ${surfaceKind}`];
|
|
137
|
+
if (detail) {
|
|
138
|
+
lines.push(`Detail: ${detail}`);
|
|
139
|
+
}
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
function cycleTasksForGoal(db, projectId, goalId) {
|
|
143
|
+
const tagsByTask = listTagsByTaskForProject(db, projectId);
|
|
144
|
+
return listTasks(db, projectId)
|
|
145
|
+
.filter((task) => task.goalId === goalId)
|
|
146
|
+
.map((task) => serializeTask(task, tagsByTask.get(task.id) ?? []));
|
|
147
|
+
}
|
|
148
|
+
function emitGoalUpdated(eventBus, goalId, projectId) {
|
|
149
|
+
eventBus.emit({ type: 'goal_updated', goalId, projectId });
|
|
150
|
+
}
|
|
151
|
+
function recordLastVerification(at, green, kind, detail) {
|
|
152
|
+
const record = { at, green, kind };
|
|
153
|
+
if (detail) {
|
|
154
|
+
record.detail = detail;
|
|
155
|
+
}
|
|
156
|
+
return JSON.stringify(record);
|
|
157
|
+
}
|
|
158
|
+
export function createGoalService(deps) {
|
|
159
|
+
const { db, eventBus } = deps;
|
|
160
|
+
return {
|
|
161
|
+
create(projectId, input) {
|
|
162
|
+
if (input.status !== undefined && !isGoalStatus(input.status)) {
|
|
163
|
+
throw new InvalidGoalStatusError(input.status);
|
|
164
|
+
}
|
|
165
|
+
validateVerificationSurfaceInput(input.verificationSurface);
|
|
166
|
+
const project = getProject(db, projectId);
|
|
167
|
+
if (!project) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const goal = db.transaction((tx) => createGoal(tx, {
|
|
171
|
+
projectId,
|
|
172
|
+
objective: input.objective,
|
|
173
|
+
status: input.status,
|
|
174
|
+
verificationSurface: input.verificationSurface,
|
|
175
|
+
constraints: input.constraints,
|
|
176
|
+
boundaries: input.boundaries,
|
|
177
|
+
iterationPolicy: input.iterationPolicy,
|
|
178
|
+
stopCondition: input.stopCondition,
|
|
179
|
+
budget: input.budget,
|
|
180
|
+
}));
|
|
181
|
+
emitGoalUpdated(eventBus, goal.id, projectId);
|
|
182
|
+
return serializeGoal(goal);
|
|
183
|
+
},
|
|
184
|
+
get(goalId) {
|
|
185
|
+
const goal = getGoal(db, goalId);
|
|
186
|
+
if (!goal) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
...serializeGoal(goal),
|
|
191
|
+
cycle_tasks: cycleTasksForGoal(db, goal.projectId, goalId),
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
listByProject(projectId) {
|
|
195
|
+
const project = getProject(db, projectId);
|
|
196
|
+
if (!project) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
return listGoals(db, projectId).map(serializeGoal);
|
|
200
|
+
},
|
|
201
|
+
update(goalId, input) {
|
|
202
|
+
const existing = getGoal(db, goalId);
|
|
203
|
+
if (!existing) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
validateVerificationSurfaceInput(input.verificationSurface);
|
|
207
|
+
const goal = db.transaction((tx) => updateGoal(tx, goalId, input));
|
|
208
|
+
if (!goal) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
212
|
+
return serializeGoal(goal);
|
|
213
|
+
},
|
|
214
|
+
pause(goalId) {
|
|
215
|
+
const existing = getGoal(db, goalId);
|
|
216
|
+
if (!existing) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (existing.status !== 'active') {
|
|
220
|
+
throw new InvalidGoalTransitionError('Goal can only be paused from active status');
|
|
221
|
+
}
|
|
222
|
+
const goal = db.transaction((tx) => updateGoalStatus(tx, goalId, 'paused'));
|
|
223
|
+
if (!goal) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
227
|
+
return serializeGoal(goal);
|
|
228
|
+
},
|
|
229
|
+
resume(goalId) {
|
|
230
|
+
const existing = getGoal(db, goalId);
|
|
231
|
+
if (!existing) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
if (existing.status !== 'paused') {
|
|
235
|
+
throw new InvalidGoalTransitionError('Goal can only be resumed from paused status');
|
|
236
|
+
}
|
|
237
|
+
const goal = db.transaction((tx) => updateGoalStatus(tx, goalId, 'active'));
|
|
238
|
+
if (!goal) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
242
|
+
return serializeGoal(goal);
|
|
243
|
+
},
|
|
244
|
+
complete(goalId, evidence) {
|
|
245
|
+
const existing = getGoal(db, goalId);
|
|
246
|
+
if (!existing) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (existing.status === 'complete') {
|
|
250
|
+
throw new InvalidGoalTransitionError('Goal is already complete');
|
|
251
|
+
}
|
|
252
|
+
if (existing.status !== 'blocked') {
|
|
253
|
+
const incomplete = listTasks(db, existing.projectId)
|
|
254
|
+
.filter((task) => task.goalId === goalId && task.status !== 'done')
|
|
255
|
+
.map((task) => task.id);
|
|
256
|
+
if (incomplete.length > 0) {
|
|
257
|
+
throw new GoalCompletionBlockedError(incomplete);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const surface = parseVerificationSurface(existing.verificationSurface);
|
|
261
|
+
const at = new Date().toISOString();
|
|
262
|
+
if (!surface) {
|
|
263
|
+
const goal = db.transaction((tx) => updateGoal(tx, goalId, {
|
|
264
|
+
status: 'complete',
|
|
265
|
+
lastVerification: recordLastVerification(at, true, null),
|
|
266
|
+
}));
|
|
267
|
+
if (!goal) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
271
|
+
return serializeGoal(goal);
|
|
272
|
+
}
|
|
273
|
+
if (!evidence || evidence.kind !== surface.kind) {
|
|
274
|
+
throw new GoalVerificationRequiredError(surface.kind);
|
|
275
|
+
}
|
|
276
|
+
const evaluation = evaluateEvidence(surface, evidence);
|
|
277
|
+
const lastVerification = recordLastVerification(at, evaluation.green, surface.kind, evaluation.detail);
|
|
278
|
+
if (evaluation.green) {
|
|
279
|
+
const goal = db.transaction((tx) => {
|
|
280
|
+
const updated = updateGoal(tx, goalId, {
|
|
281
|
+
status: 'complete',
|
|
282
|
+
lastVerification,
|
|
283
|
+
});
|
|
284
|
+
if (!updated) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
resolveBlockingTasks(tx, existing.projectId, goalId);
|
|
288
|
+
return updated;
|
|
289
|
+
});
|
|
290
|
+
if (!goal) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
294
|
+
return serializeGoal(goal);
|
|
295
|
+
}
|
|
296
|
+
const goal = db.transaction((tx) => {
|
|
297
|
+
const updated = updateGoal(tx, goalId, {
|
|
298
|
+
status: 'blocked',
|
|
299
|
+
lastVerification,
|
|
300
|
+
});
|
|
301
|
+
if (!updated) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
if (!hasUnresolvedBlockingTask(tx, existing.projectId, goalId)) {
|
|
305
|
+
createTask(tx, {
|
|
306
|
+
projectId: existing.projectId,
|
|
307
|
+
goalId,
|
|
308
|
+
label: `Fix acceptance failure: ${truncateObjective(existing.objective)}`,
|
|
309
|
+
status: 'scope',
|
|
310
|
+
description: buildBlockingTaskDescription(goalId, surface.kind, evaluation.detail),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return updated;
|
|
314
|
+
});
|
|
315
|
+
if (!goal) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
emitGoalUpdated(eventBus, goalId, existing.projectId);
|
|
319
|
+
return serializeGoal(goal);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
//# sourceMappingURL=goals.js.map
|
package/dist/services/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type NoteService } from './notes.js';
|
|
|
8
8
|
import { type ProjectService } from './projects.js';
|
|
9
9
|
import { type AgentRunService } from './agent-runs.js';
|
|
10
10
|
import { type TagService } from './tags.js';
|
|
11
|
+
import { type GoalService } from './goals.js';
|
|
11
12
|
import { type TaskService } from './tasks.js';
|
|
12
13
|
import { type TokenService } from './tokens.js';
|
|
13
14
|
import { type ShareService } from './share.js';
|
|
@@ -19,6 +20,7 @@ export type ServicesDeps = {
|
|
|
19
20
|
export type Services = {
|
|
20
21
|
eventBus: EventBus;
|
|
21
22
|
projectService: ProjectService;
|
|
23
|
+
goalService: GoalService;
|
|
22
24
|
taskService: TaskService;
|
|
23
25
|
tagService: TagService;
|
|
24
26
|
canvasService: CanvasService;
|
package/dist/services/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createNoteService } from './notes.js';
|
|
|
7
7
|
import { createProjectService } from './projects.js';
|
|
8
8
|
import { createAgentRunService } from './agent-runs.js';
|
|
9
9
|
import { createTagService } from './tags.js';
|
|
10
|
+
import { createGoalService } from './goals.js';
|
|
10
11
|
import { createTaskService } from './tasks.js';
|
|
11
12
|
import { createTokenService } from './tokens.js';
|
|
12
13
|
import { createShareService } from './share.js';
|
|
@@ -14,6 +15,7 @@ import { createSyncService } from './sync.js';
|
|
|
14
15
|
export function createServices(deps) {
|
|
15
16
|
const eventBus = deps.eventBus ?? createEventBus();
|
|
16
17
|
const projectService = createProjectService({ db: deps.db, eventBus });
|
|
18
|
+
const goalService = createGoalService({ db: deps.db, eventBus });
|
|
17
19
|
const taskService = createTaskService({ db: deps.db, eventBus });
|
|
18
20
|
const tagService = createTagService({ db: deps.db, eventBus });
|
|
19
21
|
const canvasService = createCanvasService({ db: deps.db, eventBus });
|
|
@@ -28,6 +30,7 @@ export function createServices(deps) {
|
|
|
28
30
|
return {
|
|
29
31
|
eventBus,
|
|
30
32
|
projectService,
|
|
33
|
+
goalService,
|
|
31
34
|
taskService,
|
|
32
35
|
tagService,
|
|
33
36
|
canvasService,
|
package/dist/services/notes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createNote as dbCreateNote, deleteNote as dbDeleteNote, getNote as dbGetNote, getProject, listNotes as dbListNotes, updateNote as dbUpdateNote, } from '@plandesk/db';
|
|
1
|
+
import { createNote as dbCreateNote, deleteCommentsByTarget, deleteNote as dbDeleteNote, getNote as dbGetNote, getProject, listNotes as dbListNotes, updateNote as dbUpdateNote, } from '@plandesk/db';
|
|
2
2
|
import { serializeNote } from '../serialize.js';
|
|
3
3
|
export class InvalidNoteError extends Error {
|
|
4
4
|
constructor(message) {
|
|
@@ -66,6 +66,7 @@ export function createNoteService(deps) {
|
|
|
66
66
|
if (!deleted) {
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
+
deleteCommentsByTarget(db, 'note', id);
|
|
69
70
|
eventBus.emit({ type: 'note_updated', noteId: id, projectId: existing.projectId });
|
|
70
71
|
return true;
|
|
71
72
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clearDocumentParentRefsByProject, clearFolderParentRefsByProject, createDocument, createEdge, createProject as dbCreateProject, createTask, deleteAgentRun, deleteAgentRunEventsByRunId, deleteCommentsByProjectId, deleteDocumentsByProjectId, deleteFoldersByProjectId, deleteNotesByProjectId, deleteEdgesByProjectId, deleteShareSubmissionsByProjectId, deleteSharesByProjectId, deleteTagsByProjectId, deleteSyncRemoteByProjectId, deleteSyncStateByProjectId, deleteProject as dbDeleteProject, deleteTasksByProjectId, getProject as dbGetProject, InvalidTaskStatusError, isTaskStatus, listAgentRuns, listProjects as dbListProjects, listTasks, updateProject as dbUpdateProject, } from '@plandesk/db';
|
|
1
|
+
import { clearDocumentParentRefsByProject, clearFolderParentRefsByProject, createDocument, createEdge, createProject as dbCreateProject, createTask, deleteAgentRun, deleteAgentRunEventsByRunId, deleteCommentsByProjectId, deleteDocumentsByProjectId, deleteFoldersByProjectId, deleteNotesByProjectId, deleteEdgesByProjectId, deleteGoalsByProjectId, deleteShareSubmissionsByProjectId, deleteSharesByProjectId, deleteTagsByProjectId, deleteSyncRemoteByProjectId, deleteSyncStateByProjectId, deleteProject as dbDeleteProject, deleteTasksByProjectId, getOrCreateDefaultGoal, getProject as dbGetProject, InvalidTaskStatusError, isTaskStatus, listAgentRuns, listProjects as dbListProjects, listTasks, updateProject as dbUpdateProject, } from '@plandesk/db';
|
|
2
2
|
import { emptyTaskStatusSummary, serializeDocument, serializeEdge, serializeProject, serializeProjectDetail, serializeTask, } from '../serialize.js';
|
|
3
3
|
export class InvalidScaffoldError extends Error {
|
|
4
4
|
constructor(message) {
|
|
@@ -94,6 +94,7 @@ export function createProjectService(deps) {
|
|
|
94
94
|
deleteNotesByProjectId(tx, id);
|
|
95
95
|
deleteTagsByProjectId(tx, id);
|
|
96
96
|
deleteTasksByProjectId(tx, id);
|
|
97
|
+
deleteGoalsByProjectId(tx, id);
|
|
97
98
|
deleteShareSubmissionsByProjectId(tx, id);
|
|
98
99
|
deleteSyncStateByProjectId(tx, id);
|
|
99
100
|
deleteSyncRemoteByProjectId(tx, id);
|
|
@@ -116,11 +117,13 @@ export function createProjectService(deps) {
|
|
|
116
117
|
description: input.description,
|
|
117
118
|
});
|
|
118
119
|
projectId = project.id;
|
|
120
|
+
const defaultGoal = getOrCreateDefaultGoal(tx, project.id);
|
|
119
121
|
input.tasks.forEach((taskInput, i) => {
|
|
120
122
|
const x = taskInput.x ?? (i % 4) * 240;
|
|
121
123
|
const y = taskInput.y ?? Math.floor(i / 4) * 160;
|
|
122
124
|
const task = createTask(tx, {
|
|
123
125
|
projectId: project.id,
|
|
126
|
+
goalId: defaultGoal.id,
|
|
124
127
|
label: taskInput.label,
|
|
125
128
|
status: taskInput.status,
|
|
126
129
|
description: taskInput.description,
|
package/dist/services/sync.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Db, type ShareSubmission, type ShareSubmissionStatus
|
|
1
|
+
import { type Db, type ShareSubmission, type ShareSubmissionStatus } from '@plandesk/db';
|
|
2
2
|
import type { EventBus } from '../events.js';
|
|
3
3
|
import type { ShareService } from './share.js';
|
|
4
4
|
import type { TaskService } from './tasks.js';
|
|
@@ -11,6 +11,9 @@ export declare class SyncUnauthorizedError extends Error {
|
|
|
11
11
|
export declare class InvalidTriageError extends Error {
|
|
12
12
|
constructor(message?: string);
|
|
13
13
|
}
|
|
14
|
+
export declare class InvalidTriageInputError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
14
17
|
export type SyncRemote = {
|
|
15
18
|
serverUrl: string;
|
|
16
19
|
globalProjectId: string;
|
|
@@ -65,9 +68,8 @@ export declare function createSyncService(deps: SyncServiceDeps): {
|
|
|
65
68
|
getRemote(projectId: string): SyncRemote | undefined;
|
|
66
69
|
triage(submissionId: string, action: "accept" | "reject", remote: SyncRemote, asTask?: {
|
|
67
70
|
label?: string;
|
|
68
|
-
status?: TaskStatus;
|
|
69
71
|
description?: string;
|
|
70
|
-
}): Promise<SerializedSubmission>;
|
|
72
|
+
}, linkTaskId?: string): Promise<SerializedSubmission>;
|
|
71
73
|
watchPush(projectId: string, remote: SyncRemote, opts?: WatchPushOptions): WatchPushController;
|
|
72
74
|
};
|
|
73
75
|
export type SyncService = ReturnType<typeof createSyncService>;
|
package/dist/services/sync.js
CHANGED
|
@@ -18,6 +18,12 @@ export class InvalidTriageError extends Error {
|
|
|
18
18
|
this.name = 'InvalidTriageError';
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
export class InvalidTriageInputError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'InvalidTriageInputError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
export function serializeSubmission(row) {
|
|
22
28
|
return {
|
|
23
29
|
id: row.id,
|
|
@@ -252,19 +258,56 @@ export function createSyncService(deps) {
|
|
|
252
258
|
syncToken: row.syncToken,
|
|
253
259
|
};
|
|
254
260
|
},
|
|
255
|
-
async triage(submissionId, action, remote, asTask) {
|
|
261
|
+
async triage(submissionId, action, remote, asTask, linkTaskId) {
|
|
262
|
+
if (asTask !== undefined && linkTaskId !== undefined) {
|
|
263
|
+
throw new InvalidTriageInputError('as_task and link_task_id are mutually exclusive');
|
|
264
|
+
}
|
|
256
265
|
const submission = dbGetSubmission(db, submissionId);
|
|
257
266
|
if (submission === undefined) {
|
|
258
267
|
throw new InvalidTriageError();
|
|
259
268
|
}
|
|
260
269
|
if (submission.status !== 'pending') {
|
|
270
|
+
// Idempotent retry recovery: a prior triage may have committed the terminal
|
|
271
|
+
// status locally but failed to ack the remote (sync server briefly down),
|
|
272
|
+
// leaving local/remote divergence with no recovery path. Re-ack when the
|
|
273
|
+
// retry's action matches the recorded outcome; ack is idempotent remotely.
|
|
274
|
+
if ((action === 'accept' && submission.status === 'accepted') ||
|
|
275
|
+
(action === 'reject' && submission.status === 'rejected')) {
|
|
276
|
+
await ackSubmission(remote, submissionId, submission.status);
|
|
277
|
+
}
|
|
261
278
|
return serializeSubmission(submission);
|
|
262
279
|
}
|
|
263
280
|
const projectId = submission.projectId;
|
|
281
|
+
if (action === 'accept' && linkTaskId !== undefined) {
|
|
282
|
+
const existingTask = taskService.get(linkTaskId);
|
|
283
|
+
if (existingTask === undefined || existingTask.project_id !== projectId) {
|
|
284
|
+
throw new InvalidTriageInputError('link_task_id does not reference an existing task in this project');
|
|
285
|
+
}
|
|
286
|
+
const updated = setSubmissionStatus(db, submissionId, {
|
|
287
|
+
status: 'accepted',
|
|
288
|
+
linkedTaskId: linkTaskId,
|
|
289
|
+
});
|
|
290
|
+
if (updated === undefined) {
|
|
291
|
+
throw new InvalidTriageError();
|
|
292
|
+
}
|
|
293
|
+
eventBus.emit({ type: 'submissions_pulled', projectId });
|
|
294
|
+
await ackSubmission(remote, submissionId, 'accepted');
|
|
295
|
+
return serializeSubmission(updated);
|
|
296
|
+
}
|
|
264
297
|
if (action === 'accept') {
|
|
298
|
+
// Triage never releases work to `todo`: the scope->todo gate is the human's
|
|
299
|
+
// own board action. Every accepted submission lands in `scope`, regardless of
|
|
300
|
+
// caller — enforced here at the single service chokepoint so both the HTTP
|
|
301
|
+
// route and the MCP tool are covered.
|
|
302
|
+
//
|
|
303
|
+
// create + setSubmissionStatus run as two synchronous steps, but the submission
|
|
304
|
+
// was validated to exist immediately above with no intervening await, so under
|
|
305
|
+
// better-sqlite3's single-connection sync model the row cannot vanish and
|
|
306
|
+
// setSubmissionStatus cannot spuriously return undefined. taskService.create
|
|
307
|
+
// self-transacts, so an outer transaction here is neither possible nor needed.
|
|
265
308
|
const task = taskService.create(projectId, {
|
|
266
309
|
label: asTask?.label ?? submission.title,
|
|
267
|
-
status:
|
|
310
|
+
status: 'scope',
|
|
268
311
|
description: asTask?.description ?? buildDesc(submission),
|
|
269
312
|
});
|
|
270
313
|
if (task === undefined) {
|
package/dist/services/tasks.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { type Db, type TaskStatus } from '@plandesk/db';
|
|
|
2
2
|
import type { EventBus } from '../events.js';
|
|
3
3
|
import { serializeTask, type PaginationParams } from '../serialize.js';
|
|
4
4
|
type SerializedTask = ReturnType<typeof serializeTask>;
|
|
5
|
-
export
|
|
5
|
+
export declare class InvalidGoalReferenceError extends Error {
|
|
6
|
+
constructor(goalId: string);
|
|
7
|
+
}
|
|
8
|
+
export type NextActionableReason = 'ok' | 'no_tasks' | 'no_todo_tasks' | 'all_blocked' | 'no_active_goal' | 'multiple_active_goals';
|
|
6
9
|
export type NextActionableResult = {
|
|
7
10
|
next_task: SerializedTask | null;
|
|
8
11
|
reason: NextActionableReason;
|
|
@@ -23,6 +26,7 @@ export type CreateTaskInput = {
|
|
|
23
26
|
y?: number;
|
|
24
27
|
assignee?: string | null;
|
|
25
28
|
dueDate?: Date | null;
|
|
29
|
+
goalId?: string;
|
|
26
30
|
tags?: string[];
|
|
27
31
|
};
|
|
28
32
|
export type UpdateTaskInput = {
|
|
@@ -42,6 +46,7 @@ export declare function createTaskService(deps: TaskServiceDeps): {
|
|
|
42
46
|
tags?: import("../serialize.js").SerializedTag[] | undefined;
|
|
43
47
|
id: string;
|
|
44
48
|
project_id: string;
|
|
49
|
+
goal_id: string;
|
|
45
50
|
label: string;
|
|
46
51
|
status: "scope" | "todo" | "in_progress" | "done" | "backlog";
|
|
47
52
|
description: string | null;
|
|
@@ -56,6 +61,7 @@ export declare function createTaskService(deps: TaskServiceDeps): {
|
|
|
56
61
|
tags?: import("../serialize.js").SerializedTag[] | undefined;
|
|
57
62
|
id: string;
|
|
58
63
|
project_id: string;
|
|
64
|
+
goal_id: string;
|
|
59
65
|
label: string;
|
|
60
66
|
status: "scope" | "todo" | "in_progress" | "done" | "backlog";
|
|
61
67
|
description: string | null;
|
|
@@ -70,6 +76,7 @@ export declare function createTaskService(deps: TaskServiceDeps): {
|
|
|
70
76
|
tags?: import("../serialize.js").SerializedTag[] | undefined;
|
|
71
77
|
id: string;
|
|
72
78
|
project_id: string;
|
|
79
|
+
goal_id: string;
|
|
73
80
|
label: string;
|
|
74
81
|
status: "scope" | "todo" | "in_progress" | "done" | "backlog";
|
|
75
82
|
description: string | null;
|
|
@@ -84,6 +91,7 @@ export declare function createTaskService(deps: TaskServiceDeps): {
|
|
|
84
91
|
tags?: import("../serialize.js").SerializedTag[] | undefined;
|
|
85
92
|
id: string;
|
|
86
93
|
project_id: string;
|
|
94
|
+
goal_id: string;
|
|
87
95
|
label: string;
|
|
88
96
|
status: "scope" | "todo" | "in_progress" | "done" | "backlog";
|
|
89
97
|
description: string | null;
|
|
@@ -96,6 +104,7 @@ export declare function createTaskService(deps: TaskServiceDeps): {
|
|
|
96
104
|
} | undefined;
|
|
97
105
|
delete(id: string): boolean;
|
|
98
106
|
nextActionable(projectId: string, filter?: {
|
|
107
|
+
goalId?: string;
|
|
99
108
|
tags?: string[];
|
|
100
109
|
}): NextActionableResult | undefined;
|
|
101
110
|
};
|
package/dist/services/tasks.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { createTag, createTask, deleteEdgesByTaskId, deleteTask as dbDeleteTask, deleteTaskTagsByTaskId, getProject, getTagByName, getTask, InvalidTaskStatusError, isTaskStatus, listEdges, listTagsByTaskForProject, listTagsForTask, listTasks, nullDocumentsLinkedTask, setTaskTags, taskIdsWithAnyTagName, updateTask, } from '@plandesk/db';
|
|
1
|
+
import { createTag, createTask, deleteCommentsByTarget, deleteEdgesByTaskId, deleteTask as dbDeleteTask, deleteTaskTagsByTaskId, getOrCreateDefaultGoal, getProject, listGoals, getTagByName, getTask, InvalidTaskStatusError, isTaskStatus, listEdges, listTagsByTaskForProject, listTagsForTask, listTasks, nullDocumentsLinkedTask, setTaskTags, taskIdsWithAnyTagName, updateTask, } from '@plandesk/db';
|
|
2
2
|
import { serializeTask } from '../serialize.js';
|
|
3
3
|
import { normalizeTagName } from './tags.js';
|
|
4
|
+
export class InvalidGoalReferenceError extends Error {
|
|
5
|
+
constructor(goalId) {
|
|
6
|
+
super(`Goal ${goalId} does not exist in this project`);
|
|
7
|
+
this.name = 'InvalidGoalReferenceError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
// depends_on: prerequisite = to, dependent = from. All other labels: prerequisite = from, dependent = to.
|
|
5
11
|
function prerequisiteAndDependent(edge) {
|
|
6
12
|
if (edge.fromTaskId === edge.toTaskId) {
|
|
@@ -61,9 +67,15 @@ export function createTaskService(deps) {
|
|
|
61
67
|
if (!project) {
|
|
62
68
|
return undefined;
|
|
63
69
|
}
|
|
70
|
+
if (input.goalId !== undefined &&
|
|
71
|
+
!listGoals(db, projectId).some((g) => g.id === input.goalId)) {
|
|
72
|
+
throw new InvalidGoalReferenceError(input.goalId);
|
|
73
|
+
}
|
|
64
74
|
const { task, tags } = db.transaction((tx) => {
|
|
75
|
+
const goalId = input.goalId ?? getOrCreateDefaultGoal(tx, projectId).id;
|
|
65
76
|
const row = createTask(tx, {
|
|
66
77
|
projectId,
|
|
78
|
+
goalId,
|
|
67
79
|
label: input.label,
|
|
68
80
|
status: input.status,
|
|
69
81
|
description: input.description,
|
|
@@ -120,6 +132,7 @@ export function createTaskService(deps) {
|
|
|
120
132
|
}
|
|
121
133
|
const projectId = task.projectId;
|
|
122
134
|
db.transaction((tx) => {
|
|
135
|
+
deleteCommentsByTarget(tx, 'task', id);
|
|
123
136
|
deleteEdgesByTaskId(tx, id);
|
|
124
137
|
nullDocumentsLinkedTask(tx, id);
|
|
125
138
|
deleteTaskTagsByTaskId(tx, id);
|
|
@@ -128,14 +141,28 @@ export function createTaskService(deps) {
|
|
|
128
141
|
eventBus.emit({ type: 'canvas_updated', projectId });
|
|
129
142
|
return true;
|
|
130
143
|
},
|
|
131
|
-
// filter.
|
|
132
|
-
//
|
|
133
|
-
// evaluated against all tasks in the project.
|
|
144
|
+
// filter.goalId scopes candidates to one goal; when omitted, the project's sole
|
|
145
|
+
// active goal is resolved. filter.tags (OR semantics) composes with goal scope;
|
|
146
|
+
// prerequisite completion is still evaluated against all tasks in the project.
|
|
134
147
|
nextActionable(projectId, filter = {}) {
|
|
135
148
|
const project = getProject(db, projectId);
|
|
136
149
|
if (!project) {
|
|
137
150
|
return undefined;
|
|
138
151
|
}
|
|
152
|
+
let goalId = filter.goalId;
|
|
153
|
+
if (goalId === undefined) {
|
|
154
|
+
const active = listGoals(db, projectId).filter((goal) => goal.status === 'active');
|
|
155
|
+
if (active.length === 0) {
|
|
156
|
+
return { next_task: null, reason: 'no_active_goal', blocked: [] };
|
|
157
|
+
}
|
|
158
|
+
if (active.length > 1) {
|
|
159
|
+
return { next_task: null, reason: 'multiple_active_goals', blocked: [] };
|
|
160
|
+
}
|
|
161
|
+
goalId = active[0]?.id;
|
|
162
|
+
}
|
|
163
|
+
else if (!listGoals(db, projectId).some((goal) => goal.id === goalId)) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
139
166
|
const tasks = listTasks(db, projectId).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
140
167
|
const edges = listEdges(db, projectId);
|
|
141
168
|
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
|
@@ -160,7 +187,9 @@ export function createTaskService(deps) {
|
|
|
160
187
|
if (tasks.length === 0) {
|
|
161
188
|
return { next_task: null, reason: 'no_tasks', blocked: [] };
|
|
162
189
|
}
|
|
163
|
-
const todoTasks = tasks.filter((task) => task.
|
|
190
|
+
const todoTasks = tasks.filter((task) => task.goalId === goalId &&
|
|
191
|
+
task.status === 'todo' &&
|
|
192
|
+
(tagMatches === undefined || tagMatches.has(task.id)));
|
|
164
193
|
if (todoTasks.length === 0) {
|
|
165
194
|
return { next_task: null, reason: 'no_todo_tasks', blocked: [] };
|
|
166
195
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plandesk/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"typescript": "^6.0.3",
|
|
20
20
|
"vitest": "^3.2.6",
|
|
21
|
-
"@plandesk/db": "0.
|
|
21
|
+
"@plandesk/db": "0.8.0",
|
|
22
22
|
"@plandesk/sync-server": "0.5.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|