@chatpanel/gateway 0.6.76 → 0.6.78
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/src/prefs-store.js +149 -0
- package/src/server.js +120 -2
- package/src/team-store.js +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.78",
|
|
4
4
|
"description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// CLIENT PREFERENCES — the settings every ChatPanel client shares, held here because the
|
|
2
|
+
// gateway is the one address the extension and the desktop both have.
|
|
3
|
+
//
|
|
4
|
+
// A document of SECTIONS (the MCP server list, the skills, the search engines — see
|
|
5
|
+
// @chatpanel/events/client-prefs.js), each stamped with when it was last written. A client
|
|
6
|
+
// pushes the sections it changed with its own stamps; the store keeps the newer stamp per
|
|
7
|
+
// section and tells the pusher which of its sections lost, so it can take the other side's
|
|
8
|
+
// copy. Per-section last-writer-wins: a section is one screen a person edits, and merging two
|
|
9
|
+
// edits of one list key by key would make a list neither of them made.
|
|
10
|
+
//
|
|
11
|
+
// ENCRYPTED AT REST with the same device key as memory and history — an MCP server entry can
|
|
12
|
+
// carry an Authorization header, and this file must not be the plaintext copy of it.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
20
|
+
const STORE_PATH = process.env.CHATPANEL_PREFS_STORE || join(DIR, 'prefs-store.enc');
|
|
21
|
+
const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
|
|
22
|
+
|
|
23
|
+
/** A section id is a short word; anything else is refused before it is stored. */
|
|
24
|
+
const SECTION_ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
25
|
+
/** One section's JSON, serialized — a list of a thousand MCP servers is not a preference. */
|
|
26
|
+
const MAX_SECTION_BYTES = 512 * 1024;
|
|
27
|
+
|
|
28
|
+
function loadOrCreateKey() {
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64');
|
|
31
|
+
} catch { /* regenerate below */ }
|
|
32
|
+
const key = randomBytes(32);
|
|
33
|
+
mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encrypt(key, buf) {
|
|
39
|
+
const iv = randomBytes(12);
|
|
40
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
41
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
42
|
+
return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decrypt(key, env) {
|
|
46
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
47
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
48
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class PrefsStore {
|
|
52
|
+
constructor({ storePath = STORE_PATH } = {}) {
|
|
53
|
+
this.path = storePath;
|
|
54
|
+
this._key = null;
|
|
55
|
+
this.sections = {}; // id -> { value, updatedAt, by }
|
|
56
|
+
this.revision = 0; // bumps on every accepted write, so a client can ask "anything new?"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
load() {
|
|
60
|
+
this._key = loadOrCreateKey();
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(this.path)) {
|
|
63
|
+
const env = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
64
|
+
const doc = JSON.parse(decrypt(this._key, env).toString('utf8'));
|
|
65
|
+
this.sections = doc?.sections && typeof doc.sections === 'object' ? doc.sections : {};
|
|
66
|
+
this.revision = Number(doc?.revision) || 0;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// An unreadable store is an empty one, never a crash: the clients still hold their own
|
|
70
|
+
// copies and will push them back on the next change.
|
|
71
|
+
this.sections = {};
|
|
72
|
+
this.revision = 0;
|
|
73
|
+
}
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
save() {
|
|
78
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
79
|
+
const env = encrypt(this._key, Buffer.from(JSON.stringify({ v: 1, revision: this.revision, sections: this.sections }), 'utf8'));
|
|
80
|
+
// Write beside, then rename: a crash mid-write leaves the old file, not half of a new one.
|
|
81
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
|
|
83
|
+
renameSync(tmp, this.path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every section, or one. Values are the caller's to keep — they are copies. */
|
|
87
|
+
get(id = '') {
|
|
88
|
+
if (id) {
|
|
89
|
+
const s = this.sections[id];
|
|
90
|
+
return s ? { [id]: { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' } } : {};
|
|
91
|
+
}
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const [k, s] of Object.entries(this.sections)) out[k] = { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' };
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Just the stamps — enough for a client to decide whether it needs the values. */
|
|
98
|
+
stamps() {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [k, s] of Object.entries(this.sections)) out[k] = s.updatedAt;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Merge a client's stamped sections in. A section is taken when its stamp is newer than
|
|
106
|
+
* the one held (or nothing is held); otherwise the client is told it lost and gets the
|
|
107
|
+
* held copy back. Returns `{ applied, kept, sections }` where `sections` holds the current
|
|
108
|
+
* copies of everything the client sent — the client writes `kept` ones over its own.
|
|
109
|
+
*/
|
|
110
|
+
put(incoming, { by = '' } = {}) {
|
|
111
|
+
const applied = [];
|
|
112
|
+
const kept = [];
|
|
113
|
+
const sections = {};
|
|
114
|
+
for (const [id, entry] of Object.entries(incoming || {})) {
|
|
115
|
+
if (!SECTION_ID_RE.test(id)) continue;
|
|
116
|
+
const updatedAt = Number(entry?.updatedAt) || 0;
|
|
117
|
+
if (!updatedAt || entry?.value === undefined) continue;
|
|
118
|
+
let bytes;
|
|
119
|
+
try { bytes = Buffer.byteLength(JSON.stringify(entry.value), 'utf8'); } catch { continue; }
|
|
120
|
+
if (bytes > MAX_SECTION_BYTES) continue;
|
|
121
|
+
const held = this.sections[id];
|
|
122
|
+
if (!held || updatedAt > held.updatedAt) {
|
|
123
|
+
this.sections[id] = { value: clone(entry.value), updatedAt, by: String(by || '').slice(0, 40) };
|
|
124
|
+
applied.push(id);
|
|
125
|
+
} else {
|
|
126
|
+
kept.push(id);
|
|
127
|
+
}
|
|
128
|
+
const cur = this.sections[id];
|
|
129
|
+
sections[id] = { value: clone(cur.value), updatedAt: cur.updatedAt, by: cur.by || '' };
|
|
130
|
+
}
|
|
131
|
+
if (applied.length) { this.revision += 1; this.save(); }
|
|
132
|
+
return { applied, kept, sections, revision: this.revision };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Forget one section entirely — the user removed the feature's settings, not just emptied them. */
|
|
136
|
+
remove(id) {
|
|
137
|
+
if (!this.sections[id]) return false;
|
|
138
|
+
delete this.sections[id];
|
|
139
|
+
this.revision += 1;
|
|
140
|
+
this.save();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
146
|
+
|
|
147
|
+
export function createPrefsStore(opts) {
|
|
148
|
+
return new PrefsStore(opts).load();
|
|
149
|
+
}
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,8 @@ import { startNer, ensureNer } from './ner.js';
|
|
|
33
33
|
import { installTimestampedConsole } from './log.js';
|
|
34
34
|
import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
|
|
35
35
|
import { createMemoryStore } from './memory-store.js';
|
|
36
|
+
import { createPrefsStore } from './prefs-store.js';
|
|
37
|
+
import { createTeamStore } from './team-store.js';
|
|
36
38
|
import { createHistoryStore } from './sqlite-store.js';
|
|
37
39
|
import { ingestBackups } from './backup-ingest.js';
|
|
38
40
|
import * as nerEngine from './ner-engine.js';
|
|
@@ -56,7 +58,7 @@ import * as openai from './openai.js';
|
|
|
56
58
|
import * as responses from './responses.js';
|
|
57
59
|
import * as anthropic from './anthropic.js';
|
|
58
60
|
|
|
59
|
-
export const VERSION = '0.6.
|
|
61
|
+
export const VERSION = '0.6.78';
|
|
60
62
|
|
|
61
63
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
62
64
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -64,6 +66,12 @@ export const VERSION = '0.6.76';
|
|
|
64
66
|
// See docs/architecture-data-tiers.
|
|
65
67
|
const historyStore = await createHistoryStore();
|
|
66
68
|
const memoryStore = await createMemoryStore();
|
|
69
|
+
const prefsStore = createPrefsStore();
|
|
70
|
+
const teamStore = createTeamStore();
|
|
71
|
+
// Who is watching prefs change — a client with a live subscription is told the moment a
|
|
72
|
+
// section is written by the other client, instead of waiting for its next focus.
|
|
73
|
+
const prefsWatchers = new Set();
|
|
74
|
+
const notifyPrefs = (applied, by) => { for (const fn of prefsWatchers) { try { fn({ applied, by, revision: prefsStore.revision }); } catch { /* gone */ } } };
|
|
67
75
|
|
|
68
76
|
// OBSERVABILITY — a ring of "which agent read what, when", persisted across restarts (the
|
|
69
77
|
// gateway updates often; an empty panel after each restart reads as "nothing is set up").
|
|
@@ -128,7 +136,7 @@ function originAllowed(origin, cfg) {
|
|
|
128
136
|
|
|
129
137
|
function setCors(res, origin) {
|
|
130
138
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
131
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
139
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
132
140
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
|
|
133
141
|
res.setHeader('Vary', 'Origin');
|
|
134
142
|
}
|
|
@@ -693,6 +701,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
693
701
|
&& req.method === 'POST' && !isAdminAuthorized(req)) {
|
|
694
702
|
return sendJson(res, 403, { error: { message: 'memory write — extension origin or gateway token required', type: 'forbidden' } });
|
|
695
703
|
}
|
|
704
|
+
// Client preferences travel between the extension and the desktop through here, and an
|
|
705
|
+
// MCP server entry can carry an Authorization header — so READS are gated too, unlike
|
|
706
|
+
// history and memory. A drive-by page must not learn what tools the user connected.
|
|
707
|
+
if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams')) && !isAdminAuthorized(req)) {
|
|
708
|
+
return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
|
|
709
|
+
}
|
|
696
710
|
// The access log is who-read-what — sensitive, and writable only by the local MCP
|
|
697
711
|
// process (which sends the gateway token). Extension Origin or token for both the
|
|
698
712
|
// read (dashboard) and the report (MCP child); a drive-by page has neither.
|
|
@@ -764,6 +778,110 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
764
778
|
return sendJson(res, 400, { error: { message: `ingest failed: ${e.message}`, type: 'ingest_error' } });
|
|
765
779
|
}
|
|
766
780
|
}
|
|
781
|
+
// --- CLIENT PREFERENCES — the settings every client shares (prefs-store.js).
|
|
782
|
+
// GET /v1/prefs[?section=id][&stamps=1] → { ok, revision, sections: { id: { value, updatedAt, by } } }
|
|
783
|
+
// POST /v1/prefs { sections: { id: { value, updatedAt } }, by } → { ok, revision, applied, kept, sections }
|
|
784
|
+
// DELETE /v1/prefs?section=id → { ok, removed }
|
|
785
|
+
if (pathname === '/v1/prefs' && req.method === 'GET') {
|
|
786
|
+
const section = String(url.searchParams.get('section') || '');
|
|
787
|
+
if (url.searchParams.get('stamps')) return sendJson(res, 200, { ok: true, revision: prefsStore.revision, stamps: prefsStore.stamps() });
|
|
788
|
+
return sendJson(res, 200, { ok: true, revision: prefsStore.revision, sections: prefsStore.get(section) });
|
|
789
|
+
}
|
|
790
|
+
if (pathname === '/v1/prefs' && (req.method === 'POST' || req.method === 'PUT')) {
|
|
791
|
+
try {
|
|
792
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
793
|
+
const out = prefsStore.put(body.sections || {}, { by: body.by || '' });
|
|
794
|
+
if (out.applied.length) notifyPrefs(out.applied, body.by || '');
|
|
795
|
+
return sendJson(res, 200, { ok: true, ...out });
|
|
796
|
+
} catch (e) {
|
|
797
|
+
return sendJson(res, 400, { error: { message: `prefs write failed: ${e.message}`, type: 'prefs_error' } });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (pathname === '/v1/prefs' && req.method === 'DELETE') {
|
|
801
|
+
const section = String(url.searchParams.get('section') || '');
|
|
802
|
+
const removed = section ? prefsStore.remove(section) : false;
|
|
803
|
+
if (removed) notifyPrefs([section], '');
|
|
804
|
+
return sendJson(res, 200, { ok: true, removed });
|
|
805
|
+
}
|
|
806
|
+
// Live prefs: one SSE stream a client keeps open, told which sections the OTHER client
|
|
807
|
+
// wrote. Without it a team defined on the desktop reached the extension at its next
|
|
808
|
+
// focus; with it, at once. `{ type: 'hello', revision }` first, so a reader knows where
|
|
809
|
+
// it stands; a heartbeat keeps proxies from closing an idle stream.
|
|
810
|
+
if (pathname === '/v1/prefs/events' && req.method === 'GET') {
|
|
811
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
812
|
+
const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
813
|
+
sendEv({ type: 'hello', revision: prefsStore.revision, stamps: prefsStore.stamps() });
|
|
814
|
+
const fn = (ev) => sendEv({ type: 'changed', ...ev });
|
|
815
|
+
prefsWatchers.add(fn);
|
|
816
|
+
const beat = setInterval(() => { try { res.write(': keep-alive\n\n'); } catch { /* closed */ } }, 25_000);
|
|
817
|
+
res.on('close', () => { prefsWatchers.delete(fn); clearInterval(beat); });
|
|
818
|
+
return undefined;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// --- TEAM RUNS. The board every client can read (team-store.js).
|
|
822
|
+
// GET /v1/teams/runs[?limit&team] → { ok, runs } newest first, no boards
|
|
823
|
+
// POST /v1/teams/runs { id, team, request, client } → { ok, run }
|
|
824
|
+
// GET /v1/teams/runs/:id[?events=1] → { ok, run } the board, the tasks, the proposal
|
|
825
|
+
// POST /v1/teams/runs/:id/events { events: [...] } → { ok, run } the running client appends
|
|
826
|
+
// GET /v1/teams/runs/:id/events[?after=seq] (SSE) replay from `after`, then live
|
|
827
|
+
// POST /v1/teams/runs/:id/stop → { ok, run } a stop request any client may make
|
|
828
|
+
// DELETE /v1/teams/runs/:id → { ok, removed }
|
|
829
|
+
if (pathname === '/v1/teams/runs' && req.method === 'GET') {
|
|
830
|
+
return sendJson(res, 200, { ok: true, runs: teamStore.list({ limit: url.searchParams.get('limit') || 50, team: url.searchParams.get('team') || '' }) });
|
|
831
|
+
}
|
|
832
|
+
if (pathname === '/v1/teams/runs' && req.method === 'POST') {
|
|
833
|
+
try {
|
|
834
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
835
|
+
return sendJson(res, 200, { ok: true, run: teamStore.create({ id: body.id, client: body.client, team: body.team, request: body.request }) });
|
|
836
|
+
} catch (e) {
|
|
837
|
+
return sendJson(res, 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
{
|
|
841
|
+
const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop)?$/.exec(pathname);
|
|
842
|
+
if (m) {
|
|
843
|
+
const id = m[1];
|
|
844
|
+
const sub = m[2] || '';
|
|
845
|
+
if (!sub && req.method === 'GET') {
|
|
846
|
+
const run = teamStore.get(id, { events: url.searchParams.get('events') === '1' });
|
|
847
|
+
return run ? sendJson(res, 200, { ok: true, run }) : sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
|
|
848
|
+
}
|
|
849
|
+
if (!sub && req.method === 'DELETE') return sendJson(res, 200, { ok: true, removed: teamStore.remove(id) });
|
|
850
|
+
if (sub === '/stop' && req.method === 'POST') {
|
|
851
|
+
const run = teamStore.stop(id);
|
|
852
|
+
return run ? sendJson(res, 200, { ok: true, run }) : sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
|
|
853
|
+
}
|
|
854
|
+
if (sub === '/events' && req.method === 'POST') {
|
|
855
|
+
try {
|
|
856
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
857
|
+
return sendJson(res, 200, { ok: true, run: teamStore.append(id, body.events || []) });
|
|
858
|
+
} catch (e) {
|
|
859
|
+
return sendJson(res, e.message.startsWith('no run') ? 404 : 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (sub === '/events' && req.method === 'GET') {
|
|
863
|
+
if (!teamStore.get(id)) return sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
|
|
864
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
865
|
+
const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
866
|
+
// `Number(null)` is 0, not NaN: an absent `after` must mean "from the start" (-1),
|
|
867
|
+
// or the first event of every run is skipped.
|
|
868
|
+
const after = url.searchParams.has('after') ? Number(url.searchParams.get('after')) : NaN;
|
|
869
|
+
// The record as it stands, FIRST — it flushes the headers (writeHead alone sends
|
|
870
|
+
// nothing, and a reader whose fetch has not resolved can miss the opening events),
|
|
871
|
+
// and a late reader gets the board without a second request. Then replay from
|
|
872
|
+
// `after`, then tail; the watcher is registered before the replay is read and
|
|
873
|
+
// duplicates are dropped by seq, so nothing lands in the gap.
|
|
874
|
+
let last = Number.isFinite(after) ? after : -1;
|
|
875
|
+
sendEv({ seq: -1, type: 'hello', at: Date.now(), payload: { run: teamStore.get(id), after: last } });
|
|
876
|
+
const off = teamStore.watch(id, (ev) => { if (ev.seq > last) { last = ev.seq; sendEv(ev); } });
|
|
877
|
+
for (const ev of teamStore.eventsSince(id, last)) { last = ev.seq; sendEv(ev); }
|
|
878
|
+
const beat = setInterval(() => { try { res.write(': keep-alive\n\n'); } catch { /* closed */ } }, 25_000);
|
|
879
|
+
res.on('close', () => { off(); clearInterval(beat); });
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
767
885
|
// --- MEMORY. Small, durable facts about the user, reachable by every local agent.
|
|
768
886
|
// GET /v1/memory/list → { memories }
|
|
769
887
|
// POST /v1/memory/recall { text, scopes } → { memories, block }
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// TEAM RUNS — the board every client can read, held here because the gateway is the one
|
|
2
|
+
// address the extension and the desktop both have.
|
|
3
|
+
//
|
|
4
|
+
// A run happens in ONE client (its models, its tools, its guards), but the extension cannot
|
|
5
|
+
// read the desktop's database and vice versa. So the running client APPENDS the run's
|
|
6
|
+
// events here as they happen — planned, started, a finding, done — and any client can read
|
|
7
|
+
// the run: list it, open its board, follow it live (SSE), and stop it. The store is the
|
|
8
|
+
// truth about what a team did; the client that ran it is just the first reader.
|
|
9
|
+
//
|
|
10
|
+
// Events are the unit, applied to a run record as they arrive (the shape @chatpanel/events
|
|
11
|
+
// team-run.js emits), so a reader that joins late replays and one that is watching tails.
|
|
12
|
+
// A run whose writer went quiet is reported as such: `staleAfterMs` past its last event a
|
|
13
|
+
// `running` run is marked stale, never silently kept "running" forever. Stop is a flag the
|
|
14
|
+
// running client sees on its own SSE stream and honours; the store cannot kill anything.
|
|
15
|
+
//
|
|
16
|
+
// Encrypted at rest with the device key like memory and prefs — a board carries the user's
|
|
17
|
+
// own findings — and bounded: the newest runs are kept, the oldest evicted.
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
25
|
+
const STORE_PATH = process.env.CHATPANEL_TEAMS_STORE || join(DIR, 'team-runs.enc');
|
|
26
|
+
const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
|
|
27
|
+
|
|
28
|
+
export const MAX_RUNS = 200;
|
|
29
|
+
export const MAX_EVENTS_PER_RUN = 2000;
|
|
30
|
+
export const MAX_EVENT_BYTES = 64 * 1024;
|
|
31
|
+
export const STALE_AFTER_MS = 5 * 60_000;
|
|
32
|
+
const RUN_ID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
|
|
33
|
+
const LIVE = new Set(['planning', 'running', 'merging']);
|
|
34
|
+
|
|
35
|
+
function loadOrCreateKey() {
|
|
36
|
+
try { if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64'); } catch { /* regenerate */ }
|
|
37
|
+
const key = randomBytes(32);
|
|
38
|
+
mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
|
|
39
|
+
writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
function encrypt(key, buf) {
|
|
43
|
+
const iv = randomBytes(12);
|
|
44
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
45
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
46
|
+
return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
47
|
+
}
|
|
48
|
+
function decrypt(key, env) {
|
|
49
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
50
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
51
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
|
|
52
|
+
}
|
|
53
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
54
|
+
|
|
55
|
+
/** Apply one event to a run record. The record is the fold of its events. */
|
|
56
|
+
export function applyEvent(run, ev) {
|
|
57
|
+
const type = String(ev?.type || '');
|
|
58
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
|
|
59
|
+
run.lastEventAt = ev.at;
|
|
60
|
+
switch (type) {
|
|
61
|
+
case 'run.started':
|
|
62
|
+
run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
|
|
63
|
+
run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || ev.at;
|
|
64
|
+
break;
|
|
65
|
+
case 'plan.ready':
|
|
66
|
+
run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
|
|
67
|
+
run.tasks = run.plan.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: 'pending', findings: 0 }));
|
|
68
|
+
run.status = 'running';
|
|
69
|
+
break;
|
|
70
|
+
case 'task.started': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = 'running'; t.startedAt = ev.at; } run.status = 'running'; break; }
|
|
71
|
+
case 'task.delta': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.text = String(p.text || '').slice(0, 20_000); break; }
|
|
72
|
+
case 'task.finding':
|
|
73
|
+
if (p.finding && p.finding.text) { run.board.push({ ...p.finding, at: ev.at }); const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.findings += 1; }
|
|
74
|
+
break;
|
|
75
|
+
case 'task.done':
|
|
76
|
+
case 'task.failed': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; } break; }
|
|
77
|
+
case 'run.merging': run.status = 'merging'; break;
|
|
78
|
+
case 'run.done':
|
|
79
|
+
run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = ev.at;
|
|
80
|
+
break;
|
|
81
|
+
case 'run.stop-requested': run.stopRequested = ev.at; break;
|
|
82
|
+
default: break;
|
|
83
|
+
}
|
|
84
|
+
return run;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class TeamStore {
|
|
88
|
+
constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS } = {}) {
|
|
89
|
+
this.path = storePath;
|
|
90
|
+
this.now = now;
|
|
91
|
+
this.staleAfterMs = staleAfterMs;
|
|
92
|
+
this._key = null;
|
|
93
|
+
this.runs = new Map(); // id -> { id, client, createdAt, events: [], ...folded record }
|
|
94
|
+
this.watchers = new Map(); // id -> Set<fn(ev)>
|
|
95
|
+
}
|
|
96
|
+
load() {
|
|
97
|
+
this._key = loadOrCreateKey();
|
|
98
|
+
try {
|
|
99
|
+
if (existsSync(this.path)) {
|
|
100
|
+
const env = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
101
|
+
const doc = JSON.parse(decrypt(this._key, env).toString('utf8'));
|
|
102
|
+
for (const r of Array.isArray(doc?.runs) ? doc.runs : []) if (r?.id) this.runs.set(r.id, r);
|
|
103
|
+
}
|
|
104
|
+
} catch { this.runs = new Map(); }
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
save() {
|
|
108
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
109
|
+
const env = encrypt(this._key, Buffer.from(JSON.stringify({ v: 1, runs: [...this.runs.values()] }), 'utf8'));
|
|
110
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
111
|
+
writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
|
|
112
|
+
renameSync(tmp, this.path);
|
|
113
|
+
}
|
|
114
|
+
_fresh(id, { client = '' } = {}) {
|
|
115
|
+
return { id, client: String(client || '').slice(0, 40), createdAt: this.now(), lastEventAt: this.now(), status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], proposal: null, usage: null, stopRequested: null, events: [] };
|
|
116
|
+
}
|
|
117
|
+
_evict() {
|
|
118
|
+
if (this.runs.size <= MAX_RUNS) return;
|
|
119
|
+
const byAge = [...this.runs.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
120
|
+
for (const r of byAge.slice(0, this.runs.size - MAX_RUNS)) this.runs.delete(r.id);
|
|
121
|
+
}
|
|
122
|
+
/** The record with liveness judged NOW, never as it was last written. */
|
|
123
|
+
_view(run, { events = false } = {}) {
|
|
124
|
+
const v = clone({ ...run, events: undefined });
|
|
125
|
+
delete v.events;
|
|
126
|
+
v.stale = LIVE.has(run.status) && this.now() - run.lastEventAt > this.staleAfterMs;
|
|
127
|
+
if (events) v.events = clone(run.events);
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
130
|
+
create({ id, client, team, request } = {}) {
|
|
131
|
+
if (!RUN_ID_RE.test(String(id || ''))) throw new Error('run id: 4–64 of [a-zA-Z0-9_-]');
|
|
132
|
+
if (this.runs.has(id)) throw new Error(`run ${id} already exists`);
|
|
133
|
+
const run = this._fresh(id, { client });
|
|
134
|
+
run.team = String(team || '').slice(0, 64);
|
|
135
|
+
run.request = String(request || '').slice(0, 4000);
|
|
136
|
+
this.runs.set(id, run);
|
|
137
|
+
this._evict();
|
|
138
|
+
this.save();
|
|
139
|
+
return this._view(run);
|
|
140
|
+
}
|
|
141
|
+
/** Append events (the runner's `emit` shape: `{ type, at, ...payload }`). Returns the view. */
|
|
142
|
+
append(id, events) {
|
|
143
|
+
const run = this.runs.get(String(id || ''));
|
|
144
|
+
if (!run) throw new Error(`no run ${id}`);
|
|
145
|
+
const list = Array.isArray(events) ? events : [events];
|
|
146
|
+
let seq = run.events.length;
|
|
147
|
+
for (const e of list) {
|
|
148
|
+
if (!e || typeof e !== 'object' || !e.type) continue;
|
|
149
|
+
let bytes; try { bytes = Buffer.byteLength(JSON.stringify(e), 'utf8'); } catch { continue; }
|
|
150
|
+
if (bytes > MAX_EVENT_BYTES) continue;
|
|
151
|
+
if (run.events.length >= MAX_EVENTS_PER_RUN) break;
|
|
152
|
+
const { type, at, runId: _r, ...payload } = e;
|
|
153
|
+
const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
|
|
154
|
+
run.events.push(ev);
|
|
155
|
+
applyEvent(run, ev);
|
|
156
|
+
for (const fn of this.watchers.get(run.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
|
|
157
|
+
}
|
|
158
|
+
this.save();
|
|
159
|
+
return this._view(run);
|
|
160
|
+
}
|
|
161
|
+
get(id, opts) { const r = this.runs.get(String(id || '')); return r ? this._view(r, opts) : null; }
|
|
162
|
+
/** Newest first, without boards — the list a lens shows. */
|
|
163
|
+
list({ limit = 50, team = '' } = {}) {
|
|
164
|
+
return [...this.runs.values()]
|
|
165
|
+
.filter((r) => !team || r.team === team)
|
|
166
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
167
|
+
.slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
|
|
168
|
+
.map((r) => { const v = this._view(r); return { ...v, board: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined })), findings: r.board.length }; });
|
|
169
|
+
}
|
|
170
|
+
/** Ask the running client to stop. Recorded as an event, so watchers (the runner) see it. */
|
|
171
|
+
stop(id) {
|
|
172
|
+
const run = this.runs.get(String(id || ''));
|
|
173
|
+
if (!run) return null;
|
|
174
|
+
if (!LIVE.has(run.status)) return this._view(run);
|
|
175
|
+
return this.append(id, [{ type: 'run.stop-requested', at: this.now() }]);
|
|
176
|
+
}
|
|
177
|
+
/** Events from `after` (a seq) onward, for a late reader's replay. */
|
|
178
|
+
eventsSince(id, after = -1) {
|
|
179
|
+
const run = this.runs.get(String(id || ''));
|
|
180
|
+
if (!run) return [];
|
|
181
|
+
return run.events.filter((e) => e.seq > after).map(clone);
|
|
182
|
+
}
|
|
183
|
+
watch(id, fn) {
|
|
184
|
+
if (!this.watchers.has(id)) this.watchers.set(id, new Set());
|
|
185
|
+
this.watchers.get(id).add(fn);
|
|
186
|
+
return () => this.watchers.get(id)?.delete(fn);
|
|
187
|
+
}
|
|
188
|
+
remove(id) {
|
|
189
|
+
const had = this.runs.delete(String(id || ''));
|
|
190
|
+
if (had) this.save();
|
|
191
|
+
return had;
|
|
192
|
+
}
|
|
193
|
+
get size() { return this.runs.size; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createTeamStore(opts) { return new TeamStore(opts).load(); }
|