@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.
@@ -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
@@ -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;
@@ -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,
@@ -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,
@@ -1,4 +1,4 @@
1
- import { type Db, type ShareSubmission, type ShareSubmissionStatus, type TaskStatus } from '@plandesk/db';
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>;
@@ -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: asTask?.status ?? 'todo',
310
+ status: 'scope',
268
311
  description: asTask?.description ?? buildDesc(submission),
269
312
  });
270
313
  if (task === undefined) {
@@ -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 type NextActionableReason = 'ok' | 'no_tasks' | 'no_todo_tasks' | 'all_blocked';
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
  };
@@ -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.tags (OR semantics): only tasks carrying ANY of the given tag names
132
- // are candidates for next_task / blocked; prerequisite completion is still
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.status === 'todo' && (tagMatches === undefined || tagMatches.has(task.id)));
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.8.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.6.0",
21
+ "@plandesk/db": "0.8.0",
22
22
  "@plandesk/sync-server": "0.5.0"
23
23
  },
24
24
  "dependencies": {