repoburg 1.3.198 → 1.3.201

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.
Files changed (82) hide show
  1. package/agent-pane/backend/data.db +0 -0
  2. package/agent-pane/backend/data.db-shm +0 -0
  3. package/agent-pane/backend/data.db-wal +0 -0
  4. package/agent-pane/backend/dist/index.js +1425 -272
  5. package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
  6. package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
  7. package/agent-pane/backend/src/db/index.ts +6 -0
  8. package/agent-pane/backend/src/index.ts +5 -2
  9. package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
  10. package/agent-pane/backend/src/providers/index.ts +3 -1
  11. package/agent-pane/backend/src/repoburg/action-mapping.ts +216 -0
  12. package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
  13. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +262 -0
  14. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +271 -0
  15. package/agent-pane/backend/src/repoburg/backend-resolver.ts +246 -0
  16. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +159 -0
  17. package/agent-pane/backend/src/repoburg/repoburg-client.ts +181 -0
  18. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +723 -0
  19. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +422 -0
  20. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +475 -0
  21. package/agent-pane/backend/src/repoburg/turn-projector.ts +537 -0
  22. package/agent-pane/backend/src/routes/providers.test.ts +18 -1
  23. package/agent-pane/backend/src/routes/providers.ts +19 -1
  24. package/agent-pane/backend/src/routes/sessions.ts +12 -0
  25. package/agent-pane/backend/src/routes/threads.ts +21 -6
  26. package/agent-pane/backend/src/routes/turns.test.ts +45 -1
  27. package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-34WaZYsW.css} +1 -1
  28. package/agent-pane/frontend/dist/assets/index-y7xlT86e.js +362 -0
  29. package/agent-pane/frontend/dist/index.html +2 -2
  30. package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
  31. package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
  32. package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
  33. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
  34. package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
  35. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
  36. package/agent-pane/frontend/src/constants/models.ts +37 -0
  37. package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
  38. package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
  39. package/agent-pane/frontend/src/store/store.test.ts +35 -0
  40. package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
  41. package/agent-pane/frontend/src/styles/app.css +10 -1
  42. package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
  43. package/agent-pane-bug-repro-no-agent-reply.png +0 -0
  44. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +20 -0
  45. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +112 -0
  46. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
  47. package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
  48. package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
  49. package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
  50. package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
  51. package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
  52. package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
  53. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +77 -0
  54. package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
  55. package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
  56. package/backend/dist/src/app.module.js +4 -0
  57. package/backend/dist/src/app.module.js.map +1 -1
  58. package/backend/dist/src/interactive-chat/chat.module.js +4 -0
  59. package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
  60. package/backend/dist/src/interactive-chat/chat.service.d.ts +3 -1
  61. package/backend/dist/src/interactive-chat/chat.service.js +33 -6
  62. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  63. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
  64. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
  65. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
  66. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
  67. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
  68. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
  69. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +35 -2
  70. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  71. package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
  72. package/backend/dist/src/llm-provider/ollama-llm.provider.js +20 -1
  73. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  74. package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
  75. package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
  76. package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
  77. package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
  78. package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
  79. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  80. package/daemon/dist/index.js +0 -0
  81. package/package.json +1 -1
  82. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Integration test: discussion threads — Claude fork isolation (BUG 1 + BUG 2).
3
+ *
4
+ * Exercises the REAL claudeProvider + REAL thread routes with a mocked Claude
5
+ * SDK (`query` + `forkSession`). Verifies that creating a Discuss thread forks
6
+ * the main session, stores the fork id on the thread row, and never corrupts
7
+ * the main session's `external_thread_id`; and that a failed fork starts a
8
+ * fresh isolated session instead of resuming (and contaminating) the main
9
+ * session.
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13
+ import type Database from 'better-sqlite3';
14
+ import { clearBroadcasts, capturedBroadcasts } from './helpers/ws-capture.ts';
15
+ import type { TestApp } from './helpers/test-app.ts';
16
+
17
+ // Mock the Claude SDK — both the query factory and forkSession.
18
+ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
19
+ query: vi.fn(),
20
+ forkSession: vi.fn(),
21
+ }));
22
+
23
+ vi.mock('../ws/index.ts', async (importOriginal) => {
24
+ const actual = (await importOriginal()) as Record<string, unknown>;
25
+ return {
26
+ ...actual,
27
+ broadcast: vi.fn((event: string, data: unknown) => {
28
+ capturedBroadcasts.push({ event, data });
29
+ }),
30
+ };
31
+ });
32
+
33
+ // Import after mocks are set up.
34
+ import { query, forkSession } from '@anthropic-ai/claude-agent-sdk';
35
+ import { clearAllThreads } from '../claude/claude-provider.ts';
36
+ const { getDb } = await import('../db/index.ts');
37
+ const { createTestApp, insertTestSession, waitForTurn } = await import('./helpers/test-app.ts');
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Mock helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Build a mock `Query` whose async iterator yields the given SDK messages in
44
+ * order, then ends. getContextUsage resolves an empty snapshot (0/0) so the
45
+ * result/success handler completes the turn without emitting contextUsage. */
46
+ function createClaudeQueryMock(messages: unknown[]): unknown {
47
+ return {
48
+ getContextUsage: vi.fn().mockResolvedValue({
49
+ totalTokens: 0, maxTokens: 0, rawMaxTokens: 0, percentage: 0,
50
+ categories: [], gridRows: [], model: '', memoryFiles: [], mcpTools: [],
51
+ }),
52
+ interrupt: vi.fn(),
53
+ setModel: vi.fn(),
54
+ close: vi.fn(),
55
+ stopTask: vi.fn(),
56
+ initializationResult: vi.fn(),
57
+ [Symbol.asyncIterator]: async function* () {
58
+ for (const m of messages) yield m;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** A `system/init` SDK message carrying the given Claude session id. */
64
+ function initMsg(claudeSessionId: string): unknown {
65
+ return { type: 'system', subtype: 'init', session_id: claudeSessionId };
66
+ }
67
+
68
+ /** A `result/success` SDK message that completes a turn. */
69
+ function resultSuccessMsg(claudeSessionId: string): unknown {
70
+ return {
71
+ type: 'result', subtype: 'success',
72
+ usage: { input_tokens: 10, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 5 },
73
+ modelUsage: {}, result: 'done', is_error: false, num_turns: 1,
74
+ stop_reason: 'end_turn', duration_ms: 1, duration_api_ms: 1, total_cost_usd: 0,
75
+ permission_denials: [], uuid: 'u1', session_id: claudeSessionId,
76
+ };
77
+ }
78
+
79
+ describe('Integration: discussion threads — Claude fork isolation', () => {
80
+ let testApp: TestApp;
81
+
82
+ beforeEach(async () => {
83
+ vi.clearAllMocks();
84
+ clearAllThreads();
85
+ clearBroadcasts();
86
+ testApp = await createTestApp();
87
+ });
88
+
89
+ afterEach(async () => {
90
+ clearAllThreads();
91
+ await testApp.cleanup();
92
+ });
93
+
94
+ /** Seed a Claude session + anchor turn/item, ready for a thread creation. */
95
+ function seedSession(db: Database.Database): void {
96
+ insertTestSession(db, 's1', { runtime_type: 'claude', external_thread_id: 'main-1', title: 'Test' });
97
+ const now = '2025-01-01T00:00:00Z';
98
+ db.prepare(
99
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, status, external_turn_id, created_at, updated_at)
100
+ VALUES ('t1', 's1', 'prompt', 'response', 'completed', 'msg-uuid-1', ?, ?)`,
101
+ ).run(now, now);
102
+ db.prepare(
103
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
104
+ VALUES ('i1', 't1', 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
105
+ ).run(JSON.stringify({ text: 'Some text to discuss' }), now, now);
106
+ }
107
+
108
+ /** The shared thread-creation POST payload. */
109
+ const threadPayload = {
110
+ itemId: 'i1',
111
+ turnId: 't1',
112
+ startOffset: 0,
113
+ endOffset: 10,
114
+ selectedText: 'Some text ',
115
+ message: 'What does this mean?',
116
+ };
117
+
118
+ it('successful fork: stores fork id, resumes the fork, does NOT corrupt the main session (BUG 1 + BUG 2)', async () => {
119
+ const db: Database.Database = getDb();
120
+ seedSession(db);
121
+
122
+ // forkSession returns a new forked session id.
123
+ vi.mocked(forkSession).mockResolvedValue({ sessionId: 'fork-9' } as never);
124
+ // The forked query emits init with the FORK's id, then completes the turn.
125
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fork-9'), resultSuccessMsg('fork-9')]) as never);
126
+
127
+ const res = await testApp.app.inject({
128
+ method: 'POST',
129
+ url: '/api/sessions/s1/threads',
130
+ payload: threadPayload,
131
+ });
132
+
133
+ expect(res.statusCode).toBe(200);
134
+ const body = res.json();
135
+ const threadId = body.thread.id as string;
136
+ const turnId = body.turn.id as string;
137
+
138
+ // forkSession was called with the MAIN session id + the anchor turn's message UUID.
139
+ expect(vi.mocked(forkSession).mock.calls[0][0]).toBe('main-1');
140
+ expect((vi.mocked(forkSession).mock.calls[0][1] as { upToMessageId?: string }).upToMessageId).toBe('msg-uuid-1');
141
+
142
+ // The fork id is stored on the THREAD row, not the main session.
143
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
144
+ | { external_thread_id: string | null }
145
+ | undefined;
146
+ expect(threadRow?.external_thread_id).toBe('fork-9');
147
+
148
+ // Wait for the thread turn to complete, then assert the main session is intact.
149
+ await waitForTurn(db, turnId);
150
+
151
+ // BUG 2: the thread turn resumed its OWN fork ('fork-9'), not the main session.
152
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
153
+ expect(opts.resume).toBe('fork-9');
154
+
155
+ // BUG 1: the main session's external_thread_id was NOT overwritten with the fork's id.
156
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
157
+ | { external_thread_id: string | null }
158
+ | undefined;
159
+ expect(sessionRow?.external_thread_id).toBe('main-1');
160
+
161
+ // thread:created was broadcast.
162
+ expect(capturedBroadcasts.find((b) => b.event === 'thread:created')).toBeDefined();
163
+ });
164
+
165
+ it('failed fork (null): thread still created, starts a FRESH session (resume undefined), main session untouched (BUG 2)', async () => {
166
+ const db: Database.Database = getDb();
167
+ seedSession(db);
168
+
169
+ // forkSession returns null (e.g. no main session to fork from, or SDK refused).
170
+ vi.mocked(forkSession).mockResolvedValue(null as never);
171
+ // The fresh isolated session emits its own init id, then completes.
172
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fresh-1'), resultSuccessMsg('fresh-1')]) as never);
173
+
174
+ const res = await testApp.app.inject({
175
+ method: 'POST',
176
+ url: '/api/sessions/s1/threads',
177
+ payload: threadPayload,
178
+ });
179
+
180
+ expect(res.statusCode).toBe(200);
181
+ const body = res.json();
182
+ const threadId = body.thread.id as string;
183
+ const turnId = body.turn.id as string;
184
+
185
+ // The thread row has NO fork id.
186
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
187
+ | { external_thread_id: string | null }
188
+ | undefined;
189
+ expect(threadRow?.external_thread_id).toBeNull();
190
+
191
+ await waitForTurn(db, turnId);
192
+
193
+ // BUG 2: the thread started a FRESH session — no `resume` option — and did
194
+ // NOT fall back to the main session's 'main-1'.
195
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
196
+ expect(opts.resume).toBeUndefined();
197
+
198
+ // The main session is untouched.
199
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
200
+ | { external_thread_id: string | null }
201
+ | undefined;
202
+ expect(sessionRow?.external_thread_id).toBe('main-1');
203
+ });
204
+
205
+ it('failed fork (throws): caught, thread still created, main session untouched (BUG 2)', async () => {
206
+ const db: Database.Database = getDb();
207
+ seedSession(db);
208
+
209
+ // forkSession throws — the route must catch it and still create the thread.
210
+ vi.mocked(forkSession).mockRejectedValue(new Error('boom') as never);
211
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fresh-2'), resultSuccessMsg('fresh-2')]) as never);
212
+
213
+ const res = await testApp.app.inject({
214
+ method: 'POST',
215
+ url: '/api/sessions/s1/threads',
216
+ payload: threadPayload,
217
+ });
218
+
219
+ expect(res.statusCode).toBe(200);
220
+ const body = res.json();
221
+ const threadId = body.thread.id as string;
222
+ const turnId = body.turn.id as string;
223
+
224
+ // The thread row has NO fork id (fork threw before storing anything).
225
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
226
+ | { external_thread_id: string | null }
227
+ | undefined;
228
+ expect(threadRow?.external_thread_id).toBeNull();
229
+
230
+ await waitForTurn(db, turnId);
231
+
232
+ // The thread started a fresh isolated session (no resume), not the main session.
233
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
234
+ expect(opts.resume).toBeUndefined();
235
+
236
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
237
+ | { external_thread_id: string | null }
238
+ | undefined;
239
+ expect(sessionRow?.external_thread_id).toBe('main-1');
240
+ });
241
+ });
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { codexProvider } from '../codex/codex-provider.ts';
9
9
  import { claudeProvider } from '../claude/claude-provider.ts';
10
+ import { repoburgProvider } from '../repoburg/repoburg-provider.ts';
10
11
  import { registerProvider } from './registry.ts';
11
12
 
12
13
  // Register built-in providers
13
14
  registerProvider('codex', codexProvider);
14
- registerProvider('claude', claudeProvider);
15
+ registerProvider('claude', claudeProvider);
16
+ registerProvider('repoburg', repoburgProvider);
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Shared mapping helpers for the repoburg provider: repoburg AIAction →
3
+ * agent-pane item shapes, DB item helpers, and usage mapping.
4
+ * Used by both the provider (turn orchestration) and the turn projector
5
+ * (message-stream projection).
6
+ */
7
+
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import type Database from 'better-sqlite3';
10
+ import { broadcast } from '../ws/index.ts';
11
+ import type { Item, ItemStatus } from '../db/schema.ts';
12
+ import type { RepoburgAction } from './repoburg-client.ts';
13
+
14
+ /** Safely parse a JSON string field from an action row. */
15
+ export function parseJsonArgs(raw: string | null | undefined): Record<string, unknown> | null {
16
+ if (!raw) return null;
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /** Map a repoburg AIAction (+ its first execution log) to an agent-pane item
26
+ * shape. Returns null for actions that carry no displayable content
27
+ * (final/html_final — their text is part of the LLM response). */
28
+ export function mapActionToItem(
29
+ action: RepoburgAction,
30
+ ): { type: Item['type']; data: Record<string, unknown>; status: ItemStatus } | null {
31
+ const failed = action.status === 'EXECUTION_FAILED';
32
+ const status: ItemStatus = failed ? 'failed' : 'completed';
33
+ const log = action.executionLogs?.[0];
34
+ const output = log?.output ?? '';
35
+ const errorMsg = log?.error_message ?? '';
36
+
37
+ switch (action.action_type) {
38
+ case 'create_file':
39
+ case 'overwrite_file':
40
+ case 'patch':
41
+ case 'quick_edit':
42
+ case 'apply_diff':
43
+ case 'write_todo':
44
+ case 'delete_file': {
45
+ const kind =
46
+ action.action_type === 'create_file'
47
+ ? 'add'
48
+ : action.action_type === 'delete_file'
49
+ ? 'delete'
50
+ : 'modify';
51
+ return {
52
+ type: 'file_change',
53
+ data: { changes: [{ path: action.file_path ?? '', kind }], status },
54
+ status,
55
+ };
56
+ }
57
+
58
+ case 'run_command':
59
+ case 'execute_code':
60
+ return {
61
+ type: 'command_execution',
62
+ data: {
63
+ command: action.command_string ?? action.action_type,
64
+ aggregated_output: output,
65
+ exit_code: failed ? 1 : 0,
66
+ status,
67
+ },
68
+ status,
69
+ };
70
+
71
+ case 'invoke_subagent': {
72
+ const args = parseJsonArgs(action.arguments);
73
+ return {
74
+ type: 'mcp_tool_call',
75
+ data: {
76
+ server: null,
77
+ tool: 'Agent',
78
+ arguments: {
79
+ description: (args?.agent_name as string) ?? (args?.agentName as string) ?? 'Subagent',
80
+ prompt: (args?.prompt as string) ?? '',
81
+ },
82
+ result: output ? [{ type: 'text', text: output }] : null,
83
+ error: failed ? errorMsg || 'Sub-agent failed' : null,
84
+ status,
85
+ },
86
+ status,
87
+ };
88
+ }
89
+
90
+ case 'web_search': {
91
+ const args = parseJsonArgs(action.arguments);
92
+ return { type: 'web_search', data: { query: (args?.query as string) ?? '' }, status };
93
+ }
94
+
95
+ case 'final':
96
+ case 'html_final':
97
+ case 'ask_user':
98
+ // Their text is part of the LLM response — no separate item.
99
+ return null;
100
+
101
+ default: {
102
+ // MCP tools (serverName__toolName) and remaining built-ins → generic tool call.
103
+ const isMcp = !!(action.server_name && action.tool_name);
104
+ return {
105
+ type: 'mcp_tool_call',
106
+ data: {
107
+ server: isMcp ? action.server_name : null,
108
+ tool: isMcp ? action.tool_name : action.action_type,
109
+ arguments: parseJsonArgs(action.arguments),
110
+ result: output ? [{ type: 'text', text: output }] : null,
111
+ error: failed ? errorMsg || 'Action failed' : null,
112
+ status,
113
+ },
114
+ status,
115
+ };
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Map repoburg token counts to the agent-pane TokenUsage shape. */
121
+ export function mapUsage(u: {
122
+ input: number;
123
+ cached: number;
124
+ output: number;
125
+ }): {
126
+ input_tokens: number;
127
+ cached_input_tokens: number;
128
+ cache_write_input_tokens: number;
129
+ output_tokens: number;
130
+ reasoning_output_tokens: number;
131
+ } {
132
+ return {
133
+ input_tokens: u.input,
134
+ cached_input_tokens: u.cached,
135
+ cache_write_input_tokens: 0,
136
+ output_tokens: u.output,
137
+ reasoning_output_tokens: 0,
138
+ };
139
+ }
140
+
141
+ /** Update the running state and status of a session. */
142
+ export function setSessionStatus(
143
+ db: Database.Database,
144
+ sessionId: string,
145
+ isRunning: 0 | 1,
146
+ status: string,
147
+ now: string,
148
+ hasUnreviewed = false,
149
+ ): void {
150
+ if (hasUnreviewed) {
151
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?').run(
152
+ isRunning,
153
+ status,
154
+ now,
155
+ sessionId,
156
+ );
157
+ } else {
158
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, updated_at = ? WHERE id = ?').run(
159
+ isRunning,
160
+ status,
161
+ now,
162
+ sessionId,
163
+ );
164
+ }
165
+ }
166
+
167
+ /** Insert an item row and broadcast it. Returns the created item. */
168
+ export function insertItem(
169
+ db: Database.Database,
170
+ sessionId: string,
171
+ turnId: string,
172
+ externalId: string,
173
+ type: Item['type'],
174
+ data: Record<string, unknown>,
175
+ status: ItemStatus,
176
+ broadcastEvent: 'item:started' | 'item:completed',
177
+ ): Item {
178
+ const now = new Date().toISOString();
179
+ const itemId = uuidv4();
180
+ db.prepare(
181
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
182
+ ).run(itemId, turnId, externalId, type, JSON.stringify(data), status, now, now);
183
+
184
+ const item: Item = {
185
+ id: itemId,
186
+ turn_id: turnId,
187
+ external_item_id: externalId,
188
+ type,
189
+ data,
190
+ status,
191
+ created_at: now,
192
+ updated_at: now,
193
+ };
194
+ broadcast(broadcastEvent, { sessionId, turnId, item });
195
+ return item;
196
+ }
197
+
198
+ /** Update an item's data + status and broadcast the completed item. */
199
+ export function completeItem(
200
+ db: Database.Database,
201
+ sessionId: string,
202
+ turnId: string,
203
+ item: Item,
204
+ data: Record<string, unknown>,
205
+ status: ItemStatus,
206
+ ): void {
207
+ const now = new Date().toISOString();
208
+ db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE id = ?').run(
209
+ JSON.stringify(data),
210
+ status,
211
+ now,
212
+ item.id,
213
+ );
214
+ const updated: Item = { ...item, data, status, updated_at: now };
215
+ broadcast('item:completed', { sessionId, turnId, item: updated });
216
+ }