@aron98/load-lock 0.1.2 → 1.0.0
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/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { buildDashboardView } from "./view.js";
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
eventHistory: 60,
|
|
4
|
+
slowRefreshMs: 15_000,
|
|
5
|
+
refreshDebounceMs: 40,
|
|
6
|
+
inspectEveryMs: 60_000,
|
|
7
|
+
reconnectBaseMs: 250,
|
|
8
|
+
};
|
|
9
|
+
export class DashboardFeed {
|
|
10
|
+
client;
|
|
11
|
+
opts;
|
|
12
|
+
events = [];
|
|
13
|
+
latestEventId = 0;
|
|
14
|
+
status = null;
|
|
15
|
+
inspect = null;
|
|
16
|
+
inspectAt = 0;
|
|
17
|
+
lastSyncAt = 0;
|
|
18
|
+
state = "connecting";
|
|
19
|
+
restartNote;
|
|
20
|
+
daemonStartedAt = 0;
|
|
21
|
+
refreshTimer = null;
|
|
22
|
+
slowTimer = null;
|
|
23
|
+
stopped = false;
|
|
24
|
+
unsub = null;
|
|
25
|
+
connecting = null;
|
|
26
|
+
viewSeq = 0;
|
|
27
|
+
/** Fired whenever the rendered view might have changed (state or data). */
|
|
28
|
+
onChange;
|
|
29
|
+
/** Fired on connection-state transitions. */
|
|
30
|
+
onState;
|
|
31
|
+
constructor(client, opts = {}) {
|
|
32
|
+
this.client = client;
|
|
33
|
+
this.opts = { ...DEFAULTS, ...opts };
|
|
34
|
+
}
|
|
35
|
+
currentState() {
|
|
36
|
+
return this.state;
|
|
37
|
+
}
|
|
38
|
+
setState(s) {
|
|
39
|
+
if (this.state === s)
|
|
40
|
+
return;
|
|
41
|
+
this.state = s;
|
|
42
|
+
try {
|
|
43
|
+
this.onState?.(s);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* consumer error */
|
|
47
|
+
}
|
|
48
|
+
this.emitChange();
|
|
49
|
+
}
|
|
50
|
+
emitChange() {
|
|
51
|
+
this.viewSeq++;
|
|
52
|
+
try {
|
|
53
|
+
this.onChange?.();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* consumer error */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Connect, subscribe, and pull an initial full snapshot. */
|
|
60
|
+
async start() {
|
|
61
|
+
this.stopped = false;
|
|
62
|
+
this.unsub =
|
|
63
|
+
typeof this.client.onDisconnect === "function"
|
|
64
|
+
? (this.client.onDisconnect(() => void this.handleDisconnect()) ?? undefined)
|
|
65
|
+
: null;
|
|
66
|
+
await this.connectAndSync();
|
|
67
|
+
this.slowTimer =
|
|
68
|
+
this.opts.slowRefreshMs > 0
|
|
69
|
+
? setInterval(() => {
|
|
70
|
+
if (this.state === "live")
|
|
71
|
+
void this.refresh().catch(() => { });
|
|
72
|
+
}, this.opts.slowRefreshMs)
|
|
73
|
+
: null;
|
|
74
|
+
this.slowTimer?.unref?.();
|
|
75
|
+
}
|
|
76
|
+
async stop() {
|
|
77
|
+
this.stopped = true;
|
|
78
|
+
if (this.refreshTimer)
|
|
79
|
+
clearTimeout(this.refreshTimer);
|
|
80
|
+
if (this.slowTimer)
|
|
81
|
+
clearInterval(this.slowTimer);
|
|
82
|
+
this.refreshTimer = null;
|
|
83
|
+
this.slowTimer = null;
|
|
84
|
+
this.unsub?.();
|
|
85
|
+
this.unsub = null;
|
|
86
|
+
this.client.close();
|
|
87
|
+
}
|
|
88
|
+
async connectAndSync() {
|
|
89
|
+
this.setState("connecting");
|
|
90
|
+
await this.client.connect();
|
|
91
|
+
await this.client.watch("*", (ev) => this.handleEvent(ev));
|
|
92
|
+
await this.resync();
|
|
93
|
+
this.setState("live");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Full snapshot: status + events since cursor (+ inspect if stale).
|
|
97
|
+
* Detects daemon restarts two ways — event ids going backwards, or the
|
|
98
|
+
* daemon's own startedAt changing — then resets the cursor and re-pulls
|
|
99
|
+
* history (ids are durable per daemon lifetime, not across restarts).
|
|
100
|
+
*/
|
|
101
|
+
async resync() {
|
|
102
|
+
if (this.stopped)
|
|
103
|
+
return;
|
|
104
|
+
const [status, evRes] = await Promise.all([
|
|
105
|
+
this.client.request("status", {}),
|
|
106
|
+
this.client.request("events", { sinceEventId: this.latestEventId }),
|
|
107
|
+
]);
|
|
108
|
+
const daemonStartChanged = status.daemon != null && this.daemonStartedAt !== 0 && status.daemon.startedAt !== this.daemonStartedAt;
|
|
109
|
+
this.status = status;
|
|
110
|
+
this.daemonStartedAt = status.daemon?.startedAt ?? 0;
|
|
111
|
+
this.mergeEvents(evRes.events, evRes.latestEventId);
|
|
112
|
+
let restartNote;
|
|
113
|
+
if (daemonStartChanged || evRes.latestEventId < this.latestEventId) {
|
|
114
|
+
// Daemon restarted: ids restarted from scratch. Reset and re-pull.
|
|
115
|
+
restartNote = `daemon restarted (pid ${status.daemon?.pid ?? "?"}); event history re-synced`;
|
|
116
|
+
this.events = [];
|
|
117
|
+
this.latestEventId = 0;
|
|
118
|
+
const fresh = await this.client.request("events", { sinceEventId: 0 });
|
|
119
|
+
this.mergeEvents(fresh.events, fresh.latestEventId);
|
|
120
|
+
}
|
|
121
|
+
this.restartNote = restartNote;
|
|
122
|
+
if (this.inspectAt === 0 || Date.now() - this.inspectAt > this.opts.inspectEveryMs) {
|
|
123
|
+
await this.refreshInspect();
|
|
124
|
+
}
|
|
125
|
+
this.lastSyncAt = Date.now();
|
|
126
|
+
this.emitChange();
|
|
127
|
+
}
|
|
128
|
+
/** Status-only pull (used after event bursts; keeps inspect cached). */
|
|
129
|
+
async refresh() {
|
|
130
|
+
if (this.stopped)
|
|
131
|
+
return;
|
|
132
|
+
try {
|
|
133
|
+
this.status = await this.client.request("status", {});
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
if (e?.code === "unavailable") {
|
|
137
|
+
void this.handleDisconnect();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
if (Date.now() - this.inspectAt > this.opts.inspectEveryMs)
|
|
143
|
+
await this.refreshInspect();
|
|
144
|
+
this.lastSyncAt = Date.now();
|
|
145
|
+
this.emitChange();
|
|
146
|
+
}
|
|
147
|
+
async refreshInspect() {
|
|
148
|
+
try {
|
|
149
|
+
this.inspect = await this.client.request("inspect", {});
|
|
150
|
+
this.inspectAt = Date.now();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* inspect is best-effort; status carries the essential state */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
handleEvent(raw) {
|
|
157
|
+
// The daemon confirms a watch subscription with a `watch_started` push
|
|
158
|
+
// (not a MachineEvent): {event:true, type, latestEventId}.
|
|
159
|
+
if (raw.type === "watch_started") {
|
|
160
|
+
// Confirmation push carrying the daemon's current event-id upper
|
|
161
|
+
// bound. Deliberately NOT adopted as our cursor: the cursor only
|
|
162
|
+
// advances when events are actually observed (pushed or pulled),
|
|
163
|
+
// so the initial `events since 0` fetches full history and a
|
|
164
|
+
// reconnect pulls the whole gap since the last seen id. Adopting
|
|
165
|
+
// the bound here would skip everything between cursor and now.
|
|
166
|
+
// Daemon restarts (bound going backwards) are caught in resync()
|
|
167
|
+
// by comparing the pulled latestEventId against our cursor.
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const ev = raw;
|
|
171
|
+
if (typeof ev.id === "number" && ev.id > this.latestEventId) {
|
|
172
|
+
this.latestEventId = ev.id;
|
|
173
|
+
this.events.push(ev);
|
|
174
|
+
if (this.events.length > this.opts.eventHistory) {
|
|
175
|
+
this.events.splice(0, this.events.length - this.opts.eventHistory);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Coalesce bursts: one status pull per debounce window.
|
|
179
|
+
if (!this.refreshTimer) {
|
|
180
|
+
this.refreshTimer = setTimeout(() => {
|
|
181
|
+
this.refreshTimer = null;
|
|
182
|
+
void this.refresh().catch(() => {
|
|
183
|
+
/* reconnect loop owns recovery */
|
|
184
|
+
});
|
|
185
|
+
}, this.opts.refreshDebounceMs);
|
|
186
|
+
this.refreshTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
this.emitChange();
|
|
189
|
+
}
|
|
190
|
+
mergeEvents(evs, latestEventId) {
|
|
191
|
+
const seen = new Set(this.events.map((e) => e.id));
|
|
192
|
+
for (const e of evs) {
|
|
193
|
+
if (seen.has(e.id))
|
|
194
|
+
continue;
|
|
195
|
+
seen.add(e.id);
|
|
196
|
+
this.events.push(e);
|
|
197
|
+
}
|
|
198
|
+
this.events.sort((a, b) => a.id - b.id);
|
|
199
|
+
if (this.events.length > this.opts.eventHistory) {
|
|
200
|
+
this.events.splice(0, this.events.length - this.opts.eventHistory);
|
|
201
|
+
}
|
|
202
|
+
if (latestEventId > this.latestEventId)
|
|
203
|
+
this.latestEventId = latestEventId;
|
|
204
|
+
}
|
|
205
|
+
handleDisconnect() {
|
|
206
|
+
if (this.stopped || this.state === "reconnecting")
|
|
207
|
+
return;
|
|
208
|
+
this.setState("reconnecting");
|
|
209
|
+
void this.reconnectLoop();
|
|
210
|
+
}
|
|
211
|
+
async reconnectLoop() {
|
|
212
|
+
if (this.connecting)
|
|
213
|
+
return this.connecting;
|
|
214
|
+
this.connecting = (async () => {
|
|
215
|
+
let attempt = 0;
|
|
216
|
+
while (!this.stopped) {
|
|
217
|
+
const wait = Math.min(this.opts.reconnectBaseMs * 2 ** Math.min(attempt, 5), 8_000);
|
|
218
|
+
if (attempt > 0)
|
|
219
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
220
|
+
attempt++;
|
|
221
|
+
try {
|
|
222
|
+
await this.connectAndSync();
|
|
223
|
+
this.connecting = null;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
this.setState("reconnecting");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.connecting = null;
|
|
231
|
+
})();
|
|
232
|
+
return this.connecting;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Admin force-release. Callers MUST gate this behind their own explicit
|
|
236
|
+
* confirm step — the feed itself never initiates destructive actions.
|
|
237
|
+
* Returns the reclaimed request's one-line description for toasts.
|
|
238
|
+
*/
|
|
239
|
+
async forceRelease(requestId) {
|
|
240
|
+
await this.client.request("admin_release", { requestId });
|
|
241
|
+
await this.refresh().catch(() => { });
|
|
242
|
+
}
|
|
243
|
+
/** Render-ready snapshot; countdowns are computed at `now`. */
|
|
244
|
+
view(now = Date.now()) {
|
|
245
|
+
return buildDashboardView({
|
|
246
|
+
status: this.status,
|
|
247
|
+
inspect: this.inspect,
|
|
248
|
+
events: this.events,
|
|
249
|
+
latestEventId: this.latestEventId,
|
|
250
|
+
state: this.state,
|
|
251
|
+
lastSyncAt: this.lastSyncAt,
|
|
252
|
+
daemonRestartNote: this.restartNote,
|
|
253
|
+
now,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text frame renderer for `load-lock top` — and for any other
|
|
3
|
+
* terminal surface that wants the same dashboard without a TUI toolkit.
|
|
4
|
+
* Pure function: view-model in, string[] out (one line per row).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately zero-dependency (ANSI handled by the caller): this keeps
|
|
7
|
+
* the published package lightweight and makes the layout unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
import { type DashboardView } from "./view.js";
|
|
10
|
+
export declare function utilizationBar(frac: number, width?: number): string;
|
|
11
|
+
/** Render one full frame; `width`/`height` bound the output (rows last). */
|
|
12
|
+
export declare function renderFrame(view: DashboardView, width?: number, height?: number): string[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text frame renderer for `load-lock top` — and for any other
|
|
3
|
+
* terminal surface that wants the same dashboard without a TUI toolkit.
|
|
4
|
+
* Pure function: view-model in, string[] out (one line per row).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately zero-dependency (ANSI handled by the caller): this keeps
|
|
7
|
+
* the published package lightweight and makes the layout unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
import { fmtBytes, fmtClock, fmtDuration, } from "./view.js";
|
|
10
|
+
const STATE_LABEL = {
|
|
11
|
+
live: "LIVE",
|
|
12
|
+
connecting: "CONNECTING",
|
|
13
|
+
reconnecting: "RECONNECTING",
|
|
14
|
+
down: "DAEMON DOWN",
|
|
15
|
+
};
|
|
16
|
+
export function utilizationBar(frac, width = 10) {
|
|
17
|
+
const w = Math.max(3, width);
|
|
18
|
+
const filled = Math.round(Math.min(1, Math.max(0, frac)) * w);
|
|
19
|
+
return `${"#".repeat(filled)}${"-".repeat(w - filled)}`;
|
|
20
|
+
}
|
|
21
|
+
function holderLines(pool, out) {
|
|
22
|
+
for (const h of pool.holders) {
|
|
23
|
+
const id = h.requestId.slice(0, 14);
|
|
24
|
+
let meta = h.phaseLabel;
|
|
25
|
+
if (h.phase === "claimed" && h.startDeadlineInMs !== undefined)
|
|
26
|
+
meta += ` · must start in ${fmtDuration(h.startDeadlineInMs)}`;
|
|
27
|
+
if (h.phase === "running_tracked" && h.pid !== undefined)
|
|
28
|
+
meta += ` · pid ${h.pid}` + (h.pgid !== undefined && h.pgid !== h.pid ? ` · pgid ${h.pgid}` : "");
|
|
29
|
+
if (h.phase === "running_untracked" && h.leaseDeadlineInMs !== undefined)
|
|
30
|
+
meta += ` · lease ends in ${fmtDuration(h.leaseDeadlineInMs)}`;
|
|
31
|
+
out(` HELD ${h.agent} (${id}) · ${meta} · age ${fmtDuration(h.heldMs)}`);
|
|
32
|
+
out(` reason: ${h.reason}`);
|
|
33
|
+
}
|
|
34
|
+
for (const o of pool.offers) {
|
|
35
|
+
const id = o.requestId.slice(0, 14);
|
|
36
|
+
const window = o.claimInMs >= 0 ? `claim within ${fmtDuration(o.claimInMs)}` : "claim window elapsed (pause pending)";
|
|
37
|
+
out(` OFFER ${o.agent} (${id}) · ${window} · expires ${fmtClock(o.expiresAt)}`);
|
|
38
|
+
out(` reason: ${o.reason}`);
|
|
39
|
+
}
|
|
40
|
+
for (const q of pool.queue) {
|
|
41
|
+
out(` QUEUE #${q.position} ${q.agent} — ${q.reason} (waiting ${fmtDuration(q.waitMs)})`);
|
|
42
|
+
}
|
|
43
|
+
for (const p of pool.paused) {
|
|
44
|
+
out(` PAUSED ${p.agent} — ${p.reason} (paused ${fmtDuration(p.pausedMs)} · rejoin required)`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Render one full frame; `width`/`height` bound the output (rows last). */
|
|
48
|
+
export function renderFrame(view, width = 100, height = 40) {
|
|
49
|
+
const W = Math.max(48, Math.min(width, 240));
|
|
50
|
+
const lines = [];
|
|
51
|
+
const push = (s) => {
|
|
52
|
+
if (s.length > W)
|
|
53
|
+
lines.push(s.slice(0, W - 1) + "…");
|
|
54
|
+
else
|
|
55
|
+
lines.push(s);
|
|
56
|
+
};
|
|
57
|
+
const state = STATE_LABEL[view.state] ?? String(view.state);
|
|
58
|
+
const syncAge = view.lastSyncAt ? fmtDuration(Math.max(0, view.now - view.lastSyncAt)) : "never";
|
|
59
|
+
push(`load-lock top — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
|
|
60
|
+
if (view.daemon) {
|
|
61
|
+
push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion} · events through #${view.latestEventId}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
push(`daemon: unreachable — it auto-spawns on the next load-lock command`);
|
|
65
|
+
}
|
|
66
|
+
if (view.daemonRestartNote)
|
|
67
|
+
push(`note: ${view.daemonRestartNote}`);
|
|
68
|
+
if (view.machine) {
|
|
69
|
+
const m = view.machine;
|
|
70
|
+
const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
71
|
+
push(`MACHINE ${m.logicalCores} cores (effective ${m.effectiveCores}${cg}) · load ${m.loadavg.map((n) => n.toFixed(2)).join(" ")} = ${m.loadPerEffectiveCore.toFixed(2)}/core · mem ${fmtBytes(m.memTotalBytes)} total, ${fmtBytes(m.memAvailableBytes)} avail (limit ${fmtBytes(m.memEffectiveLimitBytes)})`);
|
|
72
|
+
push(` pressure: cpu ${m.loadPressure}, mem ${m.memPressure} — ${m.note}`);
|
|
73
|
+
}
|
|
74
|
+
push("");
|
|
75
|
+
if (!view.pools.length)
|
|
76
|
+
push(`no pools configured`);
|
|
77
|
+
for (const p of view.pools) {
|
|
78
|
+
push(`POOL ${p.pool} [${utilizationBar(p.utilization)}] ${p.used}/${p.capacity} used (held ${p.heldWeight}, offered ${p.offeredWeight}, free ${p.free}) · queue ${p.queue.length} · paused ${p.paused.length}`);
|
|
79
|
+
holderLines(p, push);
|
|
80
|
+
}
|
|
81
|
+
push("");
|
|
82
|
+
push(`EVENTS (newest first)`);
|
|
83
|
+
if (!view.events.length)
|
|
84
|
+
push(` (no events yet)`);
|
|
85
|
+
for (const e of view.events.slice(0, 12)) {
|
|
86
|
+
push(` #${e.id} ${fmtClock(e.ts)} ${e.text}`);
|
|
87
|
+
}
|
|
88
|
+
push("");
|
|
89
|
+
push(`q quit · r resync · read-only viewer — light work, never acquires a permit`);
|
|
90
|
+
push(`force-release is an admin action gated behind an explicit confirm (not available here; use the OpenCode panel or load-lock recover force-release)`);
|
|
91
|
+
if (lines.length > Math.max(1, height))
|
|
92
|
+
return lines.slice(0, Math.max(1, height));
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard view-model: pure shaping of daemon data (status / inspect /
|
|
3
|
+
* durable events) into the cards every dashboard surface renders.
|
|
4
|
+
*
|
|
5
|
+
* Shared by `load-lock top` (src/cli/top.ts) and the OpenCode TUI plugin
|
|
6
|
+
* (src/adapters/opencode/tui.tsx) so both show exactly the same state and
|
|
7
|
+
* only differ in rendering. Everything here is pure and synchronous —
|
|
8
|
+
* "now" is always an explicit parameter, which makes countdowns testable.
|
|
9
|
+
*/
|
|
10
|
+
import type * as P from "../shared/protocol.js";
|
|
11
|
+
/** Connection state of the dashboard's daemon feed. */
|
|
12
|
+
export type FeedState = "connecting" | "live" | "reconnecting" | "down";
|
|
13
|
+
export interface HolderCard {
|
|
14
|
+
requestId: string;
|
|
15
|
+
pool: string;
|
|
16
|
+
agent: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
weight: number;
|
|
20
|
+
phase: P.PermitPhase;
|
|
21
|
+
/** "claimed (must start)", "running (tracked)", "running (untracked)" */
|
|
22
|
+
phaseLabel: string;
|
|
23
|
+
pid?: number;
|
|
24
|
+
pgid?: number;
|
|
25
|
+
/** How long the permit has been held (now - claimedAt proxy). */
|
|
26
|
+
heldMs: number;
|
|
27
|
+
/** Only for phase "claimed": ms until the start deadline reclaims it. */
|
|
28
|
+
startDeadlineInMs?: number;
|
|
29
|
+
/** Only for phase "running_untracked": ms until the lease lapses. */
|
|
30
|
+
leaseDeadlineInMs?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface OfferCard {
|
|
33
|
+
requestId: string;
|
|
34
|
+
pool: string;
|
|
35
|
+
agent: string;
|
|
36
|
+
agentId: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
weight: number;
|
|
39
|
+
/** ms remaining in the claim window (can be slightly negative pre-event). */
|
|
40
|
+
claimInMs: number;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
}
|
|
43
|
+
export interface QueueCard {
|
|
44
|
+
requestId: string;
|
|
45
|
+
pool: string;
|
|
46
|
+
agent: string;
|
|
47
|
+
agentId: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
weight: number;
|
|
50
|
+
position: number;
|
|
51
|
+
/** How long the request has been queued. */
|
|
52
|
+
waitMs: number;
|
|
53
|
+
}
|
|
54
|
+
export interface PausedCard {
|
|
55
|
+
requestId: string;
|
|
56
|
+
pool: string;
|
|
57
|
+
agent: string;
|
|
58
|
+
agentId: string;
|
|
59
|
+
reason: string;
|
|
60
|
+
weight: number;
|
|
61
|
+
/** How long it has been paused. */
|
|
62
|
+
pausedMs: number;
|
|
63
|
+
}
|
|
64
|
+
export interface PoolView {
|
|
65
|
+
pool: string;
|
|
66
|
+
capacity: number;
|
|
67
|
+
used: number;
|
|
68
|
+
heldWeight: number;
|
|
69
|
+
offeredWeight: number;
|
|
70
|
+
free: number;
|
|
71
|
+
utilization: number;
|
|
72
|
+
holders: HolderCard[];
|
|
73
|
+
offers: OfferCard[];
|
|
74
|
+
queue: QueueCard[];
|
|
75
|
+
paused: PausedCard[];
|
|
76
|
+
}
|
|
77
|
+
export interface EventLine {
|
|
78
|
+
id: number;
|
|
79
|
+
ts: number;
|
|
80
|
+
agoMs: number;
|
|
81
|
+
type: P.EventType;
|
|
82
|
+
pool?: string;
|
|
83
|
+
text: string;
|
|
84
|
+
}
|
|
85
|
+
export interface MachineView {
|
|
86
|
+
cpuModel?: string;
|
|
87
|
+
logicalCores: number;
|
|
88
|
+
effectiveCores: number;
|
|
89
|
+
cgroupVersion?: string;
|
|
90
|
+
cgroupCpuQuota?: number | null;
|
|
91
|
+
loadavg: [number, number, number];
|
|
92
|
+
loadPerEffectiveCore: number;
|
|
93
|
+
memTotalBytes: number;
|
|
94
|
+
memAvailableBytes: number;
|
|
95
|
+
memEffectiveLimitBytes: number;
|
|
96
|
+
loadPressure: "low" | "moderate" | "high";
|
|
97
|
+
memPressure: "low" | "moderate" | "high";
|
|
98
|
+
note: string;
|
|
99
|
+
suggestion?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface DaemonView {
|
|
102
|
+
pid: number;
|
|
103
|
+
startedAt: number;
|
|
104
|
+
uptimeMs: number;
|
|
105
|
+
protocolVersion: number;
|
|
106
|
+
configVersion: number;
|
|
107
|
+
}
|
|
108
|
+
/** The complete render-ready snapshot of the machine-wide budget. */
|
|
109
|
+
export interface DashboardView {
|
|
110
|
+
now: number;
|
|
111
|
+
state: FeedState;
|
|
112
|
+
/** False right after a daemon restart was detected (history was reset). */
|
|
113
|
+
daemonRestartNote?: string;
|
|
114
|
+
daemon: DaemonView | null;
|
|
115
|
+
pools: PoolView[];
|
|
116
|
+
events: EventLine[];
|
|
117
|
+
latestEventId: number;
|
|
118
|
+
machine: MachineView | null;
|
|
119
|
+
lastSyncAt: number;
|
|
120
|
+
}
|
|
121
|
+
/** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
|
|
122
|
+
export declare function fmtDuration(ms: number): string;
|
|
123
|
+
/** Byte counts: "12.4Gi", "512Mi", "3.4Ki". */
|
|
124
|
+
export declare function fmtBytes(bytes: number): string;
|
|
125
|
+
/** Local clock time HH:MM:SS. */
|
|
126
|
+
export declare function fmtClock(ts: number): string;
|
|
127
|
+
/** One-line label for a permit phase. */
|
|
128
|
+
export declare function phaseLabel(p: P.PermitPhase): string;
|
|
129
|
+
/** Truncate with ellipsis, keeping the head (ids, reasons). */
|
|
130
|
+
export declare function trunc(s: string, max: number): string;
|
|
131
|
+
export declare function agentName(e: {
|
|
132
|
+
agentLabel?: string;
|
|
133
|
+
agentId: string;
|
|
134
|
+
}): string;
|
|
135
|
+
/** One-line human sentence for a daemon event (durable ids are kept). */
|
|
136
|
+
export declare function eventText(ev: P.MachineEvent): string;
|
|
137
|
+
export interface DashboardInputs {
|
|
138
|
+
status: P.StatusResult | null;
|
|
139
|
+
inspect: P.InspectResult | null;
|
|
140
|
+
/** Chronological (oldest first). The feed keeps the newest N. */
|
|
141
|
+
events: P.MachineEvent[];
|
|
142
|
+
latestEventId: number;
|
|
143
|
+
state: FeedState;
|
|
144
|
+
lastSyncAt: number;
|
|
145
|
+
daemonRestartNote?: string;
|
|
146
|
+
now: number;
|
|
147
|
+
}
|
|
148
|
+
export declare function buildDashboardView(inps: DashboardInputs): DashboardView;
|