ompchamber 0.7.0 → 1.0.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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.e880517fcd.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
- package/dist/client/static/js/index.0de15c5e17.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +2 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/omp/session-list.tsx +23 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -34
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +18 -28
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/client/tailwind.css +38 -12
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/omp/rpc/manager.ts +72 -6
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
- package/src/server/lib/queue/delivery.server.ts +114 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +1 -1
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +14 -2
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +6 -2
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/g.86e2256a70.js +0 -2
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { runShell, shellOk } from '@/server/lib/fs/shell';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ahead/behind counts behind the git panel's Sync button.
|
|
5
|
+
*
|
|
6
|
+
* `git rev-list --left-right --count HEAD...@{upstream}` counts against the
|
|
7
|
+
* LOCAL remote-tracking ref, and that ref only moves on fetch/pull/push.
|
|
8
|
+
* Nothing in the chamber ever fetched, so `behind` reported the state of the
|
|
9
|
+
* last fetch rather than the remote: the badge sat at ↑0 ↓0 while origin had
|
|
10
|
+
* commits to pull, and the button — disabled by that very zero — could never
|
|
11
|
+
* correct itself.
|
|
12
|
+
*
|
|
13
|
+
* A read that asks for counts therefore refreshes the tracking ref first. The
|
|
14
|
+
* refresh is TTL'd so the git panel's 5s poll is not a 5s fetch loop, it is
|
|
15
|
+
* shared across the panels polling the same repo, and the loader starts it
|
|
16
|
+
* before its local git probes so the round trip overlaps instead of adding up.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How long a fetch attempt — success or failure — is trusted. */
|
|
20
|
+
const FETCH_TTL_MS = 60_000;
|
|
21
|
+
/**
|
|
22
|
+
* How long a read waits for the fetch before answering from the tracking ref
|
|
23
|
+
* as it stands. A slow or offline remote must not stall the panel; the fetch
|
|
24
|
+
* keeps running under its own budget and a later read picks up the new ref.
|
|
25
|
+
*/
|
|
26
|
+
const FETCH_WAIT_MS = 3_000;
|
|
27
|
+
/** Hard budget for one fetch; it is killed at the end of it. */
|
|
28
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
export interface GitSyncCount {
|
|
31
|
+
ahead: number;
|
|
32
|
+
behind: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RemoteState {
|
|
36
|
+
/** When the last attempt settled; `0` means never attempted. */
|
|
37
|
+
at: number;
|
|
38
|
+
/** The running attempt, so concurrent polls share one fetch. */
|
|
39
|
+
inFlight: Promise<void> | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const remoteStates = new Map<string, RemoteState>();
|
|
43
|
+
|
|
44
|
+
function stateFor(cwd: string): RemoteState {
|
|
45
|
+
let state = remoteStates.get(cwd);
|
|
46
|
+
if (!state) {
|
|
47
|
+
state = { at: 0, inFlight: null };
|
|
48
|
+
remoteStates.set(cwd, state);
|
|
49
|
+
}
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runs one `git fetch` with a hard deadline, returning whether it succeeded.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not `runShell`: that helper reads stdout and stderr to EOF, and
|
|
57
|
+
* a stalled fetch defeats it — git's own `git-remote-https` child survives the
|
|
58
|
+
* kill and keeps the inherited stderr pipe open, so the read never ends and the
|
|
59
|
+
* promise hangs forever. Verified on Bun 1.4.2: Bun's `timeout` closes stdout
|
|
60
|
+
* but not that inherited stderr, and the orphan lingers. Nothing here reads the
|
|
61
|
+
* fetch's output, so both streams are ignored (no pipe, nothing to hang on) and
|
|
62
|
+
* the process is killed by process group at the deadline — `detached` is what
|
|
63
|
+
* makes the group killable, and the group kill is what stops the helper from
|
|
64
|
+
* accumulating on every stalled attempt.
|
|
65
|
+
*/
|
|
66
|
+
async function runFetch(cwd: string, remote: string): Promise<boolean> {
|
|
67
|
+
const proc = Bun.spawn({
|
|
68
|
+
cmd: ['sh', '-c', `git fetch --quiet --no-tags '${remote.replace(/'/g, `'\\''`)}'`],
|
|
69
|
+
cwd,
|
|
70
|
+
stdin: 'ignore',
|
|
71
|
+
stdout: 'ignore',
|
|
72
|
+
stderr: 'ignore',
|
|
73
|
+
detached: true,
|
|
74
|
+
// Without this a remote that wants credentials blocks on stdin; the fetch
|
|
75
|
+
// then fails instead, and the counts fall back to the tracking ref.
|
|
76
|
+
env: { ...Bun.env, GIT_TERMINAL_PROMPT: '0' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
const deadline = new Promise<void>((resolve) => {
|
|
81
|
+
timer = setTimeout(resolve, FETCH_TIMEOUT_MS);
|
|
82
|
+
});
|
|
83
|
+
const exitCode = await Promise.race([
|
|
84
|
+
proc.exited,
|
|
85
|
+
deadline.then(() => {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-proc.pid, 'SIGKILL');
|
|
88
|
+
} catch {
|
|
89
|
+
// Already gone, or the group could not be signalled: `exited` below
|
|
90
|
+
// still settles, and the ref simply stays as it was.
|
|
91
|
+
}
|
|
92
|
+
return proc.exited;
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
return exitCode === 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The branch's upstream remote, or null when it has none. Cheap and local, so
|
|
101
|
+
* the no-upstream case is never cached: configuring an upstream in the terminal
|
|
102
|
+
* shows up on the next poll instead of up to a TTL later.
|
|
103
|
+
*/
|
|
104
|
+
async function readUpstreamRemote(cwd: string): Promise<string | null> {
|
|
105
|
+
try {
|
|
106
|
+
const result = await runShell('git rev-parse --abbrev-ref --symbolic-full-name @{upstream}', {
|
|
107
|
+
cwd,
|
|
108
|
+
timeout: 5_000,
|
|
109
|
+
});
|
|
110
|
+
if (!shellOk(result)) return null;
|
|
111
|
+
const upstream = result.stdout.trim();
|
|
112
|
+
const slash = upstream.indexOf('/');
|
|
113
|
+
return slash > 0 ? upstream.slice(0, slash) : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Refresh `cwd`'s remote-tracking refs, awaiting at most `FETCH_WAIT_MS`.
|
|
121
|
+
* Resolves immediately when the last attempt is still inside its TTL, and
|
|
122
|
+
* shares the running attempt when one is already in flight.
|
|
123
|
+
*/
|
|
124
|
+
export async function refreshRemoteRefs(cwd: string): Promise<void> {
|
|
125
|
+
const state = stateFor(cwd);
|
|
126
|
+
if (state.inFlight) return state.inFlight;
|
|
127
|
+
if (state.at !== 0 && Date.now() - state.at < FETCH_TTL_MS) return;
|
|
128
|
+
|
|
129
|
+
const remote = await readUpstreamRemote(cwd);
|
|
130
|
+
if (!remote) return;
|
|
131
|
+
// Reading the upstream is itself an await: another poll may have started the
|
|
132
|
+
// fetch, or completed one, in the meantime.
|
|
133
|
+
if (state.inFlight) return state.inFlight;
|
|
134
|
+
if (state.at !== 0 && Date.now() - state.at < FETCH_TTL_MS) return;
|
|
135
|
+
|
|
136
|
+
// The attempt never rejects — a failed fetch is not a broken panel — and
|
|
137
|
+
// always clears `inFlight` so a later read can retry.
|
|
138
|
+
state.inFlight = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
await runFetch(cwd, remote);
|
|
141
|
+
} finally {
|
|
142
|
+
state.at = Date.now();
|
|
143
|
+
state.inFlight = null;
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
|
|
147
|
+
// Bounded wait. `AbortSignal.timeout` rather than `Bun.sleep`: it is unref'd,
|
|
148
|
+
// so an abandoned wait cannot hold a live timer open on every poll that
|
|
149
|
+
// refreshes.
|
|
150
|
+
const deadline = new Promise<void>((resolve) => {
|
|
151
|
+
AbortSignal.timeout(FETCH_WAIT_MS).addEventListener('abort', () => resolve(), { once: true });
|
|
152
|
+
});
|
|
153
|
+
await Promise.race([state.inFlight, deadline]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Marks `cwd` as freshly fetched — a pull/push has already moved the tracking
|
|
158
|
+
* ref, so the next read must not fetch a state it just produced.
|
|
159
|
+
*/
|
|
160
|
+
export function markRemoteRefsFresh(cwd: string): void {
|
|
161
|
+
stateFor(cwd).at = Date.now();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Forces the next read to fetch. The counts are per-branch, so a checkout makes
|
|
166
|
+
* whatever the previous branch refreshed irrelevant.
|
|
167
|
+
*/
|
|
168
|
+
export function invalidateRemoteRefs(cwd: string): void {
|
|
169
|
+
stateFor(cwd).at = 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Ahead/behind against the branch's upstream, read from the local tracking ref
|
|
174
|
+
* (`refreshRemoteRefs` is what makes that ref current). Both zero when the
|
|
175
|
+
* branch has no upstream or git cannot answer.
|
|
176
|
+
*/
|
|
177
|
+
export async function gitSyncCount(cwd: string): Promise<GitSyncCount> {
|
|
178
|
+
try {
|
|
179
|
+
// `git rev-list --left-right --count HEAD...@{upstream}` prints "<ahead>\t<behind>".
|
|
180
|
+
const result = await runShell('git rev-list --left-right --count HEAD...@{upstream}', {
|
|
181
|
+
cwd,
|
|
182
|
+
timeout: 8_000,
|
|
183
|
+
});
|
|
184
|
+
if (!shellOk(result)) return { ahead: 0, behind: 0 };
|
|
185
|
+
|
|
186
|
+
const [ahead, behind] = result.stdout.trim().split(/\s+/).map(Number);
|
|
187
|
+
return { ahead: ahead || 0, behind: behind || 0 };
|
|
188
|
+
} catch {
|
|
189
|
+
return { ahead: 0, behind: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -20,8 +20,9 @@ import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
|
|
|
20
20
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
21
21
|
import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
|
|
22
22
|
import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
|
|
23
|
+
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
23
24
|
import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
|
|
24
|
-
import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
25
|
+
import { loadStreamStatuses, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
25
26
|
import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
26
27
|
|
|
27
28
|
export type {
|
|
@@ -102,10 +103,21 @@ export class AgentSessionWrapper {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** Anything in flight that a process reset would destroy: the current turn,
|
|
105
|
-
* a compaction, a shell command,
|
|
106
|
-
*
|
|
106
|
+
* a compaction, a shell command, live subagents — which outlive the turn that
|
|
107
|
+
* spawned them, so no other flag here can see them — or an ask/approval dialog
|
|
108
|
+
* omp is BLOCKED on.
|
|
109
|
+
*
|
|
110
|
+
* The dialogs are the load-bearing entry: an ask parks the tool call for as
|
|
111
|
+
* long as the user takes to answer, and omp answers RPC handlers one at a
|
|
112
|
+
* time, so every probe (`get_state`) queues behind it and times out. Without
|
|
113
|
+
* this the timeout path read the session as "idle and unresponsive", killed
|
|
114
|
+
* the child, and with it the question the user was still reading. */
|
|
107
115
|
isBusy(): boolean {
|
|
108
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
this.isRunning() ||
|
|
118
|
+
this.pendingUiDialogs.list().length > 0 ||
|
|
119
|
+
this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0
|
|
120
|
+
);
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
start(): void {
|
|
@@ -161,6 +173,22 @@ export class AgentSessionWrapper {
|
|
|
161
173
|
this.destroy();
|
|
162
174
|
}
|
|
163
175
|
|
|
176
|
+
/** Write the terminal stream badge from the ending turn's own stopReason.
|
|
177
|
+
* The `abort` command already wrote `abort` at dispatch time, but the
|
|
178
|
+
* agent_end frame arrives later and previously flattened it to `finish`.
|
|
179
|
+
* Re-check the current row and only upgrade `stream` rows, so a `finish`
|
|
180
|
+
* written here can never clobber a newer run's live `stream`/`abort`. */
|
|
181
|
+
private async markEndStatus(messages: unknown): Promise<void> {
|
|
182
|
+
if (!this.sessionId) return;
|
|
183
|
+
const aborted = Array.isArray(messages) && messages.some((entry) => {
|
|
184
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
185
|
+
return (entry as Record<string, unknown>).stopReason === 'aborted';
|
|
186
|
+
});
|
|
187
|
+
const current = await loadStreamStatuses();
|
|
188
|
+
if (current[this.sessionId] !== 'stream') return;
|
|
189
|
+
await markStreamStatus(this.sessionId, aborted ? 'abort' : 'finish');
|
|
190
|
+
}
|
|
191
|
+
|
|
164
192
|
private handleFrame(frame: RpcFrame): void {
|
|
165
193
|
this.resetIdleTimer();
|
|
166
194
|
const event = frame as AgentEvent;
|
|
@@ -177,6 +205,31 @@ export class AgentSessionWrapper {
|
|
|
177
205
|
refreshSessionList = true;
|
|
178
206
|
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
179
207
|
break;
|
|
208
|
+
case 'turn_start':
|
|
209
|
+
// Redundant with agent_start in the happy path, but the run-level
|
|
210
|
+
// counterpart can be missed (e.g. the state was cleared by a command
|
|
211
|
+
// between frames). The turn is proof enough the session is live.
|
|
212
|
+
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
213
|
+
break;
|
|
214
|
+
case 'message_start':
|
|
215
|
+
// Same recovery as turn_start: a message frame is only emitted inside
|
|
216
|
+
// a live run, so its arrival re-arms the `stream` row no matter what
|
|
217
|
+
// stale status sits there (upsert overwrites any terminal badge).
|
|
218
|
+
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
219
|
+
break;
|
|
220
|
+
case 'turn_end': {
|
|
221
|
+
// A multi-turn run emits turn_end for EVERY turn — intermediates end
|
|
222
|
+
// with `toolUse`/`stop` while the run keeps going (verified against
|
|
223
|
+
// omp 18.2.8: agent_start → turn_start → turn_end('toolUse') →
|
|
224
|
+
// turn_start → turn_end('stop') → agent_end, and the frame carries no
|
|
225
|
+
// isTerminal field). Only `aborted` is unambiguous here: the run is
|
|
226
|
+
// over, so the abort badge can be written before agent_end arrives.
|
|
227
|
+
// Everything else waits for agent_end, which knows isTerminal.
|
|
228
|
+
if (!this.sessionId) break;
|
|
229
|
+
const turn = event.message as Record<string, unknown> | undefined;
|
|
230
|
+
if (turn?.stopReason === 'aborted') void markStreamStatus(this.sessionId, 'abort');
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
180
233
|
case 'agent_end':
|
|
181
234
|
if (event.isTerminal !== false) {
|
|
182
235
|
this.streaming = false;
|
|
@@ -185,7 +238,21 @@ export class AgentSessionWrapper {
|
|
|
185
238
|
this.awaitingAgentStartDeadline = 0;
|
|
186
239
|
this.continuationGraceUntil = 0;
|
|
187
240
|
clearSessionFileCaches();
|
|
188
|
-
|
|
241
|
+
// The turn's own stopReason is the ground truth for how it ended:
|
|
242
|
+
// `aborted` = user stop, anything else (`stop`, `toolUse`, and the
|
|
243
|
+
// ambiguous `error` that tool failures also produce) completes as
|
|
244
|
+
// `finish`. This must not overwrite a fresh `abort` row from a NEWER
|
|
245
|
+
// run — guard against downgrades by checking the current row first.
|
|
246
|
+
const aborted = Array.isArray(event.messages) && event.messages.some((entry) => {
|
|
247
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
248
|
+
return (entry as Record<string, unknown>).stopReason === 'aborted';
|
|
249
|
+
});
|
|
250
|
+
if (this.sessionId) void this.markEndStatus(event.messages);
|
|
251
|
+
// The run truly ended — the server, not the browser, decides whether
|
|
252
|
+
// a queued follow-up goes out next. A user-aborted run holds the
|
|
253
|
+
// queue (stop-all semantics): the next run end or an explicit send
|
|
254
|
+
// picks it up.
|
|
255
|
+
if (!aborted) scheduleQueueDelivery(this);
|
|
189
256
|
} else {
|
|
190
257
|
this.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS;
|
|
191
258
|
}
|
|
@@ -221,7 +288,6 @@ export class AgentSessionWrapper {
|
|
|
221
288
|
this.promptRunning = false;
|
|
222
289
|
this.awaitingAgentStart = false;
|
|
223
290
|
this.awaitingAgentStartDeadline = 0;
|
|
224
|
-
if (this.sessionId) void markStreamStatus(this.sessionId, 'error');
|
|
225
291
|
this.emit({ type: 'prompt_error', errorMessage: (event.error as string) ?? 'Prompt failed' });
|
|
226
292
|
notifyRunningChange();
|
|
227
293
|
return;
|
|
@@ -13,6 +13,7 @@ import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/pr
|
|
|
13
13
|
import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, SESSION_BUSY_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
14
14
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
15
15
|
import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
|
|
16
|
+
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
16
17
|
import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
17
18
|
import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
|
|
18
19
|
|
|
@@ -28,6 +29,8 @@ export interface SessionCommandHost extends WebStateHost {
|
|
|
28
29
|
/** Real omp session id (empty before the first get_state). */
|
|
29
30
|
sessionId: string;
|
|
30
31
|
emit(event: AgentEvent): void;
|
|
32
|
+
/** One dispatch throat (same surface the wrapper's own send uses). */
|
|
33
|
+
send(command: Record<string, unknown>): Promise<unknown>;
|
|
31
34
|
resetIdleTimer(force?: boolean): void;
|
|
32
35
|
/** Forget a pending ask/approval dialog once its response is sent. */
|
|
33
36
|
resolvePendingUiDialog(id: string): void;
|
|
@@ -100,6 +103,9 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
100
103
|
releaseStreamRow();
|
|
101
104
|
host.emit({ type: 'prompt_result', agentInvoked: false });
|
|
102
105
|
notifyRunningChange();
|
|
106
|
+
// Nothing ran (agent was idle and declined) — the queue may hold the
|
|
107
|
+
// next item; give it the same delivery window a run end would.
|
|
108
|
+
scheduleQueueDelivery(host);
|
|
103
109
|
} else if (!streamingBehavior && ack?.agentInvoked !== false) {
|
|
104
110
|
host.awaitingAgentStart = true;
|
|
105
111
|
host.awaitingAgentStartDeadline = Date.now() + AWAITING_AGENT_START_TIMEOUT_MS;
|
|
@@ -72,6 +72,24 @@ export function getRunningRpcSessionIds(): string[] {
|
|
|
72
72
|
return getRunningRpcSessions().map((s) => s.id);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Sessions whose omp process is parked on a dialog only the user can release —
|
|
77
|
+
* an `ask` question or an approval gate. Deliberately independent of
|
|
78
|
+
* `isRunning()`: a blocked dialog leaves every turn flag exactly as it was, so
|
|
79
|
+
* the pending-dialog registry is the only thing that can tell a session waiting
|
|
80
|
+
* for an answer from an idle one. Feeds the sidebar's "needs input" badge, which
|
|
81
|
+
* has to reach clients that never opened the session.
|
|
82
|
+
*/
|
|
83
|
+
export function getAwaitingInputSessionIds(): string[] {
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const [sessionId, session] of getRegistry()) {
|
|
86
|
+
if (session.isAlive() && session.getPendingUiDialogs().length > 0) {
|
|
87
|
+
ids.add(session.sessionId || sessionId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...ids];
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
function getRunningListeners(): Set<(update: RunningSessionUpdate) => void> {
|
|
76
94
|
if (!globalThis.__ompRunningListeners) globalThis.__ompRunningListeners = new Set();
|
|
77
95
|
return globalThis.__ompRunningListeners;
|
|
@@ -22,7 +22,7 @@ import { isValidSessionSortOption, sortFolders } from '@/shared/lib/workspace/si
|
|
|
22
22
|
import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
23
23
|
import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
|
|
24
24
|
import { healStaleStreamStatuses, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
|
|
25
|
-
import { getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
|
|
25
|
+
import { getAwaitingInputSessionIds, getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
|
|
26
26
|
import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
|
|
27
27
|
import type { OmpSession } from '@/shared/types/omp/session';
|
|
28
28
|
|
|
@@ -110,20 +110,26 @@ export async function loadSidebarData(): Promise<SessionListPayload> {
|
|
|
110
110
|
const sidebarSort = await serverSidebarSort();
|
|
111
111
|
|
|
112
112
|
// Live stream status per session (spinner / one-shot done badge), written by
|
|
113
|
-
// the RPC manager on agent_start/agent_end
|
|
114
|
-
// session is no longer running are stale (restart mid-run) and heal to
|
|
113
|
+
// the RPC manager on agent_start/agent_end and by the abort command. `stream`
|
|
114
|
+
// rows whose session is no longer running are stale (restart mid-run) and heal to
|
|
115
115
|
// `finish` right here — the authoritative status travels with the same
|
|
116
116
|
// fetch that refreshes the sidebar list.
|
|
117
|
-
const streamStatuses: Record<string, 'stream' | 'finish' | 'abort'
|
|
117
|
+
const streamStatuses: Record<string, 'stream' | 'finish' | 'abort'> = {};
|
|
118
|
+
// Sessions blocked on a dialog nobody has answered yet. Read live from the
|
|
119
|
+
// process registry (never persisted): the child that owns the question is the
|
|
120
|
+
// same thing that owns the flag, so a restart cannot leave a stale badge.
|
|
121
|
+
const awaitingInput = new Set<string>();
|
|
118
122
|
if (!mock) {
|
|
119
123
|
await healStaleStreamStatuses(new Set(getRunningRpcSessionIds()));
|
|
120
124
|
Object.assign(streamStatuses, await loadStreamStatuses());
|
|
125
|
+
for (const id of getAwaitingInputSessionIds()) awaitingInput.add(id);
|
|
121
126
|
}
|
|
122
127
|
const foldersWithStatus = groupedFolders.map((folder) => ({
|
|
123
128
|
...folder,
|
|
124
129
|
sessions: (folder.sessions ?? []).map((s) => ({
|
|
125
130
|
...s,
|
|
126
131
|
streamStatus: streamStatuses[String(s.id)],
|
|
132
|
+
...(awaitingInput.has(String(s.id)) ? { awaitingInput: true } : {}),
|
|
127
133
|
})),
|
|
128
134
|
}));
|
|
129
135
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side follow-up queue auto-delivery: the run's own `agent_end` — as
|
|
8
|
+
* observed by the session wrapper, the process that owns the omp child —
|
|
9
|
+
* triggers the next queued prompt. This replaces the client-driven
|
|
10
|
+
* auto-delivery whose state races (tab reload wiping the table, session
|
|
11
|
+
* switches sending a queue into the wrong session) made queued messages
|
|
12
|
+
* disappear or land in the wrong conversation.
|
|
13
|
+
*
|
|
14
|
+
* Semantics:
|
|
15
|
+
* - The head is CLAIMED (transactional DELETE) before dispatch, so a second
|
|
16
|
+
* trigger — another tab's nudge, a racing run end — can never double-send.
|
|
17
|
+
* - A failed dispatch re-inserts the item at the head; nothing is lost.
|
|
18
|
+
* - A user Stop clears the pending-delivery timer instead of delivering (the
|
|
19
|
+
* item stays claimed-but-returned... it is re-queued first), so stop-all
|
|
20
|
+
* semantics live on the server now.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
24
|
+
import type { QueuedMessage } from '@/shared/types/chat';
|
|
25
|
+
|
|
26
|
+
/** Settling time between agent_end and the next prompt dispatch. Small on
|
|
27
|
+
* purpose: omp's own turn-end bookkeeping (JSONL flush, title slot) runs on
|
|
28
|
+
* the same child, and an instant dispatch races it. */
|
|
29
|
+
const DELIVERY_DELAY_MS = 500;
|
|
30
|
+
|
|
31
|
+
/** Per-session pending delivery timer, so a second agent_end while one is
|
|
32
|
+
* already scheduled cannot double-book the head. */
|
|
33
|
+
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
34
|
+
|
|
35
|
+
function clearPendingTimer(sessionId: string): void {
|
|
36
|
+
const timer = pendingTimers.get(sessionId);
|
|
37
|
+
if (timer !== undefined) {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
pendingTimers.delete(sessionId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Send one queued item to the session as a normal prompt. The model snapshot
|
|
44
|
+
* rides first (set_model / set_thinking_level) exactly like the composer's
|
|
45
|
+
* queued delivery did. Returns false when the dispatch failed. */
|
|
46
|
+
async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessage): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
if (item.model) {
|
|
49
|
+
await session.send({ type: 'set_model', provider: item.model.provider, modelId: item.model.modelId });
|
|
50
|
+
if (item.model.thinkingLevel !== 'auto') {
|
|
51
|
+
await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const images = item.attachments
|
|
55
|
+
.filter((a) => typeof a?.preview === 'string' && a.preview.startsWith('data:image/') && typeof a?.dataBase64 === 'string')
|
|
56
|
+
.map((a) => ({ type: 'image' as const, data: a.dataBase64 as string, mimeType: a.preview.slice(5).split(';')[0] || 'image/png' }));
|
|
57
|
+
await session.send({
|
|
58
|
+
type: 'prompt',
|
|
59
|
+
message: item.text,
|
|
60
|
+
...(images.length ? { images } : {}),
|
|
61
|
+
...(item.model ? { accessMode: item.model.accessMode } : {}),
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`[queue] delivery failed for session ${session.sessionId}:`, error);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal surface delivery needs from a live session wrapper. */
|
|
71
|
+
export interface QueueDeliveryHost {
|
|
72
|
+
sessionId: string;
|
|
73
|
+
isAlive(): boolean;
|
|
74
|
+
isBusy(): boolean;
|
|
75
|
+
send(command: Record<string, unknown>): Promise<unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Schedule the next queued delivery for this session. Called on terminal
|
|
80
|
+
* agent_end and on a client's mount nudge. Idempotent: an in-flight timer is
|
|
81
|
+
* reused, a claimed head can only be won by one caller.
|
|
82
|
+
*/
|
|
83
|
+
export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
|
|
84
|
+
const sessionId = session.sessionId;
|
|
85
|
+
if (!sessionId || pendingTimers.has(sessionId)) return;
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
pendingTimers.delete(sessionId);
|
|
88
|
+
void deliverNext(session);
|
|
89
|
+
}, DELIVERY_DELAY_MS);
|
|
90
|
+
pendingTimers.set(sessionId, timer);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function deliverNext(session: QueueDeliveryHost): Promise<void> {
|
|
94
|
+
const sessionId = session.sessionId;
|
|
95
|
+
if (!session.isAlive() || session.isBusy()) return;
|
|
96
|
+
const item = claimHeadQueueItem(sessionId);
|
|
97
|
+
if (!item) return;
|
|
98
|
+
const ok = await dispatchQueuedItem(session, item);
|
|
99
|
+
if (!ok) await requeueHeadQueueItem(sessionId, item);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A user Stop: return anything already claimed for this session's pending
|
|
104
|
+
* delivery to the queue and cancel the timer. The item keeps its head
|
|
105
|
+
* position; nothing is sent until the next run end or explicit send.
|
|
106
|
+
*/
|
|
107
|
+
export function cancelQueuedDelivery(sessionId: string): void {
|
|
108
|
+
clearPendingTimer(sessionId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Forget a session's timer state (process teardown, session destroy). */
|
|
112
|
+
export function forgetQueuedDelivery(sessionId: string): void {
|
|
113
|
+
clearPendingTimer(sessionId);
|
|
114
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Schema migration for `queued_messages`: installs older databases created the
|
|
8
|
+
* table with an FK to `sessions`, but omp session ids have no row there —
|
|
9
|
+
* every queue insert for a real session failed with FOREIGN KEY constraint
|
|
10
|
+
* failed. Rebuilds the table without the FK, once, marker-keyed in
|
|
11
|
+
* `app_settings` (same pattern as project-settings-migration).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
15
|
+
|
|
16
|
+
const QUEUE_FK_MARKER = 'queue_fk_removed';
|
|
17
|
+
|
|
18
|
+
const QUEUE_TABLE_SQL = `
|
|
19
|
+
CREATE TABLE queued_messages_new (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
session_id TEXT NOT NULL,
|
|
22
|
+
position INTEGER NOT NULL,
|
|
23
|
+
message TEXT NOT NULL DEFAULT '',
|
|
24
|
+
attachments TEXT NOT NULL DEFAULT '[]',
|
|
25
|
+
provider TEXT,
|
|
26
|
+
model_id TEXT,
|
|
27
|
+
thinking_level TEXT,
|
|
28
|
+
access_mode TEXT,
|
|
29
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
30
|
+
);
|
|
31
|
+
INSERT INTO queued_messages_new
|
|
32
|
+
(id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode, created_at)
|
|
33
|
+
SELECT id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode, created_at
|
|
34
|
+
FROM queued_messages;
|
|
35
|
+
DROP TABLE queued_messages;
|
|
36
|
+
ALTER TABLE queued_messages_new RENAME TO queued_messages;
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** Drop the invalid queue FK on pre-migration databases (idempotent). */
|
|
40
|
+
export async function migrateQueueTableFk(db: DbClient): Promise<void> {
|
|
41
|
+
const marker = await db.get('SELECT value FROM app_settings WHERE key = ?', [QUEUE_FK_MARKER]);
|
|
42
|
+
if (marker?.value) return;
|
|
43
|
+
|
|
44
|
+
const table = await db.get(
|
|
45
|
+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'queued_messages'",
|
|
46
|
+
);
|
|
47
|
+
const hasLegacyFk = Boolean(table?.sql) && String(table.sql).includes('FOREIGN KEY');
|
|
48
|
+
if (hasLegacyFk) await db.exec(QUEUE_TABLE_SQL);
|
|
49
|
+
await db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', [QUEUE_FK_MARKER, '1']);
|
|
50
|
+
}
|