@oar-sh/oar 0.9.9 → 0.9.11
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/CHANGELOG.md +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
package/server/relay-tools.md
CHANGED
|
@@ -24,7 +24,7 @@ Use `restart: true` in the request body when the user wants a real relay restart
|
|
|
24
24
|
|
|
25
25
|
## Preview servers
|
|
26
26
|
|
|
27
|
-
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire
|
|
27
|
+
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
28
28
|
|
|
29
29
|
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
30
30
|
|
|
@@ -38,6 +38,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
|
|
|
38
38
|
|
|
39
39
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
40
40
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
41
|
+
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
41
42
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
42
43
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
43
44
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
package/server/relay.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
extractModelDescriptors,
|
|
33
33
|
normalizeContextLimitTokens,
|
|
34
34
|
} from '../shared/model-descriptors.mjs';
|
|
35
|
+
import { windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
35
36
|
|
|
36
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
|
|
@@ -379,7 +380,7 @@ async function spawnForegroundCliViaPowerShell(tcpConnectionToken, port) {
|
|
|
379
380
|
].join(' ');
|
|
380
381
|
|
|
381
382
|
const psCommand = `$env:COPILOT_CONNECTION_TOKEN='${tcpConnectionToken}'; & '${NODE_PATH}' ${nodeArgs}`;
|
|
382
|
-
const child = await waitForProcessSpawn('powershell.exe', [
|
|
383
|
+
const child = await waitForProcessSpawn(windowsSystemToolPath('powershell.exe'), [
|
|
383
384
|
'-NoExit',
|
|
384
385
|
'-Command',
|
|
385
386
|
psCommand,
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { compareGroupMembers } from '../public/app/conversation-group-order.mjs';
|
|
6
|
+
|
|
7
|
+
// Session groups (migration 0014): the set of conversations that share a
|
|
8
|
+
// `group_id`, with a row in `conversation_groups` for what the set itself
|
|
9
|
+
// needs: a name given by hand, and the head (the session that created the
|
|
10
|
+
// others with the remote_relay tool; NULL for a group made by hand or once
|
|
11
|
+
// the head is deleted).
|
|
12
|
+
//
|
|
13
|
+
// A session an agent creates on this relay joins its creator's group at
|
|
14
|
+
// bootstrap (`attachCreatedConversation`); the origin stays the record of who
|
|
15
|
+
// created it and is not read again for the grouping. Everything else is done
|
|
16
|
+
// by hand in the sidebar. Every reader tolerates a schema without the
|
|
17
|
+
// migration: it then sees no groups and writes nothing.
|
|
18
|
+
//
|
|
19
|
+
// A group can have a hand order (migration 0015): once the owner drags a row
|
|
20
|
+
// into place, every member after the head gets a `group_position` (10, 20,
|
|
21
|
+
// 30, ...) and keeps it; later arrivals join at the end. A group nobody
|
|
22
|
+
// reordered keeps NULL positions and the activity order. Without the column
|
|
23
|
+
// the hand order is off and moves only change the membership.
|
|
24
|
+
|
|
25
|
+
const POSITION_STEP = 10;
|
|
26
|
+
|
|
27
|
+
export const GROUP_NAME_MAX = 60;
|
|
28
|
+
|
|
29
|
+
function toText(value) {
|
|
30
|
+
return String(value ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The name a group shows: its own, else the head's current title, else "Group". */
|
|
34
|
+
export function resolveGroupDisplayName({ name = null, headTitle = null } = {}) {
|
|
35
|
+
return toText(name) || toText(headTitle) || 'Group';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A group name as stored: trimmed, capped, NULL when empty. */
|
|
39
|
+
export function normalizeGroupName(value) {
|
|
40
|
+
const text = toText(value).replace(/\s+/g, ' ');
|
|
41
|
+
return text ? text.slice(0, GROUP_NAME_MAX) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rowToGroup(row) {
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
return {
|
|
47
|
+
id: row.id,
|
|
48
|
+
name: row.name || null,
|
|
49
|
+
parentConversationId: row.parent_conversation_id || null,
|
|
50
|
+
createdAt: row.created_at,
|
|
51
|
+
updatedAt: row.updated_at,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createConversationGroupRepository(db) {
|
|
56
|
+
const hasTable = !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation_groups'`).get();
|
|
57
|
+
const hasColumn = db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_id');
|
|
58
|
+
const enabled = hasTable && hasColumn;
|
|
59
|
+
const hasPosition = enabled && db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_position');
|
|
60
|
+
if (!enabled) {
|
|
61
|
+
return {
|
|
62
|
+
enabled: false,
|
|
63
|
+
getGroup: () => null,
|
|
64
|
+
listGroupsByIds: () => new Map(),
|
|
65
|
+
groupOfConversation: () => null,
|
|
66
|
+
membersOf: () => [],
|
|
67
|
+
createGroup: () => null,
|
|
68
|
+
renameGroup: () => null,
|
|
69
|
+
setMembership: () => ({ ok: false, error: 'groups-unavailable' }),
|
|
70
|
+
moveConversation: () => ({ ok: false, error: 'groups-unavailable' }),
|
|
71
|
+
attachCreatedConversation: () => null,
|
|
72
|
+
dissolveGroup: () => false,
|
|
73
|
+
forgetConversation: () => null,
|
|
74
|
+
carryToCompacted: () => null,
|
|
75
|
+
deleteIfEmpty: () => false,
|
|
76
|
+
sweepEmpty: () => 0,
|
|
77
|
+
repair: () => ({ headsDetached: 0, groupsRemoved: 0 }),
|
|
78
|
+
freezeNamesForHeads: () => 0,
|
|
79
|
+
membershipsOf: () => ({}),
|
|
80
|
+
describeGroup: () => null,
|
|
81
|
+
orderOf: () => [],
|
|
82
|
+
positionsOf: () => ({}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const getGroup = db.prepare(`SELECT * FROM conversation_groups WHERE id = ?`);
|
|
87
|
+
const insertGroup = db.prepare(`INSERT INTO conversation_groups (id, name, parent_conversation_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
|
|
88
|
+
const updateName = db.prepare(`UPDATE conversation_groups SET name = ?, updated_at = ? WHERE id = ?`);
|
|
89
|
+
const updateParent = db.prepare(`UPDATE conversation_groups SET parent_conversation_id = ?, updated_at = ? WHERE id = ?`);
|
|
90
|
+
// A frozen head title keeps to the name cap, so a later rename is never
|
|
91
|
+
// refused for a length it did not choose.
|
|
92
|
+
const freezeName = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), parent_conversation_id = NULL, updated_at = ? WHERE id = ?`);
|
|
93
|
+
const freezeNameKeepHead = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), updated_at = ? WHERE id = ? AND name IS NULL`);
|
|
94
|
+
const findGroupHeadedBy = db.prepare(`SELECT id FROM conversation_groups WHERE parent_conversation_id = ? ORDER BY created_at ASC LIMIT 1`);
|
|
95
|
+
const listGroupsWithHead = db.prepare(`
|
|
96
|
+
SELECT g.id, g.parent_conversation_id, c.title, c.status
|
|
97
|
+
FROM conversation_groups g LEFT JOIN conversations c ON c.id = g.parent_conversation_id
|
|
98
|
+
WHERE g.parent_conversation_id IS NOT NULL
|
|
99
|
+
`);
|
|
100
|
+
const deleteGroup = db.prepare(`DELETE FROM conversation_groups WHERE id = ?`);
|
|
101
|
+
const positionSelect = hasPosition ? 'group_position' : 'NULL AS group_position';
|
|
102
|
+
const getConversation = db.prepare(`SELECT id, title, archived, status, group_id, ${positionSelect}, updated_at FROM conversations WHERE id = ?`);
|
|
103
|
+
// Every change of group sets the position too: NULL unless given.
|
|
104
|
+
const setGroupStatement = db.prepare(hasPosition
|
|
105
|
+
? `UPDATE conversations SET group_id = ?, group_position = ? WHERE id = ?`
|
|
106
|
+
: `UPDATE conversations SET group_id = ? WHERE id = ?`);
|
|
107
|
+
const setGroup = {
|
|
108
|
+
run: (groupId, conversationId, position = null) => (hasPosition
|
|
109
|
+
? setGroupStatement.run(groupId, position, conversationId)
|
|
110
|
+
: setGroupStatement.run(groupId, conversationId)),
|
|
111
|
+
};
|
|
112
|
+
const setPosition = hasPosition ? db.prepare(`UPDATE conversations SET group_position = ? WHERE id = ?`) : null;
|
|
113
|
+
const clearGroup = db.prepare(hasPosition
|
|
114
|
+
? `UPDATE conversations SET group_id = NULL, group_position = NULL WHERE group_id = ?`
|
|
115
|
+
: `UPDATE conversations SET group_id = NULL WHERE group_id = ?`);
|
|
116
|
+
const listMembersByTime = db.prepare(`
|
|
117
|
+
SELECT id, title, archived, group_id, ${positionSelect}, updated_at FROM conversations
|
|
118
|
+
WHERE group_id = ? AND status != 'deleted'
|
|
119
|
+
ORDER BY updated_at DESC
|
|
120
|
+
`);
|
|
121
|
+
const maxPosition = hasPosition
|
|
122
|
+
? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
|
|
123
|
+
: null;
|
|
124
|
+
const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
|
|
125
|
+
const listEmptyGroups = db.prepare(`
|
|
126
|
+
SELECT g.id FROM conversation_groups g
|
|
127
|
+
WHERE NOT EXISTS (SELECT 1 FROM conversations c WHERE c.group_id = g.id AND c.status != 'deleted')
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
/** The group's rows in display order: the head, then the members (see compareGroupMembers). */
|
|
131
|
+
const listMembers = {
|
|
132
|
+
all(groupId) {
|
|
133
|
+
const id = toText(groupId);
|
|
134
|
+
const headId = toText(getGroup.get(id)?.parent_conversation_id);
|
|
135
|
+
const rows = listMembersByTime.all(id);
|
|
136
|
+
const head = headId ? rows.find((row) => row.id === headId) || null : null;
|
|
137
|
+
const members = rows.filter((row) => row !== head).sort(compareGroupMembers);
|
|
138
|
+
return head ? [head, ...members] : members;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const positionValue = (row) => (row?.group_position == null ? null : Number(row.group_position));
|
|
143
|
+
|
|
144
|
+
/** Where a row joining `groupId` goes: after the last placed member, or NULL when nobody ordered the group. */
|
|
145
|
+
function joiningPosition(groupId) {
|
|
146
|
+
if (!hasPosition) return null;
|
|
147
|
+
const max = maxPosition.get(toText(groupId))?.n;
|
|
148
|
+
return max == null ? null : Number(max) + POSITION_STEP;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const liveConversation = (id) => {
|
|
152
|
+
const row = getConversation.get(toText(id));
|
|
153
|
+
return row && String(row.status || '') !== 'deleted' ? row : null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function deleteIfEmpty(groupId) {
|
|
157
|
+
const id = toText(groupId);
|
|
158
|
+
if (!id) return false;
|
|
159
|
+
if (Number(countMembers.get(id)?.n || 0) > 0) return false;
|
|
160
|
+
return deleteGroup.run(id).changes > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The conversation leaves its group. A head that leaves makes its group
|
|
165
|
+
* headless: the name freezes to the head's title unless one was given by
|
|
166
|
+
* hand. An empty group is removed. Returns the previous group id.
|
|
167
|
+
*/
|
|
168
|
+
const leaveGroup = db.transaction((conversationId, nowIso, { anyStatus = false } = {}) => {
|
|
169
|
+
// A row already marked deleted (the delete path marks before it removes)
|
|
170
|
+
// still leaves its group the same way.
|
|
171
|
+
const row = anyStatus ? getConversation.get(toText(conversationId)) : liveConversation(conversationId);
|
|
172
|
+
const previous = toText(row?.group_id);
|
|
173
|
+
if (!row || !previous) return null;
|
|
174
|
+
const group = getGroup.get(previous);
|
|
175
|
+
if (group && toText(group.parent_conversation_id) === row.id) freezeName.run(row.title || null, nowIso, previous);
|
|
176
|
+
setGroup.run(null, row.id);
|
|
177
|
+
deleteIfEmpty(previous);
|
|
178
|
+
return previous;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const createGroup = db.transaction(({ name = null, parentConversationId = null, conversationIds = [] } = {}, nowIso = new Date().toISOString()) => {
|
|
182
|
+
const id = `grp_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
|
183
|
+
const head = liveConversation(parentConversationId);
|
|
184
|
+
insertGroup.run(id, normalizeGroupName(name), head ? head.id : null, nowIso, nowIso);
|
|
185
|
+
const members = new Set([...(head ? [head.id] : []), ...conversationIds.map(toText).filter(Boolean)]);
|
|
186
|
+
for (const memberId of members) {
|
|
187
|
+
if (!liveConversation(memberId)) continue;
|
|
188
|
+
leaveGroup(memberId, nowIso);
|
|
189
|
+
setGroup.run(id, memberId);
|
|
190
|
+
}
|
|
191
|
+
return rowToGroup(getGroup.get(id));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const setMembership = db.transaction((conversationId, groupId, nowIso = new Date().toISOString()) => {
|
|
195
|
+
const row = liveConversation(conversationId);
|
|
196
|
+
if (!row) return { ok: false, error: 'conversation-not-found' };
|
|
197
|
+
const target = toText(groupId);
|
|
198
|
+
if (target && !getGroup.get(target)) return { ok: false, error: 'group-not-found' };
|
|
199
|
+
if (toText(row.group_id) === target) return { ok: true, changed: false, groupId: target || null, previousGroupId: target || null };
|
|
200
|
+
const previousGroupId = leaveGroup(row.id, nowIso);
|
|
201
|
+
if (target) setGroup.run(target, row.id, joiningPosition(target));
|
|
202
|
+
return { ok: true, changed: true, groupId: target || null, previousGroupId };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** The ids of a group in display order, head first. */
|
|
206
|
+
function orderOf(groupId) {
|
|
207
|
+
return listMembers.all(groupId).map((member) => member.id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A drag in the sidebar: the conversation goes into `groupId` (null: out of
|
|
212
|
+
* its group) and lands before `beforeConversationId`, or at the end when
|
|
213
|
+
* that is null or not a member there. The group then has a hand order:
|
|
214
|
+
* every member after the head is numbered 10, 20, 30, ... in the order
|
|
215
|
+
* that results. The head stays first and unnumbered; moving it within its
|
|
216
|
+
* own group, or dropping a row before it, never displaces it.
|
|
217
|
+
*/
|
|
218
|
+
const moveConversation = db.transaction((conversationId, { groupId = null, beforeConversationId = null } = {}, nowIso = new Date().toISOString()) => {
|
|
219
|
+
const row = liveConversation(conversationId);
|
|
220
|
+
if (!row) return { ok: false, error: 'conversation-not-found' };
|
|
221
|
+
const target = toText(groupId);
|
|
222
|
+
const group = target ? getGroup.get(target) : null;
|
|
223
|
+
if (target && !group) return { ok: false, error: 'group-not-found' };
|
|
224
|
+
const current = toText(row.group_id);
|
|
225
|
+
if (!target) {
|
|
226
|
+
if (!current) return { ok: true, changed: false, groupId: null, previousGroupId: null, order: [] };
|
|
227
|
+
const previousGroupId = leaveGroup(row.id, nowIso);
|
|
228
|
+
return { ok: true, changed: true, groupId: null, previousGroupId, order: [] };
|
|
229
|
+
}
|
|
230
|
+
const headId = toText(group.parent_conversation_id);
|
|
231
|
+
if (current === target && row.id === headId) {
|
|
232
|
+
return { ok: true, changed: false, groupId: target, previousGroupId: target, order: orderOf(target) };
|
|
233
|
+
}
|
|
234
|
+
let changed = false;
|
|
235
|
+
let previousGroupId = current || null;
|
|
236
|
+
if (current !== target) {
|
|
237
|
+
previousGroupId = leaveGroup(row.id, nowIso);
|
|
238
|
+
setGroup.run(target, row.id);
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
// A row that turns out to be the head (a link kept for a re-import) leads unnumbered.
|
|
242
|
+
if (hasPosition && row.id !== headId) {
|
|
243
|
+
const rows = listMembers.all(target).filter((member) => member.id !== headId);
|
|
244
|
+
const before = toText(beforeConversationId);
|
|
245
|
+
const moved = rows.find((member) => member.id === row.id);
|
|
246
|
+
// Dropped before itself: it stays where it is.
|
|
247
|
+
const others = before === row.id ? rows : rows.filter((member) => member !== moved);
|
|
248
|
+
if (before !== row.id) {
|
|
249
|
+
const index = headId && before === headId ? 0 : (before ? others.findIndex((member) => member.id === before) : -1);
|
|
250
|
+
others.splice(index < 0 ? others.length : index, 0, moved);
|
|
251
|
+
}
|
|
252
|
+
others.forEach((member, index) => {
|
|
253
|
+
const position = (index + 1) * POSITION_STEP;
|
|
254
|
+
if (positionValue(member) === position) return;
|
|
255
|
+
setPosition.run(position, member.id);
|
|
256
|
+
changed = true;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, changed, groupId: target, previousGroupId, order: orderOf(target) };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** `{ [conversationId]: position | null }` for the ids given (rows outside a group read as null). */
|
|
263
|
+
function positionsOf(conversationIds) {
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
|
|
266
|
+
const row = liveConversation(raw);
|
|
267
|
+
out[raw] = row && toText(row.group_id) ? positionValue(row) : null;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Bootstrap of a session an agent created here: the child joins the
|
|
274
|
+
* creator's group, or a new group with the creator as head. A creator that
|
|
275
|
+
* is gone leaves the child ungrouped.
|
|
276
|
+
*/
|
|
277
|
+
const attachCreatedConversation = db.transaction((childId, creatorId, nowIso = new Date().toISOString()) => {
|
|
278
|
+
const child = liveConversation(childId);
|
|
279
|
+
const creator = liveConversation(creatorId);
|
|
280
|
+
if (!child || !creator || child.id === creator.id) return null;
|
|
281
|
+
let groupId = toText(creator.group_id);
|
|
282
|
+
if (!groupId || !getGroup.get(groupId)) {
|
|
283
|
+
// A group that still names this creator as its head (its row was
|
|
284
|
+
// removed and made again, as a cache rebuild does) is taken up again
|
|
285
|
+
// rather than doubled.
|
|
286
|
+
const headed = findGroupHeadedBy.get(creator.id);
|
|
287
|
+
groupId = headed ? toText(headed.id) : createGroup({ parentConversationId: creator.id }, nowIso).id;
|
|
288
|
+
setGroup.run(groupId, creator.id);
|
|
289
|
+
}
|
|
290
|
+
setGroup.run(groupId, child.id, joiningPosition(groupId));
|
|
291
|
+
return groupId;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What a delete path that does not know groups leaves behind: a head whose
|
|
296
|
+
* row is tombstoned (the group stays headless, the title frozen as its
|
|
297
|
+
* name) and groups without a live member. A head whose row is simply
|
|
298
|
+
* absent keeps its link: a cache wipe froze its name first, and the row
|
|
299
|
+
* may be imported again under the same id and take the group up.
|
|
300
|
+
*/
|
|
301
|
+
const repair = db.transaction((nowIso = new Date().toISOString()) => {
|
|
302
|
+
let headsDetached = 0;
|
|
303
|
+
for (const row of listGroupsWithHead.all()) {
|
|
304
|
+
if (!row.status || String(row.status) !== 'deleted') continue;
|
|
305
|
+
freezeName.run(row.title || null, nowIso, row.id);
|
|
306
|
+
headsDetached += 1;
|
|
307
|
+
}
|
|
308
|
+
let groupsRemoved = 0;
|
|
309
|
+
for (const row of listEmptyGroups.all()) groupsRemoved += deleteGroup.run(row.id).changes;
|
|
310
|
+
return { headsDetached, groupsRemoved };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Before rows are wiped (the cache rebuild): the groups headed by any of
|
|
315
|
+
* them get their head's title as their name, so the group that survives
|
|
316
|
+
* (its hand-titled members stay) is not nameless. The head link stays for
|
|
317
|
+
* a re-import. Returns how many names were frozen.
|
|
318
|
+
*/
|
|
319
|
+
const freezeNamesForHeads = db.transaction((conversationIds, nowIso = new Date().toISOString()) => {
|
|
320
|
+
const ids = new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean));
|
|
321
|
+
let frozen = 0;
|
|
322
|
+
for (const row of listGroupsWithHead.all()) {
|
|
323
|
+
if (!ids.has(toText(row.parent_conversation_id))) continue;
|
|
324
|
+
frozen += freezeNameKeepHead.run(row.title || null, nowIso, row.id).changes;
|
|
325
|
+
}
|
|
326
|
+
return frozen;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/** `{ [conversationId]: { groupId, role } | null }` for the ids given (deleted rows read as null). */
|
|
330
|
+
function membershipsOf(conversationIds) {
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
|
|
333
|
+
const row = liveConversation(raw);
|
|
334
|
+
const groupId = toText(row?.group_id);
|
|
335
|
+
const group = groupId ? getGroup.get(groupId) : null;
|
|
336
|
+
out[raw] = row && group
|
|
337
|
+
? { groupId, role: toText(group.parent_conversation_id) === row.id ? 'head' : 'member' }
|
|
338
|
+
: null;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
enabled: true,
|
|
345
|
+
getGroup(groupId) {
|
|
346
|
+
return rowToGroup(getGroup.get(toText(groupId)));
|
|
347
|
+
},
|
|
348
|
+
/** `Map<id, group>` for the ids given (unknown ids are left out). */
|
|
349
|
+
listGroupsByIds(groupIds) {
|
|
350
|
+
const map = new Map();
|
|
351
|
+
for (const raw of new Set((Array.isArray(groupIds) ? groupIds : []).map(toText).filter(Boolean))) {
|
|
352
|
+
const group = rowToGroup(getGroup.get(raw));
|
|
353
|
+
if (group) map.set(group.id, group);
|
|
354
|
+
}
|
|
355
|
+
return map;
|
|
356
|
+
},
|
|
357
|
+
groupOfConversation(conversationId) {
|
|
358
|
+
const row = liveConversation(conversationId);
|
|
359
|
+
return row?.group_id ? rowToGroup(getGroup.get(row.group_id)) : null;
|
|
360
|
+
},
|
|
361
|
+
/**
|
|
362
|
+
* `[{ id, title, archived, updatedAt, groupPosition }]` in display order
|
|
363
|
+
* (the head, then the placed members, then the others newest first),
|
|
364
|
+
* deleted rows left out.
|
|
365
|
+
*/
|
|
366
|
+
membersOf(groupId) {
|
|
367
|
+
return listMembers.all(toText(groupId)).map((row) => ({
|
|
368
|
+
id: row.id,
|
|
369
|
+
title: row.title,
|
|
370
|
+
archived: Number(row.archived || 0) === 1,
|
|
371
|
+
updatedAt: row.updated_at,
|
|
372
|
+
groupPosition: positionValue(row),
|
|
373
|
+
}));
|
|
374
|
+
},
|
|
375
|
+
createGroup,
|
|
376
|
+
renameGroup(groupId, name, nowIso = new Date().toISOString()) {
|
|
377
|
+
const id = toText(groupId);
|
|
378
|
+
if (!getGroup.get(id)) return null;
|
|
379
|
+
updateName.run(normalizeGroupName(name), nowIso, id);
|
|
380
|
+
return rowToGroup(getGroup.get(id));
|
|
381
|
+
},
|
|
382
|
+
setMembership,
|
|
383
|
+
moveConversation,
|
|
384
|
+
orderOf,
|
|
385
|
+
positionsOf,
|
|
386
|
+
attachCreatedConversation,
|
|
387
|
+
/** Every member leaves; the group row goes. Conversations are untouched otherwise. */
|
|
388
|
+
dissolveGroup(groupId) {
|
|
389
|
+
const id = toText(groupId);
|
|
390
|
+
if (!getGroup.get(id)) return false;
|
|
391
|
+
clearGroup.run(id);
|
|
392
|
+
deleteGroup.run(id);
|
|
393
|
+
return true;
|
|
394
|
+
},
|
|
395
|
+
/**
|
|
396
|
+
* Called before a conversation's rows are deleted: a head leaves its group
|
|
397
|
+
* (name frozen), and a group left empty goes. Returns the group id it was in.
|
|
398
|
+
*/
|
|
399
|
+
forgetConversation(conversationId, nowIso = new Date().toISOString()) {
|
|
400
|
+
return leaveGroup(conversationId, nowIso, { anyStatus: true });
|
|
401
|
+
},
|
|
402
|
+
/** Compaction: the new conversation takes the old one's group, its place in it, and its head role. */
|
|
403
|
+
carryToCompacted(sourceId, targetId, nowIso = new Date().toISOString()) {
|
|
404
|
+
const source = liveConversation(sourceId);
|
|
405
|
+
const groupId = toText(source?.group_id);
|
|
406
|
+
if (!groupId || !getGroup.get(groupId) || !getConversation.get(toText(targetId))) return null;
|
|
407
|
+
setGroup.run(groupId, toText(targetId), positionValue(source));
|
|
408
|
+
// The old row stays in the group, unplaced: it must not tie with the new one.
|
|
409
|
+
if (toText(targetId) !== source.id) setGroup.run(groupId, source.id, null);
|
|
410
|
+
const group = getGroup.get(groupId);
|
|
411
|
+
if (toText(group?.parent_conversation_id) === source.id) updateParent.run(toText(targetId), nowIso, groupId);
|
|
412
|
+
return groupId;
|
|
413
|
+
},
|
|
414
|
+
deleteIfEmpty,
|
|
415
|
+
/** Removes groups without a live member; returns how many. */
|
|
416
|
+
sweepEmpty() {
|
|
417
|
+
let removed = 0;
|
|
418
|
+
for (const row of listEmptyGroups.all()) removed += deleteGroup.run(row.id).changes;
|
|
419
|
+
return removed;
|
|
420
|
+
},
|
|
421
|
+
repair,
|
|
422
|
+
freezeNamesForHeads,
|
|
423
|
+
membershipsOf,
|
|
424
|
+
/**
|
|
425
|
+
* The group as the list payload and the change event describe it: name,
|
|
426
|
+
* display name (the head's current title when none was given), head and
|
|
427
|
+
* member counts. `headTitle(title)` may dress the title as the list does.
|
|
428
|
+
*/
|
|
429
|
+
describeGroup(groupId, { headTitle = (title) => title } = {}) {
|
|
430
|
+
const group = rowToGroup(getGroup.get(toText(groupId)));
|
|
431
|
+
if (!group) return null;
|
|
432
|
+
const members = listMembers.all(group.id);
|
|
433
|
+
const head = group.parentConversationId ? members.find((member) => member.id === group.parentConversationId) || null : null;
|
|
434
|
+
return {
|
|
435
|
+
id: group.id,
|
|
436
|
+
name: group.name || null,
|
|
437
|
+
displayName: resolveGroupDisplayName({ name: group.name, headTitle: head ? headTitle(head.title) : null }),
|
|
438
|
+
parentConversationId: group.parentConversationId || null,
|
|
439
|
+
memberCount: members.length,
|
|
440
|
+
liveMemberCount: members.filter((member) => Number(member.archived || 0) !== 1).length,
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -29,6 +29,7 @@ export function createMessageRepository(db) {
|
|
|
29
29
|
getSharedMessages: shareVisibility.getSharedMessages,
|
|
30
30
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
31
31
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
32
|
+
listHiddenMessages: shareVisibility.listHiddenMessages,
|
|
32
33
|
setMessagePinnedAt: messagePins.setMessagePinnedAt,
|
|
33
34
|
listPinnedMessages: messagePins.listPinnedMessages,
|
|
34
35
|
countPinnedMessages: messagePins.countPinnedMessages,
|
|
@@ -346,6 +347,12 @@ export function createMessageRepository(db) {
|
|
|
346
347
|
//
|
|
347
348
|
// A turn blocked on an unanswered AskUserQuestion is never stale — it is
|
|
348
349
|
// waiting on the human, and relay_questions carries its own expiry.
|
|
350
|
+
//
|
|
351
|
+
// The ceiling counts from the last human interaction with the turn, not
|
|
352
|
+
// from its start: answering a question restarts the clock. Otherwise an
|
|
353
|
+
// interview that waited hours for its answers would be cut off by the
|
|
354
|
+
// ceiling the moment the last answer arrived (live 2026-10-10: a
|
|
355
|
+
// five-question interview lost its reply that way).
|
|
349
356
|
listRecoverableProcessing: db.prepare(`
|
|
350
357
|
SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
|
|
351
358
|
FROM queue
|
|
@@ -353,7 +360,13 @@ export function createMessageRepository(db) {
|
|
|
353
360
|
AND COALESCE(kind, '') != 'continuation'
|
|
354
361
|
AND (
|
|
355
362
|
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
356
|
-
OR (
|
|
363
|
+
OR (
|
|
364
|
+
@ceilingBefore IS NOT NULL
|
|
365
|
+
AND MAX(
|
|
366
|
+
COALESCE(processing_at, timestamp),
|
|
367
|
+
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
368
|
+
) < @ceilingBefore
|
|
369
|
+
)
|
|
357
370
|
)
|
|
358
371
|
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
359
372
|
`),
|
|
@@ -431,15 +444,29 @@ export function createMessageRepository(db) {
|
|
|
431
444
|
// A continuation with no live worker can only be torn down — replaying
|
|
432
445
|
// it as deliverable work would send the CLI's own bookkeeping text back
|
|
433
446
|
// to the CLI as a user prompt.
|
|
447
|
+
//
|
|
448
|
+
// Same staleness rules as listRecoverableProcessing: a continuation
|
|
449
|
+
// waiting on an unanswered question is never stale, and an answer
|
|
450
|
+
// restarts the ceiling clock. A background-agent interview hangs its
|
|
451
|
+
// question cards on the continuation row, so without this a long
|
|
452
|
+
// interview was torn down by the ceiling while the human was still
|
|
453
|
+
// answering (live 2026-10-10).
|
|
434
454
|
listStaleProcessingContinuations: db.prepare(`
|
|
435
|
-
SELECT id, conversation_id
|
|
455
|
+
SELECT id, conversation_id, timestamp, processing_at, owner_last_claimed_at
|
|
436
456
|
FROM queue
|
|
437
457
|
WHERE status = 'processing'
|
|
438
458
|
AND kind = 'continuation'
|
|
439
459
|
AND (
|
|
440
460
|
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
441
|
-
OR (
|
|
461
|
+
OR (
|
|
462
|
+
@ceilingBefore IS NOT NULL
|
|
463
|
+
AND MAX(
|
|
464
|
+
COALESCE(processing_at, timestamp),
|
|
465
|
+
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
466
|
+
) < @ceilingBefore
|
|
467
|
+
)
|
|
442
468
|
)
|
|
469
|
+
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
443
470
|
`),
|
|
444
471
|
listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
445
472
|
// Authoritative "is a turn in flight" signal for the conversation list.
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { createSubagentTimelineStatements } from './subagent-timeline-statements.mjs';
|
|
4
|
+
|
|
3
5
|
export function createQuestionRepository(db) {
|
|
6
|
+
const subagentTimeline = createSubagentTimelineStatements(db);
|
|
7
|
+
const runColumns = subagentTimeline.subagentRunSelectColumns;
|
|
8
|
+
const runColumnsAs = (alias) => runColumns.split(', ').map((column) => `${alias}.${column}`).join(', ');
|
|
4
9
|
return {
|
|
10
|
+
...subagentTimeline,
|
|
5
11
|
// relay questions
|
|
6
12
|
insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
|
|
7
13
|
getQuestion: db.prepare(`SELECT * FROM relay_questions WHERE id = ?`),
|
|
@@ -99,7 +105,7 @@ export function createQuestionRepository(db) {
|
|
|
99
105
|
WHERE id = ?
|
|
100
106
|
`),
|
|
101
107
|
listSubagentRunsByQueueMessage: db.prepare(`
|
|
102
|
-
SELECT
|
|
108
|
+
SELECT ${runColumns}
|
|
103
109
|
FROM subagent_runs
|
|
104
110
|
WHERE queue_message_id = ?
|
|
105
111
|
ORDER BY started_at ASC, id ASC
|
|
@@ -108,7 +114,7 @@ export function createQuestionRepository(db) {
|
|
|
108
114
|
// abort) also ends its subagent runs — nothing else ever will, and an
|
|
109
115
|
// un-reconciled row renders as a bubble stuck "running" forever.
|
|
110
116
|
listRunningSubagentRunsByQueueMessage: db.prepare(`
|
|
111
|
-
SELECT
|
|
117
|
+
SELECT ${runColumns}
|
|
112
118
|
FROM subagent_runs
|
|
113
119
|
WHERE queue_message_id = ? AND status = 'running'
|
|
114
120
|
`),
|
|
@@ -121,7 +127,7 @@ export function createQuestionRepository(db) {
|
|
|
121
127
|
WHERE queue_message_id = ? AND status = 'running'
|
|
122
128
|
`),
|
|
123
129
|
listSubagentRunsByResponse: db.prepare(`
|
|
124
|
-
SELECT sr
|
|
130
|
+
SELECT ${runColumnsAs('sr')}
|
|
125
131
|
FROM subagent_runs sr
|
|
126
132
|
WHERE sr.queue_message_id IN (
|
|
127
133
|
SELECT id FROM queue WHERE response_message_id = ?
|
|
@@ -29,7 +29,10 @@ export function createRemoteRelayRepository(db) {
|
|
|
29
29
|
const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
|
|
30
30
|
const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
|
|
31
31
|
const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
|
|
32
|
-
|
|
32
|
+
// The relay's unlocks and the approvals stored under its id (`<id>:watch`).
|
|
33
|
+
const deleteUnlocksForRelay = db.prepare(`
|
|
34
|
+
DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ? OR substr(remote_relay_id, 1, length(?) + 1) = ? || ':'
|
|
35
|
+
`);
|
|
33
36
|
const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
|
|
34
37
|
const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
|
|
35
38
|
const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
|
|
@@ -101,7 +104,8 @@ export function createRemoteRelayRepository(db) {
|
|
|
101
104
|
.map((row) => String(row.id));
|
|
102
105
|
},
|
|
103
106
|
forgetRelay(remoteRelayId) {
|
|
104
|
-
|
|
107
|
+
const id = String(remoteRelayId || '');
|
|
108
|
+
return deleteUnlocksForRelay.run(id, id, id).changes;
|
|
105
109
|
},
|
|
106
110
|
forgetConversation(conversationId) {
|
|
107
111
|
return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
|