@taskless-app/swarm 0.1.0 → 0.2.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 +22 -71
- package/dist/cli.js +1694 -297
- package/dist/overmind.js +1932 -439
- package/docs/HERO.md +3 -0
- package/package.json +17 -15
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { basename as
|
|
4
|
+
import { basename as basename3 } from "path";
|
|
5
5
|
import { createInterface } from "readline/promises";
|
|
6
6
|
import { setTimeout as sleep } from "timers/promises";
|
|
7
7
|
|
|
@@ -12,51 +12,216 @@ import { resolve as resolve2 } from "path";
|
|
|
12
12
|
|
|
13
13
|
// src/config.ts
|
|
14
14
|
import { homedir } from "os";
|
|
15
|
-
import { join, resolve } from "path";
|
|
16
|
-
import { mkdirSync, readFileSync, realpathSync } from "fs";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
import { basename, join, resolve } from "path";
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
|
|
17
|
+
|
|
18
|
+
// ../swarm-core/src/types.ts
|
|
19
|
+
var MILESTONE_KINDS = [
|
|
20
|
+
"spawned",
|
|
21
|
+
"pr_opened",
|
|
22
|
+
"merged",
|
|
23
|
+
"done",
|
|
24
|
+
"failed",
|
|
25
|
+
"stalled",
|
|
26
|
+
"conflict",
|
|
27
|
+
"ci_wake",
|
|
28
|
+
// CI-побудка (TASWM-102) — тоже реконсайл-решение, веха как conflict
|
|
29
|
+
"stopped",
|
|
30
|
+
"superseded",
|
|
31
|
+
"paused",
|
|
32
|
+
"resumed",
|
|
33
|
+
"storm_start",
|
|
34
|
+
"storm_end",
|
|
35
|
+
"storm_dead",
|
|
36
|
+
"anomaly"
|
|
37
|
+
// надзор overmind (TASWM-194) — та же нотификация, что улетела в инбокс, вехой в Истории
|
|
38
|
+
];
|
|
39
|
+
var MILESTONE_KIND_SET = new Set(MILESTONE_KINDS);
|
|
40
|
+
function isMilestoneKind(kind) {
|
|
41
|
+
return MILESTONE_KIND_SET.has(kind);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ../swarm-core/src/timeline.ts
|
|
45
|
+
function buildSegments(created, events, laneEnd) {
|
|
46
|
+
const segs = [];
|
|
47
|
+
let cursor = created;
|
|
48
|
+
let phase = "queued";
|
|
49
|
+
const push = (to) => {
|
|
50
|
+
if (to > cursor) segs.push({ phase, from: cursor, to });
|
|
51
|
+
cursor = to;
|
|
52
|
+
};
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
switch (e.kind) {
|
|
55
|
+
case "step":
|
|
56
|
+
if (phase === "queued") {
|
|
57
|
+
push(e.at);
|
|
58
|
+
phase = "running";
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case "blocked":
|
|
62
|
+
push(e.at);
|
|
63
|
+
phase = "blocked";
|
|
64
|
+
break;
|
|
65
|
+
case "answered":
|
|
66
|
+
push(e.at);
|
|
67
|
+
phase = "running";
|
|
68
|
+
break;
|
|
69
|
+
case "pr_opened":
|
|
70
|
+
push(e.at);
|
|
71
|
+
phase = "review";
|
|
72
|
+
break;
|
|
73
|
+
case "merged":
|
|
74
|
+
case "done":
|
|
75
|
+
push(e.at);
|
|
76
|
+
phase = "done";
|
|
77
|
+
break;
|
|
78
|
+
case "failed":
|
|
79
|
+
push(e.at);
|
|
80
|
+
phase = "failed";
|
|
81
|
+
break;
|
|
82
|
+
case "stalled":
|
|
83
|
+
push(e.at);
|
|
84
|
+
phase = "stalled";
|
|
85
|
+
break;
|
|
86
|
+
case "paused":
|
|
87
|
+
case "storm_dead":
|
|
88
|
+
push(e.at);
|
|
89
|
+
phase = "paused";
|
|
90
|
+
break;
|
|
91
|
+
case "resumed":
|
|
92
|
+
push(e.at);
|
|
93
|
+
phase = "running";
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
26
96
|
}
|
|
97
|
+
push(laneEnd);
|
|
98
|
+
return segs;
|
|
27
99
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
100
|
+
function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
|
|
101
|
+
switch (status) {
|
|
102
|
+
case "queued": {
|
|
103
|
+
const unmet = deps.filter((d) => !d.met);
|
|
104
|
+
if (unmet.length) {
|
|
105
|
+
const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
|
|
106
|
+
return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
|
|
107
|
+
}
|
|
108
|
+
return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
|
|
109
|
+
}
|
|
110
|
+
case "running":
|
|
111
|
+
return { icon: "\u25B6", text: lastStep ? `\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442: ${lastStep}` : "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", tone: "run" };
|
|
112
|
+
case "blocked":
|
|
113
|
+
return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
|
|
114
|
+
case "paused":
|
|
115
|
+
return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "paused" };
|
|
116
|
+
case "storm_dead":
|
|
117
|
+
return { icon: "\u{1F4A4}", text: "\u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u0448\u0442\u043E\u0440\u043C Anthropic, \u0436\u0434\u0451\u0442 \u043E\u0442\u0431\u043E\u044F", tone: "paused" };
|
|
118
|
+
case "review": {
|
|
119
|
+
if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
|
|
120
|
+
if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
|
|
121
|
+
if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
|
|
122
|
+
return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
|
|
123
|
+
}
|
|
124
|
+
case "stalled":
|
|
125
|
+
return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
|
|
126
|
+
case "failed":
|
|
127
|
+
return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
|
|
128
|
+
case "stopped":
|
|
129
|
+
return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
|
|
130
|
+
case "done":
|
|
131
|
+
return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
|
|
132
|
+
default:
|
|
133
|
+
return { icon: "\u2022", text: status, tone: "queued" };
|
|
134
|
+
}
|
|
37
135
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
136
|
+
function pickHeadline(lanes) {
|
|
137
|
+
const live = lanes.filter((l2) => l2.live && !l2.obsolete);
|
|
138
|
+
const by = (pred) => live.find(pred);
|
|
139
|
+
const fmt = (l2, extra) => `${l2.name} \xB7 ${l2.slug} \u2014 ${extra}`;
|
|
140
|
+
let l = by((x) => x.status === "review" && x.ci?.tone === "fail");
|
|
141
|
+
if (l) return { icon: "\u274C", text: fmt(l, "CI \u0443\u043F\u0430\u043B \u2014 \u0447\u0438\u043D\u0438\u0442\u044C"), tone: "fail" };
|
|
142
|
+
l = by((x) => x.status === "blocked");
|
|
143
|
+
if (l) return { icon: "\u2753", text: fmt(l, "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430"), tone: "blocked" };
|
|
144
|
+
l = by((x) => x.status === "stalled");
|
|
145
|
+
if (l) return { icon: "\u26A0\uFE0F", text: fmt(l, "stalled \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430"), tone: "stalled" };
|
|
146
|
+
l = by((x) => x.status === "review" && x.blocker.tone === "merge");
|
|
147
|
+
if (l) return { icon: "\u{1F535}", text: fmt(l, "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430"), tone: "merge" };
|
|
148
|
+
l = by((x) => x.status === "queued" && x.dependsOn.some((d) => !d.met));
|
|
149
|
+
if (l) {
|
|
150
|
+
const dep = l.dependsOn.find((d) => !d.met);
|
|
151
|
+
return { icon: "\u26D4", text: fmt(l, `\u0436\u0434\u0451\u0442 ${dep.slug}${dep.name ? " \xB7 " + dep.name : ""}`), tone: "dep" };
|
|
49
152
|
}
|
|
153
|
+
l = by((x) => x.status === "review");
|
|
154
|
+
if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
|
|
155
|
+
l = by((x) => x.status === "running");
|
|
156
|
+
if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
|
|
157
|
+
return null;
|
|
50
158
|
}
|
|
51
|
-
var
|
|
52
|
-
var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
|
|
53
|
-
var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
|
|
54
|
-
var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
|
|
55
|
-
var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
|
|
56
|
-
var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
|
|
57
|
-
var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
|
|
159
|
+
var DEFAULT_RUN_MS = 25 * 6e4;
|
|
58
160
|
|
|
59
|
-
// src/
|
|
161
|
+
// ../swarm-core/src/status.ts
|
|
162
|
+
var STATUS_META = {
|
|
163
|
+
queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
|
|
164
|
+
running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
|
|
165
|
+
blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
|
|
166
|
+
paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
|
|
167
|
+
// overmind был недоступен — ждёт пробуждения (TASWM-41)
|
|
168
|
+
review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
|
|
169
|
+
done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
|
|
170
|
+
failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
|
|
171
|
+
stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
|
|
172
|
+
// молчит/погиб, но ещё разруливается — не терминал
|
|
173
|
+
storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
|
|
174
|
+
// умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
|
|
175
|
+
stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
|
|
176
|
+
superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
|
|
177
|
+
};
|
|
178
|
+
var FALLBACK = STATUS_META.queued;
|
|
179
|
+
var TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
|
|
180
|
+
(s) => STATUS_META[s].terminal
|
|
181
|
+
);
|
|
182
|
+
function statusMeta(status) {
|
|
183
|
+
return STATUS_META[status] ?? FALLBACK;
|
|
184
|
+
}
|
|
185
|
+
function isTerminalStatus(status) {
|
|
186
|
+
return statusMeta(status).terminal;
|
|
187
|
+
}
|
|
188
|
+
function statusTuples() {
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const [status, m] of Object.entries(STATUS_META)) {
|
|
191
|
+
out[status] = [m.pill, m.label, m.color];
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ../swarm-core/src/time.ts
|
|
197
|
+
function stamp(d = /* @__PURE__ */ new Date()) {
|
|
198
|
+
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
199
|
+
}
|
|
200
|
+
function stampFromEpochSec(sec) {
|
|
201
|
+
return stamp(new Date(sec * 1e3));
|
|
202
|
+
}
|
|
203
|
+
function sqliteMs(ts) {
|
|
204
|
+
return Date.parse(ts.replace(" ", "T") + "Z");
|
|
205
|
+
}
|
|
206
|
+
function toStamp(v) {
|
|
207
|
+
if (typeof v !== "string" || !v) return null;
|
|
208
|
+
const d = new Date(v);
|
|
209
|
+
if (Number.isNaN(d.getTime())) return v.includes("T") ? v.slice(0, 19).replace("T", " ") : v;
|
|
210
|
+
return stamp(d);
|
|
211
|
+
}
|
|
212
|
+
var DAY_MS = 864e5;
|
|
213
|
+
|
|
214
|
+
// ../swarm-core/src/windows.ts
|
|
215
|
+
var ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
|
|
216
|
+
function resolveRoyWindows(env = {}) {
|
|
217
|
+
return {
|
|
218
|
+
windowMs: Number(env.ROY_WINDOW_H || ROY_WINDOW_DEFAULTS.windowH) * 36e5,
|
|
219
|
+
maxTerminalLanes: Number(env.ROY_MAX_DONE || ROY_WINDOW_DEFAULTS.maxDone),
|
|
220
|
+
maxAxisMs: Number(env.ROY_AXIS_H || ROY_WINDOW_DEFAULTS.axisH) * 36e5
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ../swarm-core/src/names.ts
|
|
60
225
|
var DRONE_POOL = [
|
|
61
226
|
// Латинский бестиарий (15)
|
|
62
227
|
"SCARAB",
|
|
@@ -85,7 +250,7 @@ var DRONE_POOL = [
|
|
|
85
250
|
"LEVIATHAN",
|
|
86
251
|
"SURTUR"
|
|
87
252
|
];
|
|
88
|
-
var
|
|
253
|
+
var ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
|
|
89
254
|
function toRoman(n) {
|
|
90
255
|
if (n < 1 || n > 3999) return String(n);
|
|
91
256
|
const table = [
|
|
@@ -113,6 +278,81 @@ function genName(base2, g) {
|
|
|
113
278
|
function baseName(name) {
|
|
114
279
|
return name.split(" ")[0];
|
|
115
280
|
}
|
|
281
|
+
|
|
282
|
+
// src/config.ts
|
|
283
|
+
var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
|
|
284
|
+
mkdirSync(SWARM_HOME, { recursive: true });
|
|
285
|
+
function normalizeRepo(repo) {
|
|
286
|
+
const r = (repo ?? "").trim();
|
|
287
|
+
if (!r) return "";
|
|
288
|
+
try {
|
|
289
|
+
return realpathSync(r);
|
|
290
|
+
} catch {
|
|
291
|
+
return resolve(r);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
|
|
295
|
+
var WORKTREES_DIR = process.env.SWARM_WORKTREES || join(SWARM_HOME, "worktrees");
|
|
296
|
+
var RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
|
|
297
|
+
function droneWorktreePath(worktreesDir, repo, slug) {
|
|
298
|
+
return join(worktreesDir, basename(normalizeRepo(repo)), slug);
|
|
299
|
+
}
|
|
300
|
+
var DIST_BUILD = true;
|
|
301
|
+
var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
|
|
302
|
+
var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
|
|
303
|
+
function envNum(name, def) {
|
|
304
|
+
const raw = process.env[name];
|
|
305
|
+
if (raw === void 0 || raw === "") return def;
|
|
306
|
+
const n = Number(raw);
|
|
307
|
+
return Number.isFinite(n) && n >= 0 ? n : def;
|
|
308
|
+
}
|
|
309
|
+
var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
|
|
310
|
+
var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
|
|
311
|
+
var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
|
|
312
|
+
var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
|
|
313
|
+
var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
|
|
314
|
+
var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
|
|
315
|
+
var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
|
|
316
|
+
var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
|
|
317
|
+
var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
|
|
318
|
+
var SWARM_REPO = process.env.SWARM_REPO || "";
|
|
319
|
+
var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
|
|
320
|
+
var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
|
|
321
|
+
var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
|
|
322
|
+
function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
|
|
323
|
+
const base2 = api.replace(/\/$/, "");
|
|
324
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
325
|
+
return `${base2}${p}`;
|
|
326
|
+
}
|
|
327
|
+
function tokenFromFile() {
|
|
328
|
+
try {
|
|
329
|
+
return readFileSync(join(SWARM_HOME, "token"), "utf8").trim();
|
|
330
|
+
} catch {
|
|
331
|
+
return "";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
|
|
335
|
+
var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
|
|
336
|
+
var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
|
|
337
|
+
var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
|
|
338
|
+
var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
|
|
339
|
+
var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
|
|
340
|
+
var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
|
|
341
|
+
var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
|
|
342
|
+
var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
|
|
343
|
+
var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
|
|
344
|
+
var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
|
|
345
|
+
var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
|
|
346
|
+
var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
|
|
347
|
+
var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
|
|
348
|
+
var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
|
|
349
|
+
var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
|
|
350
|
+
var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
|
|
351
|
+
var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
|
|
352
|
+
var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
|
|
353
|
+
|
|
354
|
+
// src/names.ts
|
|
355
|
+
var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
|
|
116
356
|
function pickName(rows) {
|
|
117
357
|
const live = /* @__PURE__ */ new Set();
|
|
118
358
|
const lastUse = /* @__PURE__ */ new Map();
|
|
@@ -163,6 +403,8 @@ function decodeProbe(text) {
|
|
|
163
403
|
}
|
|
164
404
|
|
|
165
405
|
// src/db.ts
|
|
406
|
+
var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
|
|
407
|
+
var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
|
|
166
408
|
var SCHEMA = `
|
|
167
409
|
CREATE TABLE IF NOT EXISTS runs (
|
|
168
410
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -185,6 +427,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
185
427
|
stop_reason TEXT,
|
|
186
428
|
respawns INTEGER NOT NULL DEFAULT 0,
|
|
187
429
|
resolves_run_id INTEGER,
|
|
430
|
+
reviews_run_id INTEGER,
|
|
431
|
+
review_verdict TEXT,
|
|
188
432
|
task_title TEXT,
|
|
189
433
|
task_desc TEXT,
|
|
190
434
|
task_url TEXT,
|
|
@@ -262,6 +506,8 @@ function rowToRun(row) {
|
|
|
262
506
|
respawns: row.respawns ?? 0,
|
|
263
507
|
// старые прогоны без колонки → 0
|
|
264
508
|
resolves_run_id: row.resolves_run_id ?? null,
|
|
509
|
+
reviews_run_id: row.reviews_run_id ?? null,
|
|
510
|
+
review_verdict: row.review_verdict ?? null,
|
|
265
511
|
task_title: row.task_title ?? null,
|
|
266
512
|
task_desc: row.task_desc ?? null,
|
|
267
513
|
task_url: row.task_url ?? null,
|
|
@@ -295,7 +541,11 @@ var SwarmDb = class {
|
|
|
295
541
|
// Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
|
|
296
542
|
"ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
|
|
297
543
|
"ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
|
|
298
|
-
"ALTER TABLE events ADD COLUMN actor TEXT"
|
|
544
|
+
"ALTER TABLE events ADD COLUMN actor TEXT",
|
|
545
|
+
// Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
|
|
546
|
+
// вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
|
|
547
|
+
"ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
|
|
548
|
+
"ALTER TABLE runs ADD COLUMN review_verdict TEXT"
|
|
299
549
|
]) {
|
|
300
550
|
try {
|
|
301
551
|
this.db.exec(alter);
|
|
@@ -315,7 +565,7 @@ var SwarmDb = class {
|
|
|
315
565
|
createRun(input) {
|
|
316
566
|
const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
|
|
317
567
|
const res = this.db.prepare(
|
|
318
|
-
"INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
568
|
+
"INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
319
569
|
).run(
|
|
320
570
|
input.task_slug,
|
|
321
571
|
name,
|
|
@@ -329,10 +579,11 @@ var SwarmDb = class {
|
|
|
329
579
|
input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
|
|
330
580
|
input.model ?? null,
|
|
331
581
|
input.decision ? JSON.stringify(input.decision) : null,
|
|
332
|
-
input.resolves_run_id ?? null
|
|
582
|
+
input.resolves_run_id ?? null,
|
|
583
|
+
input.reviews_run_id ?? null
|
|
333
584
|
);
|
|
334
585
|
const id = Number(res.lastInsertRowid);
|
|
335
|
-
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
586
|
+
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
336
587
|
this.addEvent(id, "spawned", spawnNote);
|
|
337
588
|
return this.getRun(id);
|
|
338
589
|
}
|
|
@@ -385,7 +636,7 @@ var SwarmDb = class {
|
|
|
385
636
|
*/
|
|
386
637
|
activeResolverFor(reviewRunId) {
|
|
387
638
|
const row = this.db.prepare(
|
|
388
|
-
|
|
639
|
+
`SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
|
|
389
640
|
).get(reviewRunId);
|
|
390
641
|
return row ? rowToRun(row) : null;
|
|
391
642
|
}
|
|
@@ -394,10 +645,21 @@ var SwarmDb = class {
|
|
|
394
645
|
const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
|
|
395
646
|
return row.n;
|
|
396
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR (авто-ревью, TASWM-148) —
|
|
650
|
+
* one-shot идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → повторно не
|
|
651
|
+
* спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО вердикта) в счёт НЕ идут —
|
|
652
|
+
* иначе один транзиентный сбой спавна (диск/git/gh) навсегда блокировал бы ревью этого PR без
|
|
653
|
+
* единого шанса на повтор — тихо нарушая политику владельца «каждый PR ревьюится».
|
|
654
|
+
*/
|
|
655
|
+
reviewerCountFor(reviewRunId) {
|
|
656
|
+
const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled')").get(reviewRunId);
|
|
657
|
+
return row.n;
|
|
658
|
+
}
|
|
397
659
|
/** Последний живой (не терминальный) прогон по slug — для swarm cancel. */
|
|
398
660
|
activeRunForSlug(slug) {
|
|
399
661
|
const row = this.db.prepare(
|
|
400
|
-
|
|
662
|
+
`SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
|
|
401
663
|
).get(slug);
|
|
402
664
|
return row ? rowToRun(row) : null;
|
|
403
665
|
}
|
|
@@ -629,11 +891,10 @@ var SwarmDb = class {
|
|
|
629
891
|
taskUrl: row.taskUrl ?? null
|
|
630
892
|
});
|
|
631
893
|
}
|
|
632
|
-
const MILESTONES = /* @__PURE__ */ new Set(["spawned", "pr_opened", "merged", "done", "failed", "stalled", "conflict", "stopped", "superseded", "paused", "resumed", "storm_start", "storm_end", "storm_dead"]);
|
|
633
894
|
for (const row of this.db.prepare(
|
|
634
895
|
"SELECT e.*, r.task_slug slug, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM events e LEFT JOIN runs r ON r.id = e.run_id"
|
|
635
896
|
).all()) {
|
|
636
|
-
if (!
|
|
897
|
+
if (!isMilestoneKind(row.kind)) continue;
|
|
637
898
|
const global = row.run_id == null;
|
|
638
899
|
items.push({
|
|
639
900
|
kind: "milestone",
|
|
@@ -694,159 +955,985 @@ var SwarmDb = class {
|
|
|
694
955
|
return row.n;
|
|
695
956
|
}
|
|
696
957
|
/**
|
|
697
|
-
* Спавн-фейлов (событий failed на
|
|
698
|
-
* Резолверные
|
|
958
|
+
* Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
|
|
959
|
+
* cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
|
|
960
|
+
* считаем штормовым сигналом.
|
|
699
961
|
*/
|
|
700
962
|
spawnFailCountSince(cutoffSeconds) {
|
|
701
963
|
const row = this.db.prepare(
|
|
702
|
-
"SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
|
|
964
|
+
"SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND r.reviews_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
|
|
703
965
|
).get(cutoffSeconds);
|
|
704
966
|
return row.n;
|
|
705
967
|
}
|
|
706
|
-
/** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
|
|
707
|
-
recordStormProbe(probe, ext) {
|
|
708
|
-
this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
|
|
968
|
+
/** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
|
|
969
|
+
recordStormProbe(probe, ext) {
|
|
970
|
+
this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
|
|
971
|
+
}
|
|
972
|
+
/** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
|
|
973
|
+
latestStormProbe() {
|
|
974
|
+
const row = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 1").get();
|
|
975
|
+
return row ? { rec: decodeProbe(row.text), at: row.at } : null;
|
|
976
|
+
}
|
|
977
|
+
/** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
|
|
978
|
+
stormProbeRecordsSince(sinceIso) {
|
|
979
|
+
const rows = this.db.prepare("SELECT text FROM events WHERE run_id IS NULL AND kind='storm_probe' AND at >= ? ORDER BY id").all(sinceIso);
|
|
980
|
+
return rows.map((r) => decodeProbe(r.text));
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// src/swarmClient.ts
|
|
985
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
986
|
+
import { dirname } from "path";
|
|
987
|
+
import { randomUUID } from "crypto";
|
|
988
|
+
|
|
989
|
+
// src/resources.ts
|
|
990
|
+
import os from "os";
|
|
991
|
+
import { execFileSync } from "child_process";
|
|
992
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
993
|
+
function isTransientResourceError(e) {
|
|
994
|
+
const code = e?.code;
|
|
995
|
+
return code === "ENFILE" || code === "EMFILE" || code === "EAGAIN";
|
|
996
|
+
}
|
|
997
|
+
function logTransientSpawnError(label, e) {
|
|
998
|
+
if (isTransientResourceError(e)) {
|
|
999
|
+
const code = e.code;
|
|
1000
|
+
console.warn(`[overmind] ${label}: \u041E\u0421 \u043D\u0435 \u0434\u0430\u0451\u0442 \u0441\u043F\u0430\u0432\u043D\u0438\u0442\u044C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u044B (${code}) \u2014 \u043F\u0440\u043E\u043F\u0443\u0441\u043A\u0430\u044E \u0441\u0438\u0433\u043D\u0430\u043B \u044D\u0442\u043E\u0433\u043E \u0442\u0438\u043A\u0430, \u043D\u0435 \u043F\u0430\u0434\u0430\u044E`);
|
|
1001
|
+
} else {
|
|
1002
|
+
console.warn(`[overmind] ${label} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F (\u043D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445 \u0437\u0430 \u044D\u0442\u043E\u0442 \u0442\u0438\u043A): ${String(e)}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/swarmClient.ts
|
|
1007
|
+
var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
|
|
1008
|
+
var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
|
|
1009
|
+
function runRef(runKey) {
|
|
1010
|
+
return { __runRef: runKey };
|
|
1011
|
+
}
|
|
1012
|
+
function isRunRef(v) {
|
|
1013
|
+
return typeof v === "object" && v !== null && "__runRef" in v;
|
|
1014
|
+
}
|
|
1015
|
+
function jsonFromTextContent(res) {
|
|
1016
|
+
const text = res?.content?.find((c) => c.type === "text")?.text;
|
|
1017
|
+
if (text) {
|
|
1018
|
+
try {
|
|
1019
|
+
return JSON.parse(text);
|
|
1020
|
+
} catch {
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return res?.structuredContent ?? null;
|
|
1024
|
+
}
|
|
1025
|
+
var SwarmClient = class _SwarmClient {
|
|
1026
|
+
call;
|
|
1027
|
+
defaultRepo;
|
|
1028
|
+
autoFlush;
|
|
1029
|
+
listLimit;
|
|
1030
|
+
// Зеркало.
|
|
1031
|
+
runs = /* @__PURE__ */ new Map();
|
|
1032
|
+
runtime = /* @__PURE__ */ new Map();
|
|
1033
|
+
eventsLog = [];
|
|
1034
|
+
questionsLog = [];
|
|
1035
|
+
nextRunId = 1;
|
|
1036
|
+
nextEventId = 1;
|
|
1037
|
+
nextQuestionId = 1;
|
|
1038
|
+
// Корреляция с продом.
|
|
1039
|
+
keyByLocal = /* @__PURE__ */ new Map();
|
|
1040
|
+
// local run id → runKey
|
|
1041
|
+
prodIdByKey = /* @__PURE__ */ new Map();
|
|
1042
|
+
// runKey → prod cuid
|
|
1043
|
+
seenProdEventIds = /* @__PURE__ */ new Set();
|
|
1044
|
+
// дедуп событий при повторной гидрации
|
|
1045
|
+
// Outbox / flush.
|
|
1046
|
+
outboxPath;
|
|
1047
|
+
mapPath;
|
|
1048
|
+
stormPath;
|
|
1049
|
+
// durable-сайдкар глобальных (штормовых) событий
|
|
1050
|
+
pending = [];
|
|
1051
|
+
nextSeq = 1;
|
|
1052
|
+
flushing = false;
|
|
1053
|
+
flushTimer = null;
|
|
1054
|
+
backoffMs = 500;
|
|
1055
|
+
static BACKOFF_BASE = 500;
|
|
1056
|
+
static BACKOFF_MAX = 3e4;
|
|
1057
|
+
constructor(opts) {
|
|
1058
|
+
this.call = opts.call;
|
|
1059
|
+
this.defaultRepo = normalizeRepo(opts.defaultRepo ?? "");
|
|
1060
|
+
this.autoFlush = opts.autoFlush ?? true;
|
|
1061
|
+
this.listLimit = opts.listLimit ?? 200;
|
|
1062
|
+
this.outboxPath = opts.outboxPath;
|
|
1063
|
+
this.mapPath = `${opts.outboxPath}.prodmap.json`;
|
|
1064
|
+
this.stormPath = `${opts.outboxPath}.storm.jsonl`;
|
|
1065
|
+
mkdirSync2(dirname(this.outboxPath), { recursive: true });
|
|
1066
|
+
this.recover();
|
|
1067
|
+
}
|
|
1068
|
+
// ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
|
|
1069
|
+
recover() {
|
|
1070
|
+
if (existsSync2(this.mapPath)) {
|
|
1071
|
+
try {
|
|
1072
|
+
const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
|
|
1073
|
+
for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
|
|
1074
|
+
} catch {
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (existsSync2(this.outboxPath)) {
|
|
1078
|
+
const txt = readFileSync3(this.outboxPath, "utf8");
|
|
1079
|
+
for (const line of txt.split("\n")) {
|
|
1080
|
+
const s = line.trim();
|
|
1081
|
+
if (!s) continue;
|
|
1082
|
+
try {
|
|
1083
|
+
this.pending.push(JSON.parse(s));
|
|
1084
|
+
} catch {
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
this.nextSeq = this.pending.reduce((m, e) => Math.max(m, e.seq), 0) + 1;
|
|
1088
|
+
if (this.autoFlush && this.pending.length) this.scheduleFlush();
|
|
1089
|
+
}
|
|
1090
|
+
this.replayGlobalEvents();
|
|
1091
|
+
}
|
|
1092
|
+
// Реплей глобальных (штормовых) событий из сайдкара в зеркало на старте. Идут в
|
|
1093
|
+
// eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
|
|
1094
|
+
// переприсваиваем локально (id событий — только порядок, не корреляция с продом).
|
|
1095
|
+
replayGlobalEvents() {
|
|
1096
|
+
if (!existsSync2(this.stormPath)) return;
|
|
1097
|
+
let txt;
|
|
1098
|
+
try {
|
|
1099
|
+
txt = readFileSync3(this.stormPath, "utf8");
|
|
1100
|
+
} catch {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
for (const line of txt.split("\n")) {
|
|
1104
|
+
const s = line.trim();
|
|
1105
|
+
if (!s) continue;
|
|
1106
|
+
try {
|
|
1107
|
+
const g = JSON.parse(s);
|
|
1108
|
+
this.eventsLog.push({
|
|
1109
|
+
id: this.nextEventId++,
|
|
1110
|
+
run_id: null,
|
|
1111
|
+
kind: g.kind,
|
|
1112
|
+
text: g.text ?? null,
|
|
1113
|
+
actor: g.actor ?? null,
|
|
1114
|
+
at: g.at
|
|
1115
|
+
});
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// Дописать глобальное событие в durable-сайдкар (append-only, синхронно — как outbox).
|
|
1121
|
+
persistGlobalEvent(ev) {
|
|
1122
|
+
try {
|
|
1123
|
+
appendFileSync(this.stormPath, JSON.stringify(ev) + "\n");
|
|
1124
|
+
} catch {
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// ── зеркало: наложить рантайм на durable-прогон ──────────────────────────────
|
|
1128
|
+
mat(d) {
|
|
1129
|
+
const rt = this.runtime.get(d.id);
|
|
1130
|
+
const { runKey: _k, ...rest } = d;
|
|
1131
|
+
return { ...rest, pid: rt?.pid ?? null, worktree_path: rt?.worktree_path ?? null };
|
|
1132
|
+
}
|
|
1133
|
+
runKeyFor(localId) {
|
|
1134
|
+
return this.keyByLocal.get(localId);
|
|
1135
|
+
}
|
|
1136
|
+
// cuid прода → локальный id (по runKey гидрированного прогона `p:<cuid>`).
|
|
1137
|
+
// Для маппинга прод heroId → локальный hero_run_id при гидрации. Резолвится, когда
|
|
1138
|
+
// прогон-герой присутствует в зеркале; иначе null (уточнится следующим рефрешем).
|
|
1139
|
+
localIdForProdId(prodId) {
|
|
1140
|
+
return [...this.runs.values()].find((r) => r.runKey === `p:${prodId}`)?.id;
|
|
1141
|
+
}
|
|
1142
|
+
// ── outbox: поставить операцию в очередь (зеркало уже обновлено вызывающим) ───
|
|
1143
|
+
enqueue(e) {
|
|
1144
|
+
const full = { ...e, seq: this.nextSeq++ };
|
|
1145
|
+
this.pending.push(full);
|
|
1146
|
+
try {
|
|
1147
|
+
appendFileSync(this.outboxPath, JSON.stringify(full) + "\n");
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
this.scheduleFlush();
|
|
1151
|
+
}
|
|
1152
|
+
// Прод-патч прогона: локальные поля Run → поля swarm_run_upsert (см. TASWM-74).
|
|
1153
|
+
// pid/worktree_path (рантайм), respawns (нет в схеме тула) и снимок задачи (task_*,
|
|
1154
|
+
// прод берёт join'ом из Task) в прод НЕ уходят. kind → role (плоский enum). Self-ref
|
|
1155
|
+
// прогонов hero_run_id/resolves_run_id/superseded_by → heroId/resolvesRunId/
|
|
1156
|
+
// supersededById как ОТЛОЖЕННЫЕ ссылки (RunRef) — cuid резолвится на флаше.
|
|
1157
|
+
runFields(patch) {
|
|
1158
|
+
const out = {};
|
|
1159
|
+
if ("status" in patch && patch.status !== void 0) out.status = patch.status;
|
|
1160
|
+
if ("area" in patch) out.area = patch.area ?? null;
|
|
1161
|
+
if ("model" in patch) out.model = patch.model ?? null;
|
|
1162
|
+
if ("name" in patch) out.droneName = patch.name ?? null;
|
|
1163
|
+
if ("cost_usd" in patch && patch.cost_usd !== void 0) out.costUsd = patch.cost_usd;
|
|
1164
|
+
if ("pr_url" in patch) out.prUrl = patch.pr_url ?? null;
|
|
1165
|
+
if ("branch" in patch) out.branch = patch.branch ?? null;
|
|
1166
|
+
if ("repo" in patch) out.repo = patch.repo ?? null;
|
|
1167
|
+
if ("stop_reason" in patch) out.stopReason = patch.stop_reason ?? null;
|
|
1168
|
+
if ("depends_on" in patch && patch.depends_on) out.dependsOn = patch.depends_on;
|
|
1169
|
+
if ("decision" in patch) out.decision = patch.decision ? JSON.stringify(patch.decision) : null;
|
|
1170
|
+
if ("kind" in patch && patch.kind !== void 0) out.role = patch.kind;
|
|
1171
|
+
if ("review_verdict" in patch) out.reviewVerdict = patch.review_verdict ?? null;
|
|
1172
|
+
this.setRunRefField(out, "heroId", patch, "hero_run_id");
|
|
1173
|
+
this.setRunRefField(out, "resolvesRunId", patch, "resolves_run_id");
|
|
1174
|
+
this.setRunRefField(out, "supersededById", patch, "superseded_by");
|
|
1175
|
+
this.setRunRefField(out, "reviewsRunId", patch, "reviews_run_id");
|
|
1176
|
+
return out;
|
|
1177
|
+
}
|
|
1178
|
+
// Положить self-ref прогона в прод-патч: локальный number → отложенная ссылка
|
|
1179
|
+
// {__runRef: runKey} (резолв в cuid на флаше, resolveArgs/materializeRefs). Явный
|
|
1180
|
+
// null → очистка поля. Локальный id без runKey (прогон не в зеркале) — пропускаем.
|
|
1181
|
+
setRunRefField(out, prodKey, patch, localKey) {
|
|
1182
|
+
if (!(localKey in patch)) return;
|
|
1183
|
+
const localId = patch[localKey];
|
|
1184
|
+
if (localId == null) {
|
|
1185
|
+
out[prodKey] = null;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const key = this.keyByLocal.get(localId);
|
|
1189
|
+
if (key) out[prodKey] = runRef(key);
|
|
1190
|
+
}
|
|
1191
|
+
enqueueRun(run, fields) {
|
|
1192
|
+
if (Object.keys(fields).length === 0) return;
|
|
1193
|
+
this.enqueue({ t: "run", runKey: run.runKey, taskRef: run.task_slug, fields });
|
|
1194
|
+
}
|
|
1195
|
+
// ── runs ─────────────────────────────────────────────────────────────────────
|
|
1196
|
+
createRun(input) {
|
|
1197
|
+
const rows = [...this.runs.values()].map((r) => ({ id: r.id, name: r.name, status: r.status }));
|
|
1198
|
+
const name = input.name ?? pickName(rows);
|
|
1199
|
+
const id = this.nextRunId++;
|
|
1200
|
+
const now = stamp();
|
|
1201
|
+
const d = {
|
|
1202
|
+
id,
|
|
1203
|
+
runKey: randomUUID(),
|
|
1204
|
+
task_slug: input.task_slug,
|
|
1205
|
+
name,
|
|
1206
|
+
kind: input.kind ?? "drone",
|
|
1207
|
+
// роль прогона: дефолт — рабочий дрон (TASWM-78)
|
|
1208
|
+
hero_run_id: input.hero_run_id ?? null,
|
|
1209
|
+
// отряд героя (задел иерархии)
|
|
1210
|
+
repo: normalizeRepo(input.repo || this.defaultRepo),
|
|
1211
|
+
branch: null,
|
|
1212
|
+
status: "queued",
|
|
1213
|
+
area: input.area ?? null,
|
|
1214
|
+
depends_on: input.depends_on && input.depends_on.length ? input.depends_on : [],
|
|
1215
|
+
model: input.model ?? null,
|
|
1216
|
+
cost_usd: 0,
|
|
1217
|
+
pr_url: null,
|
|
1218
|
+
superseded_by: null,
|
|
1219
|
+
decision: input.decision ?? null,
|
|
1220
|
+
stop_reason: null,
|
|
1221
|
+
respawns: 0,
|
|
1222
|
+
resolves_run_id: input.resolves_run_id ?? null,
|
|
1223
|
+
reviews_run_id: input.reviews_run_id ?? null,
|
|
1224
|
+
review_verdict: null,
|
|
1225
|
+
task_title: null,
|
|
1226
|
+
task_desc: null,
|
|
1227
|
+
task_url: null,
|
|
1228
|
+
created_at: now,
|
|
1229
|
+
updated_at: now
|
|
1230
|
+
};
|
|
1231
|
+
this.runs.set(id, d);
|
|
1232
|
+
this.keyByLocal.set(id, d.runKey);
|
|
1233
|
+
this.enqueueRun(d, this.runFields({
|
|
1234
|
+
status: "queued",
|
|
1235
|
+
area: d.area,
|
|
1236
|
+
model: d.model,
|
|
1237
|
+
name: d.name,
|
|
1238
|
+
repo: d.repo,
|
|
1239
|
+
depends_on: d.depends_on,
|
|
1240
|
+
decision: d.decision,
|
|
1241
|
+
kind: d.kind,
|
|
1242
|
+
hero_run_id: d.hero_run_id,
|
|
1243
|
+
resolves_run_id: d.resolves_run_id,
|
|
1244
|
+
// резолвер конфликта → связь на review-прогон
|
|
1245
|
+
reviews_run_id: d.reviews_run_id
|
|
1246
|
+
// ревьюер (авто-ревью, TASWM-148) → связь на review-прогон
|
|
1247
|
+
}));
|
|
1248
|
+
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
1249
|
+
this.addEvent(id, "spawned", spawnNote);
|
|
1250
|
+
return this.mat(d);
|
|
1251
|
+
}
|
|
1252
|
+
getRun(id) {
|
|
1253
|
+
const d = this.runs.get(id);
|
|
1254
|
+
return d ? this.mat(d) : null;
|
|
1255
|
+
}
|
|
1256
|
+
listRuns(status) {
|
|
1257
|
+
const all = [...this.runs.values()].filter((r) => status ? r.status === status : true);
|
|
1258
|
+
all.sort((a, b) => b.id - a.id);
|
|
1259
|
+
return all.map((d) => this.mat(d));
|
|
1260
|
+
}
|
|
1261
|
+
/** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
|
|
1262
|
+
squadRuns(heroId) {
|
|
1263
|
+
return [...this.runs.values()].filter((r) => r.hero_run_id === heroId).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
|
|
1264
|
+
}
|
|
1265
|
+
updateRun(id, patch) {
|
|
1266
|
+
const d = this.runs.get(id);
|
|
1267
|
+
if (!d) return null;
|
|
1268
|
+
if (Object.keys(patch).length === 0) return this.mat(d);
|
|
1269
|
+
if ("pid" in patch || "worktree_path" in patch) {
|
|
1270
|
+
const rt = this.runtime.get(id) ?? { pid: null, worktree_path: null };
|
|
1271
|
+
if ("pid" in patch) rt.pid = patch.pid ?? null;
|
|
1272
|
+
if ("worktree_path" in patch) rt.worktree_path = patch.worktree_path ?? null;
|
|
1273
|
+
this.runtime.set(id, rt);
|
|
1274
|
+
}
|
|
1275
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1276
|
+
if (k === "pid" || k === "worktree_path") continue;
|
|
1277
|
+
d[k] = v;
|
|
1278
|
+
}
|
|
1279
|
+
d.updated_at = stamp();
|
|
1280
|
+
this.enqueueRun(d, this.runFields(patch));
|
|
1281
|
+
return this.mat(d);
|
|
1282
|
+
}
|
|
1283
|
+
incRespawn(id) {
|
|
1284
|
+
const d = this.runs.get(id);
|
|
1285
|
+
if (!d) return 0;
|
|
1286
|
+
d.respawns += 1;
|
|
1287
|
+
d.updated_at = stamp();
|
|
1288
|
+
return d.respawns;
|
|
1289
|
+
}
|
|
1290
|
+
cancel(id, reason) {
|
|
1291
|
+
const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
|
|
1292
|
+
if (run) this.addEvent(id, "stopped", reason);
|
|
1293
|
+
return run;
|
|
1294
|
+
}
|
|
1295
|
+
activeResolverFor(reviewRunId) {
|
|
1296
|
+
const cands = [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId && !RESOLVER_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
|
|
1297
|
+
return cands.length ? this.mat(cands[0]) : null;
|
|
1298
|
+
}
|
|
1299
|
+
resolverCountFor(reviewRunId) {
|
|
1300
|
+
return [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId).length;
|
|
1301
|
+
}
|
|
1302
|
+
// failed/stalled ревьюеры (сорвался спавн ДО вердикта) в счёт НЕ идут — см. db.ts:reviewerCountFor.
|
|
1303
|
+
reviewerCountFor(reviewRunId) {
|
|
1304
|
+
return [...this.runs.values()].filter(
|
|
1305
|
+
(r) => r.reviews_run_id === reviewRunId && r.status !== "failed" && r.status !== "stalled"
|
|
1306
|
+
).length;
|
|
1307
|
+
}
|
|
1308
|
+
activeRunForSlug(slug) {
|
|
1309
|
+
const cands = [...this.runs.values()].filter((r) => r.task_slug === slug && !SLUG_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
|
|
1310
|
+
return cands.length ? this.mat(cands[0]) : null;
|
|
1311
|
+
}
|
|
1312
|
+
runningAreas(repo) {
|
|
1313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1314
|
+
for (const r of this.runs.values()) {
|
|
1315
|
+
if (!(r.status === "running" || r.status === "blocked")) continue;
|
|
1316
|
+
if (r.area == null) continue;
|
|
1317
|
+
if (repo !== void 0 && r.repo !== repo) continue;
|
|
1318
|
+
seen.add(r.area);
|
|
1319
|
+
}
|
|
1320
|
+
return [...seen];
|
|
1321
|
+
}
|
|
1322
|
+
unmetDeps(depends_on) {
|
|
1323
|
+
if (!depends_on || depends_on.length === 0) return [];
|
|
1324
|
+
const done = /* @__PURE__ */ new Set();
|
|
1325
|
+
for (const r of this.runs.values()) {
|
|
1326
|
+
if (r.status === "done" && r.resolves_run_id == null) done.add(r.task_slug);
|
|
1327
|
+
}
|
|
1328
|
+
return depends_on.filter((slug) => !done.has(slug));
|
|
1329
|
+
}
|
|
1330
|
+
distinctRepos() {
|
|
1331
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1332
|
+
for (const r of this.runs.values()) if (r.repo) seen.add(r.repo);
|
|
1333
|
+
return [...seen];
|
|
1334
|
+
}
|
|
1335
|
+
// ── questions ──────────────────────────────────────────────────────────────
|
|
1336
|
+
ask(run_id, text, options) {
|
|
1337
|
+
const q = {
|
|
1338
|
+
id: this.nextQuestionId++,
|
|
1339
|
+
run_id,
|
|
1340
|
+
text,
|
|
1341
|
+
options: options && options.length ? options : null,
|
|
1342
|
+
status: "open",
|
|
1343
|
+
answer: null,
|
|
1344
|
+
answered_by: null,
|
|
1345
|
+
escalated: false,
|
|
1346
|
+
asked_at: stamp(),
|
|
1347
|
+
answered_at: null
|
|
1348
|
+
};
|
|
1349
|
+
this.questionsLog.push(q);
|
|
1350
|
+
this.updateRun(run_id, { status: "blocked" });
|
|
1351
|
+
this.addEvent(run_id, "blocked", text);
|
|
1352
|
+
const key = this.runKeyFor(run_id);
|
|
1353
|
+
if (key) this.enqueue({ t: "ask", runKey: key, text });
|
|
1354
|
+
return q;
|
|
1355
|
+
}
|
|
1356
|
+
escalateQuestion(id) {
|
|
1357
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1358
|
+
if (q) q.escalated = true;
|
|
1359
|
+
}
|
|
1360
|
+
withdrawQuestion(id, reason) {
|
|
1361
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1362
|
+
if (q && q.status === "open") {
|
|
1363
|
+
q.status = "withdrawn";
|
|
1364
|
+
q.answer = reason;
|
|
1365
|
+
q.answered_at = stamp();
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
answer(question_id, answer, by = "human") {
|
|
1369
|
+
const q = this.questionsLog.find((x) => x.id === question_id);
|
|
1370
|
+
if (q && q.status === "open") {
|
|
1371
|
+
q.status = "answered";
|
|
1372
|
+
q.answer = answer;
|
|
1373
|
+
q.answered_by = by;
|
|
1374
|
+
q.answered_at = stamp();
|
|
1375
|
+
this.updateRun(q.run_id, { status: "running" });
|
|
1376
|
+
this.addEvent(q.run_id, "answered", answer, by);
|
|
1377
|
+
}
|
|
1378
|
+
return q ? { ...q } : null;
|
|
1379
|
+
}
|
|
1380
|
+
supersede(oldId, newId, by = "human") {
|
|
1381
|
+
this.updateRun(oldId, { superseded_by: newId });
|
|
1382
|
+
this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
|
|
1383
|
+
}
|
|
1384
|
+
getQuestion(id) {
|
|
1385
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1386
|
+
return q ? { ...q } : null;
|
|
1387
|
+
}
|
|
1388
|
+
openQuestions() {
|
|
1389
|
+
return this.questionsLog.filter((q) => q.status === "open").sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
|
|
1390
|
+
}
|
|
1391
|
+
runIdsWithQuestions() {
|
|
1392
|
+
return [...new Set(this.questionsLog.map((q) => q.run_id))];
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Открытые вопросы дронов отряда Героя #heroId (вопрос → его прогон → hero_run_id).
|
|
1396
|
+
* Гейт «отвечать только своим» для answer_drone + список для squad_status (TASWM-81).
|
|
1397
|
+
*/
|
|
1398
|
+
openQuestionsFor(heroId) {
|
|
1399
|
+
return this.questionsLog.filter((q) => q.status === "open" && this.runs.get(q.run_id)?.hero_run_id === heroId).sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
|
|
1400
|
+
}
|
|
1401
|
+
// ── events / хроника ─────────────────────────────────────────────────────────
|
|
1402
|
+
addEvent(run_id, kind, text, actor = null) {
|
|
1403
|
+
const ev = { id: this.nextEventId++, run_id, kind, text: text ?? null, actor: actor ?? null, at: stamp() };
|
|
1404
|
+
this.eventsLog.push(ev);
|
|
1405
|
+
if (run_id == null) {
|
|
1406
|
+
this.persistGlobalEvent({ kind, text: ev.text, actor: ev.actor, at: ev.at });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const key = this.runKeyFor(run_id);
|
|
1410
|
+
if (key) this.enqueue({ t: "event", runKey: key, kind, text: text ?? null, actor: actor ?? null });
|
|
1411
|
+
}
|
|
1412
|
+
events(run_id) {
|
|
1413
|
+
return this.eventsLog.filter((e) => e.run_id === run_id).sort((a, b) => a.id - b.id).map((e) => ({ ...e }));
|
|
1414
|
+
}
|
|
1415
|
+
history(limit = 50) {
|
|
1416
|
+
const items = [];
|
|
1417
|
+
const runById = (id) => id == null ? null : this.runs.get(id) ?? null;
|
|
1418
|
+
const nameOf = (r, runId) => r?.name ?? `#${runId}`;
|
|
1419
|
+
for (const q of this.questionsLog) {
|
|
1420
|
+
const r = runById(q.run_id);
|
|
1421
|
+
items.push({
|
|
1422
|
+
kind: "qa",
|
|
1423
|
+
at: q.answered_at ?? q.asked_at,
|
|
1424
|
+
slug: r?.task_slug ?? "?",
|
|
1425
|
+
name: nameOf(r, q.run_id),
|
|
1426
|
+
repo: r?.repo ?? "",
|
|
1427
|
+
area: r?.area ?? null,
|
|
1428
|
+
qid: q.id,
|
|
1429
|
+
question: q.text,
|
|
1430
|
+
options: q.options,
|
|
1431
|
+
answer: q.answer,
|
|
1432
|
+
answered_by: q.answered_by,
|
|
1433
|
+
escalated: q.escalated,
|
|
1434
|
+
open: q.status === "open",
|
|
1435
|
+
title: r?.task_title ?? null,
|
|
1436
|
+
pr: r?.pr_url ?? null,
|
|
1437
|
+
taskUrl: r?.task_url ?? null
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
for (const e of this.eventsLog) {
|
|
1441
|
+
if (!isMilestoneKind(e.kind)) continue;
|
|
1442
|
+
const global = e.run_id == null;
|
|
1443
|
+
const r = runById(e.run_id);
|
|
1444
|
+
items.push({
|
|
1445
|
+
kind: "milestone",
|
|
1446
|
+
at: e.at,
|
|
1447
|
+
slug: r?.task_slug ?? (global ? "\u0440\u043E\u0439" : "?"),
|
|
1448
|
+
name: global ? "\u0440\u043E\u0439" : nameOf(r, e.run_id),
|
|
1449
|
+
repo: r?.repo ?? "",
|
|
1450
|
+
event: e.kind,
|
|
1451
|
+
text: e.text,
|
|
1452
|
+
actor: e.actor,
|
|
1453
|
+
title: r?.task_title ?? null,
|
|
1454
|
+
pr: r?.pr_url ?? null,
|
|
1455
|
+
taskUrl: r?.task_url ?? null
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
|
|
1459
|
+
return items.slice(0, limit);
|
|
1460
|
+
}
|
|
1461
|
+
mergedToday() {
|
|
1462
|
+
const today = stamp().slice(0, 10);
|
|
1463
|
+
return this.eventsLog.filter((e) => e.kind === "merged" && e.at.slice(0, 10) === today).length;
|
|
1464
|
+
}
|
|
1465
|
+
// ── штормовой режим ──────────────────────────────────────────────────────────
|
|
1466
|
+
lastGlobal(kinds) {
|
|
1467
|
+
for (let i = this.eventsLog.length - 1; i >= 0; i--) {
|
|
1468
|
+
const e = this.eventsLog[i];
|
|
1469
|
+
if (e.run_id == null && kinds.includes(e.kind)) return e;
|
|
1470
|
+
}
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
stormActive() {
|
|
1474
|
+
return this.lastGlobal(["storm_start", "storm_end"])?.kind === "storm_start";
|
|
1475
|
+
}
|
|
1476
|
+
currentStormStartedAt() {
|
|
1477
|
+
const e = this.lastGlobal(["storm_start", "storm_end"]);
|
|
1478
|
+
return e?.kind === "storm_start" ? e.at : null;
|
|
1479
|
+
}
|
|
1480
|
+
startStorm(reason) {
|
|
1481
|
+
this.addEvent(null, "storm_start", reason);
|
|
1482
|
+
}
|
|
1483
|
+
endStorm(reason) {
|
|
1484
|
+
this.addEvent(null, "storm_end", reason);
|
|
1485
|
+
}
|
|
1486
|
+
recordDeath(runId, note) {
|
|
1487
|
+
this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
|
|
1488
|
+
}
|
|
1489
|
+
deathCountSince(cutoffSeconds) {
|
|
1490
|
+
const cutoff = stampFromEpochSec(cutoffSeconds);
|
|
1491
|
+
return this.eventsLog.filter((e) => e.kind === "died" && e.at >= cutoff).length;
|
|
1492
|
+
}
|
|
1493
|
+
spawnFailCountSince(cutoffSeconds) {
|
|
1494
|
+
const cutoff = stampFromEpochSec(cutoffSeconds);
|
|
1495
|
+
return this.eventsLog.filter((e) => {
|
|
1496
|
+
if (e.kind !== "failed" || e.run_id == null || e.at < cutoff) return false;
|
|
1497
|
+
const r = this.runs.get(e.run_id);
|
|
1498
|
+
return !!r && r.resolves_run_id == null && r.reviews_run_id == null;
|
|
1499
|
+
}).length;
|
|
1500
|
+
}
|
|
1501
|
+
recordStormProbe(probe, ext) {
|
|
1502
|
+
this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
|
|
1503
|
+
}
|
|
1504
|
+
latestStormProbe() {
|
|
1505
|
+
const e = this.lastGlobal(["storm_probe"]);
|
|
1506
|
+
return e ? { rec: decodeProbe(e.text), at: e.at } : null;
|
|
1507
|
+
}
|
|
1508
|
+
stormProbeRecordsSince(sinceIso) {
|
|
1509
|
+
return this.eventsLog.filter((e) => e.run_id == null && e.kind === "storm_probe" && e.at >= sinceIso).sort((a, b) => a.id - b.id).map((e) => decodeProbe(e.text));
|
|
1510
|
+
}
|
|
1511
|
+
// ── гидрация зеркала из прода (старт + реконсайл-рефреш) ──────────────────────
|
|
1512
|
+
async hydrate() {
|
|
1513
|
+
let res;
|
|
1514
|
+
try {
|
|
1515
|
+
res = await this.call("swarm_list_runs", { limit: this.listLimit });
|
|
1516
|
+
} catch {
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (res?.isError) return;
|
|
1520
|
+
const runs = res.structuredContent?.runs ?? [];
|
|
1521
|
+
for (const pr of runs) this.hydrateRun(pr);
|
|
1522
|
+
for (const pr of runs) this.linkRefs(pr);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Реконсайл рантайма после рестарта демона (client-режим). pid/worktree_path живут в
|
|
1526
|
+
* ОТДЕЛЬНОЙ Map (машинное — в прод не уходят) и рестарт демона НЕ переживают: гидрация
|
|
1527
|
+
* из прода приносит running/blocked-прогоны с pid=null. Но демон и дроны — РАЗНЫЕ
|
|
1528
|
+
* detached-процессы (launcher: detached+unref), поэтому рестарт демона дронов НЕ убивает
|
|
1529
|
+
* — часть их ещё жива. Сопоставляем каждый running/blocked-прогон зеркала с живым
|
|
1530
|
+
* процессом (ps, injected listProcesses) по детерминированному worktree
|
|
1531
|
+
* (<worktreesDir>/<repo-basename>/<slug>):
|
|
1532
|
+
* • нашёлся живой процесс → восстанавливаем pid+worktree_path (дрон жив — overmind его
|
|
1533
|
+
* НЕ похоронит и не заспавнит второго поверх);
|
|
1534
|
+
* • не нашёлся → дрон не пережил рестарт: восстанавливаем worktree_path, если каталог
|
|
1535
|
+
* цел (для respawn --continue), pid оставляем null. overmind (tickOnce шаг 3) ловит
|
|
1536
|
+
* running/blocked с pid=null как смерть и оживляет/усыновляет/стопорит по общей ветке.
|
|
1537
|
+
* Рантайм пишем ПРЯМО в runtime-Map (в обход updateRun) — чисто локально, без outbox и
|
|
1538
|
+
* без прод-раунд-трипа. Идемпотентно; вызывается один раз на старте после hydrate().
|
|
1539
|
+
* Возвращает счётчики (для лога демона) + reconciled (TASWM-145): false → listProcesses()
|
|
1540
|
+
* бросил (ENFILE и т.п.), реконсайл этого запуска ПРОПУЩЕН целиком (не «confirmed пусто» —
|
|
1541
|
+
* см. коммент в catch ниже). Вызывающий (runDaemon) ОБЯЗАН пропустить startup-sync
|
|
1542
|
+
* (syncCrashedOnStartup) при reconciled=false: та трактует pid==null как «не пережил
|
|
1543
|
+
* рестарт» и безусловно хоронит (stalled/done) — при пропущенном реконсайле это massively
|
|
1544
|
+
* похоронило бы ВСЕХ running/blocked, включая реально живых detached-дронов, тем же
|
|
1545
|
+
* транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
|
|
1546
|
+
*/
|
|
1547
|
+
reconcileRuntime(opts) {
|
|
1548
|
+
const wtExists = opts.worktreeExists ?? existsSync2;
|
|
1549
|
+
const pidByWorktree = /* @__PURE__ */ new Map();
|
|
1550
|
+
try {
|
|
1551
|
+
for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
logTransientSpawnError("ps (reconcileRuntime)", e);
|
|
1554
|
+
return { alive: 0, dead: 0, reconciled: false };
|
|
1555
|
+
}
|
|
1556
|
+
let alive = 0;
|
|
1557
|
+
let dead = 0;
|
|
1558
|
+
for (const d of this.runs.values()) {
|
|
1559
|
+
if (d.status !== "running" && d.status !== "blocked") continue;
|
|
1560
|
+
if (this.runtime.get(d.id)?.pid != null) continue;
|
|
1561
|
+
const workerWt = droneWorktreePath(opts.worktreesDir, d.repo || this.defaultRepo, d.task_slug);
|
|
1562
|
+
let matchWt = null;
|
|
1563
|
+
let matchPid = null;
|
|
1564
|
+
if (d.resolves_run_id == null && d.reviews_run_id == null) {
|
|
1565
|
+
const p = pidByWorktree.get(workerWt);
|
|
1566
|
+
if (p != null) {
|
|
1567
|
+
matchWt = workerWt;
|
|
1568
|
+
matchPid = p;
|
|
1569
|
+
}
|
|
1570
|
+
} else if (d.resolves_run_id != null) {
|
|
1571
|
+
const prefix = `${workerWt}-resolve-`;
|
|
1572
|
+
for (const [wt, p] of pidByWorktree) {
|
|
1573
|
+
if (wt.startsWith(prefix)) {
|
|
1574
|
+
matchWt = wt;
|
|
1575
|
+
matchPid = p;
|
|
1576
|
+
break;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
} else {
|
|
1580
|
+
const reviewed = this.runs.get(d.reviews_run_id);
|
|
1581
|
+
const reviewedWt = reviewed ? droneWorktreePath(opts.worktreesDir, reviewed.repo || this.defaultRepo, reviewed.task_slug) : workerWt;
|
|
1582
|
+
const prefix = `${reviewedWt}-review-`;
|
|
1583
|
+
for (const [wt, p] of pidByWorktree) {
|
|
1584
|
+
if (wt.startsWith(prefix)) {
|
|
1585
|
+
matchWt = wt;
|
|
1586
|
+
matchPid = p;
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
if (matchPid != null && matchWt) {
|
|
1592
|
+
this.runtime.set(d.id, { pid: matchPid, worktree_path: matchWt });
|
|
1593
|
+
alive++;
|
|
1594
|
+
} else {
|
|
1595
|
+
const wt = d.resolves_run_id == null && d.reviews_run_id == null && wtExists(workerWt) ? workerWt : null;
|
|
1596
|
+
this.runtime.set(d.id, { pid: null, worktree_path: wt });
|
|
1597
|
+
dead++;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return { alive, dead, reconciled: true };
|
|
1601
|
+
}
|
|
1602
|
+
// Долинковать self-ref прогона по прод-cuid → локальному id (после того как все
|
|
1603
|
+
// прогоны гидрированы). Идемпотентно: ставит найденное, иначе не трогает.
|
|
1604
|
+
linkRefs(pr) {
|
|
1605
|
+
const localId = this.localIdForProdId(String(pr.id ?? ""));
|
|
1606
|
+
const local = localId === void 0 ? void 0 : this.runs.get(localId);
|
|
1607
|
+
if (!local) return;
|
|
1608
|
+
const hero = pr.heroId ?? null;
|
|
1609
|
+
if (hero) local.hero_run_id = this.localIdForProdId(hero) ?? local.hero_run_id;
|
|
1610
|
+
const res = pr.resolvesRunId ?? null;
|
|
1611
|
+
if (res) local.resolves_run_id = this.localIdForProdId(res) ?? local.resolves_run_id;
|
|
1612
|
+
const sup = pr.supersededById ?? null;
|
|
1613
|
+
if (sup) local.superseded_by = this.localIdForProdId(sup) ?? local.superseded_by;
|
|
1614
|
+
const rev = pr.reviewsRunId ?? null;
|
|
1615
|
+
if (rev) local.reviews_run_id = this.localIdForProdId(rev) ?? local.reviews_run_id;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Авто-интейк (TASWM-100): взять следующую claimable-задачу владельца токена через
|
|
1619
|
+
* прод-тул swarm_claim_work и завести её прогон в зеркале. claim атомарен НА СЕРВЕРЕ
|
|
1620
|
+
* (FIFO по createdAt + pg_advisory_xact_lock + дедуп «нет активного прогона») — гонку
|
|
1621
|
+
* двух тиков overmind и двойной claim решает прод, клиенту делать этого не надо. Непустой
|
|
1622
|
+
* ответ → ГИДРИРУЕМ созданный прогон как `p:<cuid>` (тот же путь, что hydrate/реконсайл),
|
|
1623
|
+
* а НЕ createRun: локальный create завёл бы ВТОРОЙ прогон с create-outbox по taskRef и
|
|
1624
|
+
* задвоил бы зеркало с уже существующим прод-прогоном. Гидрация лишь зеркалит прод — его
|
|
1625
|
+
* cuid известен сразу, никакого create-flush. Возвращает локальный Run (queued; overmind
|
|
1626
|
+
* заспавнит его следующим тиком) либо null: нет claimable-задачи / прод недоступен / тул
|
|
1627
|
+
* отверг — во всех случаях тик продолжается, следующий ретраит.
|
|
1628
|
+
*/
|
|
1629
|
+
async claim() {
|
|
1630
|
+
let res;
|
|
1631
|
+
try {
|
|
1632
|
+
res = await this.call("swarm_claim_work", {});
|
|
1633
|
+
} catch {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
if (res?.isError) return null;
|
|
1637
|
+
const sc = res.structuredContent ?? {};
|
|
1638
|
+
if (!sc.claimed || typeof sc.run !== "object" || sc.run === null) return null;
|
|
1639
|
+
const local = this.hydrateRun(sc.run);
|
|
1640
|
+
return local ? this.mat(local) : null;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Живой потолок дронов владельца из Taskless (TASWM-126): читает who_am_i.maxDrones
|
|
1644
|
+
* (личная настройка uiPrefs.swarm.maxDrones, дефолт 3). overmind капит им ВСЕ спавны —
|
|
1645
|
+
* очередь (шаг 1), резолвер (шаг 2), авто-claim (шаг 5) — чтобы активных дронов не было
|
|
1646
|
+
* больше числа из UI (инцидент 15.07: 4-6 дронов разом уронили ноут). who_am_i отдаёт
|
|
1647
|
+
* плоский JSON в text-контенте (не {display,data}). Прод недоступен / нет валидного числа
|
|
1648
|
+
* → null: ридер (makeMaxDronesReader) берёт env-фолбэк.
|
|
1649
|
+
*/
|
|
1650
|
+
async ownerMaxDrones() {
|
|
1651
|
+
let res;
|
|
1652
|
+
try {
|
|
1653
|
+
res = await this.call("who_am_i", {});
|
|
1654
|
+
} catch {
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
if (res?.isError) return null;
|
|
1658
|
+
const obj = jsonFromTextContent(res);
|
|
1659
|
+
const n = obj?.maxDrones;
|
|
1660
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Пульс overmind в прод (TASWM-129): swarm_heartbeat обновляет User.swarmHeartbeatAt —
|
|
1664
|
+
* дашборд /swarm сравнивает свежесть со своим порогом устаревания («нет связи с
|
|
1665
|
+
* овермайндом» вместо устаревшего «живого» роя). Best-effort, как ownerMaxDrones/claim:
|
|
1666
|
+
* прод недоступен/тул отверг → просто не обновили в этом тике, следующий ретраит —
|
|
1667
|
+
* не роняем тик демона из-за упавшего пульса.
|
|
1668
|
+
*/
|
|
1669
|
+
async heartbeat() {
|
|
1670
|
+
try {
|
|
1671
|
+
await this.call("swarm_heartbeat", {});
|
|
1672
|
+
} catch {
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Встроенный надзор (TASWM-194): нотификация аномалии в инбокс владельца — прод-MCP
|
|
1677
|
+
* `swarm_notify`, ПРЯМОЙ вызов (НЕ outbox): аномалия — «сообщи СЕЙЧАС», не durable-факт,
|
|
1678
|
+
* который обязан пережить рестарт демона в неизменном порядке (как run-апдейты/события) —
|
|
1679
|
+
* задержка на цикл outbox-флаша (обычно доли секунды, но в худшем случае — до
|
|
1680
|
+
* восстановления сети) тут не оправдана.
|
|
1681
|
+
* runId (локальный numeric) резолвим в прод-cuid по той же карте, что flush аутбокса
|
|
1682
|
+
* (runKeyFor→prodIdByKey); прогон ещё не успел долететь до прода (cuid неизвестен) →
|
|
1683
|
+
* шлём без runId (нотификация всё равно уйдёт, просто без SwarmEvent-вехи на прогоне).
|
|
1684
|
+
*
|
|
1685
|
+
* БРОСАЕТ при сбое (транспорт ИЛИ isError:true от тула) — НЕ глотает, в отличие от
|
|
1686
|
+
* heartbeat() выше (code-review TASWM-194): вызывающий (overmind.ts tickOnce) коммитит
|
|
1687
|
+
* AnomalyTracker ТОЛЬКО после успешного return — молча проглоченная ошибка означала бы,
|
|
1688
|
+
* что трекер считает недоставленную нотификацию отправленной и молчит renotifyCooldownMs
|
|
1689
|
+
* (по умолчанию час) без единой реальной попытки. `res?.isError` — тот же паттерн, что
|
|
1690
|
+
* ownerMaxDrones() чуть выше: registerTool ловит throw внутри run() и превращает его в
|
|
1691
|
+
* `{isError:true}`, а не в отклонённый промис — без этой проверки схемная/бизнес-ошибка
|
|
1692
|
+
* тула (напр. TASWM-194 review: body > zod max) молча резолвилась бы успехом.
|
|
1693
|
+
*/
|
|
1694
|
+
async notifyAnomaly(signal) {
|
|
1695
|
+
const prodRunId = signal.runId != null ? this.prodIdByKey.get(this.runKeyFor(signal.runId) ?? "") : void 0;
|
|
1696
|
+
const res = await this.call("swarm_notify", {
|
|
1697
|
+
tag: signal.key,
|
|
1698
|
+
title: signal.title,
|
|
1699
|
+
body: signal.body,
|
|
1700
|
+
...signal.href ? { href: signal.href } : {},
|
|
1701
|
+
...prodRunId ? { runId: prodRunId } : {}
|
|
1702
|
+
});
|
|
1703
|
+
if (res?.isError) throw new Error(`swarm_notify \u043E\u0442\u0432\u0435\u0440\u0433 \xAB${signal.key}\xBB`);
|
|
1704
|
+
}
|
|
1705
|
+
hydrateRun(pr) {
|
|
1706
|
+
const prodId = String(pr.id ?? "");
|
|
1707
|
+
if (!prodId) return null;
|
|
1708
|
+
const runKey = `p:${prodId}`;
|
|
1709
|
+
this.prodIdByKey.set(runKey, prodId);
|
|
1710
|
+
let local = [...this.runs.values()].find((r) => r.runKey === runKey);
|
|
1711
|
+
if (!local) local = [...this.runs.values()].find((r) => this.prodIdByKey.get(r.runKey) === prodId);
|
|
1712
|
+
if (!local) {
|
|
1713
|
+
const id = this.nextRunId++;
|
|
1714
|
+
local = {
|
|
1715
|
+
id,
|
|
1716
|
+
runKey,
|
|
1717
|
+
task_slug: "",
|
|
1718
|
+
name: `#${id}`,
|
|
1719
|
+
kind: "drone",
|
|
1720
|
+
hero_run_id: null,
|
|
1721
|
+
repo: "",
|
|
1722
|
+
branch: null,
|
|
1723
|
+
status: "queued",
|
|
1724
|
+
area: null,
|
|
1725
|
+
depends_on: [],
|
|
1726
|
+
model: null,
|
|
1727
|
+
cost_usd: 0,
|
|
1728
|
+
pr_url: null,
|
|
1729
|
+
superseded_by: null,
|
|
1730
|
+
decision: null,
|
|
1731
|
+
stop_reason: null,
|
|
1732
|
+
respawns: 0,
|
|
1733
|
+
resolves_run_id: null,
|
|
1734
|
+
reviews_run_id: null,
|
|
1735
|
+
review_verdict: null,
|
|
1736
|
+
task_title: null,
|
|
1737
|
+
task_desc: null,
|
|
1738
|
+
task_url: null,
|
|
1739
|
+
created_at: stamp(),
|
|
1740
|
+
updated_at: stamp()
|
|
1741
|
+
};
|
|
1742
|
+
this.runs.set(id, local);
|
|
1743
|
+
this.keyByLocal.set(id, runKey);
|
|
1744
|
+
}
|
|
1745
|
+
const slug = pr.taskSlug ?? pr.task?.slug ?? pr.taskId ?? local.task_slug;
|
|
1746
|
+
local.task_slug = slug || local.task_slug;
|
|
1747
|
+
local.task_title = pr.taskTitle ?? local.task_title;
|
|
1748
|
+
local.task_desc = pr.taskDesc ?? local.task_desc;
|
|
1749
|
+
local.name = pr.droneName ?? local.name;
|
|
1750
|
+
local.kind = pr.role ?? local.kind;
|
|
1751
|
+
const heroProd = pr.heroId ?? null;
|
|
1752
|
+
if (heroProd) local.hero_run_id = this.localIdForProdId(heroProd) ?? local.hero_run_id;
|
|
1753
|
+
const resProd = pr.resolvesRunId ?? null;
|
|
1754
|
+
if (resProd) local.resolves_run_id = this.localIdForProdId(resProd) ?? local.resolves_run_id;
|
|
1755
|
+
const supProd = pr.supersededById ?? null;
|
|
1756
|
+
if (supProd) local.superseded_by = this.localIdForProdId(supProd) ?? local.superseded_by;
|
|
1757
|
+
const revProd = pr.reviewsRunId ?? null;
|
|
1758
|
+
if (revProd) local.reviews_run_id = this.localIdForProdId(revProd) ?? local.reviews_run_id;
|
|
1759
|
+
local.review_verdict = pr.reviewVerdict ?? local.review_verdict;
|
|
1760
|
+
local.repo = pr.repo ?? "";
|
|
1761
|
+
local.branch = pr.branch ?? null;
|
|
1762
|
+
local.status = pr.status ?? local.status;
|
|
1763
|
+
local.area = pr.area ?? null;
|
|
1764
|
+
local.depends_on = Array.isArray(pr.dependsOn) ? pr.dependsOn : [];
|
|
1765
|
+
local.model = pr.model ?? null;
|
|
1766
|
+
local.cost_usd = typeof pr.costUsd === "number" ? pr.costUsd : 0;
|
|
1767
|
+
local.pr_url = pr.prUrl ?? null;
|
|
1768
|
+
local.stop_reason = pr.stopReason ?? null;
|
|
1769
|
+
local.respawns = typeof pr.respawns === "number" ? pr.respawns : 0;
|
|
1770
|
+
local.decision = parseDecision(pr.decision);
|
|
1771
|
+
local.created_at = toStamp(pr.createdAt) ?? local.created_at;
|
|
1772
|
+
local.updated_at = toStamp(pr.updatedAt) ?? local.updated_at;
|
|
1773
|
+
const evs = pr.events ?? [];
|
|
1774
|
+
for (const pe of evs) {
|
|
1775
|
+
const eid = String(pe.id ?? "");
|
|
1776
|
+
if (eid && this.seenProdEventIds.has(eid)) continue;
|
|
1777
|
+
if (eid) this.seenProdEventIds.add(eid);
|
|
1778
|
+
const kind = String(pe.kind ?? "");
|
|
1779
|
+
const text = pe.text ?? null;
|
|
1780
|
+
const at = toStamp(pe.at) ?? stamp();
|
|
1781
|
+
if (kind === "question") {
|
|
1782
|
+
this.questionsLog.push({
|
|
1783
|
+
id: this.nextQuestionId++,
|
|
1784
|
+
run_id: local.id,
|
|
1785
|
+
text: text ?? "",
|
|
1786
|
+
options: null,
|
|
1787
|
+
status: "open",
|
|
1788
|
+
answer: null,
|
|
1789
|
+
answered_by: null,
|
|
1790
|
+
escalated: false,
|
|
1791
|
+
asked_at: at,
|
|
1792
|
+
answered_at: null
|
|
1793
|
+
});
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
const localKind = kind === "answer" ? "answered" : kind;
|
|
1797
|
+
this.eventsLog.push({
|
|
1798
|
+
id: this.nextEventId++,
|
|
1799
|
+
run_id: local.id,
|
|
1800
|
+
kind: localKind,
|
|
1801
|
+
text,
|
|
1802
|
+
actor: pe.actor ?? null,
|
|
1803
|
+
at
|
|
1804
|
+
});
|
|
1805
|
+
if (kind === "answer" || kind === "answered") {
|
|
1806
|
+
const open = [...this.questionsLog].reverse().find((q) => q.run_id === local.id && q.status === "open");
|
|
1807
|
+
if (open) {
|
|
1808
|
+
open.status = "answered";
|
|
1809
|
+
open.answer = text;
|
|
1810
|
+
open.answered_by = pe.actor ?? "human";
|
|
1811
|
+
open.answered_at = at;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return local;
|
|
1816
|
+
}
|
|
1817
|
+
// ── flush outbox в прод ──────────────────────────────────────────────────────
|
|
1818
|
+
scheduleFlush() {
|
|
1819
|
+
if (!this.autoFlush || this.flushing || this.flushTimer || this.pending.length === 0) return;
|
|
1820
|
+
this.flushTimer = setTimeout(() => {
|
|
1821
|
+
this.flushTimer = null;
|
|
1822
|
+
void this.flush();
|
|
1823
|
+
}, this.backoffMs);
|
|
1824
|
+
if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
|
|
709
1825
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1826
|
+
resolveArgs(e) {
|
|
1827
|
+
if (e.t === "run") {
|
|
1828
|
+
const fields = this.materializeRefs(e.fields);
|
|
1829
|
+
const prodId2 = this.prodIdByKey.get(e.runKey);
|
|
1830
|
+
return prodId2 ? { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } } : { tool: "swarm_run_upsert", args: { taskId: e.taskRef, ...fields } };
|
|
1831
|
+
}
|
|
1832
|
+
const prodId = this.prodIdByKey.get(e.runKey);
|
|
1833
|
+
if (!prodId) throw new Error(`\u043D\u0435\u0442 prod-id \u0434\u043B\u044F ${e.runKey} \u2014 \u043F\u0440\u043E\u0433\u043E\u043D \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D \u0432 \u043F\u0440\u043E\u0434\u0435`);
|
|
1834
|
+
if (e.t === "event") return { tool: "swarm_event_append", args: { runId: prodId, kind: e.kind, text: e.text, actor: e.actor } };
|
|
1835
|
+
return { tool: "swarm_ask", args: { runId: prodId, text: e.text } };
|
|
714
1836
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1837
|
+
// Резолв отложенных self-ref ({__runRef: runKey}) в прод-cuid на флаше. Цель уже
|
|
1838
|
+
// создана в проде (её create-запись с меньшим seq флашится раньше) → cuid известен.
|
|
1839
|
+
// Не резолвится (ещё не создан) — поле выкидываем: лучше не слать, чем слать мусор;
|
|
1840
|
+
// догонит следующий апдейт/реконсайл. Обычные значения (null/строки) — как есть.
|
|
1841
|
+
materializeRefs(fields) {
|
|
1842
|
+
const out = {};
|
|
1843
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
1844
|
+
if (isRunRef(v)) {
|
|
1845
|
+
const cuid = this.prodIdByKey.get(v.__runRef);
|
|
1846
|
+
if (cuid) out[k] = cuid;
|
|
1847
|
+
} else {
|
|
1848
|
+
out[k] = v;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return out;
|
|
719
1852
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (to > cursor) segs.push({ phase, from: cursor, to });
|
|
735
|
-
cursor = to;
|
|
736
|
-
};
|
|
737
|
-
for (const e of events) {
|
|
738
|
-
switch (e.kind) {
|
|
739
|
-
case "step":
|
|
740
|
-
if (phase === "queued") {
|
|
741
|
-
push(e.at);
|
|
742
|
-
phase = "running";
|
|
1853
|
+
/** Слить накопленное в прод. Возвращает число успешно отправленных операций.
|
|
1854
|
+
* Порядок сохраняется; ошибка/недоступность → стоп, буфер остаётся, retry-backoff. */
|
|
1855
|
+
async flush() {
|
|
1856
|
+
if (this.flushing) return 0;
|
|
1857
|
+
this.flushing = true;
|
|
1858
|
+
let sent = 0;
|
|
1859
|
+
try {
|
|
1860
|
+
while (this.pending.length) {
|
|
1861
|
+
const e = this.pending[0];
|
|
1862
|
+
let call;
|
|
1863
|
+
try {
|
|
1864
|
+
call = this.resolveArgs(e);
|
|
1865
|
+
} catch {
|
|
1866
|
+
break;
|
|
743
1867
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1868
|
+
let res;
|
|
1869
|
+
try {
|
|
1870
|
+
res = await this.call(call.tool, call.args);
|
|
1871
|
+
} catch {
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
if (res?.isError) break;
|
|
1875
|
+
if (e.t === "run") {
|
|
1876
|
+
const run = res.structuredContent?.run;
|
|
1877
|
+
if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
|
|
1878
|
+
this.prodIdByKey.set(e.runKey, run.id);
|
|
1879
|
+
this.persistMap();
|
|
1880
|
+
}
|
|
1881
|
+
} else {
|
|
1882
|
+
const evId = res.structuredContent?.event?.id;
|
|
1883
|
+
if (evId) this.seenProdEventIds.add(evId);
|
|
1884
|
+
}
|
|
1885
|
+
this.pending.shift();
|
|
1886
|
+
sent++;
|
|
1887
|
+
}
|
|
1888
|
+
this.persistPending();
|
|
1889
|
+
if (this.pending.length === 0) {
|
|
1890
|
+
this.backoffMs = _SwarmClient.BACKOFF_BASE;
|
|
1891
|
+
} else {
|
|
1892
|
+
this.backoffMs = Math.min(this.backoffMs * 2, _SwarmClient.BACKOFF_MAX);
|
|
1893
|
+
this.scheduleFlush();
|
|
1894
|
+
}
|
|
1895
|
+
} finally {
|
|
1896
|
+
this.flushing = false;
|
|
770
1897
|
}
|
|
1898
|
+
return sent;
|
|
771
1899
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
switch (status) {
|
|
777
|
-
case "queued": {
|
|
778
|
-
const unmet = deps.filter((d) => !d.met);
|
|
779
|
-
if (unmet.length) {
|
|
780
|
-
const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
|
|
781
|
-
return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
|
|
782
|
-
}
|
|
783
|
-
return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
|
|
1900
|
+
persistPending() {
|
|
1901
|
+
try {
|
|
1902
|
+
writeFileSync2(this.outboxPath, this.pending.map((e) => JSON.stringify(e)).join("\n") + (this.pending.length ? "\n" : ""));
|
|
1903
|
+
} catch {
|
|
784
1904
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "blocked" };
|
|
791
|
-
case "review": {
|
|
792
|
-
if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
|
|
793
|
-
if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
|
|
794
|
-
if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
|
|
795
|
-
return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
|
|
1905
|
+
}
|
|
1906
|
+
persistMap() {
|
|
1907
|
+
try {
|
|
1908
|
+
writeFileSync2(this.mapPath, JSON.stringify(Object.fromEntries(this.prodIdByKey)));
|
|
1909
|
+
} catch {
|
|
796
1910
|
}
|
|
797
|
-
case "stalled":
|
|
798
|
-
return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
|
|
799
|
-
case "failed":
|
|
800
|
-
return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
|
|
801
|
-
case "stopped":
|
|
802
|
-
return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
|
|
803
|
-
case "done":
|
|
804
|
-
return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
|
|
805
|
-
default:
|
|
806
|
-
return { icon: "\u2022", text: status, tone: "queued" };
|
|
807
1911
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1912
|
+
/** Незалитых операций в буфере (для тестов/наблюдаемости). */
|
|
1913
|
+
pendingCount() {
|
|
1914
|
+
return this.pending.length;
|
|
1915
|
+
}
|
|
1916
|
+
close() {
|
|
1917
|
+
if (this.flushTimer) {
|
|
1918
|
+
clearTimeout(this.flushTimer);
|
|
1919
|
+
this.flushTimer = null;
|
|
1920
|
+
}
|
|
1921
|
+
void this.flush();
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
function parseDecision(v) {
|
|
1925
|
+
if (!v || typeof v !== "string") return null;
|
|
1926
|
+
try {
|
|
1927
|
+
return JSON.parse(v);
|
|
1928
|
+
} catch {
|
|
1929
|
+
return null;
|
|
825
1930
|
}
|
|
826
|
-
l = by((x) => x.status === "review");
|
|
827
|
-
if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
|
|
828
|
-
l = by((x) => x.status === "running");
|
|
829
|
-
if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
|
|
830
|
-
return null;
|
|
831
1931
|
}
|
|
832
1932
|
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning" },
|
|
838
|
-
paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning" },
|
|
839
|
-
// overmind был недоступен — ждёт пробуждения (TASWM-41)
|
|
840
|
-
review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info" },
|
|
841
|
-
done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success" },
|
|
842
|
-
failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive" },
|
|
843
|
-
stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive" },
|
|
844
|
-
storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning" },
|
|
845
|
-
// умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
|
|
846
|
-
stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground" },
|
|
847
|
-
superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground" }
|
|
848
|
-
};
|
|
849
|
-
var FALLBACK = STATUS_META.queued;
|
|
1933
|
+
// src/web.ts
|
|
1934
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1935
|
+
import { basename as basename2 } from "path";
|
|
1936
|
+
import { serve } from "@hono/node-server";
|
|
850
1937
|
|
|
851
1938
|
// src/diff.ts
|
|
852
1939
|
import { statSync } from "fs";
|
|
@@ -1027,9 +2114,49 @@ async function collectRunDiff(worktree, opts = {}) {
|
|
|
1027
2114
|
};
|
|
1028
2115
|
}
|
|
1029
2116
|
|
|
2117
|
+
// src/gh.ts
|
|
2118
|
+
import { $ as $2 } from "execa";
|
|
2119
|
+
var realChecksRunner = async (pr) => (await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
|
|
2120
|
+
async function prCheckRows(pr, run) {
|
|
2121
|
+
try {
|
|
2122
|
+
const out = await run(pr);
|
|
2123
|
+
const rows = JSON.parse(out);
|
|
2124
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
2125
|
+
return rows.map((r) => ({ name: r.name ?? "?", bucket: (r.bucket ?? "").toLowerCase(), link: r.link ?? "" }));
|
|
2126
|
+
} catch {
|
|
2127
|
+
return null;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
async function ghChecks(pr, run = realChecksRunner) {
|
|
2131
|
+
const rows = await prCheckRows(pr, run);
|
|
2132
|
+
if (!rows) return null;
|
|
2133
|
+
let pass = 0, fail = 0, pending2 = 0;
|
|
2134
|
+
for (const r of rows) {
|
|
2135
|
+
if (r.bucket === "pass") pass++;
|
|
2136
|
+
else if (r.bucket === "fail" || r.bucket === "cancel") fail++;
|
|
2137
|
+
else if (r.bucket === "pending") pending2++;
|
|
2138
|
+
}
|
|
2139
|
+
const total = rows.length;
|
|
2140
|
+
let bucket, text, tone;
|
|
2141
|
+
if (fail > 0) {
|
|
2142
|
+
bucket = "fail";
|
|
2143
|
+
tone = "fail";
|
|
2144
|
+
text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
|
|
2145
|
+
} else if (pending2 > 0) {
|
|
2146
|
+
bucket = "pending";
|
|
2147
|
+
tone = "review";
|
|
2148
|
+
text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
|
|
2149
|
+
} else {
|
|
2150
|
+
bucket = "pass";
|
|
2151
|
+
tone = "merge";
|
|
2152
|
+
text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
|
|
2153
|
+
}
|
|
2154
|
+
return { bucket, text, tone, total, pass, fail, pending: pending2 };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1030
2157
|
// src/oauth.ts
|
|
1031
2158
|
import { createHash, randomBytes } from "crypto";
|
|
1032
|
-
import { readFileSync as
|
|
2159
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync } from "fs";
|
|
1033
2160
|
import { join as join3 } from "path";
|
|
1034
2161
|
var REGISTER = "/api/oauth/register";
|
|
1035
2162
|
var AUTHORIZE = "/api/oauth/authorize";
|
|
@@ -1121,13 +2248,13 @@ function toStore(clientId, t, now) {
|
|
|
1121
2248
|
}
|
|
1122
2249
|
function loadStore() {
|
|
1123
2250
|
try {
|
|
1124
|
-
return JSON.parse(
|
|
2251
|
+
return JSON.parse(readFileSync4(STORE_PATH, "utf8"));
|
|
1125
2252
|
} catch {
|
|
1126
2253
|
return null;
|
|
1127
2254
|
}
|
|
1128
2255
|
}
|
|
1129
2256
|
function saveStore(s) {
|
|
1130
|
-
|
|
2257
|
+
writeFileSync3(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
|
|
1131
2258
|
}
|
|
1132
2259
|
function clearStore() {
|
|
1133
2260
|
try {
|
|
@@ -1137,7 +2264,7 @@ function clearStore() {
|
|
|
1137
2264
|
}
|
|
1138
2265
|
function loadClientId() {
|
|
1139
2266
|
try {
|
|
1140
|
-
return JSON.parse(
|
|
2267
|
+
return JSON.parse(readFileSync4(CLIENT_PATH, "utf8")).clientId;
|
|
1141
2268
|
} catch {
|
|
1142
2269
|
return null;
|
|
1143
2270
|
}
|
|
@@ -1146,7 +2273,7 @@ async function ensureClientId(api, redirectUri, fetchFn = fetch) {
|
|
|
1146
2273
|
const existing = loadClientId();
|
|
1147
2274
|
if (existing) return existing;
|
|
1148
2275
|
const clientId = await registerClient({ api, redirectUri }, fetchFn);
|
|
1149
|
-
|
|
2276
|
+
writeFileSync3(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
|
|
1150
2277
|
return clientId;
|
|
1151
2278
|
}
|
|
1152
2279
|
async function ensureAccess(store, opts = {}) {
|
|
@@ -1168,13 +2295,11 @@ async function ensureAccess(store, opts = {}) {
|
|
|
1168
2295
|
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1169
2296
|
var jsonForScript = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
1170
2297
|
var TASKLESS_MARK = `<svg viewBox="0 0 60 54" width="19" height="19" fill="none" aria-hidden="true" style="display:block"><g transform="matrix(1.0512,0,0,1.0512,0,-0.021)"><path fill="currentColor" fill-rule="nonzero" d="M46.92,0 L10.24,0 C4.59,0 0,4.59 0,10.24 L0,10.4 C0,16.05 4.59,20.64 10.24,20.64 L16.84,20.64 C19.56,20.64 22.12,19.59 24.05,17.67 L36.06,5.77 C37.25,4.6 38.82,3.95 40.49,3.95 L46.93,3.95 C50.4,3.95 53.22,6.77 53.22,10.24 L53.22,10.4 C53.22,13.87 50.4,16.69 46.93,16.69 L39.82,16.69 C37.05,16.69 34.36,17.83 32.44,19.83 L21.13,31.58 C19.28,33.5 18.27,36.02 18.27,38.68 L18.27,40.94 C18.27,46.59 22.86,51.18 28.51,51.18 C34.16,51.18 38.75,46.59 38.75,40.94 L38.75,20.74 C39.1,20.68 39.46,20.64 39.82,20.64 L46.93,20.64 C52.58,20.64 57.17,16.05 57.17,10.4 L57.17,10.24 C57.17,4.59 52.58,0 46.93,0 L46.92,0 Z M21.26,14.87 C20.07,16.04 18.5,16.69 16.83,16.69 L10.23,16.69 C6.76,16.69 3.94,13.87 3.94,10.4 L3.94,10.24 C3.94,6.77 6.76,3.95 10.23,3.95 L32.27,3.95 L21.25,14.87 L21.26,14.87 Z M34.79,40.94 C34.79,44.41 31.97,47.23 28.5,47.23 C25.03,47.23 22.21,44.41 22.21,40.94 L22.21,38.68 C22.21,37.05 22.83,35.5 23.97,34.32 L34.79,23.07 L34.79,40.94 L34.79,40.94 Z"/></g></svg>`;
|
|
1171
|
-
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1172
|
-
var sqliteMs = (ts) => Date.parse(ts.replace(" ", "T") + "Z");
|
|
1173
2298
|
var sqliteIso = (ts) => {
|
|
1174
2299
|
const ms = sqliteMs(ts);
|
|
1175
2300
|
return Number.isNaN(ms) ? ts : new Date(ms).toISOString();
|
|
1176
2301
|
};
|
|
1177
|
-
function roySnapshot(db2, now = Date.now()) {
|
|
2302
|
+
function roySnapshot(db2, now = Date.now(), overmindLog = []) {
|
|
1178
2303
|
const all = db2.listRuns();
|
|
1179
2304
|
const c = (s) => all.filter((r) => r.status === s).length;
|
|
1180
2305
|
const withQ = new Set(db2.runIdsWithQuestions());
|
|
@@ -1244,13 +2369,15 @@ function roySnapshot(db2, now = Date.now()) {
|
|
|
1244
2369
|
active: db2.stormActive(),
|
|
1245
2370
|
since: db2.currentStormStartedAt(),
|
|
1246
2371
|
dead: all.filter((r) => r.status === "storm_dead").length
|
|
1247
|
-
}
|
|
2372
|
+
},
|
|
2373
|
+
overmindLog
|
|
1248
2374
|
};
|
|
1249
2375
|
}
|
|
1250
|
-
var
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
2376
|
+
var {
|
|
2377
|
+
windowMs: TL_RECENT_TERMINAL_MS,
|
|
2378
|
+
maxTerminalLanes: TL_MAX_TERMINAL_LANES,
|
|
2379
|
+
maxAxisMs: TL_MAX_AXIS_MS
|
|
2380
|
+
} = resolveRoyWindows(process.env);
|
|
1254
2381
|
var ciCache = /* @__PURE__ */ new Map();
|
|
1255
2382
|
var CI_TTL = 2e4;
|
|
1256
2383
|
var CI_MOCK = (() => {
|
|
@@ -1266,50 +2393,18 @@ function mockCi(bucket) {
|
|
|
1266
2393
|
if (b === "pending") return { bucket: "pending", tone: "review", text: "\u23F3 CI \u0438\u0434\u0451\u0442 (1/2)", total: 2, pass: 1, fail: 0, pending: 1 };
|
|
1267
2394
|
return { bucket: "pass", tone: "merge", text: "\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443", total: 2, pass: 2, fail: 0, pending: 0 };
|
|
1268
2395
|
}
|
|
1269
|
-
async function ghChecks(pr) {
|
|
1270
|
-
try {
|
|
1271
|
-
const { stdout: out } = await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`;
|
|
1272
|
-
const rows = JSON.parse(out);
|
|
1273
|
-
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
1274
|
-
let pass = 0, fail = 0, pending2 = 0;
|
|
1275
|
-
for (const r of rows) {
|
|
1276
|
-
const b = (r.bucket || "").toLowerCase();
|
|
1277
|
-
if (b === "pass") pass++;
|
|
1278
|
-
else if (b === "fail" || b === "cancel") fail++;
|
|
1279
|
-
else if (b === "pending") pending2++;
|
|
1280
|
-
}
|
|
1281
|
-
const total = rows.length;
|
|
1282
|
-
let bucket, text, tone;
|
|
1283
|
-
if (fail > 0) {
|
|
1284
|
-
bucket = "fail";
|
|
1285
|
-
tone = "fail";
|
|
1286
|
-
text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
|
|
1287
|
-
} else if (pending2 > 0) {
|
|
1288
|
-
bucket = "pending";
|
|
1289
|
-
tone = "review";
|
|
1290
|
-
text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
|
|
1291
|
-
} else {
|
|
1292
|
-
bucket = "pass";
|
|
1293
|
-
tone = "merge";
|
|
1294
|
-
text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
|
|
1295
|
-
}
|
|
1296
|
-
return { bucket, text, tone, total, pass, fail, pending: pending2 };
|
|
1297
|
-
} catch {
|
|
1298
|
-
return null;
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
2396
|
async function refreshCI(prs) {
|
|
1302
2397
|
await Promise.all(prs.map(async (pr) => {
|
|
1303
2398
|
const ci = await ghChecks(pr);
|
|
1304
2399
|
ciCache.set(pr, { ci, at: Date.now() });
|
|
1305
2400
|
}));
|
|
1306
2401
|
}
|
|
1307
|
-
function timelineState(db2, now = Date.now()) {
|
|
2402
|
+
function timelineState(db2, now = Date.now(), overmindLog = []) {
|
|
1308
2403
|
const runs = db2.listRuns().slice().sort((a, b) => a.id - b.id);
|
|
1309
2404
|
const doneNameBySlug = /* @__PURE__ */ new Map();
|
|
1310
2405
|
const activeBySlug = /* @__PURE__ */ new Map();
|
|
1311
2406
|
for (const r of runs) {
|
|
1312
|
-
if (!
|
|
2407
|
+
if (!isTerminalStatus(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
|
|
1313
2408
|
if (r.status === "done") doneNameBySlug.set(r.task_slug, r.name);
|
|
1314
2409
|
}
|
|
1315
2410
|
const openQ = /* @__PURE__ */ new Map();
|
|
@@ -1319,7 +2414,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1319
2414
|
const lanes = [];
|
|
1320
2415
|
const reviewPRs = [];
|
|
1321
2416
|
for (const r of runs) {
|
|
1322
|
-
const live = !
|
|
2417
|
+
const live = !isTerminalStatus(r.status);
|
|
1323
2418
|
const recencyGated = !live || r.status === "stalled";
|
|
1324
2419
|
if (recencyGated && sqliteMs(r.updated_at) < cutoff) continue;
|
|
1325
2420
|
const events = db2.events(r.id).map((e) => ({ kind: e.kind, text: e.text ?? null, at: sqliteIso(e.at) }));
|
|
@@ -1347,7 +2442,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1347
2442
|
id: r.id,
|
|
1348
2443
|
name: r.name,
|
|
1349
2444
|
slug: r.task_slug,
|
|
1350
|
-
repo:
|
|
2445
|
+
repo: basename2(r.repo || "") || "?",
|
|
1351
2446
|
status: r.status,
|
|
1352
2447
|
area: r.area,
|
|
1353
2448
|
start: sqliteIso(r.created_at),
|
|
@@ -1361,7 +2456,10 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1361
2456
|
ci,
|
|
1362
2457
|
pr: r.pr_url,
|
|
1363
2458
|
dependsOn: deps,
|
|
1364
|
-
cost: r.cost_usd || 0
|
|
2459
|
+
cost: r.cost_usd || 0,
|
|
2460
|
+
role: r.kind,
|
|
2461
|
+
heroRunId: r.hero_run_id,
|
|
2462
|
+
reviewVerdict: r.review_verdict
|
|
1365
2463
|
});
|
|
1366
2464
|
}
|
|
1367
2465
|
const liveLanes = lanes.filter((l) => l.live);
|
|
@@ -1391,7 +2489,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1391
2489
|
const headline = pickHeadline(shown);
|
|
1392
2490
|
const stale = [...new Set(reviewPRs)].filter((pr) => now - (ciCache.get(pr)?.at ?? 0) > CI_TTL);
|
|
1393
2491
|
if (stale.length) void refreshCI(stale);
|
|
1394
|
-
return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown };
|
|
2492
|
+
return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown, overmindLog };
|
|
1395
2493
|
}
|
|
1396
2494
|
var CSS = `
|
|
1397
2495
|
:root {
|
|
@@ -1529,7 +2627,7 @@ main { max-width: 1080px; margin: 0 auto; width: 100%; }
|
|
|
1529
2627
|
.pill .sd { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
1530
2628
|
.pill.run { color: var(--primary); background: var(--primary-soft); } .pill.run .sd { animation: pulse 1.8s infinite; }
|
|
1531
2629
|
.pill.review { color: var(--info); background: color-mix(in oklch, var(--info) 14%, transparent); }
|
|
1532
|
-
.pill.blocked { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
|
|
2630
|
+
.pill.blocked, .pill.paused, .pill.storm { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
|
|
1533
2631
|
.pill.done { color: var(--success); background: color-mix(in oklch, var(--success) 13%, transparent); }
|
|
1534
2632
|
.pill.stalled, .pill.failed { color: var(--destructive); background: color-mix(in oklch, var(--destructive) 14%, transparent); }
|
|
1535
2633
|
.pill.stopped, .pill.queued, .pill.superseded { color: var(--muted-foreground); background: var(--secondary); }
|
|
@@ -1822,9 +2920,11 @@ var GLYPH = {
|
|
|
1822
2920
|
hex:'<path d="M12 2l8 4.6v9.2L12 21l-8-5.2V6.6L12 2z" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linejoin="round"/><circle cx="12" cy="12" r="2" fill="currentColor"/>'
|
|
1823
2921
|
};
|
|
1824
2922
|
var GK = ['claw','spore','eye','hex'];
|
|
1825
|
-
|
|
2923
|
+
/* \u041A\u0430\u0440\u0442\u0430 \u0441\u0442\u0430\u0442\u0443\u0441\u2192[pill,label,color] \u0438 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u043E\u0441\u0442\u044C \u2014 \u0438\u0437 swarm-core (statusTuples/
|
|
2924
|
+
TERMINAL_STATUSES), \u0430 \u043D\u0435 \u0445\u0430\u0440\u0434\u043A\u043E\u0434-\u043A\u043E\u043F\u0438\u044F (\u043E\u043D\u0430 \u0443\u0436\u0435 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u043B\u0430\u0441\u044C: paused/storm_dead). */
|
|
2925
|
+
var ST = ${jsonForScript(statusTuples())};
|
|
1826
2926
|
var ORDER = { running:0, blocked:1, paused:2, storm_dead:3, review:4, stalled:5, queued:6, stopped:7, done:8, failed:9, superseded:10 };
|
|
1827
|
-
var TERM = {
|
|
2927
|
+
var TERM = ${jsonForScript(Object.fromEntries(TERMINAL_STATUSES.map((s) => [s, 1])))};
|
|
1828
2928
|
var EVL = { spawned:'\u0434\u0440\u043E\u043D \u0437\u0430\u043F\u0443\u0449\u0435\u043D', step:'', blocked:'\u0432\u043E\u043F\u0440\u043E\u0441 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0443', answered:'\u043F\u043E\u043B\u0443\u0447\u0435\u043D \u043E\u0442\u0432\u0435\u0442', pr_opened:'PR \u043E\u0442\u043A\u0440\u044B\u0442', merged:'PR \u0441\u043C\u0435\u0440\u0436\u0451\u043D, \u0437\u0430\u0434\u0430\u0447\u0430 \u0437\u0430\u043A\u0440\u044B\u0442\u0430', done:'\u0433\u043E\u0442\u043E\u0432\u043E', failed:'\u0434\u0440\u043E\u043D \u0443\u043F\u0430\u043B', stalled:'\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431', respawned:'\u0430\u0432\u0442\u043E-\u043E\u0436\u0438\u0432\u043B\u0435\u043D\u0438\u0435', paused:'\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D', resumed:'overmind \u0432\u0435\u0440\u043D\u0443\u043B\u0441\u044F \u2014 \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u043B', rescued:'worktree \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D \u2014 \u0437\u0430\u0431\u0435\u0440\u0438 \u0440\u0443\u043A\u0430\u043C\u0438', conflict:'\u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442 \u0441 main (merge-main-in)', died:'\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440', storm_start:'\u26A1 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 \u2014 Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442', storm_end:'\u0441\u0432\u044F\u0437\u044C \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u2014 \u0440\u043E\u0439 \u043F\u0440\u043E\u0431\u0443\u0436\u0434\u0430\u0435\u0442\u0441\u044F', storm_dead:'\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 (\u0448\u0442\u043E\u0440\u043C)', stopped:'\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D', superseded:'\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D' };
|
|
1829
2929
|
var MDOT = { merged:'--success', done:'--success', pr_opened:'--info', spawned:'--muted-foreground', stalled:'--destructive', failed:'--destructive', respawned:'--warning', paused:'--warning', resumed:'--success', rescued:'--destructive', conflict:'--warning', died:'--destructive', storm_start:'--destructive', storm_end:'--success', storm_dead:'--warning', stopped:'--muted-foreground', superseded:'--muted-foreground' };
|
|
1830
2930
|
|
|
@@ -2326,14 +3426,15 @@ async function runDiff(db2, id, now = Date.now()) {
|
|
|
2326
3426
|
diffCache.set(id, { at: now, payload });
|
|
2327
3427
|
return payload;
|
|
2328
3428
|
}
|
|
2329
|
-
|
|
3429
|
+
var readNarratorLog = (log) => log?.() ?? [];
|
|
3430
|
+
function eventsStream(db2, narratorLog) {
|
|
2330
3431
|
let iv;
|
|
2331
3432
|
let last = "";
|
|
2332
3433
|
const stream = new ReadableStream({
|
|
2333
3434
|
start(controller) {
|
|
2334
3435
|
const enc = new TextEncoder();
|
|
2335
3436
|
const send = () => {
|
|
2336
|
-
const snap = JSON.stringify(roySnapshot(db2));
|
|
3437
|
+
const snap = JSON.stringify(roySnapshot(db2, Date.now(), readNarratorLog(narratorLog)));
|
|
2337
3438
|
if (snap === last) return;
|
|
2338
3439
|
last = snap;
|
|
2339
3440
|
try {
|
|
@@ -2367,11 +3468,11 @@ function makeHandler(deps) {
|
|
|
2367
3468
|
const p = url.pathname;
|
|
2368
3469
|
if (p === "/") {
|
|
2369
3470
|
const store = auth();
|
|
2370
|
-
return html(store ? renderDashboard(store.email, roySnapshot(deps.db), taskUrlBase) : renderLogin());
|
|
3471
|
+
return html(store ? renderDashboard(store.email, roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)), taskUrlBase) : renderLogin());
|
|
2371
3472
|
}
|
|
2372
|
-
if (p === "/events") return eventsStream(deps.db);
|
|
2373
|
-
if (p === "/state") return Response.json(roySnapshot(deps.db));
|
|
2374
|
-
if (p === "/timeline") return Response.json(timelineState(deps.db));
|
|
3473
|
+
if (p === "/events") return eventsStream(deps.db, deps.narratorLog);
|
|
3474
|
+
if (p === "/state") return Response.json(roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
|
|
3475
|
+
if (p === "/timeline") return Response.json(timelineState(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
|
|
2375
3476
|
const diffMatch = p.match(/^\/run\/(\d+)\/diff$/);
|
|
2376
3477
|
if (diffMatch) {
|
|
2377
3478
|
if (!auth()) return unauthorized();
|
|
@@ -2449,39 +3550,103 @@ function makeHandler(deps) {
|
|
|
2449
3550
|
function startWeb(deps, port = WEB_PORT) {
|
|
2450
3551
|
const handle = makeHandler(deps);
|
|
2451
3552
|
const server = serve({ fetch: handle, port, hostname: "127.0.0.1" });
|
|
3553
|
+
server.on("error", (err) => {
|
|
3554
|
+
const e = err;
|
|
3555
|
+
if (e.code === "EADDRINUSE") {
|
|
3556
|
+
console.error(`\u043F\u043E\u0440\u0442 ${port} \u0437\u0430\u043D\u044F\u0442 \u2014 \u043F\u043E\u0445\u043E\u0436\u0435, overmind \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${port}).`);
|
|
3557
|
+
} else {
|
|
3558
|
+
console.error(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0430\u0441\u044C: ${e.message}`);
|
|
3559
|
+
}
|
|
3560
|
+
process.exit(1);
|
|
3561
|
+
});
|
|
2452
3562
|
return { port, server };
|
|
2453
3563
|
}
|
|
2454
3564
|
|
|
2455
3565
|
// src/cli.ts
|
|
2456
|
-
import { cancelRun, respawnRun, OVERMIND_ENTRY } from "./overmind.js";
|
|
3566
|
+
import { cancelRun, respawnRun, runDaemon, OVERMIND_ENTRY, listDroneProcesses } from "./overmind.js";
|
|
2457
3567
|
|
|
2458
3568
|
// src/launcher.ts
|
|
2459
3569
|
import { spawn } from "child_process";
|
|
2460
|
-
import { readdirSync, writeFileSync as
|
|
3570
|
+
import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync3, openSync, readdirSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
2461
3571
|
import { homedir as homedir2 } from "os";
|
|
2462
3572
|
import { join as join4 } from "path";
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
3573
|
+
var ISOLATION_FLAGS = ["--setting-sources", "project", "--strict-mcp-config"];
|
|
3574
|
+
function claudeArgs(cfgPath, prompt, continueSession = false, model, pluginDirs = []) {
|
|
3575
|
+
const pluginFlags = pluginDirs.flatMap((dir) => ["--plugin-dir", dir]);
|
|
3576
|
+
const base2 = [
|
|
3577
|
+
"--dangerously-skip-permissions",
|
|
3578
|
+
...ISOLATION_FLAGS,
|
|
3579
|
+
"--mcp-config",
|
|
3580
|
+
cfgPath,
|
|
3581
|
+
...pluginFlags,
|
|
3582
|
+
"--output-format",
|
|
3583
|
+
"json",
|
|
3584
|
+
"-p",
|
|
3585
|
+
prompt
|
|
3586
|
+
];
|
|
3587
|
+
const withModel = model ? ["--model", model, ...base2] : base2;
|
|
3588
|
+
return continueSession ? ["--continue", ...withModel] : withModel;
|
|
3589
|
+
}
|
|
3590
|
+
function roleDir(worktree, kind) {
|
|
3591
|
+
return join4(worktree, ".claude", "roles", kind);
|
|
3592
|
+
}
|
|
3593
|
+
function nextRunLogPath(runLogsDir, runId) {
|
|
3594
|
+
const dir = join4(runLogsDir, String(runId));
|
|
3595
|
+
mkdirSync3(dir, { recursive: true });
|
|
3596
|
+
let files = [];
|
|
3597
|
+
try {
|
|
3598
|
+
files = readdirSync(dir);
|
|
3599
|
+
} catch {
|
|
3600
|
+
}
|
|
3601
|
+
let next = 0;
|
|
3602
|
+
for (const f of files) {
|
|
3603
|
+
const m = /^(\d+)\.json/.exec(f);
|
|
3604
|
+
if (m) next = Math.max(next, Number(m[1]) + 1);
|
|
3605
|
+
}
|
|
3606
|
+
return join4(dir, `${next}.json`);
|
|
3607
|
+
}
|
|
3608
|
+
function openNextRunLog(runLogsDir, runId) {
|
|
3609
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
3610
|
+
try {
|
|
3611
|
+
return openSync(nextRunLogPath(runLogsDir, runId), "wx");
|
|
3612
|
+
} catch (e) {
|
|
3613
|
+
if (e.code !== "EEXIST") throw e;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
throw new Error(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0439\u0442\u0438 \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u0439 run-log \u0444\u0430\u0439\u043B \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${runId} (\u0433\u043E\u043D\u043A\u0430?)`);
|
|
2466
3617
|
}
|
|
2467
3618
|
function makeLauncher(cfg) {
|
|
2468
3619
|
return ({ worktree, run, prompt, continueSession }) => {
|
|
3620
|
+
const isCompiled = cfg.overmindEntry.endsWith(".js");
|
|
3621
|
+
if (!isCompiled && !cfg.overmindEntry.endsWith(".ts")) {
|
|
3622
|
+
throw new Error(`overmindEntry \u043D\u0438 .ts, \u043D\u0438 .js: ${cfg.overmindEntry}`);
|
|
3623
|
+
}
|
|
2469
3624
|
const mcpConfig = {
|
|
2470
3625
|
mcpServers: {
|
|
2471
3626
|
overmind: {
|
|
2472
|
-
command: "bun",
|
|
2473
|
-
args: ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
|
|
3627
|
+
command: isCompiled ? process.execPath : "bun",
|
|
3628
|
+
args: isCompiled ? [cfg.overmindEntry, "--drone", "--run", String(run.id)] : ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
|
|
2474
3629
|
env: { SWARM_DB: cfg.dbPath }
|
|
2475
3630
|
}
|
|
2476
3631
|
}
|
|
2477
3632
|
};
|
|
2478
3633
|
const cfgPath = join4(worktree, ".swarm-mcp.json");
|
|
2479
|
-
|
|
2480
|
-
const
|
|
3634
|
+
writeFileSync4(cfgPath, JSON.stringify(mcpConfig, null, 2));
|
|
3635
|
+
const model = run.model ?? process.env.SWARM_DRONE_MODEL ?? void 0;
|
|
3636
|
+
const dir = roleDir(worktree, run.kind);
|
|
3637
|
+
const pluginDirs = existsSync3(dir) ? [dir] : [];
|
|
3638
|
+
let stdio = "ignore";
|
|
3639
|
+
let outFd;
|
|
3640
|
+
if (cfg.runLogsDir) {
|
|
3641
|
+
outFd = openNextRunLog(cfg.runLogsDir, run.id);
|
|
3642
|
+
stdio = ["ignore", outFd, "ignore"];
|
|
3643
|
+
}
|
|
3644
|
+
const child = spawn(cfg.claudeBin ?? "claude", claudeArgs(cfgPath, prompt, continueSession, model, pluginDirs), {
|
|
2481
3645
|
cwd: worktree,
|
|
2482
3646
|
detached: true,
|
|
2483
|
-
stdio
|
|
3647
|
+
stdio
|
|
2484
3648
|
});
|
|
3649
|
+
if (outFd !== void 0) closeSync(outFd);
|
|
2485
3650
|
child.unref();
|
|
2486
3651
|
if (child.pid == null) throw new Error("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u043D\u0435\u0442 pid)");
|
|
2487
3652
|
return child.pid;
|
|
@@ -2501,19 +3666,19 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2501
3666
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2502
3667
|
|
|
2503
3668
|
// src/workspace.ts
|
|
2504
|
-
import { readFileSync as
|
|
3669
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, unlinkSync as unlinkSync2 } from "fs";
|
|
2505
3670
|
import { join as join5 } from "path";
|
|
2506
3671
|
var WORKSPACE_PATH = join5(SWARM_HOME, "workspace.json");
|
|
2507
3672
|
function loadWorkspace() {
|
|
2508
3673
|
try {
|
|
2509
|
-
const w = JSON.parse(
|
|
3674
|
+
const w = JSON.parse(readFileSync5(WORKSPACE_PATH, "utf8"));
|
|
2510
3675
|
return w && typeof w.id === "string" && w.id ? w : null;
|
|
2511
3676
|
} catch {
|
|
2512
3677
|
return null;
|
|
2513
3678
|
}
|
|
2514
3679
|
}
|
|
2515
3680
|
function saveWorkspace(w) {
|
|
2516
|
-
|
|
3681
|
+
writeFileSync5(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
|
|
2517
3682
|
}
|
|
2518
3683
|
function clearWorkspace() {
|
|
2519
3684
|
try {
|
|
@@ -2543,7 +3708,7 @@ async function connectSwarmMcp(cfg) {
|
|
|
2543
3708
|
}
|
|
2544
3709
|
if (!token) token = TASKLESS_TOKEN;
|
|
2545
3710
|
if (!token) return null;
|
|
2546
|
-
const url = new URL(
|
|
3711
|
+
const url = new URL(mcpEndpoint(api));
|
|
2547
3712
|
const transport = new StreamableHTTPClientTransport(url, {
|
|
2548
3713
|
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
2549
3714
|
});
|
|
@@ -2633,7 +3798,187 @@ async function openBrowser(url) {
|
|
|
2633
3798
|
}
|
|
2634
3799
|
}
|
|
2635
3800
|
|
|
3801
|
+
// src/screenshot.ts
|
|
3802
|
+
var DESKTOP_VIEWPORT = { name: "desktop", width: 1360, height: 940 };
|
|
3803
|
+
var MOBILE_VIEWPORT = { name: "mobile", width: 390, height: 844 };
|
|
3804
|
+
var DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
|
|
3805
|
+
var DEFAULT_SHOT_TIMEOUT_MS = 2e4;
|
|
3806
|
+
async function captureScreenshots(launchBrowser, opts) {
|
|
3807
|
+
const viewports = opts.viewports ?? DEFAULT_VIEWPORTS;
|
|
3808
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SHOT_TIMEOUT_MS;
|
|
3809
|
+
const fullPage = opts.fullPage ?? true;
|
|
3810
|
+
const browser = await launchBrowser();
|
|
3811
|
+
try {
|
|
3812
|
+
const results = [];
|
|
3813
|
+
for (const vp of viewports) {
|
|
3814
|
+
const ctx = await browser.newContext({
|
|
3815
|
+
viewport: { width: vp.width, height: vp.height },
|
|
3816
|
+
...opts.storageStatePath ? { storageState: opts.storageStatePath } : {}
|
|
3817
|
+
});
|
|
3818
|
+
try {
|
|
3819
|
+
const page = await ctx.newPage();
|
|
3820
|
+
await page.goto(opts.url, { timeout: timeoutMs, waitUntil: "load" });
|
|
3821
|
+
const path = `${opts.outPrefix}-${vp.name}.png`;
|
|
3822
|
+
await page.screenshot({ path, fullPage, timeout: timeoutMs });
|
|
3823
|
+
results.push({ viewport: vp.name, path });
|
|
3824
|
+
} finally {
|
|
3825
|
+
await ctx.close();
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
return results;
|
|
3829
|
+
} finally {
|
|
3830
|
+
await browser.close();
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
async function launchChromium(opts = {}) {
|
|
3834
|
+
let mod;
|
|
3835
|
+
try {
|
|
3836
|
+
mod = await import("playwright-core");
|
|
3837
|
+
} catch {
|
|
3838
|
+
throw new Error(
|
|
3839
|
+
"playwright-core \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438 \u043E\u0434\u0438\u043D \u0440\u0430\u0437: (\u0432 \u044D\u0442\u043E\u043C \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u043E) pnpm --filter @taskless-app/swarm add -D playwright-core; (\u043E\u0442\u0434\u0435\u043B\u044C\u043D\u043E) npm i -D playwright-core \u2014 \u0438 \u0432 \u043E\u0431\u043E\u0438\u0445 \u0441\u043B\u0443\u0447\u0430\u044F\u0445 npx playwright install chromium."
|
|
3840
|
+
);
|
|
3841
|
+
}
|
|
3842
|
+
return mod.chromium.launch({ headless: true, ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {} });
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// src/shotCli.ts
|
|
3846
|
+
function isFlagValue(v) {
|
|
3847
|
+
return v !== void 0 && !v.startsWith("--");
|
|
3848
|
+
}
|
|
3849
|
+
function parseShotArgs(argv) {
|
|
3850
|
+
const url = argv[0];
|
|
3851
|
+
const outPrefix = argv[1];
|
|
3852
|
+
if (!url || !outPrefix) return null;
|
|
3853
|
+
let timeoutMs = SWARM_SHOT_TIMEOUT_MS;
|
|
3854
|
+
let storageStatePath = null;
|
|
3855
|
+
for (let i = 2; i < argv.length; i++) {
|
|
3856
|
+
if (argv[i] === "--timeout" && isFlagValue(argv[i + 1])) {
|
|
3857
|
+
const n = Number(argv[i + 1]);
|
|
3858
|
+
if (Number.isFinite(n) && n >= 0) timeoutMs = n;
|
|
3859
|
+
i++;
|
|
3860
|
+
} else if (argv[i] === "--storage-state" && isFlagValue(argv[i + 1])) {
|
|
3861
|
+
storageStatePath = argv[i + 1];
|
|
3862
|
+
i++;
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
return { url, outPrefix, timeoutMs, storageStatePath };
|
|
3866
|
+
}
|
|
3867
|
+
var VIEWPORTS_COUNT = DEFAULT_VIEWPORTS.length;
|
|
3868
|
+
var LAUNCH_BUDGET_MS = 15e3;
|
|
3869
|
+
async function runShotCli(argv, deps = {}) {
|
|
3870
|
+
const log = deps.log ?? ((l) => console.log(l));
|
|
3871
|
+
const error = deps.error ?? ((l) => console.error(l));
|
|
3872
|
+
const args = parseShotArgs(argv);
|
|
3873
|
+
if (!args) {
|
|
3874
|
+
error("\u043D\u0443\u0436\u043D\u043E: swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]");
|
|
3875
|
+
return 1;
|
|
3876
|
+
}
|
|
3877
|
+
const launchBudgetMs = deps.launchBudgetMs ?? LAUNCH_BUDGET_MS;
|
|
3878
|
+
const launched = { browser: null };
|
|
3879
|
+
const trackedLaunch = async () => {
|
|
3880
|
+
const launch = deps.launchBrowser ?? (() => launchChromium({ timeoutMs: launchBudgetMs }));
|
|
3881
|
+
const browser = await launch();
|
|
3882
|
+
launched.browser = browser;
|
|
3883
|
+
return browser;
|
|
3884
|
+
};
|
|
3885
|
+
const budgetMs = args.timeoutMs * VIEWPORTS_COUNT + launchBudgetMs;
|
|
3886
|
+
let timer;
|
|
3887
|
+
const watchdog = new Promise((_, reject) => {
|
|
3888
|
+
timer = setTimeout(
|
|
3889
|
+
() => reject(new Error(`watchdog ${budgetMs}\u043C\u0441 \u0438\u0441\u0442\u0451\u043A \u2014 \u0447\u0442\u043E-\u0442\u043E \u0437\u0430\u0432\u0438\u0441\u043B\u043E \u0432\u043D\u0435 per-viewport \u0442\u0430\u0439\u043C\u0430\u0443\u0442\u0430`)),
|
|
3890
|
+
budgetMs
|
|
3891
|
+
);
|
|
3892
|
+
});
|
|
3893
|
+
try {
|
|
3894
|
+
const results = await Promise.race([
|
|
3895
|
+
captureScreenshots(trackedLaunch, {
|
|
3896
|
+
url: args.url,
|
|
3897
|
+
outPrefix: args.outPrefix,
|
|
3898
|
+
timeoutMs: args.timeoutMs,
|
|
3899
|
+
storageStatePath: args.storageStatePath
|
|
3900
|
+
}),
|
|
3901
|
+
watchdog
|
|
3902
|
+
]);
|
|
3903
|
+
for (const r of results) log(`${r.viewport} \u2192 ${r.path}`);
|
|
3904
|
+
return 0;
|
|
3905
|
+
} catch (e) {
|
|
3906
|
+
error(`swarm shot: ${e instanceof Error ? e.message : String(e)}`);
|
|
3907
|
+
return 1;
|
|
3908
|
+
} finally {
|
|
3909
|
+
clearTimeout(timer);
|
|
3910
|
+
await launched.browser?.close().catch(() => {
|
|
3911
|
+
});
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
// package.json
|
|
3916
|
+
var package_default = {
|
|
3917
|
+
name: "@taskless-app/swarm",
|
|
3918
|
+
version: "0.2.0",
|
|
3919
|
+
type: "module",
|
|
3920
|
+
description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
|
|
3921
|
+
keywords: [
|
|
3922
|
+
"ai-agents",
|
|
3923
|
+
"coding-agents",
|
|
3924
|
+
"swarm",
|
|
3925
|
+
"orchestration",
|
|
3926
|
+
"autonomous-agents",
|
|
3927
|
+
"mcp",
|
|
3928
|
+
"taskless",
|
|
3929
|
+
"cli"
|
|
3930
|
+
],
|
|
3931
|
+
license: "SEE LICENSE IN LICENSE",
|
|
3932
|
+
author: "Ilya Smurov <ilya@smurov.com>",
|
|
3933
|
+
homepage: "https://github.com/ilyasmurov/taskless/tree/main/packages/swarm-daemon#readme",
|
|
3934
|
+
repository: {
|
|
3935
|
+
type: "git",
|
|
3936
|
+
url: "git+https://github.com/ilyasmurov/taskless.git",
|
|
3937
|
+
directory: "packages/swarm-daemon"
|
|
3938
|
+
},
|
|
3939
|
+
engines: {
|
|
3940
|
+
node: ">=22"
|
|
3941
|
+
},
|
|
3942
|
+
bin: {
|
|
3943
|
+
swarm: "dist/cli.js"
|
|
3944
|
+
},
|
|
3945
|
+
files: [
|
|
3946
|
+
"dist",
|
|
3947
|
+
"docs/HERO.md"
|
|
3948
|
+
],
|
|
3949
|
+
scripts: {
|
|
3950
|
+
overmind: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/overmind.ts',
|
|
3951
|
+
swarm: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/cli.ts',
|
|
3952
|
+
build: "tsup",
|
|
3953
|
+
start: "node dist/overmind.js",
|
|
3954
|
+
prepublishOnly: "pnpm run build",
|
|
3955
|
+
test: "vitest run && SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
|
|
3956
|
+
"test:client": "SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
|
|
3957
|
+
"test:watch": "vitest",
|
|
3958
|
+
typecheck: "tsc -p tsconfig.json --noEmit",
|
|
3959
|
+
lint: "echo 'no lint in swarm-daemon yet'"
|
|
3960
|
+
},
|
|
3961
|
+
dependencies: {
|
|
3962
|
+
"@hono/node-server": "^1.13.0",
|
|
3963
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
3964
|
+
"better-sqlite3": "^11.8.0",
|
|
3965
|
+
execa: "^9.5.0",
|
|
3966
|
+
zod: "3.25.76"
|
|
3967
|
+
},
|
|
3968
|
+
devDependencies: {
|
|
3969
|
+
"@taskless/swarm-core": "workspace:*",
|
|
3970
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
3971
|
+
"@types/node": "22.10.5",
|
|
3972
|
+
"playwright-core": "1.49.1",
|
|
3973
|
+
tsup: "^8.5.1",
|
|
3974
|
+
tsx: "4.19.2",
|
|
3975
|
+
typescript: "5.7.3",
|
|
3976
|
+
vitest: "2.1.8"
|
|
3977
|
+
}
|
|
3978
|
+
};
|
|
3979
|
+
|
|
2636
3980
|
// src/cli.ts
|
|
3981
|
+
var PACKAGE_VERSION = package_default.version;
|
|
2637
3982
|
function takeRepo(argv) {
|
|
2638
3983
|
const i = argv.indexOf("--repo");
|
|
2639
3984
|
if (i === -1) return { repo: null, rest: argv };
|
|
@@ -2716,7 +4061,7 @@ async function runLogin(db2, opts) {
|
|
|
2716
4061
|
if (!chosen) return 1;
|
|
2717
4062
|
saveWorkspace(toWorkspaceSel(chosen));
|
|
2718
4063
|
console.log(`\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${chosen.name} (${chosen.slug}) \u2192 ${SWARM_HOME}/workspace.json`);
|
|
2719
|
-
console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \
|
|
4064
|
+
console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \u041F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: swarm start --repo <\u043F\u0443\u0442\u044C \u043A \u0440\u0435\u043F\u043E>");
|
|
2720
4065
|
return 0;
|
|
2721
4066
|
} finally {
|
|
2722
4067
|
web.server.close();
|
|
@@ -2737,14 +4082,16 @@ var STATUS_MARK = {
|
|
|
2737
4082
|
function fmtRun(r) {
|
|
2738
4083
|
const mark = STATUS_MARK[r.status] ?? "?";
|
|
2739
4084
|
const area = r.area ? ` [${r.area}]` : "";
|
|
2740
|
-
const repo = r.repo ? ` (${
|
|
4085
|
+
const repo = r.repo ? ` (${basename3(r.repo)})` : "";
|
|
2741
4086
|
const q = r.status === "blocked" ? " \u2190 \u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430" : "";
|
|
2742
4087
|
const why = r.status === "stopped" && r.stop_reason ? ` \u2014 ${r.stop_reason}` : "";
|
|
2743
4088
|
return `${mark} #${r.id} ${r.name} ${r.task_slug}${repo}${area} ${r.status}${q}${why}`;
|
|
2744
4089
|
}
|
|
2745
4090
|
function usage() {
|
|
2746
|
-
console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432
|
|
4091
|
+
console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432 (v${PACKAGE_VERSION})
|
|
2747
4092
|
|
|
4093
|
+
swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
4094
|
+
swarm start [--repo <path>] \u043F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0435\u0442 \u043E\u0447\u0435\u0440\u0435\u0434\u044C, \u0441\u043F\u0430\u0432\u043D\u0438\u0442/\u0431\u0443\u0434\u0438\u0442 \u0434\u0440\u043E\u043D\u043E\u0432
|
|
2748
4095
|
swarm ls [status] \u0441\u043F\u0438\u0441\u043E\u043A \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 (\u043E\u043F\u0446. \u0444\u0438\u043B\u044C\u0442\u0440 \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0443)
|
|
2749
4096
|
swarm spawn <slug> [area] [--repo <path>] [--after <s1,s2>] \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u044C (--after \u2192 \u0436\u0434\u0430\u0442\u044C done \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0435\u0439)
|
|
2750
4097
|
swarm spawn <slug> --hero [--name HYDRA] [--repo <path>] [--after <s1,s2>] \u0413\u0435\u0440\u043E\u0439-\u043B\u0435\u0439\u0442\u0435\u043D\u0430\u043D\u0442 \u044D\u043F\u0438\u043A\u0430
|
|
@@ -2754,14 +4101,95 @@ function usage() {
|
|
|
2754
4101
|
swarm cancel <slug> <\u043F\u0440\u0438\u0447\u0438\u043D\u0430\u2026> \u043E\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u0441\u0442\u0430\u0442\u0443\u0441 \xAB\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\xBB + \u043F\u0440\u0438\u0447\u0438\u043D\u0430; \u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u043E\u0441\u0442\u0430\u0451\u0442\u0441\u044F)
|
|
2755
4102
|
swarm import-db <path> --repo <path> \u0438\u043C\u043F\u043E\u0440\u0442 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u043E\u0439 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E)
|
|
2756
4103
|
swarm log <runId> \u043B\u043E\u0433 \u043F\u0440\u043E\u0433\u043E\u043D\u0430
|
|
2757
|
-
swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
2758
4104
|
swarm logout \u0437\u0430\u0431\u044B\u0442\u044C \u0442\u043E\u043A\u0435\u043D\u044B \u0438 \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
2759
4105
|
swarm whoami \u043A\u0442\u043E \u0432\u043E\u0448\u0451\u043B \u0438 \u0432 \u043A\u0430\u043A\u043E\u043C \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0435
|
|
2760
4106
|
swarm web \u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 (\u0432\u0445\u043E\u0434 + \u0441\u0442\u0430\u0442\u0443\u0441) \u043D\u0430 127.0.0.1
|
|
4107
|
+
swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]
|
|
4108
|
+
headless-\u0441\u043A\u0440\u0438\u043D desktop+mobile \u041E\u0414\u041D\u041E\u0419 \u043A\u043E\u043C\u0430\u043D\u0434\u043E\u0439 (TASWM-40):
|
|
4109
|
+
<out-prefix>-desktop.png, <out-prefix>-mobile.png
|
|
4110
|
+
swarm --version \u0432\u0435\u0440\u0441\u0438\u044F \u043F\u0430\u043A\u0435\u0442\u0430
|
|
2761
4111
|
`);
|
|
2762
4112
|
}
|
|
2763
|
-
|
|
4113
|
+
async function makeCliStore(repo) {
|
|
4114
|
+
if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
|
|
4115
|
+
const failing = async () => ({ isError: true });
|
|
4116
|
+
const call = await connectSwarmMcp() ?? failing;
|
|
4117
|
+
const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo });
|
|
4118
|
+
await client.hydrate();
|
|
4119
|
+
client.reconcileRuntime({ worktreesDir: WORKTREES_DIR, listProcesses: listDroneProcesses });
|
|
4120
|
+
return client;
|
|
4121
|
+
}
|
|
4122
|
+
async function finishClient(db2) {
|
|
4123
|
+
if (!(db2 instanceof SwarmClient)) return;
|
|
4124
|
+
await db2.flush();
|
|
4125
|
+
db2.close();
|
|
4126
|
+
process.exit(0);
|
|
4127
|
+
}
|
|
2764
4128
|
var [cmd, ...rawArgs] = process.argv.slice(2);
|
|
4129
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
4130
|
+
console.log(PACKAGE_VERSION);
|
|
4131
|
+
process.exit(0);
|
|
4132
|
+
}
|
|
4133
|
+
if (cmd === "logout") {
|
|
4134
|
+
clearStore();
|
|
4135
|
+
clearWorkspace();
|
|
4136
|
+
console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
|
|
4137
|
+
process.exit(0);
|
|
4138
|
+
}
|
|
4139
|
+
if (cmd === "whoami") {
|
|
4140
|
+
const store = loadStore();
|
|
4141
|
+
if (!store) {
|
|
4142
|
+
console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
|
|
4143
|
+
process.exit(0);
|
|
4144
|
+
}
|
|
4145
|
+
const ws = loadWorkspace();
|
|
4146
|
+
console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
|
|
4147
|
+
console.log(
|
|
4148
|
+
ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
|
|
4149
|
+
);
|
|
4150
|
+
process.exit(0);
|
|
4151
|
+
}
|
|
4152
|
+
if (cmd === "import-db") {
|
|
4153
|
+
const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
|
|
4154
|
+
const path = args[0];
|
|
4155
|
+
const repo = repoFlag ?? SWARM_REPO;
|
|
4156
|
+
if (!path || !repo) {
|
|
4157
|
+
console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
|
|
4158
|
+
process.exit(1);
|
|
4159
|
+
}
|
|
4160
|
+
const sdb = new SwarmDb(DB_PATH, repo);
|
|
4161
|
+
const res = sdb.importDb(path, repo);
|
|
4162
|
+
if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
|
|
4163
|
+
else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
|
|
4164
|
+
sdb.close();
|
|
4165
|
+
process.exit(0);
|
|
4166
|
+
}
|
|
4167
|
+
if (cmd === "start") {
|
|
4168
|
+
if (SWARM_BACKEND !== "client") {
|
|
4169
|
+
console.warn("SWARM_BACKEND=client \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u0434\u0435\u043C\u043E\u043D \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C sqlite-\u0440\u0435\u0436\u0438\u043C\u0435, \u0411\u0415\u0417 \u0441\u0432\u044F\u0437\u0438 \u0441 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u043E\u043C Taskless (\u043D\u0435 \u0443\u0432\u0438\u0434\u0438\u0442 \u0437\u0430\u0434\u0430\u0447\u0438, \u043D\u0435 \u043F\u043E\u0448\u043B\u0451\u0442 \u043F\u0443\u043B\u044C\u0441).");
|
|
4170
|
+
}
|
|
4171
|
+
const { repo: repoFlag } = takeRepo(rawArgs);
|
|
4172
|
+
const repo = repoFlag ?? SWARM_REPO;
|
|
4173
|
+
if (!repo) {
|
|
4174
|
+
console.warn("SWARM_REPO \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u043F\u0440\u043E\u0433\u043E\u043D\u044B \u0431\u0435\u0437 \u044F\u0432\u043D\u043E\u0433\u043E repo \u043D\u0435 \u0437\u0430\u043F\u0443\u0441\u0442\u044F\u0442\u0441\u044F (\u043D\u0443\u0436\u0435\u043D spawn --repo).");
|
|
4175
|
+
}
|
|
4176
|
+
try {
|
|
4177
|
+
await runDaemon({ repo });
|
|
4178
|
+
} catch (e) {
|
|
4179
|
+
console.error(`\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0441\u044F: ${e instanceof Error ? e.message : e}`);
|
|
4180
|
+
process.exit(1);
|
|
4181
|
+
}
|
|
4182
|
+
process.exit(0);
|
|
4183
|
+
}
|
|
4184
|
+
if (cmd === "shot") {
|
|
4185
|
+
const code = await runShotCli(rawArgs);
|
|
4186
|
+
process.exit(code);
|
|
4187
|
+
}
|
|
4188
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
4189
|
+
usage();
|
|
4190
|
+
process.exit(0);
|
|
4191
|
+
}
|
|
4192
|
+
var db = await makeCliStore(SWARM_REPO);
|
|
2765
4193
|
switch (cmd) {
|
|
2766
4194
|
case "ls": {
|
|
2767
4195
|
const runs = db.listRuns(rawArgs[0]);
|
|
@@ -2812,7 +4240,7 @@ switch (cmd) {
|
|
|
2812
4240
|
console.error(`\u043F\u0440\u043E\u0433\u043E\u043D #${runId} \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (running) \u2014 \u0431\u0443\u0434\u0438\u0442\u044C \u043D\u0435\u0447\u0435\u0433\u043E`);
|
|
2813
4241
|
process.exit(1);
|
|
2814
4242
|
}
|
|
2815
|
-
const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH });
|
|
4243
|
+
const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH, runLogsDir: RUN_LOGS_DIR });
|
|
2816
4244
|
await respawnRun(
|
|
2817
4245
|
{ db, launch, repoFallback: SWARM_REPO, worktreesDir: WORKTREES_DIR, getTask: () => Promise.resolve(null) },
|
|
2818
4246
|
run,
|
|
@@ -2821,19 +4249,6 @@ switch (cmd) {
|
|
|
2821
4249
|
console.log(`\u0440\u0430\u0437\u0431\u0443\u0436\u0435\u043D (--continue): ${fmtRun(db.getRun(runId))}`);
|
|
2822
4250
|
break;
|
|
2823
4251
|
}
|
|
2824
|
-
case "import-db": {
|
|
2825
|
-
const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
|
|
2826
|
-
const path = args[0];
|
|
2827
|
-
const repo = repoFlag ?? SWARM_REPO;
|
|
2828
|
-
if (!path || !repo) {
|
|
2829
|
-
console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
|
|
2830
|
-
process.exit(1);
|
|
2831
|
-
}
|
|
2832
|
-
const res = db.importDb(path, repo);
|
|
2833
|
-
if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
|
|
2834
|
-
else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
|
|
2835
|
-
break;
|
|
2836
|
-
}
|
|
2837
4252
|
case "questions": {
|
|
2838
4253
|
const qs = db.openQuestions();
|
|
2839
4254
|
if (qs.length === 0) console.log("\u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0432\u043E\u043F\u0440\u043E\u0441\u043E\u0432 \u043D\u0435\u0442");
|
|
@@ -2887,7 +4302,7 @@ switch (cmd) {
|
|
|
2887
4302
|
process.exit(1);
|
|
2888
4303
|
}
|
|
2889
4304
|
const run = db.getRun(runId);
|
|
2890
|
-
if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${
|
|
4305
|
+
if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename3(run.repo)})` : ""}`);
|
|
2891
4306
|
const events = db.events(runId);
|
|
2892
4307
|
if (events.length === 0) console.log("\u0441\u043E\u0431\u044B\u0442\u0438\u0439 \u043D\u0435\u0442");
|
|
2893
4308
|
else events.forEach((e) => console.log(`${e.at} ${e.kind}${e.text ? " " + e.text : ""}`));
|
|
@@ -2900,25 +4315,6 @@ switch (cmd) {
|
|
|
2900
4315
|
db.close();
|
|
2901
4316
|
process.exit(code);
|
|
2902
4317
|
}
|
|
2903
|
-
case "logout": {
|
|
2904
|
-
clearStore();
|
|
2905
|
-
clearWorkspace();
|
|
2906
|
-
console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
|
|
2907
|
-
break;
|
|
2908
|
-
}
|
|
2909
|
-
case "whoami": {
|
|
2910
|
-
const store = loadStore();
|
|
2911
|
-
if (!store) {
|
|
2912
|
-
console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
|
|
2913
|
-
break;
|
|
2914
|
-
}
|
|
2915
|
-
const ws = loadWorkspace();
|
|
2916
|
-
console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
|
|
2917
|
-
console.log(
|
|
2918
|
-
ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
|
|
2919
|
-
);
|
|
2920
|
-
break;
|
|
2921
|
-
}
|
|
2922
4318
|
case "web": {
|
|
2923
4319
|
const web = startWeb({ db });
|
|
2924
4320
|
console.log(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${web.port} \u2190 \u043E\u0442\u043A\u0440\u043E\u0439 \u0438 \u0432\u043E\u0439\u0434\u0438 \u0447\u0435\u0440\u0435\u0437 Taskless`);
|
|
@@ -2929,4 +4325,5 @@ switch (cmd) {
|
|
|
2929
4325
|
default:
|
|
2930
4326
|
usage();
|
|
2931
4327
|
}
|
|
4328
|
+
await finishClient(db);
|
|
2932
4329
|
db.close();
|