@venturewild/workspace 0.6.11 → 0.6.14
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/package.json +1 -1
- package/server/src/chat-sessions.mjs +262 -0
- package/server/src/index.mjs +153 -0
- package/server/src/session-rails.mjs +111 -0
- package/web/dist/assets/index-ANYsjOG0.js +131 -0
- package/web/dist/assets/index-Cr3TMP4J.css +32 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B6yyhI9h.js +0 -131
- package/web/dist/assets/index-DaYYu_T9.css +0 -32
package/package.json
CHANGED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Chat sessions — first-class, server-backed conversations per workspace.
|
|
2
|
+
//
|
|
3
|
+
// WHY: chat history used to live ONLY in each browser's localStorage, so the same
|
|
4
|
+
// workspace opened on a second device showed a blank chat. A "session" is now a
|
|
5
|
+
// thread made first-class and stored on the HOST (under the workspace's
|
|
6
|
+
// `.wild-workspace/sessions/`), so every device that reaches this host (all of one
|
|
7
|
+
// account's devices do — vw-proxy → one daemon → one server) sees, switches, and
|
|
8
|
+
// continues the same conversations. The Claude *resume token* still lives in
|
|
9
|
+
// `chat-session[-<id>].json` (unchanged); this store holds the VISIBLE message
|
|
10
|
+
// history + a per-workspace session registry.
|
|
11
|
+
//
|
|
12
|
+
// Content on the host disk has precedent (transcript.mjs already writes the
|
|
13
|
+
// conversation to ~/.wild-workspace/transcripts/<ws>/). `.wild-workspace` is
|
|
14
|
+
// gitignored AND daemon-sync-ignored, so this never pollutes the user's repo or
|
|
15
|
+
// collides with file-sync. Cross-MEMBER (different accounts on different hosts)
|
|
16
|
+
// sharing rides the rails — see session-rails.mjs.
|
|
17
|
+
//
|
|
18
|
+
// Modeled on settings.mjs / canvas/core.mjs: atomic temp-write+rename, hard caps,
|
|
19
|
+
// and degrade-never-throw (a read-only FS must never break chat — it just falls
|
|
20
|
+
// back to the client's localStorage cache).
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { nanoid } from 'nanoid';
|
|
25
|
+
|
|
26
|
+
// The primary/legacy chat. Maps to threadId=null for turnKey + the Claude resume
|
|
27
|
+
// file (`chat-session.json`) so existing single-chat installs are byte-identical
|
|
28
|
+
// on upgrade. Its message history lives in `sessions/main.json`.
|
|
29
|
+
export const MAIN_SESSION_ID = 'main';
|
|
30
|
+
|
|
31
|
+
const MAX_MESSAGES = 200; // server keeps a bit more than the client's localStorage cap
|
|
32
|
+
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB per session's messages file
|
|
33
|
+
const MAX_SESSIONS = 200; // registry soft cap
|
|
34
|
+
const TITLE_CAP = 120;
|
|
35
|
+
|
|
36
|
+
// An id is usable as a filename component ONLY if it round-trips through the same
|
|
37
|
+
// charset sanitizeThreadId (index.mjs) enforces — reject anything else so the
|
|
38
|
+
// `<id>.json` path can never traverse. nanoid()'s alphabet (A-Za-z0-9_-) + 'main'
|
|
39
|
+
// pass unchanged.
|
|
40
|
+
function safeId(id) {
|
|
41
|
+
if (typeof id !== 'string' || !id || id.length > 64) return null;
|
|
42
|
+
if (id === '.' || id === '..') return null;
|
|
43
|
+
return /^[a-zA-Z0-9._-]+$/.test(id) ? id : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJsonSafe(file, fallback) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nowMs() {
|
|
55
|
+
return Date.now();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Recency ordering needs strictly-increasing activity stamps: two touches in the
|
|
59
|
+
// same millisecond (easy on a fast machine/CI) would otherwise tie and the sort
|
|
60
|
+
// would fall back to undefined order. Clamp each new stamp strictly above every
|
|
61
|
+
// existing one (mirrors workspace-registry.mjs nextOpenStamp).
|
|
62
|
+
function nextStamp(sessions) {
|
|
63
|
+
let max = 0;
|
|
64
|
+
for (const s of sessions) {
|
|
65
|
+
if (s.lastActivityAt > max) max = s.lastActivityAt;
|
|
66
|
+
if (s.createdAt > max) max = s.createdAt;
|
|
67
|
+
}
|
|
68
|
+
return Math.max(nowMs(), max + 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitizeRec(s) {
|
|
72
|
+
const id = safeId(s?.id);
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
title: typeof s.title === 'string' && s.title.trim() ? s.title.trim().slice(0, TITLE_CAP) : null,
|
|
77
|
+
account: typeof s.account === 'string' && s.account ? s.account : 'local',
|
|
78
|
+
createdAt: Number(s.createdAt) || nowMs(),
|
|
79
|
+
lastActivityAt: Number(s.lastActivityAt) || Number(s.createdAt) || nowMs(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Per-workspace session store. Construct from the workspace's dataDir (the
|
|
85
|
+
* `.wild-workspace` folder) — `createChatSessions({ dataDir: workspaceFor(c).dataDir })`.
|
|
86
|
+
*/
|
|
87
|
+
export function createChatSessions({ dataDir } = {}) {
|
|
88
|
+
const dir = path.join(dataDir, 'sessions');
|
|
89
|
+
const indexFile = path.join(dir, 'index.json');
|
|
90
|
+
const msgFile = (id) => path.join(dir, `${id}.json`);
|
|
91
|
+
// The Claude resume file for a session: Main → legacy un-suffixed; others suffixed.
|
|
92
|
+
const resumeFile = (id) =>
|
|
93
|
+
id === MAIN_SESSION_ID
|
|
94
|
+
? path.join(dataDir, 'chat-session.json')
|
|
95
|
+
: path.join(dataDir, `chat-session-${id}.json`);
|
|
96
|
+
|
|
97
|
+
function readIndex() {
|
|
98
|
+
const v = readJsonSafe(indexFile, null);
|
|
99
|
+
const arr = v && Array.isArray(v.sessions) ? v.sessions : [];
|
|
100
|
+
return arr.map(sanitizeRec).filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeIndex(sessions) {
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
const tmp = `${indexFile}.${process.pid}.tmp`;
|
|
107
|
+
fs.writeFileSync(tmp, JSON.stringify({ sessions }, null, 2));
|
|
108
|
+
fs.renameSync(tmp, indexFile);
|
|
109
|
+
} catch {
|
|
110
|
+
/* read-only fs — degrade to not-persisted (client localStorage still works) */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** All sessions, most-recently-active first. */
|
|
115
|
+
function list() {
|
|
116
|
+
return readIndex().sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findIndex(sessions, id) {
|
|
120
|
+
return sessions.findIndex((s) => s.id === id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Idempotently ensure the Main session exists (maps to the legacy null thread). */
|
|
124
|
+
function ensureMain(account = 'local') {
|
|
125
|
+
const sessions = readIndex();
|
|
126
|
+
if (findIndex(sessions, MAIN_SESSION_ID) >= 0) return;
|
|
127
|
+
sessions.push(
|
|
128
|
+
sanitizeRec({
|
|
129
|
+
id: MAIN_SESSION_ID,
|
|
130
|
+
title: 'Main',
|
|
131
|
+
account,
|
|
132
|
+
createdAt: nowMs(),
|
|
133
|
+
lastActivityAt: nowMs(),
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
writeIndex(sessions);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Create a new session. Returns the record. */
|
|
140
|
+
function create({ title, account } = {}) {
|
|
141
|
+
const sessions = readIndex();
|
|
142
|
+
// Soft cap: evict the oldest non-Main session that has no stored messages.
|
|
143
|
+
if (sessions.length >= MAX_SESSIONS) {
|
|
144
|
+
const victim = [...sessions]
|
|
145
|
+
.filter((s) => s.id !== MAIN_SESSION_ID && !fs.existsSync(msgFile(s.id)))
|
|
146
|
+
.sort((a, b) => a.lastActivityAt - b.lastActivityAt)[0];
|
|
147
|
+
if (victim) sessions.splice(findIndex(sessions, victim.id), 1);
|
|
148
|
+
}
|
|
149
|
+
const stamp = nextStamp(sessions);
|
|
150
|
+
const rec = sanitizeRec({
|
|
151
|
+
id: nanoid(10),
|
|
152
|
+
title: title || null,
|
|
153
|
+
account: account || 'local',
|
|
154
|
+
createdAt: stamp,
|
|
155
|
+
lastActivityAt: stamp,
|
|
156
|
+
});
|
|
157
|
+
sessions.push(rec);
|
|
158
|
+
writeIndex(sessions);
|
|
159
|
+
return rec;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getMessages(id) {
|
|
163
|
+
const safe = safeId(id);
|
|
164
|
+
if (!safe) return [];
|
|
165
|
+
const v = readJsonSafe(msgFile(safe), null);
|
|
166
|
+
return v && Array.isArray(v.messages) ? v.messages : [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Replace a session's messages (capped). Upserts the registry record (so a PUT
|
|
171
|
+
* is self-sufficient even if create raced). Returns { ok, count } — ok:false on
|
|
172
|
+
* a bad id or an oversized body.
|
|
173
|
+
*/
|
|
174
|
+
function putMessages(id, messages, { account } = {}) {
|
|
175
|
+
const safe = safeId(id);
|
|
176
|
+
if (!safe || !Array.isArray(messages)) return { ok: false, count: 0 };
|
|
177
|
+
const capped = messages.slice(-MAX_MESSAGES);
|
|
178
|
+
const body = JSON.stringify({ messages: capped });
|
|
179
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_BYTES) return { ok: false, count: 0, error: 'too_large' };
|
|
180
|
+
try {
|
|
181
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
182
|
+
const tmp = `${msgFile(safe)}.${process.pid}.tmp`;
|
|
183
|
+
fs.writeFileSync(tmp, body);
|
|
184
|
+
fs.renameSync(tmp, msgFile(safe));
|
|
185
|
+
} catch {
|
|
186
|
+
return { ok: false, count: 0, error: 'write_failed' };
|
|
187
|
+
}
|
|
188
|
+
// Upsert + touch the registry record.
|
|
189
|
+
const sessions = readIndex();
|
|
190
|
+
const stamp = nextStamp(sessions);
|
|
191
|
+
const i = findIndex(sessions, safe);
|
|
192
|
+
if (i >= 0) {
|
|
193
|
+
sessions[i] = { ...sessions[i], lastActivityAt: stamp };
|
|
194
|
+
} else {
|
|
195
|
+
sessions.push(
|
|
196
|
+
sanitizeRec({
|
|
197
|
+
id: safe,
|
|
198
|
+
title: safe === MAIN_SESSION_ID ? 'Main' : null,
|
|
199
|
+
account: account || 'local',
|
|
200
|
+
createdAt: stamp,
|
|
201
|
+
lastActivityAt: stamp,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
writeIndex(sessions);
|
|
206
|
+
return { ok: true, count: capped.length };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function rename(id, title) {
|
|
210
|
+
const safe = safeId(id);
|
|
211
|
+
if (!safe) return null;
|
|
212
|
+
const sessions = readIndex();
|
|
213
|
+
const i = findIndex(sessions, safe);
|
|
214
|
+
if (i < 0) return null;
|
|
215
|
+
sessions[i] = {
|
|
216
|
+
...sessions[i],
|
|
217
|
+
title: typeof title === 'string' && title.trim() ? title.trim().slice(0, TITLE_CAP) : null,
|
|
218
|
+
};
|
|
219
|
+
writeIndex(sessions);
|
|
220
|
+
return sessions[i];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Remove a session (never Main). Also unlinks its messages + Claude resume file. */
|
|
224
|
+
function remove(id) {
|
|
225
|
+
const safe = safeId(id);
|
|
226
|
+
if (!safe || safe === MAIN_SESSION_ID) return false;
|
|
227
|
+
const sessions = readIndex();
|
|
228
|
+
const i = findIndex(sessions, safe);
|
|
229
|
+
if (i < 0) return false;
|
|
230
|
+
sessions.splice(i, 1);
|
|
231
|
+
writeIndex(sessions);
|
|
232
|
+
for (const f of [msgFile(safe), resumeFile(safe)]) {
|
|
233
|
+
try { fs.unlinkSync(f); } catch { /* already gone / read-only */ }
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Bump lastActivityAt (used when a turn finishes, even from another device). */
|
|
239
|
+
function touch(id, { account } = {}) {
|
|
240
|
+
const safe = safeId(id);
|
|
241
|
+
if (!safe) return;
|
|
242
|
+
const sessions = readIndex();
|
|
243
|
+
const stamp = nextStamp(sessions);
|
|
244
|
+
const i = findIndex(sessions, safe);
|
|
245
|
+
if (i >= 0) {
|
|
246
|
+
sessions[i] = { ...sessions[i], lastActivityAt: stamp };
|
|
247
|
+
} else {
|
|
248
|
+
sessions.push(
|
|
249
|
+
sanitizeRec({
|
|
250
|
+
id: safe,
|
|
251
|
+
title: safe === MAIN_SESSION_ID ? 'Main' : null,
|
|
252
|
+
account: account || 'local',
|
|
253
|
+
createdAt: stamp,
|
|
254
|
+
lastActivityAt: stamp,
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
writeIndex(sessions);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { dir, list, ensureMain, create, getMessages, putMessages, rename, remove, touch };
|
|
262
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import { PairingStore } from './pairing.mjs';
|
|
|
34
34
|
import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
|
|
35
35
|
import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
|
|
36
36
|
import { browseDir, browseRoots, listDrives } from './lobby-browse.mjs';
|
|
37
|
+
import { createChatSessions, MAIN_SESSION_ID } from './chat-sessions.mjs';
|
|
38
|
+
import { createSessionRails } from './session-rails.mjs';
|
|
37
39
|
import { InboxWatcher } from './inbox.mjs';
|
|
38
40
|
import { ActivityBus } from './activity.mjs';
|
|
39
41
|
import { createWorkspacePresence } from './workspace-presence.mjs';
|
|
@@ -424,6 +426,9 @@ export async function createServer(overrides = {}) {
|
|
|
424
426
|
// cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
|
|
425
427
|
// agent theme). Person-scoped state stores are built lazily + cached per personKey.
|
|
426
428
|
const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
|
|
429
|
+
// Cross-host shared chat sessions client (Phase 2). Inert without an account /
|
|
430
|
+
// bmo-sync URL; only used for SHARED workspaces (local ones stay per-host).
|
|
431
|
+
const sessionRails = overrides.sessionRails || createSessionRails(config, config.account);
|
|
427
432
|
// Shared-workspace membership client (sharing slice — design §4). The account
|
|
428
433
|
// token is kept top-level on config (out of the broadcast `config.account`), so
|
|
429
434
|
// pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
|
|
@@ -703,6 +708,11 @@ export async function createServer(overrides = {}) {
|
|
|
703
708
|
saveChatSessionId(workspace.dataDir, threadId, session.sessionId);
|
|
704
709
|
}
|
|
705
710
|
}
|
|
711
|
+
// Keep the session list's recency honest even when the turn was driven from
|
|
712
|
+
// another device (the message PUT is client-driven; this is server-side).
|
|
713
|
+
try {
|
|
714
|
+
createChatSessions({ dataDir: workspace.dataDir }).touch(threadId || MAIN_SESSION_ID);
|
|
715
|
+
} catch { /* best-effort — never break a turn on a registry hiccup */ }
|
|
706
716
|
broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
|
|
707
717
|
activityBus.publish({ type: 'chat-end', messageId: id, code });
|
|
708
718
|
});
|
|
@@ -2783,6 +2793,149 @@ export async function createServer(overrides = {}) {
|
|
|
2783
2793
|
return c.json({ ok: true, ...result });
|
|
2784
2794
|
});
|
|
2785
2795
|
|
|
2796
|
+
// --- chat sessions (server-backed, cross-device) ---------------------------
|
|
2797
|
+
// A "session" is a thread made first-class + stored on the host, so every device
|
|
2798
|
+
// that reaches this host (all of one account's devices) sees/switches/continues
|
|
2799
|
+
// the same conversations. Main maps to the legacy null thread. Reads gate on
|
|
2800
|
+
// `chat`, writes on `chatWrite` (viewers get a read-only list). Per-workspace via
|
|
2801
|
+
// workspaceFor(c).dataDir. (Cross-MEMBER sharing rides the rails — Phase 2.)
|
|
2802
|
+
function sessionsFor(c) {
|
|
2803
|
+
return createChatSessions({ dataDir: workspaceFor(c).dataDir });
|
|
2804
|
+
}
|
|
2805
|
+
// A session id → the turn/resume threadId (Main is the legacy null thread).
|
|
2806
|
+
const threadForSession = (id) => (id === MAIN_SESSION_ID ? null : id);
|
|
2807
|
+
const accountLabel = () => config.account?.email || 'local';
|
|
2808
|
+
// The shared-workspace slug for the active workspace, or null (a LOCAL workspace
|
|
2809
|
+
// stays per-host — Phase 1 only). Phase 2 syncs shared sessions over the rails.
|
|
2810
|
+
function sharedSlugFor(c) {
|
|
2811
|
+
const entry = getWorkspace(workspaceFor(c).id, registryEnv);
|
|
2812
|
+
return entry?.kind === 'shared' && entry.shared?.slug ? entry.shared.slug : null;
|
|
2813
|
+
}
|
|
2814
|
+
// The rails session id is namespaced "<slug>::<localId>" (globally unique + tied
|
|
2815
|
+
// to the workspace). Map both directions.
|
|
2816
|
+
const railsId = (slug, lid) => `${slug}::${lid}`;
|
|
2817
|
+
const fromRailsId = (slug, rid) =>
|
|
2818
|
+
rid.startsWith(`${slug}::`) ? rid.slice(slug.length + 2) : rid;
|
|
2819
|
+
|
|
2820
|
+
app.get('/api/sessions', async (c) => {
|
|
2821
|
+
const forbidden = require(c, 'chat');
|
|
2822
|
+
if (forbidden) return forbidden;
|
|
2823
|
+
const ws = workspaceFor(c);
|
|
2824
|
+
const store = sessionsFor(c);
|
|
2825
|
+
store.ensureMain(accountLabel());
|
|
2826
|
+
const byId = new Map(store.list().map((s) => [s.id, s]));
|
|
2827
|
+
// Shared workspace → merge in the cross-host sessions (labeled by owner email).
|
|
2828
|
+
const slug = sharedSlugFor(c);
|
|
2829
|
+
if (slug && sessionRails.capable) {
|
|
2830
|
+
const r = await sessionRails.listSessions(slug);
|
|
2831
|
+
if (r.ok) {
|
|
2832
|
+
for (const rs of r.sessions) {
|
|
2833
|
+
const lid = fromRailsId(slug, rs.id);
|
|
2834
|
+
const existing = byId.get(lid);
|
|
2835
|
+
byId.set(lid, {
|
|
2836
|
+
id: lid,
|
|
2837
|
+
title: rs.title || existing?.title || (lid === MAIN_SESSION_ID ? 'Main' : null),
|
|
2838
|
+
account: rs.owner_email || existing?.account || 'local',
|
|
2839
|
+
createdAt: (rs.created_at ? rs.created_at * 1000 : existing?.createdAt) || Date.now(),
|
|
2840
|
+
lastActivityAt: (rs.updated_at ? rs.updated_at * 1000 : existing?.lastActivityAt) || 0,
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
const sessions = [...byId.values()]
|
|
2846
|
+
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
2847
|
+
.map((s) => ({ ...s, live: currentTurns.has(turnKey(ws.id, threadForSession(s.id))) }));
|
|
2848
|
+
return c.json({ sessions });
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
app.post('/api/sessions', async (c) => {
|
|
2852
|
+
const forbidden = require(c, 'chatWrite');
|
|
2853
|
+
if (forbidden) return forbidden;
|
|
2854
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2855
|
+
const rec = sessionsFor(c).create({
|
|
2856
|
+
title: typeof body.title === 'string' ? body.title : null,
|
|
2857
|
+
account: accountLabel(),
|
|
2858
|
+
});
|
|
2859
|
+
const slug = sharedSlugFor(c);
|
|
2860
|
+
if (slug && sessionRails.capable) {
|
|
2861
|
+
sessionRails.createSession(slug, railsId(slug, rec.id), rec.title).catch(() => {});
|
|
2862
|
+
}
|
|
2863
|
+
auditAction(c, 'sessions.create', `id=${rec.id}`);
|
|
2864
|
+
return c.json({ session: rec });
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
app.get('/api/sessions/:id/messages', async (c) => {
|
|
2868
|
+
const forbidden = require(c, 'chat');
|
|
2869
|
+
if (forbidden) return forbidden;
|
|
2870
|
+
const id = c.req.param('id');
|
|
2871
|
+
const slug = sharedSlugFor(c);
|
|
2872
|
+
if (slug && sessionRails.capable) {
|
|
2873
|
+
const r = await sessionRails.pullMessages(slug, railsId(slug, id));
|
|
2874
|
+
if (r.ok && r.messages.length) {
|
|
2875
|
+
const msgs = [];
|
|
2876
|
+
for (const m of r.messages) {
|
|
2877
|
+
try { msgs.push(JSON.parse(m.json)); } catch { /* skip a corrupt row */ }
|
|
2878
|
+
}
|
|
2879
|
+
// refresh the local cache so an offline reload still has it
|
|
2880
|
+
try { sessionsFor(c).putMessages(id, msgs, { account: accountLabel() }); } catch { /* ignore */ }
|
|
2881
|
+
return c.json({ messages: msgs });
|
|
2882
|
+
}
|
|
2883
|
+
// rails empty/unreachable → fall through to the local store
|
|
2884
|
+
}
|
|
2885
|
+
return c.json({ messages: sessionsFor(c).getMessages(id) });
|
|
2886
|
+
});
|
|
2887
|
+
|
|
2888
|
+
app.put('/api/sessions/:id/messages', async (c) => {
|
|
2889
|
+
const forbidden = require(c, 'chatWrite');
|
|
2890
|
+
if (forbidden) return forbidden;
|
|
2891
|
+
const id = c.req.param('id');
|
|
2892
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2893
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
2894
|
+
const res = sessionsFor(c).putMessages(id, messages, { account: accountLabel() });
|
|
2895
|
+
if (!res.ok) return c.json({ error: res.error || 'bad_request' }, 400);
|
|
2896
|
+
const slug = sharedSlugFor(c);
|
|
2897
|
+
if (slug && sessionRails.capable) {
|
|
2898
|
+
const payload = messages
|
|
2899
|
+
.slice(-200)
|
|
2900
|
+
.filter((m) => m && m.id != null)
|
|
2901
|
+
.map((m) => ({ id: String(m.id), json: JSON.stringify(m) }));
|
|
2902
|
+
if (payload.length) sessionRails.pushMessages(slug, railsId(slug, id), payload).catch(() => {});
|
|
2903
|
+
}
|
|
2904
|
+
return c.json({ ok: true, count: res.count });
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
app.patch('/api/sessions/:id', async (c) => {
|
|
2908
|
+
const forbidden = require(c, 'chatWrite');
|
|
2909
|
+
if (forbidden) return forbidden;
|
|
2910
|
+
const id = c.req.param('id');
|
|
2911
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2912
|
+
const rec = sessionsFor(c).rename(id, body.title);
|
|
2913
|
+
if (!rec) return c.json({ error: 'not_found' }, 404);
|
|
2914
|
+
const slug = sharedSlugFor(c);
|
|
2915
|
+
if (slug && sessionRails.capable) {
|
|
2916
|
+
sessionRails.createSession(slug, railsId(slug, id), rec.title).catch(() => {});
|
|
2917
|
+
}
|
|
2918
|
+
auditAction(c, 'sessions.rename', `id=${rec.id}`);
|
|
2919
|
+
return c.json({ session: rec });
|
|
2920
|
+
});
|
|
2921
|
+
|
|
2922
|
+
app.delete('/api/sessions/:id', (c) => {
|
|
2923
|
+
const forbidden = require(c, 'chatWrite');
|
|
2924
|
+
if (forbidden) return forbidden;
|
|
2925
|
+
const id = c.req.param('id');
|
|
2926
|
+
if (id === MAIN_SESSION_ID) return c.json({ error: 'cannot_remove_main' }, 400);
|
|
2927
|
+
const ws = workspaceFor(c);
|
|
2928
|
+
// Stop a live turn for this session before dropping it (mirror the WS cancel).
|
|
2929
|
+
const live = currentTurns.get(turnKey(ws.id, threadForSession(id)));
|
|
2930
|
+
if (live?.session) {
|
|
2931
|
+
try { live.session.close(); } catch { /* best-effort */ }
|
|
2932
|
+
currentTurns.delete(turnKey(ws.id, threadForSession(id)));
|
|
2933
|
+
}
|
|
2934
|
+
const removed = sessionsFor(c).remove(id);
|
|
2935
|
+
auditAction(c, 'sessions.remove', `id=${id} removed=${removed}`);
|
|
2936
|
+
return c.json({ ok: true, removed });
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2786
2939
|
// The built site, served SAME-ORIGIN through this (already authed) server — no
|
|
2787
2940
|
// squatted dev port, no mixed-content under the public proxy. The build dir
|
|
2788
2941
|
// comes from preview.json (written by the agent's launch_preview / record_use).
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SessionRails — cross-host SHARED chat sessions on the rails.
|
|
2
|
+
//
|
|
3
|
+
// WHY: a session stored on one host is reachable by all of THAT account's devices
|
|
4
|
+
// (one host). For a SHARED workspace, members live on DIFFERENT hosts, so the
|
|
5
|
+
// session list + transcripts ride VW's rails (bmo-sync) — exactly like canvas
|
|
6
|
+
// state. This is a thin client over POST /api/workspace-sessions/{create,list,
|
|
7
|
+
// messages/push,messages/pull} (account-token self-authed + membership-gated; the
|
|
8
|
+
// caller is derived server-side, no IDOR). Local (unshared) workspaces never call
|
|
9
|
+
// this — they're per-host only (chat-sessions.mjs).
|
|
10
|
+
//
|
|
11
|
+
// The rails session id is namespaced "<slug>::<localId>" so it's globally unique
|
|
12
|
+
// and tied to the workspace; the Node caller (index.mjs) does that mapping.
|
|
13
|
+
//
|
|
14
|
+
// degrade-never-throw: every method fails soft so chat keeps working from the
|
|
15
|
+
// local store when the rails are down or the install has no account. Modeled on
|
|
16
|
+
// canvas-rails.mjs.
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
19
|
+
|
|
20
|
+
export class SessionRails {
|
|
21
|
+
constructor({
|
|
22
|
+
bmoSyncUrl,
|
|
23
|
+
accountToken,
|
|
24
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
25
|
+
fetchImpl = (...a) => globalThis.fetch(...a),
|
|
26
|
+
} = {}) {
|
|
27
|
+
this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
|
|
28
|
+
this.accountToken = accountToken || null;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
this.fetchImpl = fetchImpl;
|
|
31
|
+
this.capable = Boolean(this.accountToken) && Boolean(this.bmoSyncUrl);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async _post(path, body) {
|
|
35
|
+
if (!this.capable) return null;
|
|
36
|
+
const ctrl = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
38
|
+
if (timer.unref) timer.unref();
|
|
39
|
+
try {
|
|
40
|
+
const r = await this.fetchImpl(`${this.bmoSyncUrl}${path}`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ account_token: this.accountToken, ...body }),
|
|
44
|
+
signal: ctrl.signal,
|
|
45
|
+
});
|
|
46
|
+
if (!r || !r.ok) return null;
|
|
47
|
+
return await r.json().catch(() => null);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create-or-touch a shared session. `id` must be the namespaced "<slug>::<localId>". */
|
|
56
|
+
async createSession(slug, id, title = null) {
|
|
57
|
+
const resp = await this._post('/api/workspace-sessions/create', { slug, id, title });
|
|
58
|
+
return Boolean(resp && resp.ok === true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* List a shared workspace's sessions.
|
|
63
|
+
* @returns {Promise<{ok:boolean, sessions:Array}>} each: {id,title,owner_account_id,owner_email,created_at,updated_at}
|
|
64
|
+
*/
|
|
65
|
+
async listSessions(slug) {
|
|
66
|
+
const resp = await this._post('/api/workspace-sessions/list', { slug });
|
|
67
|
+
if (!resp || resp.ok !== true) return { ok: false, sessions: [] };
|
|
68
|
+
return { ok: true, sessions: Array.isArray(resp.sessions) ? resp.sessions : [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Push messages (append-with-content-upsert; idempotent per message id).
|
|
73
|
+
* @param messages Array<{id:string, json:string}> — json = the stringified message object.
|
|
74
|
+
*/
|
|
75
|
+
async pushMessages(slug, sessionId, messages, title = null) {
|
|
76
|
+
const resp = await this._post('/api/workspace-sessions/messages/push', {
|
|
77
|
+
slug,
|
|
78
|
+
session_id: sessionId,
|
|
79
|
+
title,
|
|
80
|
+
messages,
|
|
81
|
+
});
|
|
82
|
+
return Boolean(resp && resp.ok === true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pull a session's messages in stable order.
|
|
87
|
+
* @returns {Promise<{ok:boolean, messages:Array<{seq:number,json:string}>, maxSeq:number}>}
|
|
88
|
+
*/
|
|
89
|
+
async pullMessages(slug, sessionId, since = 0) {
|
|
90
|
+
const resp = await this._post('/api/workspace-sessions/messages/pull', {
|
|
91
|
+
slug,
|
|
92
|
+
session_id: sessionId,
|
|
93
|
+
since,
|
|
94
|
+
});
|
|
95
|
+
if (!resp || resp.ok !== true) return { ok: false, messages: [], maxSeq: since };
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
messages: Array.isArray(resp.messages) ? resp.messages : [],
|
|
99
|
+
maxSeq: Number(resp.max_seq) || since,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build the rails client from server config + account (or inert when not logged in). */
|
|
105
|
+
export function createSessionRails(config, account, fetchImpl) {
|
|
106
|
+
return new SessionRails({
|
|
107
|
+
bmoSyncUrl: config?.bmoSyncServerUrl,
|
|
108
|
+
accountToken: account?.accountToken,
|
|
109
|
+
fetchImpl,
|
|
110
|
+
});
|
|
111
|
+
}
|