Skip to content

Commit f33449f

Browse files
dimakisclaude
andcommitted
feat(task-board): add external_ref for idempotent task creation
Tasks can now carry an optional externalRef field (e.g. "pr_shepherd:dimakis/centaur#42"). The server checks for an existing task with the same ref before creating, returning 200 + deduplicated flag instead of a duplicate 201. A partial unique index on external_ref (WHERE NOT NULL) provides the DB-level safety net for concurrent requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c9e92a2 commit f33449f

5 files changed

Lines changed: 105 additions & 3 deletions

File tree

server/__tests__/task-routes.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ describe('task routes', () => {
158158
expect(res.body.task.annotations).toEqual(['a']);
159159
});
160160

161+
// --- POST /api/tasks idempotency ---
162+
163+
it('POST /api/tasks — deduplicates by externalRef', async () => {
164+
const ref = `dedup-test-${Date.now()}`;
165+
const first = await request(app)
166+
.post('/api/tasks')
167+
.send({ title: 'First create', externalRef: ref })
168+
.set('Cookie', authCookie);
169+
expect(first.status).toBe(201);
170+
expect(first.body.task.externalRef).toBe(ref);
171+
172+
const second = await request(app)
173+
.post('/api/tasks')
174+
.send({ title: 'Duplicate create', externalRef: ref })
175+
.set('Cookie', authCookie);
176+
expect(second.status).toBe(200);
177+
expect(second.body.deduplicated).toBe(true);
178+
expect(second.body.task.id).toBe(first.body.task.id);
179+
expect(second.body.task.title).toBe('First create');
180+
});
181+
182+
it('POST /api/tasks — no dedup without externalRef', async () => {
183+
const first = await request(app)
184+
.post('/api/tasks')
185+
.send({ title: 'No ref A' })
186+
.set('Cookie', authCookie);
187+
const second = await request(app)
188+
.post('/api/tasks')
189+
.send({ title: 'No ref B' })
190+
.set('Cookie', authCookie);
191+
expect(first.status).toBe(201);
192+
expect(second.status).toBe(201);
193+
expect(first.body.task.id).not.toBe(second.body.task.id);
194+
});
195+
161196
// --- GET /api/tasks/:id ---
162197

163198
it('GET /api/tasks/:id — returns a task', async () => {

server/__tests__/task-store.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,41 @@ describe('TaskStore', () => {
509509
store2.close();
510510
});
511511

512+
// --- externalRef ---
513+
514+
it('creates a task with externalRef', () => {
515+
const task = store.create({ title: 'Ref task', externalRef: 'pr_shepherd:org/repo#42' });
516+
expect(task.externalRef).toBe('pr_shepherd:org/repo#42');
517+
});
518+
519+
it('defaults externalRef to null', () => {
520+
const task = store.create({ title: 'No ref' });
521+
expect(task.externalRef).toBeNull();
522+
});
523+
524+
it('getByExternalRef finds a task', () => {
525+
store.create({ title: 'Findable', externalRef: 'test:find-me' });
526+
const found = store.getByExternalRef('test:find-me');
527+
expect(found).not.toBeNull();
528+
expect(found!.title).toBe('Findable');
529+
});
530+
531+
it('getByExternalRef returns null for unknown ref', () => {
532+
expect(store.getByExternalRef('nonexistent')).toBeNull();
533+
});
534+
535+
it('rejects duplicate externalRef', () => {
536+
store.create({ title: 'First', externalRef: 'unique:ref' });
537+
expect(() => store.create({ title: 'Second', externalRef: 'unique:ref' })).toThrow();
538+
});
539+
540+
it('allows multiple tasks with null externalRef', () => {
541+
store.create({ title: 'A' });
542+
store.create({ title: 'B' });
543+
const roots = store.listRoots();
544+
expect(roots.length).toBeGreaterThanOrEqual(2);
545+
});
546+
512547
it('migrates existing database to add workflow columns', () => {
513548
// Create a DB without workflow columns, then reopen to trigger migration
514549
const dbPath = join(TEST_DIR, 'migrate.db');

server/api-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export const TaskCreateBody = z.object({
123123
gateConfig: z.record(z.string(), z.unknown()).optional(),
124124
maxRetries: z.number().min(0).optional(),
125125
templateId: z.string().optional(),
126+
externalRef: z.string().optional(),
126127
});
127128

128129
export const TaskUpdateBody = z.object({

server/app.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,14 @@ app.post('/api/tasks', (req, res) => {
635635
return;
636636
}
637637
try {
638+
// Idempotency guard: if externalRef provided and already exists, return existing task
639+
if (body.data.externalRef) {
640+
const existing = taskStore.getByExternalRef(body.data.externalRef);
641+
if (existing) {
642+
res.status(200).json({ task: existing, deduplicated: true });
643+
return;
644+
}
645+
}
638646
const task = taskStore.create(body.data as TaskCreateInput);
639647
res.status(201).json({ task });
640648
// Broadcast full tree so child tasks appear correctly in all clients

server/task-store.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface Task {
4949
retryCount: number;
5050
maxRetries: number;
5151
templateId: string | null;
52+
externalRef: string | null;
5253
children: Task[];
5354
}
5455

@@ -63,6 +64,7 @@ export interface TaskCreateInput {
6364
gateConfig?: GateConfig;
6465
maxRetries?: number;
6566
templateId?: string;
67+
externalRef?: string;
6668
}
6769

6870
export interface TaskUpdateInput {
@@ -106,6 +108,7 @@ interface TaskRow {
106108
retry_count: number;
107109
max_retries: number;
108110
template_id: string | null;
111+
external_ref: string | null;
109112
}
110113

111114
const SCHEMA = `
@@ -137,11 +140,13 @@ const SCHEMA = `
137140
artifacts TEXT DEFAULT NULL,
138141
retry_count INTEGER NOT NULL DEFAULT 0,
139142
max_retries INTEGER NOT NULL DEFAULT 0,
140-
template_id TEXT DEFAULT NULL
143+
template_id TEXT DEFAULT NULL,
144+
external_ref TEXT DEFAULT NULL
141145
);
142146
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
143147
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
144148
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
149+
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_external_ref ON tasks(external_ref) WHERE external_ref IS NOT NULL;
145150
`;
146151

147152
const MIGRATIONS = [
@@ -158,6 +163,14 @@ const MIGRATIONS = [
158163
ALTER TABLE tasks ADD COLUMN template_id TEXT DEFAULT NULL;
159164
`,
160165
},
166+
// Migration 2: Add external_ref for idempotent task creation
167+
{
168+
check: "SELECT COUNT(*) as cnt FROM pragma_table_info('tasks') WHERE name = 'external_ref'",
169+
sql: `
170+
ALTER TABLE tasks ADD COLUMN external_ref TEXT DEFAULT NULL;
171+
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_external_ref ON tasks(external_ref) WHERE external_ref IS NOT NULL;
172+
`,
173+
},
161174
];
162175

163176
function rowToTask(row: TaskRow): Task {
@@ -194,6 +207,7 @@ function rowToTask(row: TaskRow): Task {
194207
retryCount: row.retry_count ?? 0,
195208
maxRetries: row.max_retries ?? 0,
196209
templateId: row.template_id ?? null,
210+
externalRef: row.external_ref ?? null,
197211
children: [],
198212
};
199213
}
@@ -259,8 +273,8 @@ export class TaskStore {
259273
const gateConfig = input.gateConfig ? JSON.stringify(input.gateConfig) : null;
260274

261275
db.prepare(
262-
`INSERT INTO tasks (id, parent_id, title, description, status, session_policy, priority, depth, annotations, stage_type, gate_config, max_retries, template_id, created_at, updated_at)
263-
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
276+
`INSERT INTO tasks (id, parent_id, title, description, status, session_policy, priority, depth, annotations, stage_type, gate_config, max_retries, template_id, external_ref, created_at, updated_at)
277+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
264278
).run(
265279
id,
266280
input.parentId ?? null,
@@ -274,6 +288,7 @@ export class TaskStore {
274288
gateConfig,
275289
input.maxRetries ?? 0,
276290
input.templateId ?? null,
291+
input.externalRef ?? null,
277292
now,
278293
now,
279294
);
@@ -288,6 +303,14 @@ export class TaskStore {
288303
return row ? rowToTask(row) : null;
289304
}
290305

306+
/** Look up a task by its external reference (e.g. "pr_shepherd:dimakis/centaur#42"). */
307+
getByExternalRef(ref: string): Task | null {
308+
const row = this.getDb()
309+
.prepare('SELECT * FROM tasks WHERE external_ref = ?')
310+
.get(ref) as TaskRow | undefined;
311+
return row ? rowToTask(row) : null;
312+
}
313+
291314
update(id: string, fields: TaskUpdateInput): Task | null {
292315
const existing = this.get(id);
293316
if (!existing) return null;

0 commit comments

Comments
 (0)