@proletariat/cli 0.3.20 → 0.3.21
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/commands/agent/login.js +2 -2
- package/dist/commands/agent/remove.d.ts +1 -0
- package/dist/commands/agent/remove.js +36 -28
- package/dist/commands/agent/shell.js +2 -2
- package/dist/commands/agent/staff/remove.js +2 -2
- package/dist/commands/agent/status.js +2 -2
- package/dist/commands/agent/themes/add-names.d.ts +1 -0
- package/dist/commands/agent/themes/add-names.js +5 -1
- package/dist/commands/agent/visit.js +2 -2
- package/dist/commands/epic/link/index.js +17 -0
- package/dist/commands/execution/config.js +22 -0
- package/dist/commands/execution/kill.d.ts +3 -0
- package/dist/commands/execution/kill.js +1 -0
- package/dist/commands/execution/list.js +5 -4
- package/dist/commands/execution/logs.js +1 -0
- package/dist/commands/phase/move.js +8 -0
- package/dist/commands/phase/template/apply.js +2 -2
- package/dist/commands/phase/template/create.js +6 -6
- package/dist/commands/phase/template/list.js +1 -1
- package/dist/commands/status/list.js +5 -3
- package/dist/commands/template/phase/index.js +4 -4
- package/dist/commands/template/ticket/delete.d.ts +1 -1
- package/dist/commands/template/ticket/delete.js +4 -2
- package/dist/commands/ticket/create.js +1 -1
- package/dist/commands/ticket/edit.js +1 -1
- package/dist/commands/ticket/list.d.ts +2 -0
- package/dist/commands/ticket/list.js +39 -2
- package/dist/commands/ticket/update.js +2 -2
- package/dist/commands/work/spawn.js +32 -8
- package/dist/commands/work/watch.js +2 -0
- package/dist/lib/agents/commands.d.ts +7 -0
- package/dist/lib/agents/commands.js +11 -0
- package/dist/lib/execution/runners.js +1 -2
- package/dist/lib/pmo/storage/epics.js +20 -10
- package/dist/lib/pmo/storage/helpers.d.ts +10 -0
- package/dist/lib/pmo/storage/helpers.js +59 -1
- package/dist/lib/pmo/storage/projects.js +20 -8
- package/dist/lib/pmo/storage/specs.js +23 -13
- package/dist/lib/pmo/storage/statuses.js +39 -18
- package/dist/lib/pmo/storage/subtasks.js +19 -8
- package/dist/lib/pmo/storage/tickets.js +27 -15
- package/oclif.manifest.json +2742 -2713
- package/package.json +1 -1
|
@@ -119,14 +119,6 @@ export default class WorkSpawn extends PMOCommand {
|
|
|
119
119
|
const { flags, argv } = await this.parse(WorkSpawn);
|
|
120
120
|
// Check if JSON output mode is active
|
|
121
121
|
const jsonMode = shouldOutputJson(flags);
|
|
122
|
-
// This command requires project context (pass JSON mode config for AI agents)
|
|
123
|
-
const projectId = await this.requireProject({
|
|
124
|
-
jsonMode: {
|
|
125
|
-
flags,
|
|
126
|
-
commandName: 'work spawn',
|
|
127
|
-
baseCommand: 'prlt work spawn',
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
122
|
// Helper to handle errors in JSON mode
|
|
131
123
|
const handleError = (code, message) => {
|
|
132
124
|
if (jsonMode) {
|
|
@@ -137,6 +129,38 @@ export default class WorkSpawn extends PMOCommand {
|
|
|
137
129
|
};
|
|
138
130
|
// Parse ticket IDs from args (everything after flags)
|
|
139
131
|
const ticketIdArgs = argv;
|
|
132
|
+
// Try to infer project from ticket IDs if provided
|
|
133
|
+
let projectId;
|
|
134
|
+
if (ticketIdArgs.length > 0) {
|
|
135
|
+
// Look up tickets to get their project IDs
|
|
136
|
+
const projectIds = new Set();
|
|
137
|
+
for (const ticketId of ticketIdArgs) {
|
|
138
|
+
// eslint-disable-next-line no-await-in-loop
|
|
139
|
+
const ticket = await this.storage.getTicket(ticketId);
|
|
140
|
+
if (ticket?.projectId) {
|
|
141
|
+
projectIds.add(ticket.projectId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (projectIds.size === 1) {
|
|
145
|
+
// All tickets from same project - use that project
|
|
146
|
+
projectId = [...projectIds][0];
|
|
147
|
+
}
|
|
148
|
+
else if (projectIds.size > 1) {
|
|
149
|
+
// Tickets from multiple projects - warn and require prompt
|
|
150
|
+
this.warn('Tickets are from multiple projects. Please specify --project.');
|
|
151
|
+
}
|
|
152
|
+
// If size === 0, tickets not found - will be handled later in validation
|
|
153
|
+
}
|
|
154
|
+
// Only call requireProject() if we couldn't infer from tickets
|
|
155
|
+
if (!projectId) {
|
|
156
|
+
projectId = await this.requireProject({
|
|
157
|
+
jsonMode: {
|
|
158
|
+
flags,
|
|
159
|
+
commandName: 'work spawn',
|
|
160
|
+
baseCommand: 'prlt work spawn',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
140
164
|
// Note: Docker check is handled by work:start command when spawning each ticket
|
|
141
165
|
// This allows for the interactive devcontainer/host selection with retry loop
|
|
142
166
|
// Get workspace info (for agent worktree paths)
|
|
@@ -42,11 +42,13 @@ export default class WorkWatch extends PMOCommand {
|
|
|
42
42
|
limit: Flags.integer({
|
|
43
43
|
char: 'l',
|
|
44
44
|
description: 'Maximum concurrent executions',
|
|
45
|
+
min: 1,
|
|
45
46
|
}),
|
|
46
47
|
interval: Flags.integer({
|
|
47
48
|
char: 'i',
|
|
48
49
|
description: 'Polling interval in seconds',
|
|
49
50
|
default: 5,
|
|
51
|
+
min: 1,
|
|
50
52
|
}),
|
|
51
53
|
once: Flags.boolean({
|
|
52
54
|
description: 'Check once and exit (no continuous watching)',
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { Agent, Repository, MountMode as DBMountMode } from '../database/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Format a list of agents for display in error messages.
|
|
4
|
+
* Truncates long lists to avoid overwhelming output.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatAgentList(agents: {
|
|
7
|
+
name: string;
|
|
8
|
+
}[], maxShow?: number): string;
|
|
2
9
|
export interface AgentStatus {
|
|
3
10
|
name: string;
|
|
4
11
|
exists: boolean;
|
|
@@ -9,6 +9,17 @@ import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAg
|
|
|
9
9
|
import { isValidAgentName, getSuggestedAgentNames, generateEphemeralAgentName, getThemePersistentDir, getThemeEphemeralDir, extractBaseName, getAgentBaseName, } from '../themes.js';
|
|
10
10
|
import { createDevcontainerConfig } from '../execution/devcontainer.js';
|
|
11
11
|
import { getPMOContext } from '../pmo/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Format a list of agents for display in error messages.
|
|
14
|
+
* Truncates long lists to avoid overwhelming output.
|
|
15
|
+
*/
|
|
16
|
+
export function formatAgentList(agents, maxShow = 10) {
|
|
17
|
+
const names = agents.map(a => a.name);
|
|
18
|
+
if (names.length <= maxShow) {
|
|
19
|
+
return names.join(', ');
|
|
20
|
+
}
|
|
21
|
+
return `${names.slice(0, maxShow).join(', ')} ...and ${names.length - maxShow} more. Run 'prlt agent list' to see all.`;
|
|
22
|
+
}
|
|
12
23
|
/**
|
|
13
24
|
* Find workspace root and return workspace information.
|
|
14
25
|
*
|
|
@@ -612,8 +612,7 @@ export function isDockerRunning() {
|
|
|
612
612
|
execSync('docker info', { stdio: 'pipe', timeout });
|
|
613
613
|
return true;
|
|
614
614
|
}
|
|
615
|
-
catch
|
|
616
|
-
console.debug(`[runners:docker] Docker check attempt ${attempt}/${maxRetries} failed:`, err);
|
|
615
|
+
catch {
|
|
617
616
|
if (attempt === maxRetries) {
|
|
618
617
|
return false;
|
|
619
618
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { PMO_TABLES } from '../schema.js';
|
|
5
5
|
import { PMOError } from '../types.js';
|
|
6
6
|
import { generateEntityId } from '../utils.js';
|
|
7
|
-
import { rowToTicket } from './helpers.js';
|
|
7
|
+
import { rowToTicket, wrapSqliteError } from './helpers.js';
|
|
8
8
|
const T = PMO_TABLES;
|
|
9
9
|
export class EpicStorage {
|
|
10
10
|
ctx;
|
|
@@ -27,10 +27,15 @@ export class EpicStorage {
|
|
|
27
27
|
`).get(projectId);
|
|
28
28
|
position = maxPos.max_pos + 1;
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
try {
|
|
31
|
+
this.ctx.db.prepare(`
|
|
32
|
+
INSERT INTO ${T.epics} (id, project_id, title, description, status, position, file_path, spec_id, created_at, updated_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
34
|
+
`).run(id, projectId, title, epic.description || null, status, position, epic.filePath || null, epic.specId || null, now, now);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
wrapSqliteError('Epic', 'create', err);
|
|
38
|
+
}
|
|
34
39
|
this.ctx.updateBoardTimestamp(projectId);
|
|
35
40
|
return {
|
|
36
41
|
id,
|
|
@@ -155,11 +160,16 @@ export class EpicStorage {
|
|
|
155
160
|
if (!epic) {
|
|
156
161
|
throw new PMOError('NOT_FOUND', `Epic not found: ${id}`);
|
|
157
162
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
try {
|
|
164
|
+
// Unlink tickets from this epic
|
|
165
|
+
this.ctx.db.prepare(`
|
|
166
|
+
UPDATE ${T.tickets} SET epic_id = NULL WHERE epic_id = ?
|
|
167
|
+
`).run(id);
|
|
168
|
+
this.ctx.db.prepare(`DELETE FROM ${T.epics} WHERE id = ?`).run(id);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
wrapSqliteError('Epic', 'delete', err);
|
|
172
|
+
}
|
|
163
173
|
this.ctx.updateBoardTimestamp(epic.projectId);
|
|
164
174
|
}
|
|
165
175
|
/**
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { AcceptanceCriterion, Spec, StateCategory, Ticket } from '../types.js';
|
|
6
6
|
import { SpecRow, TicketRow, WorkflowStatusRow } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Wrap SQLite constraint errors with user-friendly messages.
|
|
9
|
+
* This function always throws - it never returns.
|
|
10
|
+
*
|
|
11
|
+
* @param entityType - The type of entity being operated on (e.g., 'Ticket', 'Spec', 'Project')
|
|
12
|
+
* @param operation - The operation being performed ('create', 'update', 'delete')
|
|
13
|
+
* @param err - The error thrown by SQLite
|
|
14
|
+
* @throws {PMOError} Always throws a user-friendly PMOError
|
|
15
|
+
*/
|
|
16
|
+
export declare function wrapSqliteError(entityType: string, operation: 'create' | 'update' | 'delete', err: unknown): never;
|
|
7
17
|
/**
|
|
8
18
|
* Convert a database row to a Ticket object.
|
|
9
19
|
* Fetches related data (subtasks, metadata, status info).
|
|
@@ -2,7 +2,65 @@
|
|
|
2
2
|
* Helper functions for converting database rows to domain types.
|
|
3
3
|
*/
|
|
4
4
|
import { PMO_TABLES } from '../schema.js';
|
|
5
|
-
import { normalizePriority, } from '../types.js';
|
|
5
|
+
import { PMOError, normalizePriority, } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Check if an error is a SQLite UNIQUE constraint violation.
|
|
8
|
+
*/
|
|
9
|
+
function isUniqueConstraintError(err) {
|
|
10
|
+
if (!(err instanceof Error))
|
|
11
|
+
return false;
|
|
12
|
+
const sqliteErr = err;
|
|
13
|
+
return (sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
|
14
|
+
sqliteErr.message.includes('UNIQUE constraint failed'));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Check if an error is a SQLite FOREIGN KEY constraint violation.
|
|
18
|
+
*/
|
|
19
|
+
function isForeignKeyConstraintError(err) {
|
|
20
|
+
if (!(err instanceof Error))
|
|
21
|
+
return false;
|
|
22
|
+
const sqliteErr = err;
|
|
23
|
+
return (sqliteErr.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' ||
|
|
24
|
+
sqliteErr.message.includes('FOREIGN KEY constraint failed'));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if an error is a SQLite CHECK constraint violation.
|
|
28
|
+
*/
|
|
29
|
+
function isCheckConstraintError(err) {
|
|
30
|
+
if (!(err instanceof Error))
|
|
31
|
+
return false;
|
|
32
|
+
const sqliteErr = err;
|
|
33
|
+
return (sqliteErr.code === 'SQLITE_CONSTRAINT_CHECK' ||
|
|
34
|
+
sqliteErr.message.includes('CHECK constraint failed'));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap SQLite constraint errors with user-friendly messages.
|
|
38
|
+
* This function always throws - it never returns.
|
|
39
|
+
*
|
|
40
|
+
* @param entityType - The type of entity being operated on (e.g., 'Ticket', 'Spec', 'Project')
|
|
41
|
+
* @param operation - The operation being performed ('create', 'update', 'delete')
|
|
42
|
+
* @param err - The error thrown by SQLite
|
|
43
|
+
* @throws {PMOError} Always throws a user-friendly PMOError
|
|
44
|
+
*/
|
|
45
|
+
export function wrapSqliteError(entityType, operation, err) {
|
|
46
|
+
if (isUniqueConstraintError(err)) {
|
|
47
|
+
if (operation === 'create') {
|
|
48
|
+
throw new PMOError('CONFLICT', `${entityType} with this ID already exists`);
|
|
49
|
+
}
|
|
50
|
+
throw new PMOError('CONFLICT', `${entityType} already exists with that value`);
|
|
51
|
+
}
|
|
52
|
+
if (isForeignKeyConstraintError(err)) {
|
|
53
|
+
if (operation === 'delete') {
|
|
54
|
+
throw new PMOError('CONFLICT', `Cannot delete ${entityType.toLowerCase()}: it has dependencies. Remove them first.`);
|
|
55
|
+
}
|
|
56
|
+
throw new PMOError('INVALID', `Cannot ${operation} ${entityType.toLowerCase()}: referenced entity does not exist`);
|
|
57
|
+
}
|
|
58
|
+
if (isCheckConstraintError(err)) {
|
|
59
|
+
throw new PMOError('INVALID', `Invalid ${entityType.toLowerCase()} data: constraint check failed`);
|
|
60
|
+
}
|
|
61
|
+
// Re-throw unknown errors
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
6
64
|
const T = PMO_TABLES;
|
|
7
65
|
/**
|
|
8
66
|
* Convert a database row to a Ticket object.
|
|
@@ -6,7 +6,7 @@ import { PMO_TABLES } from '../schema.js';
|
|
|
6
6
|
import { PMOError, } from '../types.js';
|
|
7
7
|
import { generateEntityId, slugify } from '../utils.js';
|
|
8
8
|
import { generateBoardMarkdown } from '../markdown.js';
|
|
9
|
-
import { rowToTicket } from './helpers.js';
|
|
9
|
+
import { rowToTicket, wrapSqliteError } from './helpers.js';
|
|
10
10
|
const T = PMO_TABLES;
|
|
11
11
|
export class ProjectStorage {
|
|
12
12
|
ctx;
|
|
@@ -144,10 +144,15 @@ export class ProjectStorage {
|
|
|
144
144
|
// Use the requested workflow if it exists, otherwise fall back to default
|
|
145
145
|
const finalWorkflowId = workflow ? workflowId : 'default';
|
|
146
146
|
// Insert project with workflow
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
try {
|
|
148
|
+
this.ctx.db.prepare(`
|
|
149
|
+
INSERT OR REPLACE INTO ${T.projects} (id, name, template, description, workflow_id, created_at, updated_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
151
|
+
`).run(id, project.name, workflowId, project.description || null, finalWorkflowId, now, now);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
wrapSqliteError('Project', 'create', err);
|
|
155
|
+
}
|
|
151
156
|
return this.getBoard(id);
|
|
152
157
|
}
|
|
153
158
|
/**
|
|
@@ -241,9 +246,16 @@ export class ProjectStorage {
|
|
|
241
246
|
if (resolvedId === 'default') {
|
|
242
247
|
throw new PMOError('INVALID', 'Cannot delete the default project');
|
|
243
248
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
try {
|
|
250
|
+
const result = this.ctx.db.prepare(`DELETE FROM ${T.projects} WHERE id = ?`).run(resolvedId);
|
|
251
|
+
if (result.changes === 0) {
|
|
252
|
+
throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
if (err instanceof PMOError)
|
|
257
|
+
throw err;
|
|
258
|
+
wrapSqliteError('Project', 'delete', err);
|
|
247
259
|
}
|
|
248
260
|
// Tickets are deleted via CASCADE
|
|
249
261
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { PMO_TABLES } from '../schema.js';
|
|
5
5
|
import { PMOError } from '../types.js';
|
|
6
6
|
import { generateEntityId } from '../utils.js';
|
|
7
|
-
import { rowToSpec, rowToTicket } from './helpers.js';
|
|
7
|
+
import { rowToSpec, rowToTicket, wrapSqliteError } from './helpers.js';
|
|
8
8
|
const T = PMO_TABLES;
|
|
9
9
|
export class SpecStorage {
|
|
10
10
|
ctx;
|
|
@@ -17,16 +17,21 @@ export class SpecStorage {
|
|
|
17
17
|
async createSpec(spec) {
|
|
18
18
|
const id = spec.id || generateEntityId(this.ctx.db, 'spec');
|
|
19
19
|
const now = Date.now();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
try {
|
|
21
|
+
this.ctx.db.prepare(`
|
|
22
|
+
INSERT INTO ${T.specs} (
|
|
23
|
+
id, title, status, type, tags,
|
|
24
|
+
problem, solution, decisions, not_now, ui_ux,
|
|
25
|
+
acceptance_criteria, open_questions,
|
|
26
|
+
requirements_functional, requirements_technical,
|
|
27
|
+
context, created_at, updated_at
|
|
28
|
+
)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
30
|
+
`).run(id, spec.title || 'Untitled Spec', spec.status || 'draft', spec.type || null, spec.tags ? JSON.stringify(spec.tags) : null, spec.problem || null, spec.solution || null, spec.decisions || null, spec.notNow || null, spec.uiUx || null, spec.acceptanceCriteria || null, spec.openQuestions || null, spec.requirementsFunctional || null, spec.requirementsTechnical || null, spec.context || null, now, now);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
wrapSqliteError('Spec', 'create', err);
|
|
34
|
+
}
|
|
30
35
|
return {
|
|
31
36
|
id,
|
|
32
37
|
title: spec.title || 'Untitled Spec',
|
|
@@ -176,8 +181,13 @@ export class SpecStorage {
|
|
|
176
181
|
if (!existing) {
|
|
177
182
|
throw new PMOError('NOT_FOUND', `Spec not found: ${id}`);
|
|
178
183
|
}
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
try {
|
|
185
|
+
this.ctx.db.prepare(`DELETE FROM ${T.specs} WHERE id = ?`).run(id);
|
|
186
|
+
this.ctx.db.prepare(`UPDATE ${T.tickets} SET spec_id = NULL WHERE spec_id = ?`).run(id);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
wrapSqliteError('Spec', 'delete', err);
|
|
190
|
+
}
|
|
181
191
|
}
|
|
182
192
|
/**
|
|
183
193
|
* Link a ticket to a spec.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { PMO_TABLES } from '../schema.js';
|
|
7
7
|
import { PMOError, STATE_CATEGORY_ORDER } from '../types.js';
|
|
8
8
|
import { slugify } from '../utils.js';
|
|
9
|
+
import { wrapSqliteError } from './helpers.js';
|
|
9
10
|
const T = PMO_TABLES;
|
|
10
11
|
/**
|
|
11
12
|
* Convert database row to Workflow object.
|
|
@@ -87,10 +88,15 @@ export class StatusStorage {
|
|
|
87
88
|
if (existing) {
|
|
88
89
|
throw new PMOError('CONFLICT', `Workflow with name "${workflow.name}" already exists`);
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
try {
|
|
92
|
+
this.ctx.db.prepare(`
|
|
93
|
+
INSERT INTO ${T.workflows} (id, name, description, is_builtin, created_at, updated_at)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
95
|
+
`).run(id, workflow.name || 'New Workflow', workflow.description || null, workflow.isBuiltin ? 1 : 0, now, now);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
wrapSqliteError('Workflow', 'create', err);
|
|
99
|
+
}
|
|
94
100
|
return {
|
|
95
101
|
id,
|
|
96
102
|
name: workflow.name || 'New Workflow',
|
|
@@ -153,9 +159,14 @@ export class StatusStorage {
|
|
|
153
159
|
if (projectCount.count > 0) {
|
|
154
160
|
throw new PMOError('CONFLICT', `Cannot delete workflow: ${projectCount.count} project(s) are using it`);
|
|
155
161
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
try {
|
|
163
|
+
// Delete associated statuses first (cascaded by FK, but explicit for safety)
|
|
164
|
+
this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE workflow_id = ?`).run(id);
|
|
165
|
+
this.ctx.db.prepare(`DELETE FROM ${T.workflows} WHERE id = ?`).run(id);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
wrapSqliteError('Workflow', 'delete', err);
|
|
169
|
+
}
|
|
159
170
|
}
|
|
160
171
|
/**
|
|
161
172
|
* Get the workflow for a project.
|
|
@@ -237,10 +248,15 @@ export class StatusStorage {
|
|
|
237
248
|
WHERE workflow_id = ?
|
|
238
249
|
`).run(workflowId);
|
|
239
250
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
try {
|
|
252
|
+
this.ctx.db.prepare(`
|
|
253
|
+
INSERT INTO ${T.workflow_statuses} (id, workflow_id, name, category, position, color, description, is_default, created_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
255
|
+
`).run(id, workflowId, status.name || 'New Status', category, position, status.color || null, status.description || null, status.isDefault ? 1 : 0, now);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
wrapSqliteError('Status', 'create', err);
|
|
259
|
+
}
|
|
244
260
|
// Update workflow's updated_at timestamp
|
|
245
261
|
this.ctx.db.prepare(`UPDATE ${T.workflows} SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
246
262
|
return {
|
|
@@ -332,13 +348,18 @@ export class StatusStorage {
|
|
|
332
348
|
if (ticketCount.count > 0) {
|
|
333
349
|
throw new PMOError('CONFLICT', `Cannot delete status: ${ticketCount.count} ticket(s) are using it`);
|
|
334
350
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
351
|
+
try {
|
|
352
|
+
this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE id = ?`).run(id);
|
|
353
|
+
// Reorder remaining statuses
|
|
354
|
+
this.ctx.db.prepare(`
|
|
355
|
+
UPDATE ${T.workflow_statuses}
|
|
356
|
+
SET position = position - 1
|
|
357
|
+
WHERE workflow_id = ? AND position > ?
|
|
358
|
+
`).run(existing.workflowId, existing.position);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
wrapSqliteError('Status', 'delete', err);
|
|
362
|
+
}
|
|
342
363
|
// Update workflow's updated_at timestamp
|
|
343
364
|
this.ctx.db.prepare(`UPDATE ${T.workflows} SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), existing.workflowId);
|
|
344
365
|
}
|
|
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { PMO_TABLES } from '../schema.js';
|
|
6
6
|
import { PMOError } from '../types.js';
|
|
7
7
|
import { slugify } from '../utils.js';
|
|
8
|
+
import { wrapSqliteError } from './helpers.js';
|
|
8
9
|
const T = PMO_TABLES;
|
|
9
10
|
export class SubtaskStorage {
|
|
10
11
|
ctx;
|
|
@@ -42,10 +43,15 @@ export class SubtaskStorage {
|
|
|
42
43
|
id = `${baseId}-${counter}`;
|
|
43
44
|
}
|
|
44
45
|
const position = maxPos.max_pos + 1;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
try {
|
|
47
|
+
this.ctx.db.prepare(`
|
|
48
|
+
INSERT INTO ${T.subtasks} (id, ticket_id, title, done, position)
|
|
49
|
+
VALUES (?, ?, ?, 0, ?)
|
|
50
|
+
`).run(id, ticketId, title, position);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
wrapSqliteError('Subtask', 'create', err);
|
|
54
|
+
}
|
|
49
55
|
// Update ticket timestamp
|
|
50
56
|
this.ctx.db.prepare(`
|
|
51
57
|
UPDATE ${T.tickets} SET updated_at = ? WHERE id = ?
|
|
@@ -139,10 +145,15 @@ export class AcceptanceCriteriaStorage {
|
|
|
139
145
|
// Use UUID to guarantee uniqueness even when multiple ACs are added in the same millisecond
|
|
140
146
|
const id = `ac-${randomUUID()}`;
|
|
141
147
|
const position = maxPos.max_pos + 1;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
try {
|
|
149
|
+
this.ctx.db.prepare(`
|
|
150
|
+
INSERT INTO ${T.ticket_acceptance_criteria} (id, ticket_id, criterion, verifiable, verified, position)
|
|
151
|
+
VALUES (?, ?, ?, 1, 0, ?)
|
|
152
|
+
`).run(id, ticketId, criterion, position);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
wrapSqliteError('Acceptance criterion', 'create', err);
|
|
156
|
+
}
|
|
146
157
|
this.ctx.db.prepare(`
|
|
147
158
|
UPDATE ${T.tickets} SET updated_at = ? WHERE id = ?
|
|
148
159
|
`).run(Date.now(), ticketId);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { PMO_TABLES } from '../schema.js';
|
|
7
7
|
import { PMOError } from '../types.js';
|
|
8
8
|
import { slugify, generateEntityId } from '../utils.js';
|
|
9
|
-
import { rowToTicket } from './helpers.js';
|
|
9
|
+
import { rowToTicket, wrapSqliteError } from './helpers.js';
|
|
10
10
|
const T = PMO_TABLES;
|
|
11
11
|
export class TicketStorage {
|
|
12
12
|
ctx;
|
|
@@ -126,14 +126,19 @@ export class TicketStorage {
|
|
|
126
126
|
}
|
|
127
127
|
// Insert ticket
|
|
128
128
|
const labels = ticket.labels || [];
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
try {
|
|
130
|
+
this.ctx.db.prepare(`
|
|
131
|
+
INSERT INTO ${T.tickets} (
|
|
132
|
+
id, project_id, title, description, priority, category,
|
|
133
|
+
status_id, owner, assignee, spec_id, epic_id, labels,
|
|
134
|
+
created_at, updated_at, last_synced_from_spec, last_synced_from_board
|
|
135
|
+
)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
137
|
+
`).run(id, projectId, title, ticket.description || null, ticket.priority || null, ticket.category || null, statusId, ticket.owner || null, ticket.assignee || null, specId, ticket.epicId || null, JSON.stringify(labels), now, now, ticket.lastSyncedFromSpec || null, ticket.lastSyncedFromBoard || null);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
wrapSqliteError('Ticket', 'create', err);
|
|
141
|
+
}
|
|
137
142
|
// Insert subtasks
|
|
138
143
|
if (ticket.subtasks && ticket.subtasks.length > 0) {
|
|
139
144
|
const insertSubtask = this.ctx.db.prepare(`
|
|
@@ -335,12 +340,19 @@ export class TicketStorage {
|
|
|
335
340
|
}
|
|
336
341
|
// Delete ticket (by ID only, since IDs are globally unique)
|
|
337
342
|
// Related data (subtasks, metadata) are deleted via CASCADE
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
343
|
+
try {
|
|
344
|
+
const result = this.ctx.db.prepare(`
|
|
345
|
+
DELETE FROM ${T.tickets}
|
|
346
|
+
WHERE id = ?
|
|
347
|
+
`).run(id);
|
|
348
|
+
if (result.changes === 0) {
|
|
349
|
+
throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
if (err instanceof PMOError)
|
|
354
|
+
throw err;
|
|
355
|
+
wrapSqliteError('Ticket', 'delete', err);
|
|
344
356
|
}
|
|
345
357
|
// Update board timestamp for the ticket's project
|
|
346
358
|
this.updateProjectTimestamp(ticketProjectId);
|