toolroll 0.9.46 → 0.9.47
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 +15 -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/desktop-update.d.ts +1 -1
- package/dist/flow-card-task.d.ts +2 -0
- package/dist/flow-card-task.js +57 -0
- package/dist/flow-live.d.ts +2 -2
- package/dist/flow-live.js +69 -8
- package/dist/flows-ui.js +15 -1
- package/dist/live.d.ts +14 -0
- package/dist/live.js +71 -22
- package/dist/operate.js +6 -1
- package/dist/planner.js +4 -3
- package/dist/serve.d.ts +5 -0
- package/dist/serve.js +91 -39
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +16 -0
- package/dist/store.js +34 -0
- package/dist/task-activity.d.ts +27 -0
- package/dist/task-activity.js +53 -0
- package/dist/task-live.d.ts +17 -0
- package/dist/task-live.js +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live task page (live tasks): a task open in several browsers stays the
|
|
3
|
+
* same in all of them, and each shows who else has it open. The same design
|
|
4
|
+
* as the live canvas (flow-live.ts):
|
|
5
|
+
*
|
|
6
|
+
* - A change nudge. While anyone has a task open, one indexed read a second,
|
|
7
|
+
* keyed by the task family's root, takes the family's fingerprint (its
|
|
8
|
+
* versions' states, runs, decisions, progress checkpoints, what the agent
|
|
9
|
+
* did last, and whether each live run's worker still answers); when it
|
|
10
|
+
* changes, every open page hears "change" and reads the task again the
|
|
11
|
+
* usual way. The nudge carries no task data.
|
|
12
|
+
* - Who's here. Everyone else with the task open, by name, one line per
|
|
13
|
+
* person however many pages they have. Memory only; it goes when the page
|
|
14
|
+
* closes or hides.
|
|
15
|
+
*
|
|
16
|
+
* Streams are refresh hints only: nothing here writes, and a page that can't
|
|
17
|
+
* keep one open reads itself on its usual beat.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { DEFAULT_LIVENESS_MS } from "./runner.js";
|
|
21
|
+
import { BUILT_IN } from "./store.js";
|
|
22
|
+
const TICK_MS = 1_000;
|
|
23
|
+
const CHECK_EVERY = 15;
|
|
24
|
+
const HEARTBEAT_EVERY = 15;
|
|
25
|
+
const PRESENCE_SETTLE_MS = 150;
|
|
26
|
+
/** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
|
|
27
|
+
export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_MS) {
|
|
28
|
+
const row = store.handle.prepare(`WITH RECURSIVE family(ref, ext, depth) AS (
|
|
29
|
+
SELECT id, external_id, 0 FROM task_ref WHERE backend = ?3 AND external_id = ?1
|
|
30
|
+
UNION
|
|
31
|
+
SELECT child.id, child.external_id, family.depth + 1 FROM family
|
|
32
|
+
JOIN task_ref child ON child.backend = ?3 AND child.revision_of = family.ext WHERE family.depth < 63
|
|
33
|
+
)
|
|
34
|
+
SELECT (SELECT count(*) || ':' || group_concat(t.state || '@' || t.updated_at, ',') FROM family JOIN task t ON t.id = family.ext) AS tasks,
|
|
35
|
+
(SELECT count(*) || ':' || coalesce(max(r.id), 0) || ':' || coalesce(sum(r.outcome IS NULL), 0) || ':' || coalesce(max(coalesce(r.finished_at, r.started_at)), '') || ':' || coalesce(group_concat(r.phase), '')
|
|
36
|
+
FROM run r WHERE r.task_ref IN (SELECT ref FROM family)) AS runs,
|
|
37
|
+
(SELECT count(*) || ':' || coalesce(max(d.id), 0) || ':' || coalesce(sum(d.state = 'answered'), 0) FROM decision d JOIN run r ON r.id = d.run WHERE r.task_ref IN (SELECT ref FROM family)) AS decisions,
|
|
38
|
+
(SELECT coalesce(max(c.id), 0) FROM run_checkpoint c WHERE c.task_ref IN (SELECT ref FROM family)) AS progress,
|
|
39
|
+
(SELECT coalesce(max(a.at), '') FROM run_activity a JOIN run r ON r.id = a.run WHERE r.task_ref IN (SELECT ref FROM family) AND r.outcome IS NULL) AS activity,
|
|
40
|
+
(SELECT coalesce(group_concat(w.heartbeat_at > ?2), '') FROM run r JOIN runner w ON w.name = r.runner WHERE r.task_ref IN (SELECT ref FROM family) AND r.outcome IS NULL) AS workers
|
|
41
|
+
WHERE EXISTS (SELECT 1 FROM family)`).get(root, new Date(now.getTime() - livenessMs).toISOString(), BUILT_IN);
|
|
42
|
+
return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
|
|
43
|
+
}
|
|
44
|
+
export function createTaskRooms(fingerprint, tickMs = TICK_MS) {
|
|
45
|
+
const rooms = new Map();
|
|
46
|
+
const write = (viewer, event, data) => {
|
|
47
|
+
if (viewer.response.writableEnded || viewer.response.destroyed)
|
|
48
|
+
return;
|
|
49
|
+
viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
50
|
+
};
|
|
51
|
+
/** Everyone on the task but you, once each, by name. */
|
|
52
|
+
const others = (room, me) => [...new Set([...room.viewers].map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b));
|
|
53
|
+
const announce = (room) => {
|
|
54
|
+
if (room.settling !== null)
|
|
55
|
+
clearTimeout(room.settling);
|
|
56
|
+
room.settling = setTimeout(() => {
|
|
57
|
+
room.settling = null;
|
|
58
|
+
for (const viewer of room.viewers)
|
|
59
|
+
write(viewer, "here", { people: others(room, viewer.name) });
|
|
60
|
+
}, PRESENCE_SETTLE_MS);
|
|
61
|
+
room.settling.unref();
|
|
62
|
+
};
|
|
63
|
+
const leave = (root, viewer) => {
|
|
64
|
+
const room = rooms.get(root);
|
|
65
|
+
if (room === undefined || !room.viewers.delete(viewer))
|
|
66
|
+
return;
|
|
67
|
+
if (!viewer.response.writableEnded)
|
|
68
|
+
viewer.response.end();
|
|
69
|
+
if (room.viewers.size > 0) {
|
|
70
|
+
announce(room);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (room.timer !== null)
|
|
74
|
+
clearInterval(room.timer);
|
|
75
|
+
if (room.settling !== null)
|
|
76
|
+
clearTimeout(room.settling);
|
|
77
|
+
rooms.delete(root);
|
|
78
|
+
};
|
|
79
|
+
const tick = (root, room) => {
|
|
80
|
+
room.ticks += 1;
|
|
81
|
+
if (room.ticks % CHECK_EVERY === 0) {
|
|
82
|
+
// A signed-out or narrowed account stops hearing about the task.
|
|
83
|
+
for (const viewer of [...room.viewers])
|
|
84
|
+
if (!viewer.valid()) {
|
|
85
|
+
write(viewer, "gone", {});
|
|
86
|
+
leave(root, viewer);
|
|
87
|
+
}
|
|
88
|
+
if (!rooms.has(root))
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let now;
|
|
92
|
+
try {
|
|
93
|
+
now = fingerprint(root);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return; /* a busy database: the next tick reads again */
|
|
97
|
+
}
|
|
98
|
+
if (now === null) {
|
|
99
|
+
for (const viewer of [...room.viewers]) {
|
|
100
|
+
write(viewer, "gone", {});
|
|
101
|
+
leave(root, viewer);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (now !== room.last) {
|
|
106
|
+
room.last = now;
|
|
107
|
+
for (const viewer of room.viewers)
|
|
108
|
+
write(viewer, "change", { at: now });
|
|
109
|
+
}
|
|
110
|
+
else if (room.ticks % HEARTBEAT_EVERY === 0) {
|
|
111
|
+
for (const viewer of room.viewers)
|
|
112
|
+
if (!viewer.response.writableEnded)
|
|
113
|
+
viewer.response.write(": keep-alive\n\n");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
join(root, viewer) {
|
|
118
|
+
let room = rooms.get(root);
|
|
119
|
+
if (room === undefined) {
|
|
120
|
+
let last = null;
|
|
121
|
+
try {
|
|
122
|
+
last = fingerprint(root);
|
|
123
|
+
}
|
|
124
|
+
catch { /* the first tick reads it */ }
|
|
125
|
+
room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
|
|
126
|
+
const created = room;
|
|
127
|
+
room.timer = setInterval(() => tick(root, created), tickMs);
|
|
128
|
+
room.timer.unref();
|
|
129
|
+
rooms.set(root, room);
|
|
130
|
+
}
|
|
131
|
+
room.viewers.add(viewer);
|
|
132
|
+
write(viewer, "change", { at: room.last });
|
|
133
|
+
write(viewer, "here", { people: others(room, viewer.name) });
|
|
134
|
+
announce(room);
|
|
135
|
+
viewer.response.once("close", () => leave(root, viewer));
|
|
136
|
+
},
|
|
137
|
+
close() {
|
|
138
|
+
for (const [root, room] of [...rooms])
|
|
139
|
+
for (const viewer of [...room.viewers])
|
|
140
|
+
leave(root, viewer);
|
|
141
|
+
},
|
|
142
|
+
size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
|
|
143
|
+
};
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.47",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|