toolroll 0.9.46 → 0.9.48

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/browser/workspace.js +25 -25
  5. package/dist/browser-workspace.d.ts +18 -1
  6. package/dist/browser-workspace.js +9 -3
  7. package/dist/builder.js +4 -2
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +4 -0
  10. package/dist/contracts/cli.d.ts +87 -0
  11. package/dist/contracts/cli.js +2 -0
  12. package/dist/contracts/health.d.ts +84 -0
  13. package/dist/contracts/health.js +11 -0
  14. package/dist/flow-card-task.d.ts +2 -0
  15. package/dist/flow-card-task.js +57 -0
  16. package/dist/flow-live.d.ts +7 -15
  17. package/dist/flow-live.js +77 -115
  18. package/dist/flows-ui.js +15 -1
  19. package/dist/health-cli.d.ts +3 -0
  20. package/dist/health-cli.js +79 -0
  21. package/dist/live-bus.d.ts +89 -0
  22. package/dist/live-bus.js +321 -0
  23. package/dist/live.d.ts +14 -0
  24. package/dist/live.js +71 -22
  25. package/dist/metrics.d.ts +3 -0
  26. package/dist/metrics.js +53 -1
  27. package/dist/operate.js +43 -25
  28. package/dist/planner.js +4 -3
  29. package/dist/read-executor-worker.d.ts +1 -0
  30. package/dist/read-executor-worker.js +26 -0
  31. package/dist/read-executor.d.ts +49 -0
  32. package/dist/read-executor.js +119 -0
  33. package/dist/request-budget.d.ts +1 -0
  34. package/dist/request-budget.js +6 -0
  35. package/dist/serve.d.ts +7 -0
  36. package/dist/serve.js +118 -42
  37. package/dist/server-telemetry.d.ts +86 -0
  38. package/dist/server-telemetry.js +263 -0
  39. package/dist/setup-guide.d.ts +2 -2
  40. package/dist/sqlite-telemetry.d.ts +9 -0
  41. package/dist/sqlite-telemetry.js +98 -0
  42. package/dist/store.d.ts +22 -0
  43. package/dist/store.js +59 -3
  44. package/dist/surface.js +2 -1
  45. package/dist/task-activity.d.ts +27 -0
  46. package/dist/task-activity.js +53 -0
  47. package/dist/task-live.d.ts +8 -0
  48. package/dist/task-live.js +47 -0
  49. package/dist/work-index.js +12 -1
  50. package/dist/workspace-revision.d.ts +2 -0
  51. package/dist/workspace-revision.js +3 -1
  52. package/package.json +1 -1
package/dist/flow-live.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * The live canvas (v88): a flow open in several browsers stays the same in
3
3
  * all of them, and each shows who else has it open.
4
4
  *
5
- * - A change nudge. While anyone has a flow open, one cheap query a second
6
- * reads the flow's fingerprint (its revision, cards, moves, comments,
7
- * watchers, step runs and triggers); when it changes, every open page
5
+ * - A change nudge. When the live bus says the workspace was written
6
+ * (live-bus.ts), one small read checks the flow's fingerprint (its revision, cards, moves, comments,
7
+ * watchers, step runs, linked task progress and triggers); when it changed, every open page
8
8
  * hears "change" and reads the flow again the usual way. The nudge
9
9
  * carries no card data: what a page shows is always what its own read
10
10
  * returned, under its own checks.
@@ -13,37 +13,91 @@
13
13
  * name. It lives in memory only and goes when the page closes or hides.
14
14
  *
15
15
  * Streams are refresh hints only: nothing here writes, and a page that
16
- * can't keep one open falls back to reading every few seconds.
16
+ * can't keep one open falls back to reading every few seconds. With nothing
17
+ * written, an open flow costs no reads; a 30-second check heals a miss.
17
18
  */
18
19
  import { createHash } from "node:crypto";
19
- const TICK_MS = 1_000;
20
- const CHECK_EVERY = 15;
21
- const HEARTBEAT_EVERY = 15;
22
- const PRESENCE_SETTLE_MS = 150;
23
- /** What changes when anything on the canvas changes: one row, one read, kept short and opaque. */
24
- export function flowFingerprint(store, flow) {
25
- const row = store.handle.prepare(`SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
20
+ import { createLiveRooms } from "./live-bus.js";
21
+ // Statements belong to their database, without keeping closed Stores alive.
22
+ const fingerprintStatements = new WeakMap();
23
+ function fingerprintStatement(store) {
24
+ const cached = fingerprintStatements.get(store.handle);
25
+ if (cached !== undefined)
26
+ return cached;
27
+ // Match Store.taskFamilyOf/taskFamilyProjection: built-in tasks, same-repo
28
+ // edges, a revision brief bound to the parent's run, and at most 63 edges.
29
+ // Walk up only from active cards, then down from their proven roots. A
30
+ // missing/invalid/cyclic/over-depth ancestry leaves the linked task alone.
31
+ const statement = store.handle.prepare(`WITH RECURSIVE linked AS MATERIALIZED (
32
+ SELECT DISTINCT tr.id AS ref_id, tr.external_id AS id, tr.repo, tr.revision_of, tr.revision_brief_artifact
33
+ FROM flow_card c JOIN task_ref tr ON tr.external_id = c.task AND tr.backend = 'built-in'
34
+ JOIN task t ON t.id = tr.external_id
35
+ WHERE c.flow = $flow AND c.state = 'active' AND tr.repo IS NOT NULL
36
+ ), ancestors(linked_ref, ref_id, id, repo, revision_of, revision_brief_artifact, depth) AS (
37
+ SELECT ref_id, ref_id, id, repo, revision_of, revision_brief_artifact, 0 FROM linked
38
+ UNION ALL
39
+ SELECT child.linked_ref, parent.id, parent.external_id, parent.repo, parent.revision_of, parent.revision_brief_artifact, child.depth + 1
40
+ FROM ancestors child
41
+ JOIN task_ref parent ON parent.backend = 'built-in' AND parent.external_id = child.revision_of AND parent.repo IS child.repo
42
+ JOIN task t ON t.id = parent.external_id
43
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
44
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.id
45
+ WHERE child.depth < 63
46
+ ), roots AS MATERIALIZED (
47
+ SELECT DISTINCT ref_id, id, repo FROM ancestors WHERE revision_of IS NULL
48
+ ), descendants(ref_id, id, repo, depth) AS (
49
+ SELECT ref_id, id, repo, 0 FROM roots
50
+ UNION ALL
51
+ SELECT child.id, child.external_id, child.repo, parent.depth + 1 FROM descendants parent
52
+ JOIN task_ref child ON child.backend = 'built-in' AND child.revision_of = parent.id AND child.repo IS parent.repo
53
+ JOIN task t ON t.id = child.external_id
54
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
55
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.ref_id
56
+ WHERE parent.depth < 63
57
+ ), members AS (
58
+ SELECT ref_id FROM descendants
59
+ UNION
60
+ SELECT ref_id FROM linked WHERE NOT EXISTS (
61
+ SELECT 1 FROM ancestors WHERE linked_ref = linked.ref_id AND revision_of IS NULL
62
+ )
63
+ ) SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
26
64
  (SELECT count(*) || ':' || coalesce(max(updated_at), '') FROM flow_card WHERE flow = f.id) AS cards,
27
65
  (SELECT coalesce(max(e.id), 0) FROM flow_event e JOIN flow_card c ON c.id = e.card WHERE c.flow = f.id) AS moves,
28
66
  (SELECT coalesce(max(m.id), 0) FROM flow_comment m JOIN flow_card c ON c.id = m.card WHERE c.flow = f.id) AS comments,
29
67
  (SELECT count(*) || ':' || coalesce(max(w.added_at), '') FROM flow_card_watcher w JOIN flow_card c ON c.id = w.card WHERE c.flow = f.id) AS watchers,
30
68
  (SELECT count(*) || ':' || coalesce(sum(r.attempts), 0) || ':' || coalesce(max(coalesce(r.finished_at, r.started_at)), '') || ':' || coalesce(sum(r.state = 'running'), 0)
31
69
  FROM flow_step_run r JOIN flow_card c ON c.id = r.card WHERE c.flow = f.id) AS runs,
32
- (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers
33
- FROM flow f WHERE f.id = ?`).get(flow);
70
+ (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers,
71
+ (SELECT json_group_array(json_array(tr.id, t.id, t.state, t.updated_at, tr.plan,
72
+ s.digest, s.approved_digest, s.approved_at,
73
+ (SELECT max(p.id) FROM run_checkpoint p WHERE p.task_ref = tr.id),
74
+ (SELECT q.lease_id FROM claim q WHERE q.task_ref = tr.id AND q.released_at IS NULL AND q.expires_at > $now ORDER BY q.lease_generation DESC LIMIT 1),
75
+ (SELECT json_group_array(json_array(r.id, r.outcome, r.finished_at, r.reason,
76
+ (SELECT json_array(k.status, k.exit_code, k.updated_at) FROM run_check k WHERE k.run = r.id)) ORDER BY r.id) FROM run r WHERE r.task_ref = tr.id),
77
+ (SELECT json_group_array(json_array(d.id, d.state, d.question, d.answered_at) ORDER BY d.id) FROM decision d JOIN run r ON r.id = d.run WHERE r.task_ref = tr.id),
78
+ (SELECT json_group_array(json_array(h.id, h.reason) ORDER BY h.id) FROM hold h WHERE h.task_ref = tr.id AND (h.until IS NULL OR h.until > $now)),
79
+ (SELECT max(a.id) FROM action_ledger a WHERE a.task_id = t.id)) ORDER BY tr.id)
80
+ FROM task_ref tr JOIN task t ON t.id = tr.external_id
81
+ LEFT JOIN task_scope s ON s.task_id = t.id
82
+ WHERE tr.id IN (SELECT ref_id FROM members)) AS tasks
83
+ FROM flow f WHERE f.id = $flow`);
84
+ fingerprintStatements.set(store.handle, statement);
85
+ return statement;
86
+ }
87
+ /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
88
+ export function flowFingerprint(store, flow, now = new Date()) {
89
+ // A revision can progress while its card still links to the original task,
90
+ // including a build in another project. The page read admits the viewer;
91
+ // this stream only carries a hash. No evidence files or logs are read.
92
+ const row = fingerprintStatement(store).get({ $flow: flow, $now: now.toISOString() });
34
93
  return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
35
94
  }
36
- export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
37
- const rooms = new Map();
38
- const write = (viewer, event, data) => {
39
- if (viewer.response.writableEnded || viewer.response.destroyed)
40
- return;
41
- viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
42
- };
95
+ /** Rooms keyed by flow. With a bus, a write reaches every open page at once; without one, only the safety net. */
96
+ export function createFlowRooms(fingerprint, options = {}) {
43
97
  /** Everyone on the flow but you, one line per person however many pages they have open. */
44
- const others = (room, me) => {
98
+ const others = (viewers, me) => {
45
99
  const people = new Map();
46
- for (const one of room.viewers) {
100
+ for (const one of viewers) {
47
101
  if (one.name === me)
48
102
  continue;
49
103
  const seen = people.get(one.name) ?? { name: one.name, cards: [], editing: false };
@@ -54,97 +108,5 @@ export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
54
108
  }
55
109
  return [...people.values()].sort((a, b) => a.name.localeCompare(b.name));
56
110
  };
57
- const announce = (room) => {
58
- // A page that moves from one card to the next leaves and joins at once: say it once, after it settles.
59
- if (room.settling !== null)
60
- clearTimeout(room.settling);
61
- room.settling = setTimeout(() => {
62
- room.settling = null;
63
- for (const viewer of room.viewers)
64
- write(viewer, "here", { people: others(room, viewer.name) });
65
- }, PRESENCE_SETTLE_MS);
66
- room.settling.unref();
67
- };
68
- const leave = (flow, viewer) => {
69
- const room = rooms.get(flow);
70
- if (room === undefined || !room.viewers.delete(viewer))
71
- return;
72
- if (!viewer.response.writableEnded)
73
- viewer.response.end();
74
- if (room.viewers.size > 0) {
75
- announce(room);
76
- return;
77
- }
78
- if (room.timer !== null)
79
- clearInterval(room.timer);
80
- if (room.settling !== null)
81
- clearTimeout(room.settling);
82
- rooms.delete(flow);
83
- };
84
- const tick = (flow, room) => {
85
- room.ticks += 1;
86
- if (room.ticks % CHECK_EVERY === 0) {
87
- // A signed-out or narrowed account stops hearing about the flow.
88
- for (const viewer of [...room.viewers])
89
- if (!viewer.valid()) {
90
- write(viewer, "gone", {});
91
- leave(flow, viewer);
92
- }
93
- if (!rooms.has(flow))
94
- return;
95
- }
96
- let now;
97
- try {
98
- now = fingerprint(flow);
99
- }
100
- catch {
101
- return; /* a busy database: the next tick reads again */
102
- }
103
- if (now === null) {
104
- for (const viewer of [...room.viewers]) {
105
- write(viewer, "gone", {});
106
- leave(flow, viewer);
107
- }
108
- return;
109
- }
110
- if (now !== room.last) {
111
- room.last = now;
112
- for (const viewer of room.viewers)
113
- write(viewer, "change", { at: now });
114
- }
115
- else if (room.ticks % HEARTBEAT_EVERY === 0) {
116
- for (const viewer of room.viewers)
117
- if (!viewer.response.writableEnded)
118
- viewer.response.write(": keep-alive\n\n");
119
- }
120
- };
121
- return {
122
- join(flow, viewer) {
123
- let room = rooms.get(flow);
124
- if (room === undefined) {
125
- let last = null;
126
- try {
127
- last = fingerprint(flow);
128
- }
129
- catch { /* the first tick reads it */ }
130
- room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
131
- const created = room;
132
- room.timer = setInterval(() => tick(flow, created), tickMs);
133
- room.timer.unref();
134
- rooms.set(flow, room);
135
- }
136
- room.viewers.add(viewer);
137
- write(viewer, "change", { at: room.last });
138
- write(viewer, "here", { people: others(room, viewer.name) });
139
- announce(room);
140
- const gone = () => leave(flow, viewer);
141
- viewer.response.once("close", gone);
142
- },
143
- close() {
144
- for (const [flow, room] of [...rooms])
145
- for (const viewer of [...room.viewers])
146
- leave(flow, viewer);
147
- },
148
- size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
149
- };
111
+ return createLiveRooms(fingerprint, others, options);
150
112
  }
package/dist/flows-ui.js CHANGED
@@ -14,6 +14,8 @@ import { mailboxReady } from "./mailbox.js";
14
14
  import { labelOf, nameOf } from "./teammate-admin.js";
15
15
  import { callWords, receiptWords } from "./teammate-tools.js";
16
16
  import { undoFor } from "./teammate-week.js";
17
+ import { flowCardTaskLine } from "./flow-card-task.js";
18
+ import { readFlowSecrets } from "./flow-secrets.js";
17
19
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
18
20
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
19
21
  export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
@@ -84,9 +86,19 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
84
86
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
85
87
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
86
88
  const mates = new Map(store.teammates([flow.repo]).map(one => [one.id, one]));
89
+ const taskSecrets = new Map();
90
+ const secretsFor = (repo) => {
91
+ if (!taskSecrets.has(repo))
92
+ taskSecrets.set(repo, Object.values(readFlowSecrets(setup.dir, repo)));
93
+ return taskSecrets.get(repo);
94
+ };
95
+ const now = new Date();
87
96
  const cards = store.flowCards(flow.id, true).filter(card => card.state === "active" || Date.now() - Date.parse(card.updatedAt) < 7 * 86_400_000).map((card) => {
88
97
  const stage = stages.find(one => one.id === card.stage);
89
98
  const task = card.task ?? card.primaryTask;
99
+ // Build zones may file into another project; the flow alone does not admit its task's detail.
100
+ const taskRepo = card.task === null ? null : store.lookupRef(card.task)?.repo ?? null;
101
+ const canReadTask = taskRepo !== null && (taskRepo === flow.repo || (setup.repos.includes(taskRepo) && store.accountCanAccess(viewer.name, taskRepo)));
90
102
  const discussion = store.flowComments(card.id);
91
103
  const watchers = store.flowCardWatchers(card.id);
92
104
  const decider = stage?.kind === "approval" ? deciderOf(stage, flow) : null;
@@ -136,7 +148,9 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
136
148
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
137
149
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
138
150
  return {
139
- id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
151
+ id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
152
+ waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
153
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
140
154
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },
141
155
  createdBy: card.createdBy, updatedAt: card.updatedAt,
142
156
  canDecide,
@@ -0,0 +1,3 @@
1
+ import { type TeamCliOptions } from './team-cli.js';
2
+ /** Read the running server, never a fresh CLI process's empty counters or the local database. */
3
+ export declare function runHealthCommand(argv: readonly string[], write: (line: string) => void, options?: TeamCliOptions): Promise<number>;
@@ -0,0 +1,79 @@
1
+ import { bearerOf, centralProfile } from './team-cli.js';
2
+ import { envelopeJson } from './envelope.js';
3
+ import { healthSnapshotSchema } from './contracts/health.js';
4
+ /** Read the running server, never a fresh CLI process's empty counters or the local database. */
5
+ export async function runHealthCommand(argv, write, options = {}) {
6
+ const json = argv.includes('--json');
7
+ const fail = (message, reason = 'unavailable', code = 1) => {
8
+ write(json ? envelopeJson({ command: 'health', ok: false, reason, message }) : message);
9
+ return code;
10
+ };
11
+ if (argv.includes('--help')) {
12
+ write('toolroll health [--profile <name>] [--json]\nRecent latency, event-loop delay, write waits and live streams from the connected server. An instance operator must sign in with toolroll connect first.');
13
+ return 0;
14
+ }
15
+ let profileName;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ if (argv[i] === '--json')
18
+ continue;
19
+ if (argv[i] === '--profile' && profileName === undefined && argv[i + 1] && !argv[i + 1].startsWith('-')) {
20
+ profileName = argv[++i];
21
+ continue;
22
+ }
23
+ return fail('Use toolroll health [--profile <name>] [--json].', 'usage', 2);
24
+ }
25
+ try {
26
+ const profile = centralProfile(options, profileName);
27
+ if (!profile)
28
+ return fail('Connect to the server first with toolroll connect, then run toolroll health.', 'not-connected', 2);
29
+ const timeout = AbortSignal.timeout(30_000);
30
+ const response = await (options.fetch ?? fetch)(`${profile.origin}/health`, {
31
+ redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
32
+ headers: { authorization: bearerOf(profile), accept: json ? 'application/json' : 'text/plain' },
33
+ });
34
+ if (response.status !== 200) {
35
+ await response.body?.cancel();
36
+ return fail(response.status === 401 || response.status === 403 || response.status === 302
37
+ ? 'Sign in as an instance operator to read server health.'
38
+ : response.status === 404 ? 'This server does not provide performance health yet. Update the server.' : 'Server health is unavailable. Try again shortly.');
39
+ }
40
+ // A health response is bounded by the fixed route vocabulary. Refuse unrelated HTML and oversized replies.
41
+ const reader = response.body?.getReader();
42
+ if (!reader)
43
+ return fail('The server returned no health measurements.');
44
+ const chunks = [];
45
+ let size = 0;
46
+ try {
47
+ for (;;) {
48
+ const part = await reader.read();
49
+ if (part.done)
50
+ break;
51
+ size += part.value.byteLength;
52
+ if (size > 128 * 1024) {
53
+ await reader.cancel();
54
+ return fail('The server returned an unexpected health response.');
55
+ }
56
+ chunks.push(part.value);
57
+ }
58
+ }
59
+ finally {
60
+ reader.releaseLock();
61
+ }
62
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
63
+ if (json) {
64
+ const parsed = healthSnapshotSchema.safeParse(JSON.parse(text));
65
+ if (!parsed.success)
66
+ return fail('The server returned an unexpected health response.');
67
+ write(envelopeJson({ command: 'health', ok: true, health: parsed.data }));
68
+ }
69
+ else {
70
+ if (!response.headers.get('content-type')?.startsWith('text/plain') || !text.startsWith('Server health'))
71
+ return fail('The server returned an unexpected health response.');
72
+ write(text.trimEnd());
73
+ }
74
+ return 0;
75
+ }
76
+ catch {
77
+ return fail('Could not read server health. Check the saved connection and server availability.');
78
+ }
79
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Live views push on write. Nothing here polls once a second.
3
+ *
4
+ * - The bus. The database's own revision triggers (workspace-revision.ts)
5
+ * move one counter on every meaningful write. After each in-process
6
+ * transact() COMMIT, and whenever another process (the worker, the CLI)
7
+ * writes the database's write-ahead log, the revision is read once; when
8
+ * it moved, every subscriber hears `{ revision }`. A rolled-back or quiet
9
+ * write moves nothing and says nothing. A page left open with nothing
10
+ * being written costs no database work at all.
11
+ * - The rooms. Each open task or flow is a room keyed by what it shows.
12
+ * When the bus speaks, a room rechecks every viewer's access (a signed-out
13
+ * or narrowed account is dropped), takes its fingerprint once, and only if
14
+ * that moved tells its pages `change` with the fingerprint and revision —
15
+ * an invalidation, never content. The page reads itself again under its
16
+ * own checks, so redaction stays the page's.
17
+ * - The safety net. Every room also checks itself about every 30 s, so a
18
+ * missed signal heals, and sends a keep-alive between.
19
+ * - Back-pressure. Each page's stream is bounded: a page that falls behind
20
+ * gets one `reload` and ends so its EventSource can reconnect.
21
+ *
22
+ * Transport seam: a future WebSocket (live cursors) subscribes to the same
23
+ * LiveBus beside these SSE rooms; nothing in the Store or the triggers changes.
24
+ */
25
+ import { type FSWatcher } from "node:fs";
26
+ import type { ServerResponse } from "node:http";
27
+ import type { Store } from "./store.js";
28
+ export type LiveChange = {
29
+ revision: string;
30
+ };
31
+ export type LiveBus = {
32
+ subscribe: (listener: (change: LiveChange) => void) => () => void;
33
+ publish: (change: LiveChange) => void;
34
+ /** The last revision heard, if any. */
35
+ revision: () => string | null;
36
+ listeners: () => number;
37
+ };
38
+ export declare function createLiveBus(): LiveBus;
39
+ export type WorkspaceFollower = {
40
+ close: () => void; /** Read the revision now (tests, and a commit signal). */
41
+ check: () => void;
42
+ };
43
+ /**
44
+ * Publish the workspace revision when it moves: after this process commits,
45
+ * and when the database's write-ahead log changes under another process.
46
+ * Signals in one turn of the event loop coalesce into one read.
47
+ */
48
+ export declare function followWorkspace(store: Store, current: () => string, bus: LiveBus, options?: {
49
+ file?: string | null;
50
+ watchFile?: (path: string, listener: () => void) => FSWatcher | null;
51
+ }): WorkspaceFollower;
52
+ /** About every 30 s a room checks itself; a keep-alive goes between. */
53
+ export declare const SAFETY_NET_MS = 30000;
54
+ export declare const KEEP_ALIVE_MS = 15000;
55
+ /** What a page's stream may hold unsent before it is "behind". */
56
+ export declare const STREAM_LIMIT_BYTES: number;
57
+ /** One page's stream, bounded. Overflow is terminal, even below Node's drain threshold. */
58
+ export declare class LiveStream {
59
+ readonly response: ServerResponse;
60
+ private readonly limit;
61
+ private readonly onOverflow;
62
+ private ended;
63
+ constructor(response: ServerResponse, limit?: number, onOverflow?: () => void);
64
+ get open(): boolean;
65
+ private overflow;
66
+ private write;
67
+ send(event: string, data: unknown): void;
68
+ keepAlive(): void;
69
+ }
70
+ export type LiveViewer = {
71
+ name: string;
72
+ response: ServerResponse;
73
+ valid: () => boolean;
74
+ };
75
+ export type LiveRooms<K, V extends LiveViewer> = {
76
+ /** Keep this page's stream on the room until it closes; the first events say where it stands and who's here. */
77
+ join: (key: K, viewer: V) => void;
78
+ /** Every stream, ended (the server is closing). */
79
+ close: () => void;
80
+ size: () => number;
81
+ };
82
+ export type LiveRoomOptions = {
83
+ bus?: LiveBus | null;
84
+ safetyNetMs?: number;
85
+ keepAliveMs?: number;
86
+ limitBytes?: number;
87
+ };
88
+ /** Rooms of open pages that hear the bus. `presence` says who else is here, as each page should see it. */
89
+ export declare function createLiveRooms<K, V extends LiveViewer>(fingerprint: (key: K) => string | null, presence: (viewers: readonly V[], me: string) => unknown, options?: LiveRoomOptions): LiveRooms<K, V>;