repoburg 1.3.201 → 1.3.202
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/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +478 -250
- package/agent-pane/backend/src/db/index.ts +6 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
- package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
- package/agent-pane/backend/src/repoburg/action-mapping.ts +89 -11
- package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +23 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +85 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.ts +28 -13
- package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +21 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.ts +13 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +57 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.ts +49 -0
- package/agent-pane/backend/src/repoburg/turn-projector.test.ts +147 -1
- package/agent-pane/backend/src/repoburg/turn-projector.ts +101 -1
- package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
- package/agent-pane/backend/src/routes/sessions.ts +70 -9
- package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
- package/agent-pane/frontend/dist/assets/{index-34WaZYsW.css → index-r-xwfOKQ.css} +1 -1
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
- package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
- package/agent-pane/frontend/src/api/websocket.ts +3 -1
- package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
- package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
- package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
- package/agent-pane/frontend/src/store/store.test.ts +19 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
- package/agent-pane/frontend/src/styles/app.css +16 -0
- package/agent-pane/frontend/src/test/fixtures.ts +1 -0
- package/agent-pane/frontend/src/types/index.ts +2 -0
- package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
- package/agent-pane/shared/types.ts +17 -0
- package/askuser-after-step5-postgres-selected.png +0 -0
- package/askuser-after-step7-answer-sent.png +0 -0
- package/askuser-card-visible.png +0 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +16 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +87 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -1
- package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +16 -1
- package/backend/dist/src/app.controller.d.ts +1 -0
- package/backend/dist/src/app.service.d.ts +1 -0
- package/backend/dist/src/app.service.js +4 -1
- package/backend/dist/src/app.service.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.service.d.ts +4 -0
- package/backend/dist/src/interactive-chat/chat.service.js +21 -1
- package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +2 -0
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- package/daemon/dist/api/services.js +4 -0
- package/daemon/dist/serviceManager.js +30 -11
- package/daemon/dist/serviceManager.spec.js +26 -0
- package/daemon/dist/workspace-validation.js +63 -0
- package/daemon/dist/workspace-validation.spec.js +63 -0
- package/package.json +1 -1
- package/r2-step4-apple-banana.png +0 -0
- package/r2-step5-after-rewind-apple-last.png +0 -0
- package/r2-step7-apple-cherry.png +0 -0
- package/r2-step8-cherry-menu-no-restore.png +0 -0
- package/r3-step3-live-task-card-running.png +0 -0
- package/r3-step4-after-completion.png +0 -0
- package/r3-step5-persistence-after-reload.png +0 -0
- package/r3-step6-tasks-tab-card.png +0 -0
- package/reverify-answered-state-answer-sent.png +0 -0
- package/reverify-card-visible-after-completion.png +0 -0
- package/rewind-step3-apple-banana-turns.png +0 -0
- package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
- package/rewind-step6-apple-menu-no-restore.png +0 -0
- package/rewind-step7-final-turnlist-unchanged.png +0 -0
- package/rewind-step7-negative-error-toast.png +0 -0
- package/rewind-step7-negative-error-toast2.png +0 -0
- package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
- package/subagent-r2-step6-tasks-panel-card.png +0 -0
- package/subagent-step3-tasks-tab-done-card.png +0 -0
- package/subagent-step4-turn-stuck-no-cards.png +0 -0
- package/agent-pane/frontend/dist/assets/index-y7xlT86e.js +0 -362
|
@@ -2,14 +2,21 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
2
2
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
3
|
import { initDb, getDb } from '../db/index.ts';
|
|
4
4
|
import { registerSessionRoutes } from './sessions.ts';
|
|
5
|
-
// Mock registry to avoid spawning real provider processes
|
|
5
|
+
// Mock registry to avoid spawning real provider processes.
|
|
6
|
+
// rewindRuntime stays OUT of the default provider (only repoburg has it);
|
|
7
|
+
// tests that exercise the runtime-first path opt in via `useRewindRuntime()`.
|
|
6
8
|
const mocks = vi.hoisted(() => ({
|
|
7
9
|
clearThread: vi.fn(),
|
|
10
|
+
rewindRuntime: vi.fn(),
|
|
11
|
+
forkSession: vi.fn(),
|
|
12
|
+
rewindRuntimeActive: false,
|
|
8
13
|
}));
|
|
9
14
|
|
|
10
15
|
vi.mock('../providers/registry.ts', () => ({
|
|
11
16
|
getProvider: vi.fn(() => ({
|
|
12
17
|
clearThread: mocks.clearThread,
|
|
18
|
+
...(mocks.rewindRuntimeActive ? { rewindRuntime: mocks.rewindRuntime } : {}),
|
|
19
|
+
forkSession: mocks.forkSession,
|
|
13
20
|
startTurn: vi.fn(),
|
|
14
21
|
interruptTurn: vi.fn(),
|
|
15
22
|
clearAllThreads: vi.fn(),
|
|
@@ -65,6 +72,7 @@ describe('Session routes', () => {
|
|
|
65
72
|
|
|
66
73
|
beforeEach(async () => {
|
|
67
74
|
vi.clearAllMocks();
|
|
75
|
+
mocks.rewindRuntimeActive = false; // default provider has NO rewindRuntime
|
|
68
76
|
initDb(':memory:');
|
|
69
77
|
app = Fastify();
|
|
70
78
|
await registerSessionRoutes(app);
|
|
@@ -384,6 +392,94 @@ describe('Session routes', () => {
|
|
|
384
392
|
expect(res.statusCode).toBe(409);
|
|
385
393
|
expect(res.json().error).toContain('Cannot switch provider');
|
|
386
394
|
});
|
|
395
|
+
describe('PATCH /api/sessions/:id — cwd (workspace) change', () => {
|
|
396
|
+
it('clears the thread when cwd changes on a repoburg session', async () => {
|
|
397
|
+
const db = getDb();
|
|
398
|
+
insertSession(db, 's1', {
|
|
399
|
+
runtime_type: 'repoburg',
|
|
400
|
+
cwd: '/tmp/workspace-a',
|
|
401
|
+
external_thread_id: 'rs-1',
|
|
402
|
+
});
|
|
403
|
+
const res = await app.inject({
|
|
404
|
+
method: 'PATCH',
|
|
405
|
+
url: '/api/sessions/s1',
|
|
406
|
+
payload: { cwd: '/tmp/workspace-b' },
|
|
407
|
+
});
|
|
408
|
+
expect(res.statusCode).toBe(200);
|
|
409
|
+
const session = res.json();
|
|
410
|
+
expect(session.cwd).toBe('/tmp/workspace-b');
|
|
411
|
+
expect(session.external_thread_id).toBeNull();
|
|
412
|
+
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('clears rewind markers along with the thread on a cwd change', async () => {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
insertSession(db, 's1', { runtime_type: 'claude', cwd: '/tmp/a', external_thread_id: 'thread-1' });
|
|
418
|
+
db.prepare('UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?')
|
|
419
|
+
.run('turn-1', 'orig-1', 's1');
|
|
420
|
+
|
|
421
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
|
|
422
|
+
expect(res.statusCode).toBe(200);
|
|
423
|
+
const session = db.prepare(
|
|
424
|
+
'SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?',
|
|
425
|
+
).get('s1') as {
|
|
426
|
+
rewound_at_turn_id: string | null;
|
|
427
|
+
pre_rewind_thread_id: string | null;
|
|
428
|
+
external_thread_id: string | null;
|
|
429
|
+
};
|
|
430
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
431
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
432
|
+
expect(session.external_thread_id).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('returns 409 when changing cwd while a turn is running', async () => {
|
|
436
|
+
const db = getDb();
|
|
437
|
+
insertSession(db, 's1', {
|
|
438
|
+
runtime_type: 'repoburg',
|
|
439
|
+
cwd: '/tmp/a',
|
|
440
|
+
external_thread_id: 'rs-1',
|
|
441
|
+
is_running: 1,
|
|
442
|
+
});
|
|
443
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
|
|
444
|
+
expect(res.statusCode).toBe(409);
|
|
445
|
+
expect(res.json().error).toContain('Cannot change workspace');
|
|
446
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
447
|
+
const session = db.prepare('SELECT cwd, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
448
|
+
cwd: string | null;
|
|
449
|
+
external_thread_id: string | null;
|
|
450
|
+
};
|
|
451
|
+
expect(session.cwd).toBe('/tmp/a');
|
|
452
|
+
expect(session.external_thread_id).toBe('rs-1');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('keeps the thread when the cwd is resolve-equal (trailing slash)', async () => {
|
|
456
|
+
const db = getDb();
|
|
457
|
+
insertSession(db, 's1', {
|
|
458
|
+
runtime_type: 'repoburg',
|
|
459
|
+
cwd: '/tmp/workspace-a',
|
|
460
|
+
external_thread_id: 'rs-1',
|
|
461
|
+
});
|
|
462
|
+
const res = await app.inject({
|
|
463
|
+
method: 'PATCH',
|
|
464
|
+
url: '/api/sessions/s1',
|
|
465
|
+
payload: { cwd: '/tmp/workspace-a/' },
|
|
466
|
+
});
|
|
467
|
+
expect(res.statusCode).toBe(200);
|
|
468
|
+
expect(res.json().cwd).toBe('/tmp/workspace-a/');
|
|
469
|
+
expect(res.json().external_thread_id).toBe('rs-1');
|
|
470
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('clears the thread when cwd is cleared to null', async () => {
|
|
474
|
+
const db = getDb();
|
|
475
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', cwd: '/tmp/a', external_thread_id: 'rs-1' });
|
|
476
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: null } });
|
|
477
|
+
expect(res.statusCode).toBe(200);
|
|
478
|
+
expect(res.json().cwd).toBeNull();
|
|
479
|
+
expect(res.json().external_thread_id).toBeNull();
|
|
480
|
+
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
481
|
+
});
|
|
482
|
+
});
|
|
387
483
|
});
|
|
388
484
|
|
|
389
485
|
describe('POST /api/sessions/:id/rewind', () => {
|
|
@@ -446,6 +542,104 @@ describe('Session routes', () => {
|
|
|
446
542
|
expect(session.pre_rewind_thread_id).toBe('thread-123');
|
|
447
543
|
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
448
544
|
});
|
|
545
|
+
it('calls provider.rewindRuntime (runtime-first), hard-deletes local turns after anchor, leaves NO rewind marker', async () => {
|
|
546
|
+
const db = getDb();
|
|
547
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
548
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
549
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
550
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
551
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
552
|
+
JSON.stringify(['in-1', 'in-2']),
|
|
553
|
+
't1',
|
|
554
|
+
);
|
|
555
|
+
mocks.rewindRuntime.mockResolvedValueOnce(undefined);
|
|
556
|
+
|
|
557
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
558
|
+
expect(res.statusCode).toBe(200);
|
|
559
|
+
expect(mocks.rewindRuntime).toHaveBeenCalledTimes(1);
|
|
560
|
+
const [sessionArg, turnArg] = mocks.rewindRuntime.mock.calls[0];
|
|
561
|
+
expect(sessionArg.external_thread_id).toBe('rs-1');
|
|
562
|
+
expect(turnArg.id).toBe('t1');
|
|
563
|
+
|
|
564
|
+
// Local turns after the anchor are hard-deleted (platform parity).
|
|
565
|
+
const turns = db.prepare('SELECT id FROM turns WHERE session_id = ? ORDER BY created_at').all('s1') as { id: string }[];
|
|
566
|
+
expect(turns).toHaveLength(1);
|
|
567
|
+
expect(turns[0].id).toBe('t1');
|
|
568
|
+
|
|
569
|
+
// NO rewind marker — nothing is hidden or restorable for repoburg.
|
|
570
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
571
|
+
rewound_at_turn_id: string | null;
|
|
572
|
+
pre_rewind_thread_id: string | null;
|
|
573
|
+
};
|
|
574
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
575
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
576
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('repoburg rewind: turns created AFTER the rewind stay visible in GET /:id (regression)', async () => {
|
|
580
|
+
const db = getDb();
|
|
581
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
582
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
583
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
584
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
585
|
+
mocks.rewindRuntime.mockResolvedValue(undefined);
|
|
586
|
+
|
|
587
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
588
|
+
expect(res.statusCode).toBe(200);
|
|
589
|
+
|
|
590
|
+
// A NEW turn created after the rewind must appear in the session view —
|
|
591
|
+
// a leftover rewound_at_turn_id marker would filter it out.
|
|
592
|
+
insertTurn(db, 't3', 's1', '2025-01-02T00:00:00Z');
|
|
593
|
+
|
|
594
|
+
const view = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
595
|
+
expect(view.statusCode).toBe(200);
|
|
596
|
+
const turnIds = (view.json().turns as { id: string }[]).map((t) => t.id);
|
|
597
|
+
expect(turnIds).toEqual(['t1', 't3']);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('rewindRuntime failure → NO local rewind (error passes through)', async () => {
|
|
601
|
+
const db = getDb();
|
|
602
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
603
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
604
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
605
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
606
|
+
JSON.stringify(['in-1']),
|
|
607
|
+
't1',
|
|
608
|
+
);
|
|
609
|
+
mocks.rewindRuntime.mockRejectedValueOnce(
|
|
610
|
+
new Error('DELETE /sessions/rs-1/inputs/in-1/until-here failed: 400 {"message":"Summaries can only be discarded, not deleted."}'),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
614
|
+
expect(res.statusCode).toBe(500);
|
|
615
|
+
expect(res.json().message).toContain('Summaries can only be discarded');
|
|
616
|
+
|
|
617
|
+
// Local view untouched — no rewind markers, no clearThread
|
|
618
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
619
|
+
rewound_at_turn_id: string | null;
|
|
620
|
+
pre_rewind_thread_id: string | null;
|
|
621
|
+
};
|
|
622
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
623
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
624
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('rewindRuntime NOT called when the provider does not implement it (codex)', async () => {
|
|
628
|
+
const db = getDb();
|
|
629
|
+
insertSession(db, 's1', { external_thread_id: 'thread-123' });
|
|
630
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
631
|
+
const { getProvider } = await import('../providers/registry.ts');
|
|
632
|
+
vi.mocked(getProvider).mockReturnValueOnce({ clearThread: mocks.clearThread } as never);
|
|
633
|
+
|
|
634
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
635
|
+
expect(res.statusCode).toBe(200);
|
|
636
|
+
expect(mocks.rewindRuntime).not.toHaveBeenCalled();
|
|
637
|
+
|
|
638
|
+
const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
|
|
639
|
+
rewound_at_turn_id: string | null;
|
|
640
|
+
};
|
|
641
|
+
expect(session.rewound_at_turn_id).toBe('t1');
|
|
642
|
+
});
|
|
449
643
|
|
|
450
644
|
it('GET session filters turns when rewound', async () => {
|
|
451
645
|
const db = getDb();
|
|
@@ -491,6 +685,26 @@ describe('Session routes', () => {
|
|
|
491
685
|
const getRes = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
492
686
|
expect(getRes.json().turns).toHaveLength(2);
|
|
493
687
|
});
|
|
688
|
+
it('restore returns 409 for repoburg sessions (runtime history permanently deleted)', async () => {
|
|
689
|
+
// Simulate a LEGACY rewound repoburg session (marker set by a
|
|
690
|
+
// pre-hard-delete rewind) — restore must still be refused.
|
|
691
|
+
const db = getDb();
|
|
692
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
693
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
694
|
+
db.prepare(
|
|
695
|
+
'UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?',
|
|
696
|
+
).run('t1', 'rs-1', 's1');
|
|
697
|
+
|
|
698
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
|
|
699
|
+
expect(res.statusCode).toBe(409);
|
|
700
|
+
expect(res.json().error).toContain('permanently deleted');
|
|
701
|
+
|
|
702
|
+
// Rewind state stays intact
|
|
703
|
+
const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
|
|
704
|
+
rewound_at_turn_id: string | null;
|
|
705
|
+
};
|
|
706
|
+
expect(session.rewound_at_turn_id).toBe('t1');
|
|
707
|
+
});
|
|
494
708
|
|
|
495
709
|
it('restore returns 409 when not rewound', async () => {
|
|
496
710
|
const db = getDb();
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
4
|
import { getDb } from '../db/index.ts';
|
|
4
5
|
import { getProvider } from '../providers/registry.ts';
|
|
5
6
|
import type { Session, SessionDetail, Turn, Item, DiscussionThread, Attachment } from '../db/schema.ts';
|
|
6
7
|
|
|
7
|
-
/** Raw DB row for turns — token_usage and
|
|
8
|
-
|
|
8
|
+
/** Raw DB row for turns — token_usage, attachments and runtime_input_ids are
|
|
9
|
+
* JSON strings, not parsed; no items field. */
|
|
10
|
+
interface TurnRow extends Omit<Turn, 'token_usage' | 'items' | 'attachments' | 'runtime_input_ids'> {
|
|
9
11
|
token_usage: string | null;
|
|
10
12
|
attachments: string | null;
|
|
13
|
+
runtime_input_ids: string | null;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/** Raw DB row for items — data is a JSON string, not parsed. */
|
|
@@ -71,6 +74,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
71
74
|
...row,
|
|
72
75
|
token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
|
|
73
76
|
attachments: row.attachments ? (JSON.parse(row.attachments) as Attachment[]) : null,
|
|
77
|
+
runtime_input_ids: row.runtime_input_ids ? (JSON.parse(row.runtime_input_ids) as string[]) : null,
|
|
74
78
|
items: itemsByTurn.get(row.id) ?? [],
|
|
75
79
|
})),
|
|
76
80
|
};
|
|
@@ -160,7 +164,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
160
164
|
return { success: true };
|
|
161
165
|
});
|
|
162
166
|
|
|
163
|
-
// PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config)
|
|
167
|
+
// PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, cwd)
|
|
164
168
|
fastify.patch<{
|
|
165
169
|
Params: { id: string };
|
|
166
170
|
Body: {
|
|
@@ -171,6 +175,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
171
175
|
model_reasoning_effort?: string | null;
|
|
172
176
|
runtime_config?: string | null;
|
|
173
177
|
runtime_type?: string;
|
|
178
|
+
cwd?: string | null;
|
|
174
179
|
};
|
|
175
180
|
}>('/api/sessions/:id', {
|
|
176
181
|
schema: {
|
|
@@ -184,12 +189,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
184
189
|
model_reasoning_effort: { type: ['string', 'null'] },
|
|
185
190
|
runtime_config: { type: ['string', 'null'] },
|
|
186
191
|
runtime_type: { type: 'string' },
|
|
192
|
+
cwd: { type: ['string', 'null'] },
|
|
187
193
|
},
|
|
188
194
|
},
|
|
189
195
|
},
|
|
190
196
|
}, async (request, reply) => {
|
|
191
197
|
const { id } = request.params;
|
|
192
|
-
const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type } = request.body || {};
|
|
198
|
+
const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type, cwd } = request.body || {};
|
|
193
199
|
const db = getDb();
|
|
194
200
|
|
|
195
201
|
const existing = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
|
@@ -239,6 +245,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
239
245
|
}
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
// Workspace (cwd) change — the runtime thread belongs to the old
|
|
249
|
+
// workspace backend, so it is cleared like a provider switch; the next
|
|
250
|
+
// turn creates a fresh runtime session on the new workspace.
|
|
251
|
+
// Compare resolve-normalized paths — cosmetic differences (trailing
|
|
252
|
+
// slash) are not a workspace change.
|
|
253
|
+
let workspaceChanged = false;
|
|
254
|
+
if (cwd !== undefined) {
|
|
255
|
+
const oldAbs = existing.cwd ? path.resolve(existing.cwd) : null;
|
|
256
|
+
const newAbs = cwd ? path.resolve(cwd) : null;
|
|
257
|
+
workspaceChanged = oldAbs !== newAbs;
|
|
258
|
+
if (workspaceChanged && existing.is_running) {
|
|
259
|
+
return reply.code(409).send({ error: 'Cannot change workspace while a turn is running' });
|
|
260
|
+
}
|
|
261
|
+
updates.push('cwd = ?');
|
|
262
|
+
params.push(cwd);
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
// Model and reasoning changes update DB columns but do NOT clear the thread.
|
|
243
266
|
// The Claude SDK supports query({ resume: sessionId, model: newModel }) —
|
|
244
267
|
// changing the model within the same provider preserves conversation context.
|
|
@@ -269,11 +292,14 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
269
292
|
|
|
270
293
|
db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
|
|
271
294
|
|
|
272
|
-
//
|
|
295
|
+
// Clear the thread when the PROVIDER or WORKSPACE changes — the runtime
|
|
296
|
+
// session belongs to the old provider/backend and cannot be resumed.
|
|
273
297
|
// Model/reasoning changes within the same provider keep the thread intact.
|
|
274
|
-
if (providerChanged
|
|
275
|
-
|
|
276
|
-
|
|
298
|
+
if (providerChanged || workspaceChanged) {
|
|
299
|
+
if (existing.external_thread_id) {
|
|
300
|
+
db.prepare('UPDATE sessions SET external_thread_id = NULL, rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
|
|
301
|
+
}
|
|
302
|
+
// Drop the in-memory entry even without a persisted thread id (stale state).
|
|
277
303
|
getProvider(existing.runtime_type).clearThread(id);
|
|
278
304
|
}
|
|
279
305
|
|
|
@@ -318,6 +344,35 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
318
344
|
return reply.code(404).send({ error: 'Turn not found' });
|
|
319
345
|
}
|
|
320
346
|
|
|
347
|
+
const provider = getProvider(session.runtime_type);
|
|
348
|
+
|
|
349
|
+
// RUNTIME-FIRST (repoburg in-place truncation): hard-delete the runtime
|
|
350
|
+
// history after the target turn BEFORE any local change. Errors pass
|
|
351
|
+
// through — on failure the local view stays untouched and consistent
|
|
352
|
+
// with the runtime (e.g. 400 summary-in-range from until-here).
|
|
353
|
+
if (provider.rewindRuntime) {
|
|
354
|
+
await provider.rewindRuntime(session, targetTurnFull);
|
|
355
|
+
|
|
356
|
+
// Platform-parity HARD DELETE: the runtime history after the anchor
|
|
357
|
+
// is permanently gone, so mirror it locally — delete the local turns
|
|
358
|
+
// after the target and leave NO rewind marker (nothing to restore).
|
|
359
|
+
// Stale markers are cleared too: a marker would hide every turn
|
|
360
|
+
// created after the rewind (GET /:id filters by rewound_at_turn_id).
|
|
361
|
+
const now2 = new Date().toISOString();
|
|
362
|
+
db.prepare(
|
|
363
|
+
'DELETE FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ? AND created_at > ?)',
|
|
364
|
+
).run(id, targetTurnFull.created_at);
|
|
365
|
+
db.prepare('DELETE FROM turns WHERE session_id = ? AND created_at > ?').run(
|
|
366
|
+
id,
|
|
367
|
+
targetTurnFull.created_at,
|
|
368
|
+
);
|
|
369
|
+
db.prepare(
|
|
370
|
+
'UPDATE sessions SET rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?',
|
|
371
|
+
).run(now2, id);
|
|
372
|
+
const truncated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
373
|
+
return truncated;
|
|
374
|
+
}
|
|
375
|
+
|
|
321
376
|
const now = new Date().toISOString();
|
|
322
377
|
|
|
323
378
|
// Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
|
|
@@ -336,7 +391,6 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
336
391
|
}
|
|
337
392
|
|
|
338
393
|
// Fork the provider-native session so the provider has conversation context up to the target turn
|
|
339
|
-
const provider = getProvider(session.runtime_type);
|
|
340
394
|
if (provider.forkSession) {
|
|
341
395
|
try {
|
|
342
396
|
const forkedId = await provider.forkSession(session, targetTurnFull);
|
|
@@ -371,6 +425,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
371
425
|
if (!session.rewound_at_turn_id) {
|
|
372
426
|
return reply.code(409).send({ error: 'Session is not rewound' });
|
|
373
427
|
}
|
|
428
|
+
// Repoburg rewind = platform-parity HARD DELETE — the runtime history was
|
|
429
|
+
// permanently truncated, so local turns can never be restored.
|
|
430
|
+
if (session.runtime_type === 'repoburg') {
|
|
431
|
+
return reply.code(409).send({
|
|
432
|
+
error: 'Restore is not available for repoburg sessions — the rewind permanently deleted the runtime history.',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
374
435
|
|
|
375
436
|
const now = new Date().toISOString();
|
|
376
437
|
// Restore the original external_thread_id and clear rewind markers
|