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.
- package/CHANGELOG.md +27 -0
- package/dist/activity-line.d.ts +26 -0
- package/dist/activity-line.js +47 -0
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +18 -1
- package/dist/browser-workspace.js +9 -3
- package/dist/builder.js +4 -2
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -0
- package/dist/contracts/cli.d.ts +87 -0
- package/dist/contracts/cli.js +2 -0
- package/dist/contracts/health.d.ts +84 -0
- package/dist/contracts/health.js +11 -0
- package/dist/flow-card-task.d.ts +2 -0
- package/dist/flow-card-task.js +57 -0
- package/dist/flow-live.d.ts +7 -15
- package/dist/flow-live.js +77 -115
- package/dist/flows-ui.js +15 -1
- package/dist/health-cli.d.ts +3 -0
- package/dist/health-cli.js +79 -0
- package/dist/live-bus.d.ts +89 -0
- package/dist/live-bus.js +321 -0
- package/dist/live.d.ts +14 -0
- package/dist/live.js +71 -22
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +53 -1
- package/dist/operate.js +43 -25
- package/dist/planner.js +4 -3
- package/dist/read-executor-worker.d.ts +1 -0
- package/dist/read-executor-worker.js +26 -0
- package/dist/read-executor.d.ts +49 -0
- package/dist/read-executor.js +119 -0
- package/dist/request-budget.d.ts +1 -0
- package/dist/request-budget.js +6 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +118 -42
- package/dist/server-telemetry.d.ts +86 -0
- package/dist/server-telemetry.js +263 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/sqlite-telemetry.d.ts +9 -0
- package/dist/sqlite-telemetry.js +98 -0
- package/dist/store.d.ts +22 -0
- package/dist/store.js +59 -3
- package/dist/surface.js +2 -1
- package/dist/task-activity.d.ts +27 -0
- package/dist/task-activity.js +53 -0
- package/dist/task-live.d.ts +8 -0
- package/dist/task-live.js +47 -0
- package/dist/work-index.js +12 -1
- package/dist/workspace-revision.d.ts +2 -0
- package/dist/workspace-revision.js +3 -1
- 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.
|
|
6
|
-
*
|
|
7
|
-
* watchers, step runs and triggers); when it
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 = (
|
|
98
|
+
const others = (viewers, me) => {
|
|
45
99
|
const people = new Map();
|
|
46
|
-
for (const one of
|
|
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
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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,
|
|
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>;
|