shraga 0.1.89 → 0.1.91
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/defaults/scripts/backfill-slack-usernames.ts +5 -0
- package/dist/client/assets/index-B--gTLyT.css +10 -0
- package/dist/client/assets/{index-B-8NBGtJ.js → index-C3l1Wz_q.js} +234 -234
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/client/App.tsx +0 -2
- package/src/client/components/ChatView.tsx +78 -18
- package/src/client/components/ConversationHeader.tsx +7 -4
- package/src/client/components/Sidebar.tsx +91 -100
- package/src/client/hooks/useSessionList.ts +169 -0
- package/src/server/boot.ts +37 -2
- package/src/server/claude.ts +14 -2
- package/src/server/data-sync.ts +43 -3
- package/src/server/directives.ts +28 -7
- package/src/server/engine/index.ts +17 -0
- package/src/server/sessions.ts +167 -10
- package/dist/client/assets/index-J2NH6FvE.css +0 -10
package/src/server/boot.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFla
|
|
|
26
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
27
27
|
import { slackFeature } from './slack/feature.ts';
|
|
28
28
|
import { dataPath } from './paths.ts';
|
|
29
|
-
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
29
|
+
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, toListItem, pageSessions, isOwnSession, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
30
30
|
import { setBroadcaster } from './session-bus.ts';
|
|
31
31
|
import * as scheduler from './scheduler/index.ts';
|
|
32
32
|
import { initPolls } from './polls.ts';
|
|
@@ -249,10 +249,45 @@ app.get('/api/claude-usage', requireAuth, async (_req, res) => {
|
|
|
249
249
|
res.json(usage);
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Conversation list — PAGED and TRIMMED.
|
|
254
|
+
*
|
|
255
|
+
* It used to serialize the entire session index: 6.76 MB and ~226 ms of blocking work per call on
|
|
256
|
+
* the Circles box, most of it `triggeredSkills` + `seenSlackTs`, which are server bookkeeping the
|
|
257
|
+
* browser never reads. The full record is still one `/api/sessions/:id/meta` away.
|
|
258
|
+
*
|
|
259
|
+
* Two modes, both returning `{ sessions, nextCursor }`:
|
|
260
|
+
* ?filter=mine|all&limit=&before=<cursor> — a page of the list, newest first.
|
|
261
|
+
* ?ids=a,b,c — exactly these sessions, at any age.
|
|
262
|
+
*
|
|
263
|
+
* `ids` is what keeps the client's filters honest instead of "searches the first page only": the
|
|
264
|
+
* unread map arrives over the socket in full (per-user, `unread_sync`), so the Sidebar knows every
|
|
265
|
+
* unread id and hydrates the ones outside the loaded window by id. The active session is hydrated
|
|
266
|
+
* the same way. `mine` cannot be done that way — it is a predicate over the whole 12k index — so
|
|
267
|
+
* it is a server-side filter applied BEFORE paging.
|
|
268
|
+
*/
|
|
269
|
+
const SESSIONS_PAGE_MAX = 200;
|
|
252
270
|
app.get('/api/sessions', requireAuth, async (req, res) => {
|
|
253
271
|
const user = (req as any).user;
|
|
254
272
|
// Exclude PTY-only sessions — a standalone/terminal-first shell is not a conversation.
|
|
255
|
-
|
|
273
|
+
const all = getSessionsVisibleTo(user.uid, user.isOwner, user.email).filter((s) => s.kind !== 'terminal');
|
|
274
|
+
|
|
275
|
+
const email = String(user.email ?? '').toLowerCase();
|
|
276
|
+
const mine = (s: SessionMeta) => isOwnSession(s, user.uid, email);
|
|
277
|
+
|
|
278
|
+
const idsParam = String(req.query.ids ?? '').trim();
|
|
279
|
+
if (idsParam) {
|
|
280
|
+
const want = new Set(idsParam.split(',').map((s) => s.trim()).filter(Boolean).slice(0, SESSIONS_PAGE_MAX));
|
|
281
|
+
// Deliberately NOT narrowed to `mine`: this is how the ACTIVE conversation gets a row, and the
|
|
282
|
+
// open session must render under either filter. Each row carries `mine` so the client can scope
|
|
283
|
+
// the parts that should be scoped (the unread view and its dot) without losing the active one.
|
|
284
|
+
return void res.json({ sessions: all.filter((s) => want.has(s.sessionId)).map((s) => toListItem(s, mine(s))), nextCursor: null });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const scoped = String(req.query.filter ?? 'all') === 'mine' ? all.filter(mine) : all;
|
|
288
|
+
|
|
289
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), SESSIONS_PAGE_MAX);
|
|
290
|
+
res.json(pageSessions(scoped, { limit, before: String(req.query.before ?? '') || undefined, mine }));
|
|
256
291
|
});
|
|
257
292
|
|
|
258
293
|
app.get('/api/sessions/:id/meta', requireAuth, async (req, res) => {
|
package/src/server/claude.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { getUserContextBlock } from './user-context.ts';
|
|
|
23
23
|
import { collectTurnContext } from './turn-context.ts';
|
|
24
24
|
import { DATA_DIR, dataPath } from './paths.ts';
|
|
25
25
|
import * as contacts from './contacts.ts';
|
|
26
|
-
import { resolveAndGetEngine } from './engine/index.ts';
|
|
26
|
+
import { resolveAndGetEngine, ModelUnavailableError } from './engine/index.ts';
|
|
27
27
|
|
|
28
28
|
const CONFIG_PATH = dataPath('agent-config.json');
|
|
29
29
|
|
|
@@ -213,7 +213,19 @@ export async function* streamChat(opts: {
|
|
|
213
213
|
context?: Record<string, string>;
|
|
214
214
|
}): AsyncGenerator<WsEvent> {
|
|
215
215
|
const config = getAgentConfig();
|
|
216
|
-
const { prompt: cleanPrompt, directives: parsed } = parseDirectives(opts.prompt);
|
|
216
|
+
const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
|
|
217
|
+
|
|
218
|
+
// An explicit model selection that resolves to nothing is a HARD stop, for the same reason an
|
|
219
|
+
// unregistered engine is: continuing would run the turn on `config.model` — a different model
|
|
220
|
+
// than the caller picked — with only a console.warn to show for it. Surfaced as a turn `error`
|
|
221
|
+
// event (not a throw) so WS, Slack, scheduler, MCP and webhook all report it identically and a
|
|
222
|
+
// scheduled run is marked failed instead of quietly succeeding on the wrong model.
|
|
223
|
+
if (unresolvedModel) {
|
|
224
|
+
const message = new ModelUnavailableError(unresolvedModel).message;
|
|
225
|
+
console.error(`[stream] ${message} (user=${opts.uid} session=${opts.sessionId ?? 'new'})`);
|
|
226
|
+
yield { type: 'error', message };
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
217
229
|
|
|
218
230
|
const sessionMeta = opts.sessionId ? getSession(opts.sessionId) : undefined;
|
|
219
231
|
const directives: Directives = { ...sessionMeta?.directives, ...parsed };
|
package/src/server/data-sync.ts
CHANGED
|
@@ -107,6 +107,9 @@ export class DataSync {
|
|
|
107
107
|
private pullPending = false;
|
|
108
108
|
private ready = false;
|
|
109
109
|
private warnedDisabled = false;
|
|
110
|
+
/** key -> fingerprint+timestamp of the last DM sent for that guard, so a STANDING condition
|
|
111
|
+
* alerts once instead of on every sync cycle. See alertOnce(). */
|
|
112
|
+
private alerted = new Map<string, { fingerprint: string; at: number }>();
|
|
110
113
|
|
|
111
114
|
constructor(opts?: Partial<DataSyncOptions>) {
|
|
112
115
|
this.options = { ...new DataSyncOptions(), ...opts };
|
|
@@ -243,7 +246,16 @@ export class DataSync {
|
|
|
243
246
|
* shrink (e.g. contacts.json 111→5 lines — a legit-looking normalization that wiped shared
|
|
244
247
|
* data). Notifies owner and aborts. */
|
|
245
248
|
private async guardMassDeletions(context: string): Promise<boolean> {
|
|
246
|
-
|
|
249
|
+
// An untrackIgnored() pass is NOT a deletion: it runs `git rm --cached` over files that match
|
|
250
|
+
// the repo's OWN .gitignore (a personal global gitignore is excluded upstream) and that still
|
|
251
|
+
// exist on disk. Nothing is lost — the files simply stop being shared, which is the entire
|
|
252
|
+
// point of the call. Judging it by the 10-file deletion threshold made a normal .gitignore
|
|
253
|
+
// addition (pane-sessions/, 173 files) an unclearable block: every flush retried it, alerted,
|
|
254
|
+
// and `git reset HEAD` also discarded whatever real work was staged alongside.
|
|
255
|
+
const untracking = context === 'untrackIgnored';
|
|
256
|
+
const fileThreshold = untracking
|
|
257
|
+
? parseInt(process.env.DATA_SYNC_UNTRACK_BLOCK || '500', 10)
|
|
258
|
+
: parseInt(process.env.DATA_SYNC_DELETIONS_BLOCK || '10', 10);
|
|
247
259
|
const shrinkThreshold = parseInt(process.env.DATA_SYNC_SHRINK_BLOCK || '50', 10);
|
|
248
260
|
try {
|
|
249
261
|
// (1) Mass FILE deletions.
|
|
@@ -252,7 +264,7 @@ export class DataSync {
|
|
|
252
264
|
if (deleted.length > fileThreshold) {
|
|
253
265
|
console.error(`${TAG} 🚫 BLOCKED mass deletion (${context}): ${deleted.length} file(s) — threshold is ${fileThreshold}`);
|
|
254
266
|
const list = deleted.slice(0, 20).map(f => `• ${f}`).join('\n');
|
|
255
|
-
await this.
|
|
267
|
+
await this.alertOnce(`deletions:${context}`, deleted.join('\n'),
|
|
256
268
|
`🚫 BLOCKED mass deletion in data/ (${context}): ${deleted.length} file(s) staged for deletion (threshold: ${fileThreshold})\n\n${list}` +
|
|
257
269
|
(deleted.length > 20 ? `\n…and ${deleted.length - 20} more` : '') +
|
|
258
270
|
`\n\nCommit was aborted. Manual intervention needed.`,
|
|
@@ -273,13 +285,16 @@ export class DataSync {
|
|
|
273
285
|
}
|
|
274
286
|
if (shrunk.length) {
|
|
275
287
|
console.error(`${TAG} 🚫 BLOCKED large content shrink (${context}): ${shrunk.length} file(s) — net-removal threshold is ${shrinkThreshold}`);
|
|
276
|
-
await this.
|
|
288
|
+
await this.alertOnce(`shrink:${context}`, shrunk.join('\n'),
|
|
277
289
|
`🚫 BLOCKED large content shrink in data/ (${context}): a tracked file lost more than ${shrinkThreshold} net lines (guards against wiping shared data like contacts.json)\n\n${shrunk.slice(0, 20).join('\n')}` +
|
|
278
290
|
`\n\nCommit was aborted. If intended, raise DATA_SYNC_SHRINK_BLOCK or commit manually.`,
|
|
279
291
|
);
|
|
280
292
|
await this.git('reset', 'HEAD').catch(() => {});
|
|
281
293
|
return true;
|
|
282
294
|
}
|
|
295
|
+
// Condition cleared — a future recurrence is news again, so let it alert.
|
|
296
|
+
this.clearAlert(`deletions:${context}`);
|
|
297
|
+
this.clearAlert(`shrink:${context}`);
|
|
283
298
|
return false;
|
|
284
299
|
} catch (err) {
|
|
285
300
|
console.warn(`${TAG} Destructive-change check failed:`, (err as Error).message);
|
|
@@ -678,6 +693,31 @@ export class DataSync {
|
|
|
678
693
|
return violations;
|
|
679
694
|
}
|
|
680
695
|
|
|
696
|
+
/**
|
|
697
|
+
* DM owners about a guard trip, but only when the condition is NEW. A blocked commit is not a
|
|
698
|
+
* one-off: the same staged change is re-attempted on every flush, so a standing condition used to
|
|
699
|
+
* DM on every cycle (measured 2026-09-08: 173 files ignored-but-tracked produced an identical
|
|
700
|
+
* "BLOCKED mass deletion" DM ~15×/hour, indefinitely). Alert fatigue is a correctness bug — it
|
|
701
|
+
* buries the one alert that matters. Re-alerts only when the fingerprint CHANGES or after
|
|
702
|
+
* DATA_SYNC_ALERT_REPEAT_MS (default 6h), and every trip is still logged locally.
|
|
703
|
+
*/
|
|
704
|
+
private async alertOnce(key: string, fingerprint: string, text: string): Promise<void> {
|
|
705
|
+
const repeatMs = parseInt(process.env.DATA_SYNC_ALERT_REPEAT_MS || '', 10) || 6 * 60 * 60 * 1000;
|
|
706
|
+
const prev = this.alerted.get(key);
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
if (prev && prev.fingerprint === fingerprint && now - prev.at < repeatMs) {
|
|
709
|
+
console.warn(`${TAG} alert suppressed (unchanged since ${new Date(prev.at).toISOString()}): ${key}`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this.alerted.set(key, { fingerprint, at: now });
|
|
713
|
+
await this.notifyOwners(text);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Clear the repeat-suppression for a guard once its condition is gone, so a RECURRENCE alerts. */
|
|
717
|
+
private clearAlert(key: string): void {
|
|
718
|
+
this.alerted.delete(key);
|
|
719
|
+
}
|
|
720
|
+
|
|
681
721
|
private async notifyOwners(text: string): Promise<void> {
|
|
682
722
|
if (!this.options.notify) {
|
|
683
723
|
// Not the authoritative instance — log it so a dev run still surfaces the problem locally,
|
package/src/server/directives.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface Directives {
|
|
|
9
9
|
export interface ParsedPrompt {
|
|
10
10
|
prompt: string;
|
|
11
11
|
directives: Directives;
|
|
12
|
+
/** An EXPLICIT model selection that no alias and no registered engine could resolve. Reported
|
|
13
|
+
* instead of applied: dropping it (the old behaviour) ran the turn on `config.model` — a
|
|
14
|
+
* DIFFERENT model than the caller asked for, with nothing but a `console.warn` to show for it.
|
|
15
|
+
* The caller turns this into a turn error; see `streamChat`. */
|
|
16
|
+
unresolvedModel?: string;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/** Model used when neither directives nor config specify one. Always passed
|
|
@@ -91,8 +96,19 @@ export function parseDirectives(text: string): ParsedPrompt {
|
|
|
91
96
|
|
|
92
97
|
const directives: Directives = {};
|
|
93
98
|
let positionalIndex = 0;
|
|
99
|
+
let unresolvedModel: string | undefined;
|
|
100
|
+
|
|
101
|
+
const tokens = raw.split(',');
|
|
102
|
+
// Is this group PROVABLY a directive group (some token is a known `key:value`)? A bare positional
|
|
103
|
+
// that resolves to nothing is otherwise indistinguishable from prose — `[some bracketed text] hi`
|
|
104
|
+
// is a legitimate prompt and must never fail a turn. With a keyed sibling present the group is
|
|
105
|
+
// unambiguously directives, so an unresolvable leading model token there IS a dropped selection.
|
|
106
|
+
const provenDirectiveGroup = tokens.some((t) => {
|
|
107
|
+
const i = t.indexOf(':');
|
|
108
|
+
return i !== -1 && DIRECTIVE_KEYS.includes(t.slice(0, i).trim().toLowerCase());
|
|
109
|
+
});
|
|
94
110
|
|
|
95
|
-
for (const token of
|
|
111
|
+
for (const token of tokens) {
|
|
96
112
|
const t = token.trim();
|
|
97
113
|
if (!t) continue;
|
|
98
114
|
|
|
@@ -100,7 +116,7 @@ export function parseDirectives(text: string): ParsedPrompt {
|
|
|
100
116
|
if (colonIdx !== -1) {
|
|
101
117
|
const key = t.slice(0, colonIdx).trim().toLowerCase();
|
|
102
118
|
const val = t.slice(colonIdx + 1).trim().toLowerCase();
|
|
103
|
-
applyDirective(directives, key, val);
|
|
119
|
+
unresolvedModel = applyDirective(directives, key, val) ?? unresolvedModel;
|
|
104
120
|
} else {
|
|
105
121
|
const val = t.toLowerCase();
|
|
106
122
|
if (positionalIndex === 0 && resolveModelToken(directives, val)) {
|
|
@@ -112,6 +128,7 @@ export function parseDirectives(text: string): ParsedPrompt {
|
|
|
112
128
|
} else if (['nothink', 'nothinking'].includes(val)) {
|
|
113
129
|
directives.thinking = 'disabled';
|
|
114
130
|
} else if (positionalIndex === 0) {
|
|
131
|
+
if (provenDirectiveGroup && !isQualifiedModel(val)) unresolvedModel ??= t;
|
|
115
132
|
console.warn(isQualifiedModel(val)
|
|
116
133
|
? `[directives] Provider-qualified model needs key form: "[model:${t}]"`
|
|
117
134
|
: `[directives] Unknown model alias: "${t}"`);
|
|
@@ -120,16 +137,19 @@ export function parseDirectives(text: string): ParsedPrompt {
|
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
return { prompt, directives };
|
|
140
|
+
return { prompt, directives, unresolvedModel };
|
|
124
141
|
}
|
|
125
142
|
|
|
126
|
-
|
|
143
|
+
/** Returns the token of an EXPLICIT model selection it could not resolve, else undefined. */
|
|
144
|
+
function applyDirective(d: Directives, key: string, val: string): string | undefined {
|
|
127
145
|
switch (key) {
|
|
128
146
|
case 'model':
|
|
129
147
|
if (resolveModelToken(d, val)) break;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
148
|
+
// A `provider/model` id is already concrete — hand it to the engine, which owns the verdict.
|
|
149
|
+
if (isQualifiedModel(val)) { d.model = val; break; }
|
|
150
|
+
// Nothing knows this token. `[model:x]` is unambiguously a selection (never prose), so the
|
|
151
|
+
// turn must not quietly continue on the instance default model.
|
|
152
|
+
return val;
|
|
133
153
|
case 'turns':
|
|
134
154
|
if (/^\d+$/.test(val)) d.turns = parseInt(val, 10);
|
|
135
155
|
else console.warn(`[directives] Invalid turns value: "${val}"`);
|
|
@@ -149,4 +169,5 @@ function applyDirective(d: Directives, key: string, val: string) {
|
|
|
149
169
|
default:
|
|
150
170
|
console.warn(`[directives] Unknown directive key: "${key}"`);
|
|
151
171
|
}
|
|
172
|
+
return undefined;
|
|
152
173
|
}
|
|
@@ -64,6 +64,23 @@ export class EngineUnavailableError extends Error {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** An explicitly selected model that neither the alias table nor any REGISTERED engine could
|
|
68
|
+
* resolve. Same contract as EngineUnavailableError: the run stops instead of silently continuing
|
|
69
|
+
* on the instance default, which is a different model than the caller chose. */
|
|
70
|
+
export class ModelUnavailableError extends Error {
|
|
71
|
+
constructor(public readonly model: string) {
|
|
72
|
+
super(
|
|
73
|
+
`Model "${model}" could not be resolved on this server. It is not a known alias, not a ` +
|
|
74
|
+
`provider-qualified id (\`provider/model\`), and no registered engine advertises it. ` +
|
|
75
|
+
`Registered engines: ${getAvailableEngines().join(', ') || 'none'} — an engine registers only when enabled ` +
|
|
76
|
+
`at boot (AGENT_ENGINES must list it; the native cursor engine also needs CURSOR_API_KEY), ` +
|
|
77
|
+
`so a model owned by an absent engine cannot resolve. Check the server env and startup log. ` +
|
|
78
|
+
`The run was stopped rather than silently re-routed to the instance's default model.`,
|
|
79
|
+
);
|
|
80
|
+
this.name = 'ModelUnavailableError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
export function resolveAndGetEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }) {
|
|
68
85
|
const name = resolveEngine(directives, agentConfig);
|
|
69
86
|
// An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
|
package/src/server/sessions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync, renameSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -44,18 +44,146 @@ export interface SessionMeta {
|
|
|
44
44
|
forkedFrom?: string;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── Index cache ──────────────────────────────────────────────────────────────
|
|
48
|
+
// The index is ~9 MB / 12.8k records on the Circles box. Every mutation used to readFileSync +
|
|
49
|
+
// JSON.parse the whole file and then stringify + write it back, SYNCHRONOUSLY (~260 ms per
|
|
50
|
+
// mutation on a 2-vCPU host, and a turn does several), so the event loop stalled on bookkeeping.
|
|
51
|
+
//
|
|
52
|
+
// Single-writer, deliberately: the HTTP server, its scheduler and the MCP surface all run in ONE
|
|
53
|
+
// bun process (`src/server/index.ts` → boot.ts, which mounts the MCP in-process; the only spawned
|
|
54
|
+
// children are vendor MCP sidecars and background-job shells, none of which import this module).
|
|
55
|
+
// data-sync cannot touch it either — `sessions.json` is in the data repo's canonical .gitignore,
|
|
56
|
+
// so its pull/reset/stash never sees the file. The one out-of-band writer is the manual
|
|
57
|
+
// `defaults/scripts/backfill-slack-usernames.ts`, and this cache does NOT make that safe:
|
|
58
|
+
//
|
|
59
|
+
// The mtime guard below only runs when the cache is CLEAN. `loadIndex()` short-circuits on
|
|
60
|
+
// `if (dirty) return cache` first, so while a flush is pending the server neither sees an
|
|
61
|
+
// external edit nor survives it — the next flushIndex() writes the whole in-memory cache over
|
|
62
|
+
// the file, and the backfill's work is gone. The guard buys exactly one thing over the old
|
|
63
|
+
// read-every-time behaviour: an external edit made while nothing is pending IS picked up.
|
|
64
|
+
// Fixing the pending-flush case needs a merge or a lock, i.e. a multi-process protocol, which
|
|
65
|
+
// this file deliberately does not have. flushIndex() detects the clobber and logs it loudly
|
|
66
|
+
// instead of losing it silently — run the backfill with the server stopped.
|
|
67
|
+
let cache: SessionMeta[] | null = null;
|
|
68
|
+
let cacheMtimeMs = -1;
|
|
69
|
+
let dirty = false;
|
|
70
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Coalescing window for the 9 MB write.
|
|
74
|
+
*
|
|
75
|
+
* Durability trade, stated explicitly: a crash can lose at most this much session BOOKKEEPING —
|
|
76
|
+
* runStatus, lastModified, seenSlackTs, triggeredSkills. Conversation content is not in this file
|
|
77
|
+
* (it lives in `data/conversations/`), so no user-visible message can be lost here. Every exit
|
|
78
|
+
* path we control flushes synchronously (`process.on('exit')` — reached by the SIGTERM/SIGINT
|
|
79
|
+
* graceful shutdown, which ends in process.exit — plus setShuttingDown() at the top of the drain),
|
|
80
|
+
* so the exposure is a SIGKILL or power loss inside a 250 ms window. Taken over the alternative:
|
|
81
|
+
* a ~260 ms sync stall on every single mutation.
|
|
82
|
+
*/
|
|
83
|
+
const FLUSH_DEBOUNCE_MS = 250;
|
|
84
|
+
|
|
47
85
|
function loadIndex(): SessionMeta[] {
|
|
48
|
-
if (
|
|
86
|
+
if (cache) {
|
|
87
|
+
if (dirty) return cache; // memory is ahead of disk — we are the authority
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(SESSIONS_PATH).mtimeMs === cacheMtimeMs) return cache;
|
|
90
|
+
} catch {
|
|
91
|
+
return cache; // file vanished mid-run; keep serving what we have
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(SESSIONS_PATH)) return (cache = []);
|
|
49
95
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
96
|
+
cache = JSON.parse(readFileSync(SESSIONS_PATH, 'utf-8'));
|
|
97
|
+
cacheMtimeMs = statSync(SESSIONS_PATH).mtimeMs;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error('[sessions] index unreadable, serving an empty list:', (err as Error).message);
|
|
100
|
+
cache = [];
|
|
53
101
|
}
|
|
102
|
+
return cache!;
|
|
54
103
|
}
|
|
55
104
|
|
|
56
105
|
function saveIndex(sessions: SessionMeta[]): void {
|
|
57
|
-
|
|
58
|
-
|
|
106
|
+
cache = sessions;
|
|
107
|
+
dirty = true;
|
|
108
|
+
if (!flushTimer) scheduleFlush();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function scheduleFlush(): void {
|
|
112
|
+
const t = setTimeout(flushIndex, FLUSH_DEBOUNCE_MS);
|
|
113
|
+
(t as { unref?: () => void }).unref?.(); // never hold the process open for bookkeeping
|
|
114
|
+
flushTimer = t;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write the index to disk now. Atomic (tmp + rename): the old truncate-in-place write left a
|
|
119
|
+
* half-written 9 MB file if it was interrupted, and loadIndex's catch turned that into an EMPTY
|
|
120
|
+
* session list — a silent total loss of the index.
|
|
121
|
+
*/
|
|
122
|
+
export function flushIndex(): void {
|
|
123
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
124
|
+
if (!dirty || !cache) return;
|
|
125
|
+
dirty = false;
|
|
126
|
+
const tmp = `${SESSIONS_PATH}.tmp`;
|
|
127
|
+
try {
|
|
128
|
+
// Someone wrote the file behind us while this flush was pending (see the cache note above).
|
|
129
|
+
// We cannot merge without a multi-process protocol, so at least never lose it silently.
|
|
130
|
+
try {
|
|
131
|
+
const onDisk = statSync(SESSIONS_PATH).mtimeMs;
|
|
132
|
+
if (cacheMtimeMs >= 0 && onDisk !== cacheMtimeMs) {
|
|
133
|
+
console.error(`[sessions] index changed on disk (mtime ${onDisk} != ${cacheMtimeMs}) while a flush was pending — overwriting that external edit`);
|
|
134
|
+
}
|
|
135
|
+
} catch { /* file absent: nothing to clobber */ }
|
|
136
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
137
|
+
writeFileSync(tmp, JSON.stringify(cache)); // no pretty-print: 9 MB nobody reads by hand
|
|
138
|
+
renameSync(tmp, SESSIONS_PATH);
|
|
139
|
+
cacheMtimeMs = statSync(SESSIONS_PATH).mtimeMs;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('[sessions] index flush failed, retrying on the next tick:', (err as Error).message);
|
|
142
|
+
dirty = true;
|
|
143
|
+
scheduleFlush();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Covers the graceful-shutdown paths (SIGTERM/SIGINT end in process.exit) and any normal exit,
|
|
148
|
+
// without adding signal listeners that would change the process's default signal semantics.
|
|
149
|
+
process.on('exit', () => flushIndex());
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The subset of SessionMeta the conversation list actually renders. `triggeredSkills` (381 KB) and
|
|
153
|
+
* `seenSlackTs` (174 KB) are server bookkeeping and must never be shipped to a browser; the full
|
|
154
|
+
* record is still available per-session from /api/sessions/:id/meta.
|
|
155
|
+
*/
|
|
156
|
+
export interface SessionListItem {
|
|
157
|
+
sessionId: string;
|
|
158
|
+
title: string;
|
|
159
|
+
userName: string;
|
|
160
|
+
lastModified: number;
|
|
161
|
+
slackContext?: SessionMeta['slackContext'];
|
|
162
|
+
runStatus?: SessionMeta['runStatus'];
|
|
163
|
+
lastStopReason?: SessionMeta['lastStopReason'];
|
|
164
|
+
scheduleRunStatus?: SessionMeta['scheduleRunStatus'];
|
|
165
|
+
/** Owned by (or explicitly shared with) the caller. Stamped per-request: the trimmed row drops
|
|
166
|
+
* `uid`/`visibleTo`, so without it the client cannot scope anything by "mine". */
|
|
167
|
+
mine?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The `mine` predicate, shared by the list filter and the per-row stamp so they cannot drift. */
|
|
171
|
+
export function isOwnSession(s: SessionMeta, uid: string, email: string): boolean {
|
|
172
|
+
return s.uid === uid || !!s.visibleTo?.includes(email);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function toListItem(s: SessionMeta, mine?: boolean): SessionListItem {
|
|
176
|
+
return {
|
|
177
|
+
sessionId: s.sessionId,
|
|
178
|
+
title: s.title,
|
|
179
|
+
userName: s.userName,
|
|
180
|
+
lastModified: s.lastModified,
|
|
181
|
+
...(s.slackContext ? { slackContext: s.slackContext } : {}),
|
|
182
|
+
...(s.runStatus ? { runStatus: s.runStatus } : {}),
|
|
183
|
+
...(s.lastStopReason ? { lastStopReason: s.lastStopReason } : {}),
|
|
184
|
+
...(s.scheduleRunStatus ? { scheduleRunStatus: s.scheduleRunStatus } : {}),
|
|
185
|
+
...(mine === undefined ? {} : { mine }),
|
|
186
|
+
};
|
|
59
187
|
}
|
|
60
188
|
|
|
61
189
|
function summarize(prompt: string): string {
|
|
@@ -126,8 +254,33 @@ export function addTriggeredSkills(sessionId: string, names: string[]): void {
|
|
|
126
254
|
saveIndex(sessions);
|
|
127
255
|
}
|
|
128
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Stable pagination cursor: lastModified alone is not unique across 12k sessions, and a paging
|
|
259
|
+
* cursor that only carries the timestamp drops or repeats every record in a tie.
|
|
260
|
+
*/
|
|
261
|
+
export function sessionCursor(s: SessionMeta | SessionListItem): string {
|
|
262
|
+
return `${String(s.lastModified).padStart(16, '0')}.${s.sessionId}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One page of an already-sorted (getAllSessions order) list, newest first. `before` is the cursor
|
|
267
|
+
* from the previous page's `nextCursor`; it is composite (lastModified + sessionId) because
|
|
268
|
+
* lastModified alone is not unique and a timestamp-only cursor drops or repeats records on a tie.
|
|
269
|
+
*/
|
|
270
|
+
export function pageSessions(list: SessionMeta[], opts: { limit: number; before?: string; mine?: (s: SessionMeta) => boolean }): { sessions: SessionListItem[]; nextCursor: string | null } {
|
|
271
|
+
const rest = opts.before ? list.filter((s) => sessionCursor(s) < opts.before!) : list;
|
|
272
|
+
const page = rest.slice(0, opts.limit);
|
|
273
|
+
return {
|
|
274
|
+
sessions: page.map((s) => toListItem(s, opts.mine?.(s))),
|
|
275
|
+
nextCursor: rest.length > page.length ? sessionCursor(page[page.length - 1]) : null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
129
279
|
export function getAllSessions(): SessionMeta[] {
|
|
130
|
-
|
|
280
|
+
// Copy before sorting: loadIndex() now returns the live cache array, and an in-place sort here
|
|
281
|
+
// would silently reorder the store for every other caller. Sorted by the same composite key the
|
|
282
|
+
// pagination cursor uses, so `cursor < before` is exactly consistent with this order.
|
|
283
|
+
return [...loadIndex()].sort((a, b) => b.lastModified - a.lastModified || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0));
|
|
131
284
|
}
|
|
132
285
|
|
|
133
286
|
export function isSessionVisibleTo(s: SessionMeta, uid: string, isOwner = false, email?: string): boolean {
|
|
@@ -184,7 +337,11 @@ export function setSessionDirectives(sessionId: string, directives: NonNullable<
|
|
|
184
337
|
const sessions = loadIndex();
|
|
185
338
|
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
186
339
|
if (s) {
|
|
187
|
-
|
|
340
|
+
// COPY, don't alias. streamChat() stores its working `directives` object here and then keeps
|
|
341
|
+
// mutating it for the rest of the turn (the per-skill turn budget is gap-filled afterwards).
|
|
342
|
+
// While the index was re-parsed from disk on every read that leak was invisible; against an
|
|
343
|
+
// in-memory store it would silently PIN a skill's 250-turn budget onto the session forever.
|
|
344
|
+
s.directives = { ...directives };
|
|
188
345
|
saveIndex(sessions);
|
|
189
346
|
}
|
|
190
347
|
}
|
|
@@ -269,7 +426,7 @@ export function setAutoApprove(uid: string, value: boolean): void {
|
|
|
269
426
|
}
|
|
270
427
|
|
|
271
428
|
let _shuttingDown = false;
|
|
272
|
-
export function setShuttingDown(): void { _shuttingDown = true; }
|
|
429
|
+
export function setShuttingDown(): void { _shuttingDown = true; flushIndex(); }
|
|
273
430
|
|
|
274
431
|
export function setRunStatus(sessionId: string, status: 'running' | 'idle', origin?: 'web' | 'slack' | 'scheduler' | 'gmail', stopReason?: SessionMeta['lastStopReason']): void {
|
|
275
432
|
if (_shuttingDown && status === 'idle') return;
|