lobstah 0.1.2 → 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 +8 -0
- package/dist/main.js +720 -211
- package/docs/lobsterman.md +16 -6
- package/docs/pickup.md +39 -0
- package/docs/vocabulary.md +21 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// apps/cli/src/main.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as fs25 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
7
|
-
import { randomUUID as
|
|
8
|
-
import { spawnSync as
|
|
6
|
+
import * as path19 from "node:path";
|
|
7
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// packages/core/dist/types.js
|
|
@@ -38,6 +38,7 @@ function ensureLayout() {
|
|
|
38
38
|
fs.mkdirSync(dir, { recursive: true });
|
|
39
39
|
}
|
|
40
40
|
fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
|
|
41
42
|
}
|
|
42
43
|
function statusPath(id, lane) {
|
|
43
44
|
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
@@ -963,6 +964,173 @@ function codexInvocation(argv) {
|
|
|
963
964
|
}
|
|
964
965
|
}
|
|
965
966
|
|
|
967
|
+
// packages/core/dist/watch.js
|
|
968
|
+
import * as fs10 from "node:fs";
|
|
969
|
+
import * as path6 from "node:path";
|
|
970
|
+
import { spawnSync } from "node:child_process";
|
|
971
|
+
function watchesDir() {
|
|
972
|
+
return path6.join(lobstahHome(), "watches");
|
|
973
|
+
}
|
|
974
|
+
function slug(key) {
|
|
975
|
+
return key.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
976
|
+
}
|
|
977
|
+
function watchPath(key) {
|
|
978
|
+
return path6.join(watchesDir(), `${slug(key)}.json`);
|
|
979
|
+
}
|
|
980
|
+
function eventsPath2(key) {
|
|
981
|
+
return path6.join(watchesDir(), `${slug(key)}.events`);
|
|
982
|
+
}
|
|
983
|
+
function writeWatch(w) {
|
|
984
|
+
fs10.mkdirSync(watchesDir(), { recursive: true });
|
|
985
|
+
const file = watchPath(w.key);
|
|
986
|
+
const tmp = `${file}.tmp`;
|
|
987
|
+
fs10.writeFileSync(tmp, JSON.stringify(w, null, 2));
|
|
988
|
+
fs10.renameSync(tmp, file);
|
|
989
|
+
}
|
|
990
|
+
function readWatch(key) {
|
|
991
|
+
try {
|
|
992
|
+
return JSON.parse(fs10.readFileSync(watchPath(key), "utf8"));
|
|
993
|
+
} catch {
|
|
994
|
+
return void 0;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function listWatches() {
|
|
998
|
+
if (!fs10.existsSync(watchesDir()))
|
|
999
|
+
return [];
|
|
1000
|
+
return fs10.readdirSync(watchesDir()).filter((f) => f.endsWith(".json")).map((f) => {
|
|
1001
|
+
try {
|
|
1002
|
+
return JSON.parse(fs10.readFileSync(path6.join(watchesDir(), f), "utf8"));
|
|
1003
|
+
} catch {
|
|
1004
|
+
return void 0;
|
|
1005
|
+
}
|
|
1006
|
+
}).filter((w) => w !== void 0);
|
|
1007
|
+
}
|
|
1008
|
+
function addWatch(key, check, opts = {}) {
|
|
1009
|
+
if (!key || !check)
|
|
1010
|
+
throw new Error("watch add requires a key and a --check command");
|
|
1011
|
+
const existing = readWatch(key);
|
|
1012
|
+
const w = {
|
|
1013
|
+
key,
|
|
1014
|
+
owner: opts.owner ?? existing?.owner ?? "man",
|
|
1015
|
+
check,
|
|
1016
|
+
stream: opts.stream ?? existing?.stream,
|
|
1017
|
+
cursor: existing?.cursor ?? opts.cursor ?? "0",
|
|
1018
|
+
everySecs: opts.everySecs ?? existing?.everySecs,
|
|
1019
|
+
brief: opts.brief ?? existing?.brief,
|
|
1020
|
+
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1021
|
+
lastCheckedAt: existing?.lastCheckedAt,
|
|
1022
|
+
seen: existing?.seen ?? 0,
|
|
1023
|
+
seenAt: existing?.seenAt ?? 0,
|
|
1024
|
+
lastFollowUpId: existing?.lastFollowUpId
|
|
1025
|
+
};
|
|
1026
|
+
writeWatch(w);
|
|
1027
|
+
return w;
|
|
1028
|
+
}
|
|
1029
|
+
function removeWatch(key) {
|
|
1030
|
+
const existed = fs10.existsSync(watchPath(key));
|
|
1031
|
+
fs10.rmSync(watchPath(key), { force: true });
|
|
1032
|
+
fs10.rmSync(eventsPath2(key), { force: true });
|
|
1033
|
+
return existed;
|
|
1034
|
+
}
|
|
1035
|
+
function readWatchEvents(key) {
|
|
1036
|
+
try {
|
|
1037
|
+
return fs10.readFileSync(eventsPath2(key), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
1038
|
+
} catch {
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function appendWatchEvents(key, events) {
|
|
1043
|
+
const seen = new Set(readWatchEvents(key).map((e) => String(e.seq)));
|
|
1044
|
+
const fresh = events.filter((e) => !seen.has(String(e.seq)));
|
|
1045
|
+
if (fresh.length > 0) {
|
|
1046
|
+
fs10.appendFileSync(eventsPath2(key), fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
1047
|
+
}
|
|
1048
|
+
return fresh;
|
|
1049
|
+
}
|
|
1050
|
+
function setWatchCursor(key, cursor) {
|
|
1051
|
+
const w = readWatch(key);
|
|
1052
|
+
if (!w || w.cursor === cursor)
|
|
1053
|
+
return;
|
|
1054
|
+
w.cursor = cursor;
|
|
1055
|
+
writeWatch(w);
|
|
1056
|
+
}
|
|
1057
|
+
function watchDue(w, defaultEverySecs, now = Date.now()) {
|
|
1058
|
+
const every = (w.everySecs ?? defaultEverySecs) * 1e3;
|
|
1059
|
+
const last = w.lastCheckedAt ? Date.parse(w.lastCheckedAt) : 0;
|
|
1060
|
+
return now - last >= every;
|
|
1061
|
+
}
|
|
1062
|
+
var CHECK_TIMEOUT_MS = 9e4;
|
|
1063
|
+
function runWatchCheck(w, now = /* @__PURE__ */ new Date()) {
|
|
1064
|
+
w.lastCheckedAt = now.toISOString();
|
|
1065
|
+
writeWatch(w);
|
|
1066
|
+
const cmd = w.check.replaceAll("{cursor}", w.cursor);
|
|
1067
|
+
const res = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: CHECK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
|
|
1068
|
+
if (res.status !== 0 || res.error) {
|
|
1069
|
+
w.lastError = (res.error?.message || res.stderr?.trim() || `exit ${res.status}`).slice(0, 500);
|
|
1070
|
+
writeWatch(w);
|
|
1071
|
+
return { watch: w, fresh: [] };
|
|
1072
|
+
}
|
|
1073
|
+
let parsed;
|
|
1074
|
+
try {
|
|
1075
|
+
parsed = JSON.parse(res.stdout);
|
|
1076
|
+
} catch {
|
|
1077
|
+
w.lastError = `unparseable check output: ${res.stdout.slice(0, 200).trim()}`;
|
|
1078
|
+
writeWatch(w);
|
|
1079
|
+
return { watch: w, fresh: [] };
|
|
1080
|
+
}
|
|
1081
|
+
w.lastError = void 0;
|
|
1082
|
+
const events = Array.isArray(parsed.events) ? parsed.events.map((e) => ({
|
|
1083
|
+
seq: e.seq ?? w.cursor,
|
|
1084
|
+
summary: e.summary !== void 0 ? String(e.summary) : void 0,
|
|
1085
|
+
...e,
|
|
1086
|
+
at: now.toISOString()
|
|
1087
|
+
})) : [];
|
|
1088
|
+
const fresh = appendWatchEvents(w.key, events);
|
|
1089
|
+
if (parsed.cursor !== void 0)
|
|
1090
|
+
w.cursor = String(parsed.cursor);
|
|
1091
|
+
if (parsed.done === true)
|
|
1092
|
+
w.done = true;
|
|
1093
|
+
writeWatch(w);
|
|
1094
|
+
return { watch: w, fresh };
|
|
1095
|
+
}
|
|
1096
|
+
function pendingWatchEvents(consume, owner = "man", now = Date.now()) {
|
|
1097
|
+
const out = [];
|
|
1098
|
+
for (const w of listWatches()) {
|
|
1099
|
+
const isMan = w.owner === "man";
|
|
1100
|
+
if (owner === "man" !== isMan)
|
|
1101
|
+
continue;
|
|
1102
|
+
const events = readWatchEvents(w.key);
|
|
1103
|
+
const fresh = events.slice(w.seen);
|
|
1104
|
+
if (fresh.length === 0) {
|
|
1105
|
+
if (w.done && consume)
|
|
1106
|
+
removeWatch(w.key);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
out.push({ watch: w, events: fresh });
|
|
1110
|
+
if (consume) {
|
|
1111
|
+
w.seen = events.length;
|
|
1112
|
+
w.seenAt = now;
|
|
1113
|
+
if (w.done)
|
|
1114
|
+
removeWatch(w.key);
|
|
1115
|
+
else
|
|
1116
|
+
writeWatch(w);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return out;
|
|
1120
|
+
}
|
|
1121
|
+
function markFollowUp(key, followUpId, deliveredThrough) {
|
|
1122
|
+
const w = readWatch(key);
|
|
1123
|
+
if (!w)
|
|
1124
|
+
return;
|
|
1125
|
+
w.lastFollowUpId = followUpId;
|
|
1126
|
+
w.seen = deliveredThrough;
|
|
1127
|
+
w.seenAt = Date.now();
|
|
1128
|
+
if (w.done)
|
|
1129
|
+
removeWatch(w.key);
|
|
1130
|
+
else
|
|
1131
|
+
writeWatch(w);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
966
1134
|
// packages/supervisor/dist/liveness.js
|
|
967
1135
|
import { execFileSync } from "node:child_process";
|
|
968
1136
|
function classify(input) {
|
|
@@ -1021,26 +1189,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
1021
1189
|
}
|
|
1022
1190
|
|
|
1023
1191
|
// packages/supervisor/dist/daemon.js
|
|
1024
|
-
import * as
|
|
1192
|
+
import * as fs12 from "node:fs";
|
|
1025
1193
|
import * as os3 from "node:os";
|
|
1026
|
-
import * as
|
|
1194
|
+
import * as path8 from "node:path";
|
|
1027
1195
|
import { spawn as spawn2 } from "node:child_process";
|
|
1028
1196
|
import { createRequire as createRequire2 } from "node:module";
|
|
1029
1197
|
import { fileURLToPath } from "node:url";
|
|
1030
1198
|
|
|
1031
1199
|
// packages/supervisor/dist/notify.js
|
|
1032
|
-
import * as
|
|
1033
|
-
import * as
|
|
1200
|
+
import * as fs11 from "node:fs";
|
|
1201
|
+
import * as path7 from "node:path";
|
|
1034
1202
|
import { spawn } from "node:child_process";
|
|
1035
1203
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
1036
1204
|
function cursorPath(id, lane) {
|
|
1037
|
-
return
|
|
1205
|
+
return path7.join(laneDirs(lane).state, `${id}.notified`);
|
|
1038
1206
|
}
|
|
1039
1207
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
1040
1208
|
const log = readStatusLog(id, lane);
|
|
1041
1209
|
let cursor;
|
|
1042
1210
|
try {
|
|
1043
|
-
cursor = Number(
|
|
1211
|
+
cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
1044
1212
|
} catch {
|
|
1045
1213
|
cursor = void 0;
|
|
1046
1214
|
}
|
|
@@ -1050,13 +1218,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
1050
1218
|
if (cursor === void 0 && sinceMs > 0) {
|
|
1051
1219
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
1052
1220
|
}
|
|
1053
|
-
|
|
1221
|
+
fs11.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
1054
1222
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
1055
1223
|
}
|
|
1056
1224
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
1057
1225
|
const dir = laneDirs(lane).state;
|
|
1058
1226
|
const cutoff = Date.now() - maxAgeMs;
|
|
1059
|
-
return
|
|
1227
|
+
return fs11.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs11.statSync(path7.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
1060
1228
|
}
|
|
1061
1229
|
function execNotify(command, ev, log = () => {
|
|
1062
1230
|
}) {
|
|
@@ -1086,11 +1254,11 @@ function captureWaitBaseline() {
|
|
|
1086
1254
|
return lengths;
|
|
1087
1255
|
}
|
|
1088
1256
|
function attnCursorPath(id, lane) {
|
|
1089
|
-
return
|
|
1257
|
+
return path7.join(laneDirs(lane).state, `${id}.attn`);
|
|
1090
1258
|
}
|
|
1091
1259
|
function readAttnCursor(file) {
|
|
1092
1260
|
try {
|
|
1093
|
-
const parsed = JSON.parse(
|
|
1261
|
+
const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1094
1262
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1095
1263
|
} catch {
|
|
1096
1264
|
return { seen: 0, at: 0 };
|
|
@@ -1116,7 +1284,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1116
1284
|
continue;
|
|
1117
1285
|
out.push({ id, lane, entry });
|
|
1118
1286
|
if (consume)
|
|
1119
|
-
|
|
1287
|
+
fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1120
1288
|
}
|
|
1121
1289
|
}
|
|
1122
1290
|
return out;
|
|
@@ -1151,33 +1319,33 @@ function runnerEntry() {
|
|
|
1151
1319
|
}
|
|
1152
1320
|
function readRunnerInfo(dir) {
|
|
1153
1321
|
try {
|
|
1154
|
-
return JSON.parse(
|
|
1322
|
+
return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
|
|
1155
1323
|
} catch {
|
|
1156
1324
|
return void 0;
|
|
1157
1325
|
}
|
|
1158
1326
|
}
|
|
1159
1327
|
function listActive(lane) {
|
|
1160
1328
|
const dir = laneDirs(lane).active;
|
|
1161
|
-
return
|
|
1162
|
-
const d =
|
|
1329
|
+
return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1330
|
+
const d = path8.join(dir, id);
|
|
1163
1331
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1164
1332
|
});
|
|
1165
1333
|
}
|
|
1166
1334
|
function finalize(st) {
|
|
1167
1335
|
try {
|
|
1168
|
-
|
|
1336
|
+
fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
|
|
1169
1337
|
} catch {
|
|
1170
1338
|
}
|
|
1171
1339
|
}
|
|
1172
1340
|
function spawnRunner(st, opts) {
|
|
1173
1341
|
let nudge = opts.nudge;
|
|
1174
|
-
const handoffFile =
|
|
1175
|
-
if (!nudge &&
|
|
1176
|
-
nudge =
|
|
1177
|
-
|
|
1342
|
+
const handoffFile = path8.join(st.dir, "handoff");
|
|
1343
|
+
if (!nudge && fs12.existsSync(handoffFile)) {
|
|
1344
|
+
nudge = fs12.readFileSync(handoffFile, "utf8");
|
|
1345
|
+
fs12.unlinkSync(handoffFile);
|
|
1178
1346
|
}
|
|
1179
|
-
const logPath =
|
|
1180
|
-
const log =
|
|
1347
|
+
const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1348
|
+
const log = fs12.openSync(logPath, "a");
|
|
1181
1349
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1182
1350
|
detached: true,
|
|
1183
1351
|
stdio: ["ignore", log, log],
|
|
@@ -1188,7 +1356,7 @@ function spawnRunner(st, opts) {
|
|
|
1188
1356
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1189
1357
|
}
|
|
1190
1358
|
});
|
|
1191
|
-
|
|
1359
|
+
fs12.closeSync(log);
|
|
1192
1360
|
child.unref();
|
|
1193
1361
|
const info = {
|
|
1194
1362
|
pid: child.pid ?? -1,
|
|
@@ -1196,22 +1364,22 @@ function spawnRunner(st, opts) {
|
|
|
1196
1364
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1197
1365
|
attempts: opts.attempts
|
|
1198
1366
|
};
|
|
1199
|
-
|
|
1367
|
+
fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1200
1368
|
}
|
|
1201
1369
|
function sessionOf(st) {
|
|
1202
1370
|
try {
|
|
1203
|
-
const ev = JSON.parse(
|
|
1371
|
+
const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1204
1372
|
return ev.sessionId;
|
|
1205
1373
|
} catch {
|
|
1206
1374
|
return void 0;
|
|
1207
1375
|
}
|
|
1208
1376
|
}
|
|
1209
1377
|
function reconcileOne(st, cfg, log) {
|
|
1210
|
-
const hasDescriptor =
|
|
1378
|
+
const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
|
|
1211
1379
|
if (!hasDescriptor) {
|
|
1212
|
-
const age = Date.now() -
|
|
1380
|
+
const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
|
|
1213
1381
|
if (age > 5 * 6e4)
|
|
1214
|
-
|
|
1382
|
+
fs12.rmSync(st.dir, { recursive: true, force: true });
|
|
1215
1383
|
return;
|
|
1216
1384
|
}
|
|
1217
1385
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
@@ -1295,16 +1463,16 @@ function writeHeartbeat(cfg) {
|
|
|
1295
1463
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1296
1464
|
};
|
|
1297
1465
|
const tmp = `${executorPath()}.tmp`;
|
|
1298
|
-
|
|
1299
|
-
|
|
1466
|
+
fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1467
|
+
fs12.renameSync(tmp, executorPath());
|
|
1300
1468
|
}
|
|
1301
1469
|
function pruneChores(cfg) {
|
|
1302
1470
|
const dir = laneDirs("chore").done;
|
|
1303
1471
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1304
|
-
for (const id of
|
|
1305
|
-
const p =
|
|
1306
|
-
if (
|
|
1307
|
-
|
|
1472
|
+
for (const id of fs12.readdirSync(dir)) {
|
|
1473
|
+
const p = path8.join(dir, id);
|
|
1474
|
+
if (fs12.statSync(p).mtimeMs < cutoff)
|
|
1475
|
+
fs12.rmSync(p, { recursive: true, force: true });
|
|
1308
1476
|
}
|
|
1309
1477
|
}
|
|
1310
1478
|
var daemonStartedAt = Date.now();
|
|
@@ -1344,9 +1512,9 @@ function tick(log = () => {
|
|
|
1344
1512
|
pruneChores(loadConfig());
|
|
1345
1513
|
}
|
|
1346
1514
|
function acquireDaemonLock() {
|
|
1347
|
-
const lockPath =
|
|
1515
|
+
const lockPath = path8.join(lobstahHome(), "daemon.lock");
|
|
1348
1516
|
try {
|
|
1349
|
-
const prior = JSON.parse(
|
|
1517
|
+
const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
|
|
1350
1518
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1351
1519
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1352
1520
|
}
|
|
@@ -1354,10 +1522,10 @@ function acquireDaemonLock() {
|
|
|
1354
1522
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1355
1523
|
throw err;
|
|
1356
1524
|
}
|
|
1357
|
-
|
|
1525
|
+
fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1358
1526
|
const release = () => {
|
|
1359
1527
|
try {
|
|
1360
|
-
|
|
1528
|
+
fs12.unlinkSync(lockPath);
|
|
1361
1529
|
} catch {
|
|
1362
1530
|
}
|
|
1363
1531
|
};
|
|
@@ -1369,17 +1537,42 @@ function acquireDaemonLock() {
|
|
|
1369
1537
|
});
|
|
1370
1538
|
}
|
|
1371
1539
|
}
|
|
1540
|
+
function watchQueues(onChange) {
|
|
1541
|
+
const watchers = [];
|
|
1542
|
+
for (const lane of ["work", "chore"]) {
|
|
1543
|
+
try {
|
|
1544
|
+
watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
|
|
1545
|
+
} catch {
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return () => {
|
|
1549
|
+
for (const w of watchers)
|
|
1550
|
+
w.close();
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1372
1553
|
async function daemon(intervalMs = 5e3, log = console.log) {
|
|
1373
1554
|
ensureLayout();
|
|
1374
1555
|
acquireDaemonLock();
|
|
1375
1556
|
log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
|
|
1557
|
+
let wake;
|
|
1558
|
+
watchQueues(() => wake?.());
|
|
1376
1559
|
while (true) {
|
|
1377
1560
|
try {
|
|
1378
1561
|
tick(log);
|
|
1379
1562
|
} catch (err) {
|
|
1380
1563
|
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1381
1564
|
}
|
|
1382
|
-
await new Promise((
|
|
1565
|
+
await new Promise((resolve2) => {
|
|
1566
|
+
const timer = setTimeout(() => {
|
|
1567
|
+
wake = void 0;
|
|
1568
|
+
resolve2();
|
|
1569
|
+
}, intervalMs);
|
|
1570
|
+
wake = () => {
|
|
1571
|
+
wake = void 0;
|
|
1572
|
+
clearTimeout(timer);
|
|
1573
|
+
setTimeout(resolve2, 50);
|
|
1574
|
+
};
|
|
1575
|
+
});
|
|
1383
1576
|
}
|
|
1384
1577
|
}
|
|
1385
1578
|
|
|
@@ -1387,7 +1580,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1387
1580
|
import { spawn as spawn3 } from "node:child_process";
|
|
1388
1581
|
|
|
1389
1582
|
// apps/pick/dist/config.js
|
|
1390
|
-
import * as
|
|
1583
|
+
import * as fs13 from "node:fs";
|
|
1391
1584
|
import { execSync } from "node:child_process";
|
|
1392
1585
|
import * as os4 from "node:os";
|
|
1393
1586
|
|
|
@@ -1429,7 +1622,7 @@ function resolveTokenSource(section, label) {
|
|
|
1429
1622
|
}
|
|
1430
1623
|
if (file) {
|
|
1431
1624
|
return () => {
|
|
1432
|
-
const token =
|
|
1625
|
+
const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1433
1626
|
if (!token)
|
|
1434
1627
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1435
1628
|
return token;
|
|
@@ -1447,7 +1640,7 @@ function resolveTokenSource(section, label) {
|
|
|
1447
1640
|
}
|
|
1448
1641
|
function loadPickupConfig() {
|
|
1449
1642
|
const file = configPath();
|
|
1450
|
-
const raw =
|
|
1643
|
+
const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
|
|
1451
1644
|
const p = raw.pickup ?? {};
|
|
1452
1645
|
const out = {
|
|
1453
1646
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
@@ -1520,23 +1713,23 @@ function loadPickupConfig() {
|
|
|
1520
1713
|
}
|
|
1521
1714
|
|
|
1522
1715
|
// apps/pick/dist/state.js
|
|
1523
|
-
import * as
|
|
1524
|
-
import * as
|
|
1716
|
+
import * as fs14 from "node:fs";
|
|
1717
|
+
import * as path9 from "node:path";
|
|
1525
1718
|
var PickupState = class {
|
|
1526
1719
|
file;
|
|
1527
1720
|
data;
|
|
1528
|
-
constructor(dir =
|
|
1529
|
-
|
|
1530
|
-
this.file =
|
|
1531
|
-
this.data =
|
|
1721
|
+
constructor(dir = path9.join(lobstahHome(), "pickup")) {
|
|
1722
|
+
fs14.mkdirSync(dir, { recursive: true });
|
|
1723
|
+
this.file = path9.join(dir, "state.json");
|
|
1724
|
+
this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1532
1725
|
this.data.map ??= {};
|
|
1533
1726
|
this.data.consumedApprovals ??= {};
|
|
1534
1727
|
this.data.rebases ??= {};
|
|
1535
1728
|
}
|
|
1536
1729
|
save() {
|
|
1537
1730
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1538
|
-
|
|
1539
|
-
|
|
1731
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1732
|
+
fs14.renameSync(tmp, this.file);
|
|
1540
1733
|
}
|
|
1541
1734
|
get(key) {
|
|
1542
1735
|
return this.data.map[key];
|
|
@@ -1920,12 +2113,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
1920
2113
|
}
|
|
1921
2114
|
|
|
1922
2115
|
// apps/pick/dist/loops/report.js
|
|
1923
|
-
import * as
|
|
1924
|
-
import * as
|
|
2116
|
+
import * as fs15 from "node:fs";
|
|
2117
|
+
import * as path10 from "node:path";
|
|
1925
2118
|
function dispatchLane(uuid) {
|
|
1926
2119
|
for (const lane of ["work", "chore"]) {
|
|
1927
2120
|
const d = laneDirs(lane);
|
|
1928
|
-
if (
|
|
2121
|
+
if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`)) || fs15.existsSync(path10.join(d.done, uuid)) || fs15.existsSync(path10.join(d.state, `${uuid}.status`))) {
|
|
1929
2122
|
return lane;
|
|
1930
2123
|
}
|
|
1931
2124
|
}
|
|
@@ -1966,12 +2159,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
1966
2159
|
}
|
|
1967
2160
|
|
|
1968
2161
|
// apps/pick/dist/loops/reconcile.js
|
|
1969
|
-
import * as
|
|
1970
|
-
import * as
|
|
2162
|
+
import * as fs16 from "node:fs";
|
|
2163
|
+
import * as path11 from "node:path";
|
|
1971
2164
|
function dispatchActive(uuid) {
|
|
1972
2165
|
for (const lane of ["work", "chore"]) {
|
|
1973
2166
|
const d = laneDirs(lane);
|
|
1974
|
-
if (
|
|
2167
|
+
if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
|
|
1975
2168
|
return lane;
|
|
1976
2169
|
}
|
|
1977
2170
|
}
|
|
@@ -2011,28 +2204,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2011
2204
|
|
|
2012
2205
|
// apps/pick/dist/loops/merge.js
|
|
2013
2206
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2014
|
-
import * as
|
|
2015
|
-
import * as
|
|
2207
|
+
import * as fs18 from "node:fs";
|
|
2208
|
+
import * as path13 from "node:path";
|
|
2016
2209
|
|
|
2017
2210
|
// apps/pick/dist/merge-view.js
|
|
2018
|
-
import * as
|
|
2019
|
-
import * as
|
|
2211
|
+
import * as fs17 from "node:fs";
|
|
2212
|
+
import * as path12 from "node:path";
|
|
2020
2213
|
function viewPath() {
|
|
2021
|
-
return
|
|
2214
|
+
return path12.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2022
2215
|
}
|
|
2023
2216
|
function readMergeView() {
|
|
2024
2217
|
try {
|
|
2025
|
-
return JSON.parse(
|
|
2218
|
+
return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
|
|
2026
2219
|
} catch {
|
|
2027
2220
|
return void 0;
|
|
2028
2221
|
}
|
|
2029
2222
|
}
|
|
2030
2223
|
function writeMergeView(view) {
|
|
2031
2224
|
const file = viewPath();
|
|
2032
|
-
|
|
2225
|
+
fs17.mkdirSync(path12.dirname(file), { recursive: true });
|
|
2033
2226
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2034
|
-
|
|
2035
|
-
|
|
2227
|
+
fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2228
|
+
fs17.renameSync(tmp, file);
|
|
2036
2229
|
}
|
|
2037
2230
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2038
2231
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2054,7 +2247,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2054
2247
|
}
|
|
2055
2248
|
function readPickupMap() {
|
|
2056
2249
|
try {
|
|
2057
|
-
const raw = JSON.parse(
|
|
2250
|
+
const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2058
2251
|
return raw.map ?? {};
|
|
2059
2252
|
} catch {
|
|
2060
2253
|
return {};
|
|
@@ -2088,7 +2281,7 @@ function rebaseBrief(pr) {
|
|
|
2088
2281
|
}
|
|
2089
2282
|
function choreVerdict(uuid) {
|
|
2090
2283
|
const d = laneDirs("chore");
|
|
2091
|
-
if (
|
|
2284
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
|
|
2092
2285
|
return "active";
|
|
2093
2286
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2094
2287
|
return last === "done" ? "done" : "failed";
|
|
@@ -2181,6 +2374,126 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2181
2374
|
await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
|
|
2182
2375
|
}
|
|
2183
2376
|
|
|
2377
|
+
// apps/pick/dist/loops/watch.js
|
|
2378
|
+
import * as fs19 from "node:fs";
|
|
2379
|
+
import * as path14 from "node:path";
|
|
2380
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2381
|
+
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2382
|
+
|
|
2383
|
+
{events}
|
|
2384
|
+
|
|
2385
|
+
Read them, act on what they ask, and report the outcome.`;
|
|
2386
|
+
function laneOf(id) {
|
|
2387
|
+
for (const lane of ["work", "chore"]) {
|
|
2388
|
+
const d = laneDirs(lane);
|
|
2389
|
+
if (fs19.existsSync(path14.join(d.active, id)) || fs19.existsSync(path14.join(d.queue, `${id}.json`)) || fs19.existsSync(path14.join(d.done, id)) || fs19.existsSync(path14.join(d.state, `${id}.status`))) {
|
|
2390
|
+
return lane;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return void 0;
|
|
2394
|
+
}
|
|
2395
|
+
function descriptorOf(id, lane) {
|
|
2396
|
+
const d = laneDirs(lane);
|
|
2397
|
+
for (const bucket of ["active", "done"]) {
|
|
2398
|
+
const f = path14.join(d[bucket], id, "descriptor.json");
|
|
2399
|
+
if (fs19.existsSync(f))
|
|
2400
|
+
return JSON.parse(fs19.readFileSync(f, "utf8"));
|
|
2401
|
+
}
|
|
2402
|
+
const q = path14.join(d.queue, `${id}.json`);
|
|
2403
|
+
if (fs19.existsSync(q))
|
|
2404
|
+
return JSON.parse(fs19.readFileSync(q, "utf8"));
|
|
2405
|
+
return void 0;
|
|
2406
|
+
}
|
|
2407
|
+
function isTerminal(id) {
|
|
2408
|
+
const lane = laneOf(id);
|
|
2409
|
+
if (!lane)
|
|
2410
|
+
return true;
|
|
2411
|
+
const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
|
|
2412
|
+
return state === "done" || state === "failed";
|
|
2413
|
+
}
|
|
2414
|
+
function spawnContinuation(w, pending, log) {
|
|
2415
|
+
const owner = w.owner.slice("dispatch:".length);
|
|
2416
|
+
const target = w.lastFollowUpId && !laneOf(w.lastFollowUpId) ? owner : w.lastFollowUpId ?? owner;
|
|
2417
|
+
const targetLane = laneOf(target);
|
|
2418
|
+
if (!targetLane) {
|
|
2419
|
+
log(`watch ${w.key}: owner dispatch ${target} is gone \u2014 dropping watch`);
|
|
2420
|
+
removeWatch(w.key);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const descriptor = descriptorOf(target, targetLane);
|
|
2424
|
+
if (!descriptor) {
|
|
2425
|
+
log(`watch ${w.key}: no descriptor for ${target} \u2014 dropping watch`);
|
|
2426
|
+
removeWatch(w.key);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const id = randomUUID3();
|
|
2430
|
+
const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
|
|
2431
|
+
enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
|
|
2432
|
+
markFollowUp(w.key, id, readWatchEvents(w.key).length);
|
|
2433
|
+
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
|
|
2434
|
+
}
|
|
2435
|
+
function notifyMan(watch2, fresh, notify) {
|
|
2436
|
+
if (fresh.length === 0 || watch2.owner !== "man")
|
|
2437
|
+
return;
|
|
2438
|
+
notify({
|
|
2439
|
+
key: watch2.key,
|
|
2440
|
+
uuid: "watch",
|
|
2441
|
+
verb: "watch",
|
|
2442
|
+
note: fresh[0]?.summary ?? `${fresh.length} new event(s)`
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
function deliverDispatchOwned(log) {
|
|
2446
|
+
for (const { watch: watch2, events } of pendingWatchEvents(false, "dispatch")) {
|
|
2447
|
+
if (watch2.lastFollowUpId && !isTerminal(watch2.lastFollowUpId))
|
|
2448
|
+
continue;
|
|
2449
|
+
spawnContinuation(watch2, events, log);
|
|
2450
|
+
}
|
|
2451
|
+
for (const w of listWatches()) {
|
|
2452
|
+
if (w.done && w.owner !== "man" && readWatchEvents(w.key).length <= w.seen)
|
|
2453
|
+
removeWatch(w.key);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
async function watchLoop(defaultEverySecs, log, notify = () => {
|
|
2457
|
+
}) {
|
|
2458
|
+
for (const w of listWatches()) {
|
|
2459
|
+
if (watchDue(w, defaultEverySecs)) {
|
|
2460
|
+
const { watch: watch2, fresh } = runWatchCheck(w);
|
|
2461
|
+
if (watch2.lastError)
|
|
2462
|
+
log(`watch ${w.key}: check failed \u2014 ${watch2.lastError}`);
|
|
2463
|
+
notifyMan(watch2, fresh, notify);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
deliverDispatchOwned(log);
|
|
2467
|
+
}
|
|
2468
|
+
function handleStreamLine(key, line, log, notify) {
|
|
2469
|
+
const watch2 = readWatch(key);
|
|
2470
|
+
if (!watch2)
|
|
2471
|
+
return;
|
|
2472
|
+
let parsed;
|
|
2473
|
+
try {
|
|
2474
|
+
parsed = JSON.parse(line);
|
|
2475
|
+
} catch {
|
|
2476
|
+
log(`watch ${key}: unparseable stream line \u2014 ${line.slice(0, 120)}`);
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
if (parsed.seq === void 0) {
|
|
2480
|
+
if (parsed.cursor !== void 0)
|
|
2481
|
+
setWatchCursor(key, String(parsed.cursor));
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
const event = {
|
|
2485
|
+
seq: parsed.seq,
|
|
2486
|
+
summary: parsed.summary !== void 0 ? String(parsed.summary) : void 0,
|
|
2487
|
+
...parsed,
|
|
2488
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2489
|
+
};
|
|
2490
|
+
const fresh = appendWatchEvents(key, event.seq !== void 0 ? [event] : []);
|
|
2491
|
+
setWatchCursor(key, String(parsed.cursor ?? event.seq));
|
|
2492
|
+
notifyMan(watch2, fresh, notify);
|
|
2493
|
+
if (fresh.length > 0)
|
|
2494
|
+
deliverDispatchOwned(log);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2184
2497
|
// apps/pick/dist/run.js
|
|
2185
2498
|
function makeNotifier(command, log) {
|
|
2186
2499
|
if (!command)
|
|
@@ -2204,7 +2517,49 @@ function makeNotifier(command, log) {
|
|
|
2204
2517
|
child.unref();
|
|
2205
2518
|
};
|
|
2206
2519
|
}
|
|
2207
|
-
|
|
2520
|
+
var SingleFlight = class {
|
|
2521
|
+
chain = Promise.resolve();
|
|
2522
|
+
run(job) {
|
|
2523
|
+
const next = this.chain.then(job, job);
|
|
2524
|
+
this.chain = next.then(() => {
|
|
2525
|
+
}, () => {
|
|
2526
|
+
});
|
|
2527
|
+
return next;
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
function syncStreams(children, flight, log, notify) {
|
|
2531
|
+
for (const w of listWatches()) {
|
|
2532
|
+
if (!w.stream || children.has(w.key))
|
|
2533
|
+
continue;
|
|
2534
|
+
const cmd = w.stream.replaceAll("{cursor}", w.cursor);
|
|
2535
|
+
const child = spawn3(cmd, { shell: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
2536
|
+
children.set(w.key, child);
|
|
2537
|
+
log(`watch ${w.key}: stream attached`);
|
|
2538
|
+
let buffer = "";
|
|
2539
|
+
child.stdout.on("data", (chunk) => {
|
|
2540
|
+
buffer += chunk.toString("utf8");
|
|
2541
|
+
const lines = buffer.split("\n");
|
|
2542
|
+
buffer = lines.pop() ?? "";
|
|
2543
|
+
for (const line of lines) {
|
|
2544
|
+
if (!line.trim())
|
|
2545
|
+
continue;
|
|
2546
|
+
void flight.run(() => handleStreamLine(w.key, line, log, notify));
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
child.on("exit", () => {
|
|
2550
|
+
children.delete(w.key);
|
|
2551
|
+
log(`watch ${w.key}: stream ended \u2014 cadence covers until resync`);
|
|
2552
|
+
});
|
|
2553
|
+
child.on("error", () => children.delete(w.key));
|
|
2554
|
+
}
|
|
2555
|
+
for (const [key, child] of children) {
|
|
2556
|
+
if (!readWatch(key)?.stream) {
|
|
2557
|
+
child.kill();
|
|
2558
|
+
children.delete(key);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
async function cycle(sources, merges, state, pollSecs, log, notify) {
|
|
2208
2563
|
for (const source of sources) {
|
|
2209
2564
|
try {
|
|
2210
2565
|
await dispatchLoop(source, state, log);
|
|
@@ -2221,6 +2576,11 @@ async function cycle(sources, merges, state, log, notify) {
|
|
|
2221
2576
|
log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
|
|
2222
2577
|
}
|
|
2223
2578
|
}
|
|
2579
|
+
try {
|
|
2580
|
+
await watchLoop(pollSecs, log, notify);
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
log(`watches: ${err instanceof Error ? err.message : String(err)}`);
|
|
2583
|
+
}
|
|
2224
2584
|
}
|
|
2225
2585
|
async function runPickup(mode = "daemon") {
|
|
2226
2586
|
ensureLayout();
|
|
@@ -2237,16 +2597,22 @@ async function runPickup(mode = "daemon") {
|
|
|
2237
2597
|
if (cfg.linear)
|
|
2238
2598
|
sources.push(new LinearSource(cfg.linear));
|
|
2239
2599
|
if (sources.length === 0) {
|
|
2240
|
-
|
|
2600
|
+
if (listWatches().length === 0) {
|
|
2601
|
+
throw new Error("no [pickup.github], [pickup.linear], or registered watches \u2014 nothing to poll");
|
|
2602
|
+
}
|
|
2603
|
+
log("no tracker sources configured \u2014 watch-only mode");
|
|
2241
2604
|
}
|
|
2242
2605
|
const notify = makeNotifier(cfg.notifyCommand, log);
|
|
2243
2606
|
if (mode === "once") {
|
|
2244
|
-
await cycle(sources, merges, state, log, notify);
|
|
2607
|
+
await cycle(sources, merges, state, cfg.pollSecs, log, notify);
|
|
2245
2608
|
return;
|
|
2246
2609
|
}
|
|
2247
2610
|
log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
|
|
2611
|
+
const flight = new SingleFlight();
|
|
2612
|
+
const streamChildren = /* @__PURE__ */ new Map();
|
|
2248
2613
|
while (true) {
|
|
2249
|
-
await cycle(sources, merges, state, log, notify);
|
|
2614
|
+
await flight.run(() => cycle(sources, merges, state, cfg.pollSecs, log, notify));
|
|
2615
|
+
syncStreams(streamChildren, flight, log, notify);
|
|
2250
2616
|
await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
|
|
2251
2617
|
}
|
|
2252
2618
|
}
|
|
@@ -2265,21 +2631,21 @@ function mergeHaulHook(existing) {
|
|
|
2265
2631
|
}
|
|
2266
2632
|
|
|
2267
2633
|
// apps/cli/src/tend.ts
|
|
2268
|
-
import * as
|
|
2269
|
-
import * as
|
|
2634
|
+
import * as fs20 from "node:fs";
|
|
2635
|
+
import * as path15 from "node:path";
|
|
2270
2636
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2271
2637
|
var CLAIM_STALE_MS = 12e4;
|
|
2272
2638
|
var DAY_MS = 24 * 36e5;
|
|
2273
2639
|
function readJson(file) {
|
|
2274
2640
|
try {
|
|
2275
|
-
return JSON.parse(
|
|
2641
|
+
return JSON.parse(fs20.readFileSync(file, "utf8"));
|
|
2276
2642
|
} catch {
|
|
2277
2643
|
return void 0;
|
|
2278
2644
|
}
|
|
2279
2645
|
}
|
|
2280
2646
|
function doneIds(lane) {
|
|
2281
2647
|
try {
|
|
2282
|
-
return
|
|
2648
|
+
return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2283
2649
|
} catch {
|
|
2284
2650
|
return [];
|
|
2285
2651
|
}
|
|
@@ -2298,16 +2664,16 @@ function followUps(uuid) {
|
|
|
2298
2664
|
const desc = readJson(file);
|
|
2299
2665
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2300
2666
|
};
|
|
2301
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2302
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2303
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2667
|
+
for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
|
|
2668
|
+
for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
|
|
2669
|
+
for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
|
|
2304
2670
|
return out;
|
|
2305
2671
|
}
|
|
2306
2672
|
function bucketOf(uuid) {
|
|
2307
2673
|
const d = laneDirs("work");
|
|
2308
|
-
if (
|
|
2309
|
-
if (
|
|
2310
|
-
if (
|
|
2674
|
+
if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
|
|
2675
|
+
if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
|
|
2676
|
+
if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2311
2677
|
return void 0;
|
|
2312
2678
|
}
|
|
2313
2679
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2335,8 +2701,39 @@ function buildTendReport(now = Date.now()) {
|
|
|
2335
2701
|
ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
|
|
2336
2702
|
note: ev.entry.note
|
|
2337
2703
|
}));
|
|
2704
|
+
const watches = [];
|
|
2705
|
+
const watchByDispatch = /* @__PURE__ */ new Map();
|
|
2706
|
+
for (const w of listWatches()) {
|
|
2707
|
+
const events = readWatchEvents(w.key);
|
|
2708
|
+
const pending = events.slice(w.seen);
|
|
2709
|
+
const last = events.at(-1);
|
|
2710
|
+
watches.push({
|
|
2711
|
+
key: w.key,
|
|
2712
|
+
owner: w.owner,
|
|
2713
|
+
cursor: w.cursor,
|
|
2714
|
+
pendingEvents: pending.length,
|
|
2715
|
+
lastSummary: last?.summary,
|
|
2716
|
+
lastAt: last?.at,
|
|
2717
|
+
error: w.lastError
|
|
2718
|
+
});
|
|
2719
|
+
if (w.owner.startsWith("dispatch:")) {
|
|
2720
|
+
const label = `${w.key}${pending.length > 0 ? ` (${pending.length} pending)` : ""}`;
|
|
2721
|
+
watchByDispatch.set(w.owner.slice("dispatch:".length), label);
|
|
2722
|
+
if (w.lastFollowUpId) watchByDispatch.set(w.lastFollowUpId, label);
|
|
2723
|
+
}
|
|
2724
|
+
if (w.owner === "man" && pending.length > 0) {
|
|
2725
|
+
const oldest = pending[0];
|
|
2726
|
+
attention.push({
|
|
2727
|
+
id: w.key,
|
|
2728
|
+
lane: "work",
|
|
2729
|
+
verb: "watch",
|
|
2730
|
+
ageSecs: oldest?.at ? Math.max(0, Math.round((now - Date.parse(oldest.at)) / 1e3)) : 0,
|
|
2731
|
+
note: pending.at(-1)?.summary
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2338
2735
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2339
|
-
const st =
|
|
2736
|
+
const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2340
2737
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2341
2738
|
}, 0);
|
|
2342
2739
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2358,12 +2755,13 @@ function buildTendReport(now = Date.now()) {
|
|
|
2358
2755
|
const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
|
|
2359
2756
|
if (!fresh) continue;
|
|
2360
2757
|
const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
|
|
2361
|
-
|
|
2758
|
+
const watch2 = chain.map((d) => watchByDispatch.get(d.id)).find((w) => w !== void 0);
|
|
2759
|
+
stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl), watch: watch2 });
|
|
2362
2760
|
}
|
|
2363
2761
|
for (const id of [...active, ...queued]) {
|
|
2364
2762
|
if (storied.has(id)) continue;
|
|
2365
2763
|
const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
|
|
2366
|
-
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
|
|
2764
|
+
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
|
|
2367
2765
|
}
|
|
2368
2766
|
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
2369
2767
|
return {
|
|
@@ -2372,6 +2770,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2372
2770
|
counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
|
|
2373
2771
|
attention,
|
|
2374
2772
|
stories,
|
|
2773
|
+
watches,
|
|
2375
2774
|
merge
|
|
2376
2775
|
};
|
|
2377
2776
|
}
|
|
@@ -2407,9 +2806,26 @@ function renderTend(r) {
|
|
|
2407
2806
|
key: s.key,
|
|
2408
2807
|
dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
|
|
2409
2808
|
pr: s.prUrl ?? "",
|
|
2410
|
-
gate: s.gate ?? ""
|
|
2809
|
+
gate: s.gate ?? "",
|
|
2810
|
+
watch: s.watch ?? ""
|
|
2411
2811
|
})),
|
|
2412
|
-
["key", "dispatches", "pr", "gate"]
|
|
2812
|
+
["key", "dispatches", "pr", "gate", "watch"]
|
|
2813
|
+
)
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
if (r.watches.length > 0) {
|
|
2817
|
+
lines.push("");
|
|
2818
|
+
lines.push(
|
|
2819
|
+
toonTable(
|
|
2820
|
+
"watches",
|
|
2821
|
+
r.watches.map((w) => ({
|
|
2822
|
+
key: w.key,
|
|
2823
|
+
owner: w.owner,
|
|
2824
|
+
pending: w.pendingEvents,
|
|
2825
|
+
last: w.lastSummary ?? "",
|
|
2826
|
+
error: w.error ?? ""
|
|
2827
|
+
})),
|
|
2828
|
+
["key", "owner", "pending", "last", "error"]
|
|
2413
2829
|
)
|
|
2414
2830
|
);
|
|
2415
2831
|
}
|
|
@@ -2430,13 +2846,13 @@ function renderTend(r) {
|
|
|
2430
2846
|
}
|
|
2431
2847
|
|
|
2432
2848
|
// apps/cli/src/cull.ts
|
|
2433
|
-
import * as
|
|
2434
|
-
import * as
|
|
2849
|
+
import * as fs21 from "node:fs";
|
|
2850
|
+
import * as path16 from "node:path";
|
|
2435
2851
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2436
2852
|
var DAY = 864e5;
|
|
2437
2853
|
function idsIn(dir) {
|
|
2438
2854
|
try {
|
|
2439
|
-
return new Set(
|
|
2855
|
+
return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2440
2856
|
} catch {
|
|
2441
2857
|
return /* @__PURE__ */ new Set();
|
|
2442
2858
|
}
|
|
@@ -2451,39 +2867,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2451
2867
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2452
2868
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2453
2869
|
for (const id of idsIn(d.done)) {
|
|
2454
|
-
const p =
|
|
2455
|
-
const m =
|
|
2870
|
+
const p = path16.join(d.done, id);
|
|
2871
|
+
const m = fs21.statSync(p).mtimeMs;
|
|
2456
2872
|
doneMtimes.set(id, m);
|
|
2457
2873
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2458
2874
|
}
|
|
2459
2875
|
}
|
|
2460
|
-
const wtRoot =
|
|
2876
|
+
const wtRoot = path16.join(lobstahHome(), "worktrees");
|
|
2461
2877
|
for (const id of idsIn(wtRoot)) {
|
|
2462
2878
|
if (live.has(id)) continue;
|
|
2463
2879
|
const doneAt = doneMtimes.get(id);
|
|
2464
2880
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2465
|
-
const p =
|
|
2466
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
2881
|
+
const p = path16.join(wtRoot, id);
|
|
2882
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
|
|
2467
2883
|
}
|
|
2468
2884
|
for (const lane of ["work", "chore"]) {
|
|
2469
2885
|
const d = laneDirs(lane);
|
|
2470
2886
|
const seen = /* @__PURE__ */ new Set();
|
|
2471
|
-
for (const f of
|
|
2887
|
+
for (const f of fs21.readdirSync(d.state)) {
|
|
2472
2888
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2473
2889
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2474
|
-
const m =
|
|
2890
|
+
const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
|
|
2475
2891
|
if (m >= cutoff) continue;
|
|
2476
2892
|
seen.add(id);
|
|
2477
|
-
items.push({ kind: "state", id, target:
|
|
2893
|
+
items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2478
2894
|
}
|
|
2479
2895
|
}
|
|
2480
2896
|
return items;
|
|
2481
2897
|
}
|
|
2482
2898
|
function removeWorktree(id, dir) {
|
|
2483
2899
|
for (const lane of ["work", "chore"]) {
|
|
2484
|
-
const descFile =
|
|
2900
|
+
const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2485
2901
|
try {
|
|
2486
|
-
const desc = JSON.parse(
|
|
2902
|
+
const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
|
|
2487
2903
|
const repo = loadConfig().repos[desc.repo];
|
|
2488
2904
|
if (repo) {
|
|
2489
2905
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2492,15 +2908,15 @@ function removeWorktree(id, dir) {
|
|
|
2492
2908
|
} catch {
|
|
2493
2909
|
}
|
|
2494
2910
|
}
|
|
2495
|
-
|
|
2911
|
+
fs21.rmSync(dir, { recursive: true, force: true });
|
|
2496
2912
|
}
|
|
2497
2913
|
function applyCull(items) {
|
|
2498
2914
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2499
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
2915
|
+
for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
|
|
2500
2916
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2501
|
-
const dir =
|
|
2502
|
-
for (const f of
|
|
2503
|
-
if (f.startsWith(`${item.id}.`))
|
|
2917
|
+
const dir = path16.dirname(item.target);
|
|
2918
|
+
for (const f of fs21.readdirSync(dir)) {
|
|
2919
|
+
if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
|
|
2504
2920
|
}
|
|
2505
2921
|
}
|
|
2506
2922
|
}
|
|
@@ -2526,6 +2942,10 @@ your working set:
|
|
|
2526
2942
|
lobstah man tend [--json] tend the string: fleet verdict,
|
|
2527
2943
|
unanswered questions, each work
|
|
2528
2944
|
item's chain + PR + merge gate
|
|
2945
|
+
lobstah watch add <key> --check <cmd> stand watch on something
|
|
2946
|
+
external (a review session, a
|
|
2947
|
+
CI run) \u2014 its events wake you
|
|
2948
|
+
like any dispatch would
|
|
2529
2949
|
|
|
2530
2950
|
getting woken instead of asking:
|
|
2531
2951
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2545,12 +2965,12 @@ paste into your liaison instructions:
|
|
|
2545
2965
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2546
2966
|
|
|
2547
2967
|
// apps/cli/src/doctor.ts
|
|
2548
|
-
import * as
|
|
2968
|
+
import * as fs22 from "node:fs";
|
|
2549
2969
|
import { createRequire as createRequire3 } from "node:module";
|
|
2550
|
-
import { spawnSync } from "node:child_process";
|
|
2970
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2551
2971
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2552
2972
|
function git(repoPath, ...args) {
|
|
2553
|
-
const res =
|
|
2973
|
+
const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
|
|
2554
2974
|
return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
|
|
2555
2975
|
}
|
|
2556
2976
|
function runDoctor(now = Date.now()) {
|
|
@@ -2574,13 +2994,13 @@ function runDoctor(now = Date.now()) {
|
|
|
2574
2994
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2575
2995
|
);
|
|
2576
2996
|
const cfgFile = configPath();
|
|
2577
|
-
if (!
|
|
2997
|
+
if (!fs22.existsSync(cfgFile)) {
|
|
2578
2998
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2579
2999
|
return rows;
|
|
2580
3000
|
}
|
|
2581
3001
|
let parsed;
|
|
2582
3002
|
try {
|
|
2583
|
-
parsed = parse(
|
|
3003
|
+
parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
|
|
2584
3004
|
push("config", "ok", cfgFile);
|
|
2585
3005
|
} catch (err) {
|
|
2586
3006
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2591,7 +3011,7 @@ function runDoctor(now = Date.now()) {
|
|
|
2591
3011
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
2592
3012
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
2593
3013
|
const label = `repo ${key}`;
|
|
2594
|
-
if (!
|
|
3014
|
+
if (!fs22.existsSync(repo.path)) {
|
|
2595
3015
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
2596
3016
|
continue;
|
|
2597
3017
|
}
|
|
@@ -2619,11 +3039,11 @@ function runDoctor(now = Date.now()) {
|
|
|
2619
3039
|
}
|
|
2620
3040
|
}
|
|
2621
3041
|
const hb = executorPath();
|
|
2622
|
-
if (!
|
|
3042
|
+
if (!fs22.existsSync(hb)) {
|
|
2623
3043
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
2624
3044
|
} else {
|
|
2625
3045
|
try {
|
|
2626
|
-
const payload = JSON.parse(
|
|
3046
|
+
const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
|
|
2627
3047
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
2628
3048
|
push(
|
|
2629
3049
|
"daemon",
|
|
@@ -2639,10 +3059,10 @@ function runDoctor(now = Date.now()) {
|
|
|
2639
3059
|
}
|
|
2640
3060
|
|
|
2641
3061
|
// apps/cli/src/service.ts
|
|
2642
|
-
import * as
|
|
3062
|
+
import * as fs23 from "node:fs";
|
|
2643
3063
|
import * as os5 from "node:os";
|
|
2644
|
-
import * as
|
|
2645
|
-
import { spawnSync as
|
|
3064
|
+
import * as path17 from "node:path";
|
|
3065
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
2646
3066
|
function xml(s) {
|
|
2647
3067
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2648
3068
|
}
|
|
@@ -2665,8 +3085,8 @@ function renderLaunchdPlist(spec) {
|
|
|
2665
3085
|
</dict>
|
|
2666
3086
|
<key>RunAtLoad</key><true/>
|
|
2667
3087
|
<key>KeepAlive</key><true/>
|
|
2668
|
-
<key>StandardOutPath</key><string>${xml(
|
|
2669
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3088
|
+
<key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3089
|
+
<key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
2670
3090
|
</dict>
|
|
2671
3091
|
</plist>
|
|
2672
3092
|
`;
|
|
@@ -2681,8 +3101,8 @@ Restart=always
|
|
|
2681
3101
|
RestartSec=5
|
|
2682
3102
|
Environment=PATH=${spec.pathEnv}
|
|
2683
3103
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
2684
|
-
StandardOutput=append:${
|
|
2685
|
-
StandardError=append:${
|
|
3104
|
+
StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3105
|
+
StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
2686
3106
|
|
|
2687
3107
|
[Install]
|
|
2688
3108
|
WantedBy=default.target
|
|
@@ -2690,45 +3110,48 @@ WantedBy=default.target
|
|
|
2690
3110
|
}
|
|
2691
3111
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
2692
3112
|
const dirs = [
|
|
2693
|
-
|
|
2694
|
-
|
|
3113
|
+
path17.posix.dirname(nodePath),
|
|
3114
|
+
path17.posix.join(home, ".local", "bin"),
|
|
2695
3115
|
"/opt/homebrew/bin",
|
|
2696
3116
|
"/usr/local/bin",
|
|
2697
3117
|
"/usr/bin",
|
|
2698
3118
|
"/bin"
|
|
2699
3119
|
];
|
|
2700
|
-
return [...new Set(dirs)].join(
|
|
3120
|
+
return [...new Set(dirs)].join(":");
|
|
2701
3121
|
}
|
|
2702
3122
|
function serviceFile(kind) {
|
|
2703
|
-
return process.platform === "darwin" ?
|
|
3123
|
+
return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
2704
3124
|
}
|
|
2705
3125
|
function run(cmd, args) {
|
|
2706
|
-
const res =
|
|
3126
|
+
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
2707
3127
|
return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
2708
3128
|
}
|
|
2709
3129
|
function installService(kind) {
|
|
2710
|
-
|
|
3130
|
+
if (process.platform === "win32") {
|
|
3131
|
+
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3132
|
+
}
|
|
3133
|
+
const entry = fs23.realpathSync(process.argv[1]);
|
|
2711
3134
|
const spec = {
|
|
2712
3135
|
kind,
|
|
2713
3136
|
nodePath: process.execPath,
|
|
2714
3137
|
entry,
|
|
2715
3138
|
pathEnv: servicePathEnv(process.execPath),
|
|
2716
3139
|
home: lobstahHome(),
|
|
2717
|
-
logDir:
|
|
3140
|
+
logDir: path17.join(lobstahHome(), "logs")
|
|
2718
3141
|
};
|
|
2719
3142
|
if (!onPath("git", spec.pathEnv)) {
|
|
2720
3143
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
2721
3144
|
}
|
|
2722
|
-
|
|
3145
|
+
fs23.mkdirSync(spec.logDir, { recursive: true });
|
|
2723
3146
|
const file = serviceFile(kind);
|
|
2724
|
-
|
|
3147
|
+
fs23.mkdirSync(path17.dirname(file), { recursive: true });
|
|
2725
3148
|
if (process.platform === "darwin") {
|
|
2726
|
-
if (
|
|
2727
|
-
|
|
3149
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3150
|
+
fs23.writeFileSync(file, renderLaunchdPlist(spec));
|
|
2728
3151
|
const load = run("launchctl", ["load", file]);
|
|
2729
3152
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
2730
3153
|
}
|
|
2731
|
-
|
|
3154
|
+
fs23.writeFileSync(file, renderSystemdUnit(spec));
|
|
2732
3155
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
2733
3156
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
2734
3157
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -2736,22 +3159,22 @@ function installService(kind) {
|
|
|
2736
3159
|
function uninstallService(kind) {
|
|
2737
3160
|
const file = serviceFile(kind);
|
|
2738
3161
|
if (process.platform === "darwin") {
|
|
2739
|
-
if (
|
|
3162
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
2740
3163
|
} else {
|
|
2741
3164
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
2742
3165
|
}
|
|
2743
|
-
const removed =
|
|
2744
|
-
if (removed)
|
|
3166
|
+
const removed = fs23.existsSync(file);
|
|
3167
|
+
if (removed) fs23.unlinkSync(file);
|
|
2745
3168
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
2746
3169
|
return { file, removed };
|
|
2747
3170
|
}
|
|
2748
3171
|
|
|
2749
3172
|
// apps/cli/src/repos.ts
|
|
2750
|
-
import * as
|
|
2751
|
-
import * as
|
|
2752
|
-
import { spawnSync as
|
|
3173
|
+
import * as fs24 from "node:fs";
|
|
3174
|
+
import * as path18 from "node:path";
|
|
3175
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
2753
3176
|
function git2(dir, ...args) {
|
|
2754
|
-
const res =
|
|
3177
|
+
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
2755
3178
|
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
2756
3179
|
}
|
|
2757
3180
|
var LOCKFILE_SETUP = [
|
|
@@ -2764,37 +3187,41 @@ var LOCKFILE_SETUP = [
|
|
|
2764
3187
|
["poetry.lock", ["poetry install"]]
|
|
2765
3188
|
];
|
|
2766
3189
|
function detectRepo(dir) {
|
|
2767
|
-
const abs =
|
|
3190
|
+
const abs = path18.resolve(dir);
|
|
2768
3191
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
2769
3192
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
2770
|
-
|
|
3193
|
+
const canon = (p) => {
|
|
3194
|
+
const real = path18.resolve(fs24.realpathSync.native(p));
|
|
3195
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3196
|
+
};
|
|
3197
|
+
if (!top || canon(top) !== canon(abs)) return void 0;
|
|
2771
3198
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
2772
3199
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
2773
3200
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
2774
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
2775
|
-
return { key:
|
|
3201
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
|
|
3202
|
+
return { key: path18.basename(abs), path: abs, trunk, origin, setup };
|
|
2776
3203
|
}
|
|
2777
3204
|
function scanForRepos(roots) {
|
|
2778
3205
|
const found = [];
|
|
2779
3206
|
for (const root of roots) {
|
|
2780
|
-
const abs =
|
|
2781
|
-
if (!
|
|
3207
|
+
const abs = path18.resolve(root);
|
|
3208
|
+
if (!fs24.existsSync(abs)) continue;
|
|
2782
3209
|
const self = detectRepo(abs);
|
|
2783
3210
|
if (self) {
|
|
2784
3211
|
found.push(self);
|
|
2785
3212
|
continue;
|
|
2786
3213
|
}
|
|
2787
|
-
for (const entry of
|
|
3214
|
+
for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
|
|
2788
3215
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
2789
|
-
const child = detectRepo(
|
|
3216
|
+
const child = detectRepo(path18.join(abs, entry.name));
|
|
2790
3217
|
if (child) found.push(child);
|
|
2791
3218
|
}
|
|
2792
3219
|
}
|
|
2793
3220
|
return found;
|
|
2794
3221
|
}
|
|
2795
3222
|
function configuredRepoKeys(file = configPath()) {
|
|
2796
|
-
if (!
|
|
2797
|
-
const raw = parse(
|
|
3223
|
+
if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3224
|
+
const raw = parse(fs24.readFileSync(file, "utf8"));
|
|
2798
3225
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
2799
3226
|
}
|
|
2800
3227
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -2802,7 +3229,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
2802
3229
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
2803
3230
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
2804
3231
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
2805
|
-
|
|
3232
|
+
fs24.appendFileSync(file, `${lines.join("\n")}
|
|
2806
3233
|
`);
|
|
2807
3234
|
}
|
|
2808
3235
|
|
|
@@ -2833,6 +3260,16 @@ work (humans and agents):
|
|
|
2833
3260
|
entries, orphaned worktrees, stale state.
|
|
2834
3261
|
Dry run by default (14 days).
|
|
2835
3262
|
cancel <uuid> request cancellation
|
|
3263
|
+
watch [add <key> --check <cmd> [--for <uuid>] [--cursor <c>] [--every <s>]
|
|
3264
|
+
[--brief <template>] [--stream <cmd>] | rm <key>]
|
|
3265
|
+
stand watch on something external: the check
|
|
3266
|
+
command answers "anything since {cursor}?"
|
|
3267
|
+
in JSON. Events wake man wait/haul (default)
|
|
3268
|
+
or fork a continuation of --for's dispatch
|
|
3269
|
+
chain. --stream holds a long-lived NDJSON
|
|
3270
|
+
child under pick for ms-latency delivery
|
|
3271
|
+
(the check stays the guarantee). Bare
|
|
3272
|
+
\`watch\` lists.
|
|
2836
3273
|
|
|
2837
3274
|
host processes:
|
|
2838
3275
|
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
@@ -2850,10 +3287,12 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
2850
3287
|
PR, and merge-gate status from pick's last
|
|
2851
3288
|
observation. Pure disk read \u2014 no forge calls.
|
|
2852
3289
|
man wait [--timeout <secs>] [--peek]
|
|
2853
|
-
block until a dispatch
|
|
2854
|
-
print the event and
|
|
2855
|
-
on timeout.
|
|
2856
|
-
|
|
3290
|
+
block until a dispatch or watched source
|
|
3291
|
+
needs attention, then print the event and
|
|
3292
|
+
what to do next; exit 2 on timeout. Runs due
|
|
3293
|
+
watch checks itself when no pick process is.
|
|
3294
|
+
Unanswered questions re-fire every
|
|
3295
|
+
remindSecs until answered.
|
|
2857
3296
|
man init [--shared|--global] [--marker]
|
|
2858
3297
|
install the haul Stop hook: this project's
|
|
2859
3298
|
.claude/settings.local.json by default,
|
|
@@ -2890,10 +3329,26 @@ function arg(args, flag) {
|
|
|
2890
3329
|
const i = args.indexOf(flag);
|
|
2891
3330
|
return i >= 0 ? args[i + 1] : void 0;
|
|
2892
3331
|
}
|
|
3332
|
+
var WATCH_EVERY_SECS = 45;
|
|
3333
|
+
function runDueManWatches() {
|
|
3334
|
+
for (const w of listWatches()) {
|
|
3335
|
+
if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
function emitWatchAttention(attns) {
|
|
3339
|
+
for (const a of attns) {
|
|
3340
|
+
for (const e of a.events) {
|
|
3341
|
+
console.log(toonKV({ watch: a.watch.key, seq: e.seq, summary: e.summary }));
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
console.log(
|
|
3345
|
+
"next: handle the watched update now (a review round, a finished run \u2014 `lobstah watch ls` for context), then re-arm a background `lobstah man wait`."
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
2893
3348
|
function findLane(id) {
|
|
2894
3349
|
for (const lane of ["work", "chore"]) {
|
|
2895
3350
|
const d = laneDirs(lane);
|
|
2896
|
-
if (
|
|
3351
|
+
if (fs25.existsSync(path19.join(d.active, id)) || fs25.existsSync(path19.join(d.queue, `${id}.json`)) || fs25.existsSync(path19.join(d.done, id)) || fs25.existsSync(path19.join(d.state, `${id}.status`))) {
|
|
2897
3352
|
return lane;
|
|
2898
3353
|
}
|
|
2899
3354
|
}
|
|
@@ -2901,7 +3356,7 @@ function findLane(id) {
|
|
|
2901
3356
|
}
|
|
2902
3357
|
function rowsFor(lane, bucket) {
|
|
2903
3358
|
const dir = laneDirs(lane)[bucket];
|
|
2904
|
-
const entries =
|
|
3359
|
+
const entries = fs25.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs25.statSync(path19.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
2905
3360
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2906
3361
|
return sliced.map(({ f, m }) => {
|
|
2907
3362
|
const id = f.replace(/\.json$/, "");
|
|
@@ -2927,9 +3382,9 @@ async function mainCli() {
|
|
|
2927
3382
|
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
2928
3383
|
}
|
|
2929
3384
|
const d = {
|
|
2930
|
-
id: arg(args, "--id") ??
|
|
3385
|
+
id: arg(args, "--id") ?? randomUUID4(),
|
|
2931
3386
|
repo,
|
|
2932
|
-
brief: briefText ??
|
|
3387
|
+
brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
|
|
2933
3388
|
harness: arg(args, "--harness"),
|
|
2934
3389
|
model: arg(args, "--model"),
|
|
2935
3390
|
effort: arg(args, "--effort"),
|
|
@@ -2966,17 +3421,17 @@ async function mainCli() {
|
|
|
2966
3421
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
2967
3422
|
const lane = findLane(id);
|
|
2968
3423
|
const file = eventsPath(id, lane);
|
|
2969
|
-
if (
|
|
3424
|
+
if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
|
|
2970
3425
|
if (args.includes("--follow")) {
|
|
2971
|
-
let size =
|
|
3426
|
+
let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
|
|
2972
3427
|
setInterval(() => {
|
|
2973
|
-
if (!
|
|
2974
|
-
const now =
|
|
3428
|
+
if (!fs25.existsSync(file)) return;
|
|
3429
|
+
const now = fs25.statSync(file).size;
|
|
2975
3430
|
if (now > size) {
|
|
2976
|
-
const fd =
|
|
3431
|
+
const fd = fs25.openSync(file, "r");
|
|
2977
3432
|
const buf = Buffer.alloc(now - size);
|
|
2978
|
-
|
|
2979
|
-
|
|
3433
|
+
fs25.readSync(fd, buf, 0, buf.length, size);
|
|
3434
|
+
fs25.closeSync(fd);
|
|
2980
3435
|
process.stdout.write(buf.toString("utf8"));
|
|
2981
3436
|
size = now;
|
|
2982
3437
|
}
|
|
@@ -3032,23 +3487,23 @@ async function mainCli() {
|
|
|
3032
3487
|
);
|
|
3033
3488
|
}
|
|
3034
3489
|
const dirs = laneDirs(lane);
|
|
3035
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
3036
|
-
const harness = descriptorFile ? JSON.parse(
|
|
3490
|
+
const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
|
|
3491
|
+
const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
3037
3492
|
let sessionId;
|
|
3038
3493
|
try {
|
|
3039
|
-
sessionId = JSON.parse(
|
|
3494
|
+
sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
3040
3495
|
} catch {
|
|
3041
3496
|
}
|
|
3042
3497
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
3043
|
-
const worktree =
|
|
3044
|
-
const cwd =
|
|
3498
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
3499
|
+
const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
|
|
3045
3500
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3046
3501
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
3047
3502
|
if (args.includes("--print")) {
|
|
3048
3503
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
3049
3504
|
break;
|
|
3050
3505
|
}
|
|
3051
|
-
const res =
|
|
3506
|
+
const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
3052
3507
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
3053
3508
|
break;
|
|
3054
3509
|
}
|
|
@@ -3056,20 +3511,20 @@ async function mainCli() {
|
|
|
3056
3511
|
const id = args[0];
|
|
3057
3512
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
3058
3513
|
const lane = findLane(id);
|
|
3059
|
-
const activeDir =
|
|
3060
|
-
if (!
|
|
3061
|
-
const descFile =
|
|
3062
|
-
const descriptor = JSON.parse(
|
|
3514
|
+
const activeDir = path19.join(laneDirs(lane).active, id);
|
|
3515
|
+
if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
3516
|
+
const descFile = path19.join(activeDir, "descriptor.json");
|
|
3517
|
+
const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
|
|
3063
3518
|
const fromHarness = descriptor.harness ?? "default";
|
|
3064
3519
|
for (const key of ["harness", "model", "effort"]) {
|
|
3065
3520
|
const v = arg(args, `--${key}`);
|
|
3066
3521
|
if (v) descriptor[key] = v;
|
|
3067
3522
|
}
|
|
3068
|
-
|
|
3069
|
-
const worktree =
|
|
3523
|
+
fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
3524
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
3070
3525
|
let progress = "No worktree progress recorded.";
|
|
3071
|
-
if (
|
|
3072
|
-
const git3 = (...a) =>
|
|
3526
|
+
if (fs25.existsSync(worktree)) {
|
|
3527
|
+
const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
3073
3528
|
const commits = git3("log", "--oneline", "-15");
|
|
3074
3529
|
const status = git3("status", "--short");
|
|
3075
3530
|
progress = `Commits so far:
|
|
@@ -3078,17 +3533,17 @@ ${commits || "(none)"}
|
|
|
3078
3533
|
Uncommitted changes:
|
|
3079
3534
|
${status || "(clean)"}`;
|
|
3080
3535
|
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3536
|
+
fs25.writeFileSync(
|
|
3537
|
+
path19.join(activeDir, "handoff"),
|
|
3083
3538
|
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
3084
3539
|
|
|
3085
3540
|
${progress}`
|
|
3086
3541
|
);
|
|
3087
|
-
const runnerFile =
|
|
3088
|
-
if (
|
|
3089
|
-
const runner = JSON.parse(
|
|
3542
|
+
const runnerFile = path19.join(activeDir, "runner.json");
|
|
3543
|
+
if (fs25.existsSync(runnerFile)) {
|
|
3544
|
+
const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
|
|
3090
3545
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
3091
|
-
|
|
3546
|
+
fs25.unlinkSync(runnerFile);
|
|
3092
3547
|
}
|
|
3093
3548
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
3094
3549
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -3164,9 +3619,13 @@ ${progress}`
|
|
|
3164
3619
|
);
|
|
3165
3620
|
};
|
|
3166
3621
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3169
|
-
|
|
3622
|
+
const consume = !args.includes("--peek");
|
|
3623
|
+
runDueManWatches();
|
|
3624
|
+
const standing = attentionNow(consume, remindMs);
|
|
3625
|
+
const standingWatches = pendingWatchEvents(consume);
|
|
3626
|
+
if (standing.length > 0 || standingWatches.length > 0) {
|
|
3627
|
+
if (standing.length > 0) emit(standing);
|
|
3628
|
+
if (standingWatches.length > 0) emitWatchAttention(standingWatches);
|
|
3170
3629
|
break;
|
|
3171
3630
|
}
|
|
3172
3631
|
const baseline = captureWaitBaseline();
|
|
@@ -3177,26 +3636,32 @@ ${progress}`
|
|
|
3177
3636
|
emit(fresh);
|
|
3178
3637
|
return;
|
|
3179
3638
|
}
|
|
3639
|
+
runDueManWatches();
|
|
3640
|
+
const watched = pendingWatchEvents(true);
|
|
3641
|
+
if (watched.length > 0) {
|
|
3642
|
+
emitWatchAttention(watched);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3180
3645
|
}
|
|
3181
3646
|
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
3182
3647
|
process.exitCode = 2;
|
|
3183
3648
|
break;
|
|
3184
3649
|
}
|
|
3185
3650
|
case "man:init": {
|
|
3186
|
-
const file = args.includes("--global") ?
|
|
3651
|
+
const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
3187
3652
|
let existing;
|
|
3188
3653
|
try {
|
|
3189
|
-
existing = JSON.parse(
|
|
3654
|
+
existing = JSON.parse(fs25.readFileSync(file, "utf8"));
|
|
3190
3655
|
} catch {
|
|
3191
3656
|
existing = void 0;
|
|
3192
3657
|
}
|
|
3193
3658
|
const { settings, changed } = mergeHaulHook(existing);
|
|
3194
3659
|
if (changed) {
|
|
3195
|
-
|
|
3196
|
-
|
|
3660
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3661
|
+
fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
3197
3662
|
`);
|
|
3198
3663
|
}
|
|
3199
|
-
if (args.includes("--marker"))
|
|
3664
|
+
if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
|
|
3200
3665
|
console.log(
|
|
3201
3666
|
toonKV({
|
|
3202
3667
|
hook: "lobstah man haul",
|
|
@@ -3209,32 +3674,41 @@ ${progress}`
|
|
|
3209
3674
|
}
|
|
3210
3675
|
case "man:haul": {
|
|
3211
3676
|
try {
|
|
3212
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
3677
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
|
|
3213
3678
|
const anyActive = ["work", "chore"].some(
|
|
3214
|
-
(l) =>
|
|
3679
|
+
(l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
3215
3680
|
);
|
|
3216
|
-
|
|
3681
|
+
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
3682
|
+
if (!anyActive && !anyWatch) break;
|
|
3217
3683
|
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
3218
3684
|
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
3219
3685
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
3686
|
+
runDueManWatches();
|
|
3220
3687
|
let evs = attentionNow(true, remindMs);
|
|
3221
|
-
|
|
3688
|
+
let watched = pendingWatchEvents(true);
|
|
3689
|
+
if (evs.length === 0 && watched.length === 0) {
|
|
3222
3690
|
const baseline = captureWaitBaseline();
|
|
3223
3691
|
while (Date.now() < deadline) {
|
|
3224
3692
|
await new Promise((r) => setTimeout(r, 1500));
|
|
3225
3693
|
evs = freshWakeEvents(baseline);
|
|
3226
3694
|
if (evs.length === 0) evs = attentionNow(true, remindMs);
|
|
3227
|
-
|
|
3695
|
+
runDueManWatches();
|
|
3696
|
+
watched = pendingWatchEvents(true);
|
|
3697
|
+
if (evs.length > 0 || watched.length > 0) break;
|
|
3228
3698
|
}
|
|
3229
3699
|
}
|
|
3230
|
-
if (evs.length === 0) break;
|
|
3231
|
-
const lines =
|
|
3232
|
-
(ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
|
|
3233
|
-
|
|
3700
|
+
if (evs.length === 0 && watched.length === 0) break;
|
|
3701
|
+
const lines = [
|
|
3702
|
+
...evs.map((ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`),
|
|
3703
|
+
...watched.flatMap(
|
|
3704
|
+
(a) => a.events.map((e) => `- watch ${a.watch.key}${e.summary ? ` \u2014 ${e.summary}` : ` (seq ${e.seq})`}`)
|
|
3705
|
+
)
|
|
3706
|
+
];
|
|
3234
3707
|
const reason = [
|
|
3235
|
-
"A lobstah dispatch needs attention:",
|
|
3708
|
+
"A lobstah dispatch or watched source needs attention:",
|
|
3236
3709
|
...lines,
|
|
3237
|
-
'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3710
|
+
'Check a dispatch with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3711
|
+
"A watch line means an external source updated (e.g. a review round) \u2014 handle it directly.",
|
|
3238
3712
|
"Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
|
|
3239
3713
|
].join("\n");
|
|
3240
3714
|
console.log(JSON.stringify({ decision: "block", reason }));
|
|
@@ -3283,7 +3757,7 @@ ${progress}`
|
|
|
3283
3757
|
path: r.path,
|
|
3284
3758
|
trunk: r.trunk,
|
|
3285
3759
|
pickup: r.pickup ?? false,
|
|
3286
|
-
exists:
|
|
3760
|
+
exists: fs25.existsSync(r.path)
|
|
3287
3761
|
}));
|
|
3288
3762
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3289
3763
|
break;
|
|
@@ -3292,8 +3766,43 @@ ${progress}`
|
|
|
3292
3766
|
console.log(lobstahVersion());
|
|
3293
3767
|
break;
|
|
3294
3768
|
}
|
|
3769
|
+
case "watch": {
|
|
3770
|
+
const sub = args[0];
|
|
3771
|
+
if (sub === "add") {
|
|
3772
|
+
const key = args[1];
|
|
3773
|
+
const check = arg(args, "--check");
|
|
3774
|
+
if (!key || key.startsWith("--") || !check) throw new Error("watch add requires a key and --check <command>");
|
|
3775
|
+
const forId = arg(args, "--for");
|
|
3776
|
+
const every = arg(args, "--every");
|
|
3777
|
+
const w = addWatch(key, check, {
|
|
3778
|
+
owner: forId ? `dispatch:${forId}` : "man",
|
|
3779
|
+
cursor: arg(args, "--cursor"),
|
|
3780
|
+
everySecs: every ? Number(every) : void 0,
|
|
3781
|
+
brief: arg(args, "--brief"),
|
|
3782
|
+
stream: arg(args, "--stream")
|
|
3783
|
+
});
|
|
3784
|
+
console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
|
|
3785
|
+
break;
|
|
3786
|
+
}
|
|
3787
|
+
if (sub === "rm") {
|
|
3788
|
+
const key = args[1];
|
|
3789
|
+
if (!key) throw new Error("watch rm requires a key");
|
|
3790
|
+
console.log(toonKV({ key, removed: removeWatch(key) }));
|
|
3791
|
+
break;
|
|
3792
|
+
}
|
|
3793
|
+
const rows = listWatches().map((w) => ({
|
|
3794
|
+
key: w.key,
|
|
3795
|
+
owner: w.owner,
|
|
3796
|
+
cursor: w.cursor,
|
|
3797
|
+
pending: readWatchEvents(w.key).length - w.seen,
|
|
3798
|
+
lastChecked: w.lastCheckedAt ?? "-",
|
|
3799
|
+
error: w.lastError ?? ""
|
|
3800
|
+
}));
|
|
3801
|
+
console.log(toonTable("watches", rows, ["key", "owner", "cursor", "pending", "lastChecked", "error"]));
|
|
3802
|
+
break;
|
|
3803
|
+
}
|
|
3295
3804
|
case "init": {
|
|
3296
|
-
if (!
|
|
3805
|
+
if (!fs25.existsSync(configPath())) {
|
|
3297
3806
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3298
3807
|
path = "~/src/example"
|
|
3299
3808
|
trunk = "main"
|
|
@@ -3302,7 +3811,7 @@ trunk = "main"
|
|
|
3302
3811
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3303
3812
|
|
|
3304
3813
|
`;
|
|
3305
|
-
|
|
3814
|
+
fs25.writeFileSync(
|
|
3306
3815
|
configPath(),
|
|
3307
3816
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
3308
3817
|
# Top-level keys must come before any [section].
|
|
@@ -3340,12 +3849,12 @@ wallClockSecs = 3600
|
|
|
3340
3849
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3341
3850
|
try {
|
|
3342
3851
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3343
|
-
if (
|
|
3852
|
+
if (fs25.existsSync(local)) reference = local;
|
|
3344
3853
|
} catch {
|
|
3345
3854
|
}
|
|
3346
3855
|
console.log(
|
|
3347
3856
|
toonKV({
|
|
3348
|
-
home:
|
|
3857
|
+
home: path19.dirname(configPath()),
|
|
3349
3858
|
config: configPath(),
|
|
3350
3859
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3351
3860
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|