@stim-cli/core 1.14.0 → 1.16.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 +18 -0
- package/dist/{core-CmZJEFj_.mjs → core-DWOUn9xp.mjs} +179 -5
- package/dist/index-DPo8Bexj.d.mts +2228 -0
- package/dist/index.d.mts +42 -1
- package/dist/index.mjs +2 -2
- package/dist/oversight.d.mts +308 -0
- package/dist/oversight.mjs +666 -0
- package/dist/ownership-claim-Xxxr9MPp.d.mts +152 -0
- package/dist/ownership-claim.d.mts +1 -151
- package/dist/phone-protocol.d.mts +458 -0
- package/dist/phone-protocol.mjs +2 -0
- package/dist/process-identity-BTyww2Xj.d.mts +37 -0
- package/dist/process-identity.d.mts +2 -30
- package/dist/process-identity.mjs +10 -2
- package/dist/protocol-B0KcaUee.d.mts +1465 -0
- package/dist/protocol.d.mts +2 -0
- package/dist/protocol.mjs +379 -0
- package/dist/receive-protocol.d.mts +12 -0
- package/dist/receive-protocol.mjs +19751 -0
- package/dist/state.d.mts +2 -777
- package/dist/state.mjs +4126 -1133
- package/package.json +21 -1
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
//#region oversight.ts
|
|
2
|
+
/** What a notification is about; each can be switched off on its own. */
|
|
3
|
+
const OVERSIGHT_CATEGORIES = [
|
|
4
|
+
"started",
|
|
5
|
+
"stuck",
|
|
6
|
+
"looping",
|
|
7
|
+
"finished",
|
|
8
|
+
"machine",
|
|
9
|
+
"control",
|
|
10
|
+
"attention"
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_STUCK_MINUTES = 15;
|
|
13
|
+
/**
|
|
14
|
+
* What counts as someone working on a device: agent actions, reloads and Stim runs. App log records do not, because an
|
|
15
|
+
* idle app keeps logging; a new log error counts through the error count instead. The owned Chrome page records only
|
|
16
|
+
* an agent's input as agent actions, not its navigations or scripts, so its page log counts while an agent drives it.
|
|
17
|
+
*/
|
|
18
|
+
const WORK_EVIDENCE = [
|
|
19
|
+
"agent-action",
|
|
20
|
+
"metro-bundle",
|
|
21
|
+
"workspace-use"
|
|
22
|
+
];
|
|
23
|
+
const DISK_CRITICAL_BYTES = 5e9;
|
|
24
|
+
/** Free space a low-disk episode needs back before it ends, so space hovering at the floor notifies once. */
|
|
25
|
+
const DISK_RECOVERED_BYTES = 6e9;
|
|
26
|
+
const MEMORY_SETTLE_MS = 6e4;
|
|
27
|
+
const OFFLINE_SETTLE_MS = 6e4;
|
|
28
|
+
/** How long an agent must be gone, with nothing happening, before its green work counts as finished. */
|
|
29
|
+
const FINISH_SETTLE_MS = 3e5;
|
|
30
|
+
const LOOP_COUNT = 3;
|
|
31
|
+
/** A workspace absent this long is forgotten; a shorter gap is a status that briefly left it out. */
|
|
32
|
+
const FORGET_MS = 12e4;
|
|
33
|
+
const platformName = (platform) => platform === "ios" ? "iOS" : platform === "android" ? "Android" : platform;
|
|
34
|
+
const basename = (path) => path.split("/").findLast(Boolean) ?? path;
|
|
35
|
+
const time = (text) => text ? Date.parse(text) : NaN;
|
|
36
|
+
function worktreeRoot(path) {
|
|
37
|
+
const parts = path.split("/");
|
|
38
|
+
for (let i = parts.length - 2; i > 0; i--) {
|
|
39
|
+
if (parts[i] === ".worktrees") return parts.slice(0, i).join("/");
|
|
40
|
+
if (parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i - 1).join("/");
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function markedCheckout(path) {
|
|
45
|
+
const parts = path.split("/");
|
|
46
|
+
for (let i = parts.length - 2; i > 0; i--) if (parts[i] === ".worktrees" || parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i + 2).join("/");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** The name home shows for a workspace: its branch, else its checkout's folder. */
|
|
50
|
+
function oversightTitle(env, status) {
|
|
51
|
+
if (env.worktree?.branch) return env.worktree.branch;
|
|
52
|
+
const checkout = env.worktree?.path ?? markedCheckout(env.path);
|
|
53
|
+
if (checkout) return basename(checkout);
|
|
54
|
+
const roots = /* @__PURE__ */ new Set();
|
|
55
|
+
for (const { path, worktree } of status.environments) roots.add(worktreeRoot(path) ?? worktree?.repository ?? path);
|
|
56
|
+
for (const { path, repository } of status.unprovisionedWorktrees ?? []) {
|
|
57
|
+
const root = worktreeRoot(path) ?? repository;
|
|
58
|
+
if (root) roots.add(root);
|
|
59
|
+
}
|
|
60
|
+
const root = worktreeRoot(env.path) ?? env.worktree?.repository ?? [...roots].filter((r) => env.path === r || env.path.startsWith(`${r}/`)).reduce((best, r) => best === null || r.length < best.length ? r : best, null) ?? env.path;
|
|
61
|
+
return basename(root);
|
|
62
|
+
}
|
|
63
|
+
function devicesOf(env) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const add = (slot, ios, android) => {
|
|
66
|
+
if (ios) {
|
|
67
|
+
const model = /\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*$/.exec(ios.name ?? "")?.[1];
|
|
68
|
+
out.push({
|
|
69
|
+
platform: "ios",
|
|
70
|
+
slot,
|
|
71
|
+
model: model ?? "iOS Simulator",
|
|
72
|
+
...model === void 0 ? { modelLabel: "ios-simulator" } : {},
|
|
73
|
+
running: ios.state === "Booted",
|
|
74
|
+
activity: ios.activity
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (android) out.push({
|
|
78
|
+
platform: "android",
|
|
79
|
+
slot,
|
|
80
|
+
model: android.physical ? "Android device" : "Android Emulator",
|
|
81
|
+
modelLabel: android.physical ? "android-device" : "android-emulator",
|
|
82
|
+
running: android.state === "detected",
|
|
83
|
+
activity: android.activity
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
add("default", env.ios, env.android);
|
|
87
|
+
for (const slot of env.slots ?? []) add(slot.slot, slot.ios, slot.android);
|
|
88
|
+
if (env.web) out.push({
|
|
89
|
+
platform: "web",
|
|
90
|
+
slot: "default",
|
|
91
|
+
model: "Chrome",
|
|
92
|
+
modelLabel: "chrome",
|
|
93
|
+
running: env.web.running,
|
|
94
|
+
activity: env.web.activity
|
|
95
|
+
});
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function agentDriven(device, ownLeases) {
|
|
99
|
+
const activity = device.activity;
|
|
100
|
+
if (activity?.state !== "driven") return false;
|
|
101
|
+
const since = activity.driver?.since;
|
|
102
|
+
return !(activity.driver?.tool === "stim device lock" && since && ownLeases.includes(since));
|
|
103
|
+
}
|
|
104
|
+
function newestBuild(env) {
|
|
105
|
+
return [env.lastBuilds?.ios, env.lastBuilds?.android].filter((b) => b !== void 0).reduce((a, b) => a === null || time(b.startedAt) > time(a.startedAt) ? b : a, null);
|
|
106
|
+
}
|
|
107
|
+
function lastActivityAt(env, devices, seen) {
|
|
108
|
+
const times = seen.map((at) => at ?? NaN);
|
|
109
|
+
for (const device of devices) {
|
|
110
|
+
const recent = device.activity?.recent;
|
|
111
|
+
if (!recent) times.push(time(device.activity?.lastActivityAt));
|
|
112
|
+
else {
|
|
113
|
+
times.push(...WORK_EVIDENCE.map((basis) => time(recent[basis])));
|
|
114
|
+
if (device.platform === "web" && device.activity?.state === "driven") times.push(time(recent["page-log"]));
|
|
115
|
+
}
|
|
116
|
+
times.push(time(device.activity?.driver?.since));
|
|
117
|
+
}
|
|
118
|
+
for (const build of [env.lastBuilds?.ios, env.lastBuilds?.android]) times.push(time(build?.startedAt), time(build?.finishedAt));
|
|
119
|
+
times.push(time(env.build?.startedAt));
|
|
120
|
+
const finite = times.filter(Number.isFinite);
|
|
121
|
+
return finite.length ? Math.max(...finite) : null;
|
|
122
|
+
}
|
|
123
|
+
const LANGUAGES = {
|
|
124
|
+
swift: "Swift",
|
|
125
|
+
m: "Objective-C",
|
|
126
|
+
mm: "Objective-C++",
|
|
127
|
+
kt: "Kotlin",
|
|
128
|
+
java: "Java",
|
|
129
|
+
c: "C",
|
|
130
|
+
cc: "C++",
|
|
131
|
+
cpp: "C++",
|
|
132
|
+
h: "C",
|
|
133
|
+
hpp: "C++",
|
|
134
|
+
js: "JavaScript",
|
|
135
|
+
ts: "TypeScript",
|
|
136
|
+
tsx: "TypeScript",
|
|
137
|
+
gradle: "Gradle",
|
|
138
|
+
kts: "Gradle"
|
|
139
|
+
};
|
|
140
|
+
const failed = (build) => (build.result ?? build.status) === "failed";
|
|
141
|
+
function causeOf(build) {
|
|
142
|
+
const at = build.diagnostics?.find((d) => d.file && d.line !== null);
|
|
143
|
+
if (at) return {
|
|
144
|
+
key: `${at.file}:${at.line}`,
|
|
145
|
+
at
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
key: build.errorCode ?? "failed",
|
|
149
|
+
at: null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** The streak of failures at the head of a platform's history that share the newest one's cause. */
|
|
153
|
+
function failureStreak(platform, history) {
|
|
154
|
+
const head = history?.[0];
|
|
155
|
+
if (!head || !failed(head)) return null;
|
|
156
|
+
const cause = causeOf(head);
|
|
157
|
+
let count = 0;
|
|
158
|
+
for (const build of history) {
|
|
159
|
+
if (!failed(build) || causeOf(build).key !== cause.key) break;
|
|
160
|
+
count++;
|
|
161
|
+
}
|
|
162
|
+
const body = (n, format = attentionBody) => {
|
|
163
|
+
if (cause.at) {
|
|
164
|
+
const file = basename(cause.at.file);
|
|
165
|
+
const language = LANGUAGES[file.split(".").at(-1)?.toLowerCase() ?? ""];
|
|
166
|
+
return format({
|
|
167
|
+
kind: "diagnostic-loop",
|
|
168
|
+
platform,
|
|
169
|
+
count: n,
|
|
170
|
+
file,
|
|
171
|
+
line: cause.at.line,
|
|
172
|
+
language
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (head.errorCode === "STIM_LAUNCH_FAILED") return format({
|
|
176
|
+
kind: "launch-loop",
|
|
177
|
+
platform,
|
|
178
|
+
count: n
|
|
179
|
+
});
|
|
180
|
+
return format({
|
|
181
|
+
kind: "build-loop",
|
|
182
|
+
platform,
|
|
183
|
+
count: n,
|
|
184
|
+
errorCode: head.errorCode
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
signature: cause.key,
|
|
189
|
+
count,
|
|
190
|
+
body
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function heldFor(held, now) {
|
|
194
|
+
return held ?? {
|
|
195
|
+
since: now,
|
|
196
|
+
notified: false
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function wake(run, at) {
|
|
200
|
+
if (at > run.now) run.wakeAt = run.wakeAt === null ? at : Math.min(run.wakeAt, at);
|
|
201
|
+
}
|
|
202
|
+
/** Notifies an event now; one missed for quiet hours or a switched-off category is dropped. */
|
|
203
|
+
function event(run, notification) {
|
|
204
|
+
if (!run.baseline && !run.prefs.quiet && run.prefs.categories.includes(notification.category)) run.notifications.push(notification);
|
|
205
|
+
}
|
|
206
|
+
/** Whether a lasting problem is settled: notified now, or dropped when its category is off. */
|
|
207
|
+
function lasting(run, notification) {
|
|
208
|
+
if (run.baseline || !run.prefs.categories.includes(notification.category)) return true;
|
|
209
|
+
if (run.prefs.quiet) return false;
|
|
210
|
+
run.notifications.push(notification);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
function machineNotification(run, id, body) {
|
|
214
|
+
return {
|
|
215
|
+
id: `machine:${id}`,
|
|
216
|
+
category: "machine",
|
|
217
|
+
title: run.input.machine,
|
|
218
|
+
body,
|
|
219
|
+
quiet: true,
|
|
220
|
+
thread: null,
|
|
221
|
+
target: { kind: "machine" }
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const LINK_BODY = {
|
|
225
|
+
offline: "Offline",
|
|
226
|
+
unpaired: "Not paired: pair again",
|
|
227
|
+
refused: "Refused the connection: pair again or update"
|
|
228
|
+
};
|
|
229
|
+
function overseeMachine(run, previous, state, awakeSince) {
|
|
230
|
+
const { input, now } = run;
|
|
231
|
+
const kind = input.link;
|
|
232
|
+
if (kind !== null && kind !== "open") {
|
|
233
|
+
const held = {
|
|
234
|
+
...heldFor(previous?.link?.kind === kind ? previous.link : void 0, now),
|
|
235
|
+
kind
|
|
236
|
+
};
|
|
237
|
+
const due = Math.max(held.since, awakeSince) + (kind === "offline" ? OFFLINE_SETTLE_MS : 0);
|
|
238
|
+
if (!held.notified && now < due) wake(run, due);
|
|
239
|
+
else if (!held.notified) held.notified = lasting(run, machineNotification(run, "link", LINK_BODY[kind]));
|
|
240
|
+
state.link = held;
|
|
241
|
+
}
|
|
242
|
+
const lowest = input.volumes?.reduce((min, v) => min === null ? v.freeBytes : Math.min(min, v.freeBytes), null);
|
|
243
|
+
if (input.volumes === null) {
|
|
244
|
+
if (previous?.disk) state.disk = previous.disk;
|
|
245
|
+
} else if (lowest !== null && lowest !== void 0 && lowest < (previous?.disk ? DISK_RECOVERED_BYTES : DISK_CRITICAL_BYTES)) {
|
|
246
|
+
const held = { ...heldFor(previous?.disk, now) };
|
|
247
|
+
const body = `${formatBytes(lowest)} free, below Stim's floor`;
|
|
248
|
+
if (!held.notified) held.notified = lasting(run, machineNotification(run, "disk", body));
|
|
249
|
+
state.disk = held;
|
|
250
|
+
}
|
|
251
|
+
state.memoryKnown = input.memoryPressure !== null || previous?.memoryKnown === true;
|
|
252
|
+
if (input.memoryPressure === "critical") {
|
|
253
|
+
const held = { ...heldFor(previous?.memory, now) };
|
|
254
|
+
if (!previous?.memoryKnown) held.notified = true;
|
|
255
|
+
const due = held.since + MEMORY_SETTLE_MS;
|
|
256
|
+
if (!held.notified && now < due && !run.baseline) wake(run, due);
|
|
257
|
+
else if (!held.notified) held.notified = lasting(run, machineNotification(run, "memory", "Memory pressure is critical"));
|
|
258
|
+
state.memory = held;
|
|
259
|
+
} else if (input.memoryPressure === null && previous?.memory) state.memory = previous.memory;
|
|
260
|
+
}
|
|
261
|
+
function deviceTarget(env, device) {
|
|
262
|
+
return {
|
|
263
|
+
kind: "device",
|
|
264
|
+
path: env.path,
|
|
265
|
+
platform: device.platform,
|
|
266
|
+
slot: device.slot
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function overseeStart(run, { env, entry, notify, driven }) {
|
|
270
|
+
if (env.phase === "warming" && !entry.warmed) {
|
|
271
|
+
entry.warmed = true;
|
|
272
|
+
event(run, notify("started", `Warming on ${run.input.machine}`, {
|
|
273
|
+
kind: "workspace",
|
|
274
|
+
path: env.path
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
const first = driven[0];
|
|
278
|
+
if (!first) return;
|
|
279
|
+
entry.drove = true;
|
|
280
|
+
entry.drivenAt = run.now;
|
|
281
|
+
entry.finished = false;
|
|
282
|
+
if (entry.driveNotified) return;
|
|
283
|
+
entry.driveNotified = true;
|
|
284
|
+
event(run, notify("started", `${first.activity?.driver?.tool ?? "An agent"} started driving ${first.model} on ${run.input.machine}`, deviceTarget(env, first)));
|
|
285
|
+
}
|
|
286
|
+
/** Work finishes when the agent stops after a green build; it looks stuck when nothing happens for too long. */
|
|
287
|
+
function overseeProgress(run, { env, entry, notify, devices, driven }) {
|
|
288
|
+
const { now } = run;
|
|
289
|
+
const idle = !env.live && (env.phase === void 0 || env.phase === "idle");
|
|
290
|
+
const building = env.build?.state === "running";
|
|
291
|
+
const newest = newestBuild(env);
|
|
292
|
+
const green = newest?.status === "ok";
|
|
293
|
+
const releasedGreen = driven.length === 0 && green;
|
|
294
|
+
const quietSince = lastActivityAt(env, devices, [entry.errorsAt, driven.length ? null : entry.drivenAt]);
|
|
295
|
+
if (entry.stuckAt !== null && quietSince !== null && quietSince > entry.stuckAt) entry.stuckAt = null;
|
|
296
|
+
if (entry.drove && releasedGreen && newest && !building && quietSince !== null && !entry.finished) {
|
|
297
|
+
const due = quietSince + FINISH_SETTLE_MS;
|
|
298
|
+
if (!idle && now < due) wake(run, due);
|
|
299
|
+
else {
|
|
300
|
+
entry.finished = true;
|
|
301
|
+
event(run, notify("finished", `Agent stopped after a green ${platformName(newest.platform)} build`, {
|
|
302
|
+
kind: "workspace",
|
|
303
|
+
path: env.path
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (idle || entry.finished) {
|
|
308
|
+
entry.drove = false;
|
|
309
|
+
entry.driveNotified = false;
|
|
310
|
+
}
|
|
311
|
+
if (idle) {
|
|
312
|
+
entry.warmed = false;
|
|
313
|
+
entry.finished = false;
|
|
314
|
+
}
|
|
315
|
+
const device = driven[0] ?? devices.find((d) => d.running);
|
|
316
|
+
if (!entry.drove || !device?.running || building || quietSince === null || releasedGreen) return;
|
|
317
|
+
if (entry.stuckAt !== null) return;
|
|
318
|
+
const due = quietSince + run.prefs.stuckMinutes * 6e4;
|
|
319
|
+
if (now < due) return wake(run, due);
|
|
320
|
+
if (lasting(run, notify("stuck", `No agent activity for ${Math.floor((now - quietSince) / 6e4)} min${green && newest ? ` after a green ${platformName(newest.platform)} build` : ""}; ${device.model} still up`, deviceTarget(env, device)))) entry.stuckAt = quietSince;
|
|
321
|
+
}
|
|
322
|
+
function overseeLoops(run, { env, entry, notify }, prev) {
|
|
323
|
+
for (const platform of ["ios", "android"]) {
|
|
324
|
+
const streak = failureStreak(platform, env.builds?.[platform]);
|
|
325
|
+
if (!streak) continue;
|
|
326
|
+
const before = prev?.loops[platform];
|
|
327
|
+
const loop = before && before.signature === streak.signature && streak.count >= LOOP_COUNT ? { ...before } : {
|
|
328
|
+
signature: streak.signature,
|
|
329
|
+
notified: false
|
|
330
|
+
};
|
|
331
|
+
if (streak.count >= LOOP_COUNT && !loop.notified) {
|
|
332
|
+
const target = {
|
|
333
|
+
kind: "build",
|
|
334
|
+
path: env.path,
|
|
335
|
+
platform
|
|
336
|
+
};
|
|
337
|
+
loop.notified = lasting(run, notify("looping", streak.body(streak.count), target, `looping-${platform}`));
|
|
338
|
+
}
|
|
339
|
+
entry.loops[platform] = loop;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A pull request that became ready for review or merged, or, without GitHub, a branch git finds merged. */
|
|
343
|
+
function overseeMerge(run, { env, entry, notify }, prev) {
|
|
344
|
+
const pr = run.input.pullRequests[env.path];
|
|
345
|
+
if (pr !== void 0) {
|
|
346
|
+
const known = entry.pr;
|
|
347
|
+
const current = pr && {
|
|
348
|
+
number: pr.number,
|
|
349
|
+
ready: pr.state === "open" && !pr.draft,
|
|
350
|
+
merged: pr.state === "merged"
|
|
351
|
+
};
|
|
352
|
+
if (pr && current && known !== void 0) {
|
|
353
|
+
const url = {
|
|
354
|
+
kind: "url",
|
|
355
|
+
path: env.path,
|
|
356
|
+
url: pr.url
|
|
357
|
+
};
|
|
358
|
+
const same = known?.number === pr.number;
|
|
359
|
+
if (current.ready && !(same && known.ready)) event(run, notify("finished", `PR #${pr.number} is ready for review`, url));
|
|
360
|
+
if (current.merged && !(same && known.merged) && !entry.mergeNotified) {
|
|
361
|
+
entry.mergeNotified = true;
|
|
362
|
+
event(run, notify("finished", `PR #${pr.number} merged`, url));
|
|
363
|
+
}
|
|
364
|
+
} else if (current?.merged) entry.mergeNotified = true;
|
|
365
|
+
entry.pr = current;
|
|
366
|
+
}
|
|
367
|
+
if (!prev) {
|
|
368
|
+
if (entry.mergedInto !== null) entry.mergeNotified = true;
|
|
369
|
+
} else if (entry.mergedInto === null) {
|
|
370
|
+
if (env.worktree?.git && !entry.pr?.merged) entry.mergeNotified = false;
|
|
371
|
+
} else if (prev.mergedInto === null && !entry.mergeNotified) {
|
|
372
|
+
entry.mergeNotified = true;
|
|
373
|
+
event(run, notify("finished", `Merged into ${entry.mergedInto}`, {
|
|
374
|
+
kind: "workspace",
|
|
375
|
+
path: env.path
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** What only a person can fix, notified once per episode; what an agent handles is left out. */
|
|
380
|
+
function overseeAttention(run, { env, entry, notify }, prev) {
|
|
381
|
+
const items = workspaceItems(env, {
|
|
382
|
+
now: run.now,
|
|
383
|
+
stuckMinutes: run.prefs.stuckMinutes,
|
|
384
|
+
easSessionMinutes: EAS_SESSION_MINUTES,
|
|
385
|
+
ownLeases: run.input.ownLeases
|
|
386
|
+
});
|
|
387
|
+
for (const item of items) {
|
|
388
|
+
if (item.category !== "attention") continue;
|
|
389
|
+
let target = {
|
|
390
|
+
kind: "workspace",
|
|
391
|
+
path: env.path
|
|
392
|
+
};
|
|
393
|
+
if (item.id.startsWith("run-")) {
|
|
394
|
+
const platform = item.id.startsWith("run-ios") ? "ios" : "android";
|
|
395
|
+
if ((failureStreak(platform, env.builds?.[platform])?.count ?? 0) >= LOOP_COUNT) continue;
|
|
396
|
+
target = {
|
|
397
|
+
kind: "build",
|
|
398
|
+
path: env.path,
|
|
399
|
+
platform
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const notification = {
|
|
403
|
+
...notify("attention", item.body, target),
|
|
404
|
+
id: item.id
|
|
405
|
+
};
|
|
406
|
+
if (prev?.attention?.includes(item.id) || lasting(run, notification)) entry.attention.push(item.id);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function overseeWorkspace(run, env, status, prev) {
|
|
410
|
+
const errors = env.logs?.errorsSinceMarker ?? 0;
|
|
411
|
+
const git = env.worktree?.git;
|
|
412
|
+
const entry = {
|
|
413
|
+
seenAt: run.now,
|
|
414
|
+
warmed: prev?.warmed ?? false,
|
|
415
|
+
drove: prev?.drove ?? false,
|
|
416
|
+
drivenAt: prev?.drivenAt ?? null,
|
|
417
|
+
driveNotified: prev?.driveNotified ?? false,
|
|
418
|
+
errors,
|
|
419
|
+
errorsAt: prev && prev.errors !== errors ? run.now : prev?.errorsAt ?? null,
|
|
420
|
+
stuckAt: prev?.stuckAt ?? null,
|
|
421
|
+
finished: prev?.finished ?? false,
|
|
422
|
+
attention: [],
|
|
423
|
+
loops: {},
|
|
424
|
+
pr: prev?.pr,
|
|
425
|
+
mergedInto: git ? git.mergedInto : prev?.mergedInto ?? null,
|
|
426
|
+
mergeNotified: prev?.mergeNotified ?? false
|
|
427
|
+
};
|
|
428
|
+
const title = oversightTitle(env, status);
|
|
429
|
+
const notify = (category, body, target, id = category) => ({
|
|
430
|
+
id: `${id}:${env.path}`,
|
|
431
|
+
category,
|
|
432
|
+
title,
|
|
433
|
+
body,
|
|
434
|
+
quiet: true,
|
|
435
|
+
thread: category === "started" ? `started:${run.input.machine}` : null,
|
|
436
|
+
target
|
|
437
|
+
});
|
|
438
|
+
const devices = devicesOf(env);
|
|
439
|
+
const look = {
|
|
440
|
+
env,
|
|
441
|
+
entry,
|
|
442
|
+
notify,
|
|
443
|
+
devices,
|
|
444
|
+
driven: devices.filter((device) => agentDriven(device, run.input.ownLeases))
|
|
445
|
+
};
|
|
446
|
+
overseeStart(run, look);
|
|
447
|
+
overseeProgress(run, look);
|
|
448
|
+
overseeLoops(run, look, prev);
|
|
449
|
+
overseeMerge(run, look, prev);
|
|
450
|
+
overseeAttention(run, look, prev);
|
|
451
|
+
return entry;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The notifications a machine owes since `previous`, the state the last call returned. Null `previous` records
|
|
455
|
+
* what is already true without notifying, so a restart or a new registration stays quiet. Each workspace and
|
|
456
|
+
* machine problem notifies once per episode, under one id per category, so a later episode replaces it.
|
|
457
|
+
* `awakeSince` restarts the offline settle time, for a checker that was not running.
|
|
458
|
+
*/
|
|
459
|
+
function oversee(previous, input, prefs, now, awakeSince = 0) {
|
|
460
|
+
const run = {
|
|
461
|
+
input,
|
|
462
|
+
prefs,
|
|
463
|
+
now,
|
|
464
|
+
baseline: previous === null,
|
|
465
|
+
notifications: [],
|
|
466
|
+
wakeAt: null
|
|
467
|
+
};
|
|
468
|
+
const state = { workspaces: {} };
|
|
469
|
+
overseeMachine(run, previous, state, awakeSince);
|
|
470
|
+
const status = input.status;
|
|
471
|
+
if (status === null) state.workspaces = { ...previous?.workspaces };
|
|
472
|
+
else {
|
|
473
|
+
for (const env of status.environments) state.workspaces[env.path] = overseeWorkspace(run, env, status, previous?.workspaces[env.path]);
|
|
474
|
+
for (const [path, entry] of Object.entries(previous?.workspaces ?? {})) if (!(path in state.workspaces) && now - entry.seenAt < FORGET_MS) state.workspaces[path] = entry;
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
state,
|
|
478
|
+
notifications: run.notifications,
|
|
479
|
+
wakeAt: run.wakeAt
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function formatBytes(bytes) {
|
|
483
|
+
if (bytes >= 0xe8d4a51000) return `${(bytes / 0xe8d4a51000).toFixed(1)} TB`;
|
|
484
|
+
const gb = bytes / 1e9;
|
|
485
|
+
return gb >= 100 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`;
|
|
486
|
+
}
|
|
487
|
+
/** Whether `minuteOfDay` falls in quiet hours from `start` to `end`, minutes after midnight; they may span midnight. */
|
|
488
|
+
function inQuietHours(quietHours, minuteOfDay) {
|
|
489
|
+
if (!quietHours || quietHours.start === quietHours.end) return false;
|
|
490
|
+
const { start, end } = quietHours;
|
|
491
|
+
return start < end ? minuteOfDay >= start && minuteOfDay < end : minuteOfDay >= start || minuteOfDay < end;
|
|
492
|
+
}
|
|
493
|
+
/** How long a billable EAS session may run with no agent before it needs a person. */
|
|
494
|
+
const EAS_SESSION_MINUTES = 30;
|
|
495
|
+
function attentionBody(message) {
|
|
496
|
+
switch (message.kind) {
|
|
497
|
+
case "issue": return message.slot ? `${message.slot}: ${message.message}` : message.message;
|
|
498
|
+
case "signing": return `${platformName(message.platform)} signing or provisioning failed (${message.code})`;
|
|
499
|
+
case "diagnostic-loop": return `Same ${message.language ? `${message.language} ` : `${platformName(message.platform)} build `}error ${message.count}x at ${message.file}:${message.line}`;
|
|
500
|
+
case "launch-loop": return `App failed to launch on ${platformName(message.platform)} ${message.count}x in a row`;
|
|
501
|
+
case "build-loop": {
|
|
502
|
+
const code = message.errorCode ? ` (${message.errorCode})` : "";
|
|
503
|
+
return `${platformName(message.platform)} build failed ${message.count}x in a row${code}`;
|
|
504
|
+
}
|
|
505
|
+
case "lease": return `Lease on ${message.leased} expired`;
|
|
506
|
+
case "eas": return `EAS session running for ${message.minutes} min with no agent; billed while it runs`;
|
|
507
|
+
case "stuck": {
|
|
508
|
+
const after = message.green ? ` after a green ${platformName(message.green)} build` : "";
|
|
509
|
+
return `No agent activity for ${message.minutes} min${after}; ${message.model} still up`;
|
|
510
|
+
}
|
|
511
|
+
case "disk": return `${formatBytes(message.freeBytes)} free, below Stim's floor`;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const PERSON_ISSUES = /* @__PURE__ */ new Set([
|
|
515
|
+
"port-not-ours",
|
|
516
|
+
"supervisor-unverified",
|
|
517
|
+
"browser-unverified",
|
|
518
|
+
"avd-unchecked"
|
|
519
|
+
]);
|
|
520
|
+
const SIGNING_CODES = [
|
|
521
|
+
"STIM_NO_SIGNING_IDENTITY",
|
|
522
|
+
"STIM_CODESIGN_FAILED",
|
|
523
|
+
"STIM_NO_PROFILE",
|
|
524
|
+
"STIM_PROFILE_MISMATCH"
|
|
525
|
+
];
|
|
526
|
+
/** A failed run older than this on an idle workspace is history, not a problem. */
|
|
527
|
+
const STALE_MS = 864e5;
|
|
528
|
+
const recent = (env, at, now) => env.live || now - time(at) < 864e5;
|
|
529
|
+
function workspaceItems(env, input, format = attentionBody) {
|
|
530
|
+
const { now } = input;
|
|
531
|
+
const items = [];
|
|
532
|
+
for (const issue of env.issues ?? []) {
|
|
533
|
+
if (issue.severity === "info" || !PERSON_ISSUES.has(issue.code)) continue;
|
|
534
|
+
items.push({
|
|
535
|
+
id: `issue-${issue.code}-${issue.slot ?? "default"}:${env.path}`,
|
|
536
|
+
category: "attention",
|
|
537
|
+
severity: issue.severity === "error" ? "error" : "warning",
|
|
538
|
+
workspace: env.path,
|
|
539
|
+
body: format({
|
|
540
|
+
kind: "issue",
|
|
541
|
+
slot: issue.slot,
|
|
542
|
+
message: issue.message
|
|
543
|
+
}),
|
|
544
|
+
remedy: issue.remedy
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
for (const platform of ["ios", "android"]) {
|
|
548
|
+
const last = env.lastBuilds?.[platform];
|
|
549
|
+
const building = env.build?.state === "running" && env.build.platform === platform;
|
|
550
|
+
const code = last?.errorCode ?? "";
|
|
551
|
+
if (last && last.status === "failed" && !building && SIGNING_CODES.includes(code)) {
|
|
552
|
+
if (recent(env, last.finishedAt ?? last.startedAt, now)) {
|
|
553
|
+
items.push({
|
|
554
|
+
id: `run-${platform}:${env.path}`,
|
|
555
|
+
category: "attention",
|
|
556
|
+
severity: "error",
|
|
557
|
+
workspace: env.path,
|
|
558
|
+
body: format({
|
|
559
|
+
kind: "signing",
|
|
560
|
+
platform,
|
|
561
|
+
code
|
|
562
|
+
}),
|
|
563
|
+
remedy: null
|
|
564
|
+
});
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const history = env.builds?.[platform];
|
|
569
|
+
const streak = failureStreak(platform, history);
|
|
570
|
+
const head = history?.[0];
|
|
571
|
+
if (streak && head && streak.count >= LOOP_COUNT && recent(env, head.finishedAt ?? head.startedAt, now)) items.push({
|
|
572
|
+
id: `looping-${platform}:${env.path}`,
|
|
573
|
+
category: "looping",
|
|
574
|
+
severity: "error",
|
|
575
|
+
workspace: env.path,
|
|
576
|
+
body: streak.body(streak.count, format),
|
|
577
|
+
remedy: null
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
for (const device of env.physicalDevices ?? []) {
|
|
581
|
+
if (!(time(device.lease.expiresAt) <= now)) continue;
|
|
582
|
+
const slot = device.slot === "default" ? "" : ` --slot ${device.slot}`;
|
|
583
|
+
items.push({
|
|
584
|
+
id: `lease-${device.platform}-${device.slot}:${env.path}`,
|
|
585
|
+
category: "attention",
|
|
586
|
+
severity: "warning",
|
|
587
|
+
workspace: env.path,
|
|
588
|
+
body: format({
|
|
589
|
+
kind: "lease",
|
|
590
|
+
leased: device.name ?? device.model ?? device.id
|
|
591
|
+
}),
|
|
592
|
+
remedy: `stim device unlock ${device.platform}${slot}`
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const devices = devicesOf(env);
|
|
596
|
+
const driven = devices.find((d) => d.running && agentDriven(d, input.ownLeases ?? []));
|
|
597
|
+
for (const session of env.remoteDevices ?? []) {
|
|
598
|
+
const started = time(session.startedAt);
|
|
599
|
+
if (driven || !(now - started >= input.easSessionMinutes * 6e4)) continue;
|
|
600
|
+
items.push({
|
|
601
|
+
id: `eas-${session.sessionId}:${env.path}`,
|
|
602
|
+
category: "attention",
|
|
603
|
+
severity: "warning",
|
|
604
|
+
workspace: env.path,
|
|
605
|
+
body: format({
|
|
606
|
+
kind: "eas",
|
|
607
|
+
minutes: Math.floor((now - started) / 6e4)
|
|
608
|
+
}),
|
|
609
|
+
remedy: "stim stop"
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const since = lastActivityAt(env, devices, []);
|
|
613
|
+
if (driven && env.build?.state !== "running" && since !== null && now - since >= input.stuckMinutes * 6e4) {
|
|
614
|
+
const newest = newestBuild(env);
|
|
615
|
+
items.push({
|
|
616
|
+
id: `stuck:${env.path}`,
|
|
617
|
+
category: "stuck",
|
|
618
|
+
severity: "warning",
|
|
619
|
+
workspace: env.path,
|
|
620
|
+
body: format({
|
|
621
|
+
kind: "stuck",
|
|
622
|
+
minutes: Math.floor((now - since) / 6e4),
|
|
623
|
+
green: newest?.status === "ok" && (newest.platform === "ios" || newest.platform === "android") ? newest.platform : null,
|
|
624
|
+
model: driven.model,
|
|
625
|
+
modelLabel: driven.modelLabel
|
|
626
|
+
}),
|
|
627
|
+
remedy: null
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return items;
|
|
631
|
+
}
|
|
632
|
+
const severityRank = (item) => item.severity === "error" ? 0 : 1;
|
|
633
|
+
/**
|
|
634
|
+
* The items, errors first, then the machine's, then live workspaces' before idle ones', each in status order. Log
|
|
635
|
+
* errors, a single failed run, and issues an agent's next `stim` command repairs are left out: agents handle them.
|
|
636
|
+
*/
|
|
637
|
+
function needsAttention(input, format = attentionBody) {
|
|
638
|
+
const ranked = [];
|
|
639
|
+
const lowest = input.volumes?.reduce((min, v) => min === null ? v.freeBytes : Math.min(min, v.freeBytes), null);
|
|
640
|
+
if (lowest !== null && lowest !== void 0 && lowest < DISK_CRITICAL_BYTES) ranked.push({
|
|
641
|
+
scope: 0,
|
|
642
|
+
item: {
|
|
643
|
+
id: "machine:disk",
|
|
644
|
+
category: "machine",
|
|
645
|
+
severity: "error",
|
|
646
|
+
workspace: null,
|
|
647
|
+
body: format({
|
|
648
|
+
kind: "disk",
|
|
649
|
+
freeBytes: lowest
|
|
650
|
+
}),
|
|
651
|
+
remedy: null
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
for (const env of input.environments) for (const item of workspaceItems(env, input, format)) ranked.push({
|
|
655
|
+
item,
|
|
656
|
+
scope: env.live ? 1 : 2
|
|
657
|
+
});
|
|
658
|
+
const order = ranked.map((entry, index) => ({
|
|
659
|
+
entry,
|
|
660
|
+
index
|
|
661
|
+
}));
|
|
662
|
+
order.sort((a, b) => severityRank(a.entry.item) - severityRank(b.entry.item) || a.entry.scope - b.entry.scope || a.index - b.index);
|
|
663
|
+
return order.map(({ entry }) => entry.item);
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
export { DEFAULT_STUCK_MINUTES, OVERSIGHT_CATEGORIES, STALE_MS, inQuietHours, needsAttention, oversee, oversightTitle };
|