@lumi.ai/runner 0.3.5 → 0.4.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 +16 -3
- package/dist/cli.js +126 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,10 +37,23 @@ runner id and Ship list from the approval — nothing is copied or pasted. If yo
|
|
|
37
37
|
|
|
38
38
|
`setup` then runs `doctor` and offers to install the background service.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
### A server or VPS
|
|
41
|
+
|
|
42
|
+
`setup` is interactive and refuses without a terminal, but it does not need a browser **on that
|
|
43
|
+
machine**:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
lumi-runner setup --no-browser
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It prints the approval link and an 8-character code. Open the link anywhere you do have a browser,
|
|
50
|
+
approve this Ship, and the server finishes on its own — nothing is pasted back.
|
|
51
|
+
|
|
52
|
+
For CI or unattended provisioning, where nobody is at a terminal at all, paste a Ship key from the
|
|
53
|
+
Daemons page instead:
|
|
41
54
|
|
|
42
55
|
```bash
|
|
43
|
-
lumi-runner login --
|
|
56
|
+
lumi-runner login --key <shipKey> --api-key <firebaseWebApiKey> --project <projectId>
|
|
44
57
|
```
|
|
45
58
|
|
|
46
59
|
## Commands
|
|
@@ -48,7 +61,7 @@ lumi-runner login --token <customToken> --api-key <key> --project <projectId>
|
|
|
48
61
|
| Command | What it does |
|
|
49
62
|
|---|---|
|
|
50
63
|
| `setup` | One-time onboarding: login → pick Ships → doctor → background service |
|
|
51
|
-
| `login` | Connect this machine (browser approval, or `--
|
|
64
|
+
| `login` | Connect this machine (browser approval, `--no-browser` for a server, or `--key` for CI) |
|
|
52
65
|
| `ship add [ids…]` / `remove` / `list` | Which Ships this machine serves (omit ids to pick from a list) |
|
|
53
66
|
| `doctor` | **Preflight** — can this machine actually run a job? Non-zero exit if not |
|
|
54
67
|
| `service install` / `uninstall` / `restart` / `status` | Run the daemon in the background, always |
|
package/dist/cli.js
CHANGED
|
@@ -432,6 +432,10 @@ var SECRET_DOCS = {
|
|
|
432
432
|
*/
|
|
433
433
|
runnerKey: "runnerKey"
|
|
434
434
|
};
|
|
435
|
+
function missingSecretsFor(engineId, secrets) {
|
|
436
|
+
const bag = secrets;
|
|
437
|
+
return getEngine(engineId).requiredSecrets.filter((req) => !bag?.[req.key]).map((req) => req.label);
|
|
438
|
+
}
|
|
435
439
|
|
|
436
440
|
// ../shared/dist/ship.js
|
|
437
441
|
var DEFAULT_SHIP_SETTINGS = {
|
|
@@ -528,12 +532,17 @@ function saveConfig(config2) {
|
|
|
528
532
|
fs.writeFileSync(configPath(), `${JSON.stringify(config2, null, 2)}
|
|
529
533
|
`, { mode: 384 });
|
|
530
534
|
}
|
|
535
|
+
function forgetShip(config2, shipId) {
|
|
536
|
+
const shipKeys = { ...config2.shipKeys ?? {} };
|
|
537
|
+
delete shipKeys[shipId];
|
|
538
|
+
return { ...config2, shipKeys, ships: config2.ships.filter((id) => id !== shipId) };
|
|
539
|
+
}
|
|
531
540
|
function mcpUrl(config2) {
|
|
532
541
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
533
542
|
}
|
|
534
543
|
|
|
535
544
|
// src/version.ts
|
|
536
|
-
var RUNNER_VERSION = true ? "0.
|
|
545
|
+
var RUNNER_VERSION = true ? "0.4.0" : "0.0.0-dev";
|
|
537
546
|
|
|
538
547
|
// src/auth.ts
|
|
539
548
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -605,13 +614,11 @@ async function probeShipKey(config2, shipId) {
|
|
|
605
614
|
}
|
|
606
615
|
function pruneDeadShips(config2, failures) {
|
|
607
616
|
const dropped = failures.filter((f) => f.gone).map((f) => f.shipId);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
dropped
|
|
614
|
-
};
|
|
617
|
+
return { config: dropped.reduce(forgetShip, config2), dropped };
|
|
618
|
+
}
|
|
619
|
+
function isEnrolmentGone(error) {
|
|
620
|
+
const code = error?.code;
|
|
621
|
+
return code === "permission-denied" || code === "firestore/permission-denied";
|
|
615
622
|
}
|
|
616
623
|
async function signInToShip(fb, config2, shipId) {
|
|
617
624
|
const key = config2.shipKeys?.[shipId];
|
|
@@ -1594,9 +1601,6 @@ async function loadRunnerSecrets(db, shipId) {
|
|
|
1594
1601
|
);
|
|
1595
1602
|
return snap.exists() ? snap.data() : null;
|
|
1596
1603
|
}
|
|
1597
|
-
function missingSecretsFor(engineId, secrets) {
|
|
1598
|
-
return getEngine(engineId).requiredSecrets.filter((req) => !secrets?.[req.key]).map((req) => req.label);
|
|
1599
|
-
}
|
|
1600
1604
|
|
|
1601
1605
|
// src/jobs/engineLimits.ts
|
|
1602
1606
|
import {
|
|
@@ -1820,9 +1824,9 @@ async function startDaemon() {
|
|
|
1820
1824
|
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
1821
1825
|
return s;
|
|
1822
1826
|
};
|
|
1823
|
-
const
|
|
1827
|
+
const serving = new Set(sessions.map((s) => s.shipId));
|
|
1824
1828
|
console.log(
|
|
1825
|
-
`Runner ${config2.runnerId} serving ${
|
|
1829
|
+
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
1826
1830
|
);
|
|
1827
1831
|
const logLines = [];
|
|
1828
1832
|
const log2 = (line) => {
|
|
@@ -1835,7 +1839,7 @@ async function startDaemon() {
|
|
|
1835
1839
|
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
1836
1840
|
const startedAt = Date.now();
|
|
1837
1841
|
let currentJob = null;
|
|
1838
|
-
|
|
1842
|
+
let currentJobShip = null;
|
|
1839
1843
|
const approved = /* @__PURE__ */ new Map();
|
|
1840
1844
|
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
1841
1845
|
const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
@@ -1846,44 +1850,53 @@ async function startDaemon() {
|
|
|
1846
1850
|
beating = true;
|
|
1847
1851
|
const now = Date.now();
|
|
1848
1852
|
try {
|
|
1849
|
-
for (const shipId of
|
|
1850
|
-
|
|
1851
|
-
shipRunnerRef(shipId)
|
|
1852
|
-
{
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1853
|
+
for (const shipId of [...serving]) {
|
|
1854
|
+
try {
|
|
1855
|
+
const snap = await getDoc5(shipRunnerRef(shipId));
|
|
1856
|
+
if (!snap.exists()) {
|
|
1857
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
const isApproved = snap.data()?.approved === true;
|
|
1861
|
+
const wasApproved = approved.get(shipId) === true;
|
|
1862
|
+
approved.set(shipId, isApproved);
|
|
1863
|
+
if (!isApproved && !warnedUnapproved.has(shipId)) {
|
|
1864
|
+
warnedUnapproved.add(shipId);
|
|
1865
|
+
log2(
|
|
1866
|
+
`Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
if (isApproved && !wasApproved) {
|
|
1870
|
+
warnedUnapproved.delete(shipId);
|
|
1871
|
+
needsRefill.add(shipId);
|
|
1872
|
+
}
|
|
1873
|
+
await setDoc2(
|
|
1874
|
+
shipRunnerRef(shipId),
|
|
1875
|
+
{
|
|
1876
|
+
hostname: os3.hostname(),
|
|
1877
|
+
version: RUNNER_VERSION,
|
|
1878
|
+
status: "online",
|
|
1879
|
+
lastSeenAt: now,
|
|
1880
|
+
startedAt,
|
|
1881
|
+
currentJob: currentJob ?? null,
|
|
1882
|
+
lastLogLines: [...logLines]
|
|
1883
|
+
},
|
|
1884
|
+
{ merge: true }
|
|
1872
1885
|
);
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1886
|
+
} catch (e) {
|
|
1887
|
+
if (isEnrolmentGone(e)) {
|
|
1888
|
+
forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
1877
1892
|
}
|
|
1878
1893
|
}
|
|
1879
|
-
} catch (e) {
|
|
1880
|
-
console.error("heartbeat failed:", e instanceof Error ? e.message : e);
|
|
1881
1894
|
} finally {
|
|
1882
1895
|
beating = false;
|
|
1883
1896
|
}
|
|
1884
1897
|
for (const shipId of [...needsRefill]) {
|
|
1885
1898
|
needsRefill.delete(shipId);
|
|
1886
|
-
if (approved.get(shipId) !== true) continue;
|
|
1899
|
+
if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
|
|
1887
1900
|
try {
|
|
1888
1901
|
const snap = await getDocs3(
|
|
1889
1902
|
query3(
|
|
@@ -1908,14 +1921,21 @@ async function startDaemon() {
|
|
|
1908
1921
|
const pending = /* @__PURE__ */ new Map();
|
|
1909
1922
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
1910
1923
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
1911
|
-
const
|
|
1912
|
-
|
|
1924
|
+
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
1925
|
+
const listenerError = (shipId, what) => (e) => {
|
|
1926
|
+
if (isEnrolmentGone(e)) {
|
|
1927
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
console.error(`${what} listener error (${shipId}):`, e.message);
|
|
1931
|
+
};
|
|
1932
|
+
for (const shipId of serving) {
|
|
1913
1933
|
const q = query3(
|
|
1914
1934
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
1915
1935
|
where3("status", "==", "queued"),
|
|
1916
1936
|
orderBy3("createdAt", "asc")
|
|
1917
1937
|
);
|
|
1918
|
-
|
|
1938
|
+
unsubsByShip.set(shipId, [
|
|
1919
1939
|
onSnapshot2(
|
|
1920
1940
|
q,
|
|
1921
1941
|
(snap) => {
|
|
@@ -1930,10 +1950,9 @@ async function startDaemon() {
|
|
|
1930
1950
|
}
|
|
1931
1951
|
poke();
|
|
1932
1952
|
},
|
|
1933
|
-
(
|
|
1934
|
-
)
|
|
1935
|
-
|
|
1936
|
-
unsubs.push(
|
|
1953
|
+
listenerError(shipId, "jobs")
|
|
1954
|
+
),
|
|
1955
|
+
// Agents, purely so the claim gate can resolve a queued job's engine without a read.
|
|
1937
1956
|
onSnapshot2(
|
|
1938
1957
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
1939
1958
|
(snap) => {
|
|
@@ -1942,10 +1961,11 @@ async function startDaemon() {
|
|
|
1942
1961
|
}
|
|
1943
1962
|
poke();
|
|
1944
1963
|
},
|
|
1945
|
-
(
|
|
1946
|
-
)
|
|
1947
|
-
|
|
1948
|
-
|
|
1964
|
+
listenerError(shipId, "agents")
|
|
1965
|
+
),
|
|
1966
|
+
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
1967
|
+
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
1968
|
+
// session to rediscover it.
|
|
1949
1969
|
subscribeEngineLimits(
|
|
1950
1970
|
sess(shipId).fb.db,
|
|
1951
1971
|
shipId,
|
|
@@ -1964,9 +1984,46 @@ async function startDaemon() {
|
|
|
1964
1984
|
}
|
|
1965
1985
|
sweepLimits();
|
|
1966
1986
|
},
|
|
1967
|
-
(
|
|
1987
|
+
listenerError(shipId, "engine limits")
|
|
1968
1988
|
)
|
|
1989
|
+
]);
|
|
1990
|
+
}
|
|
1991
|
+
function forgetShipLocally(shipId, why) {
|
|
1992
|
+
if (!serving.delete(shipId)) return;
|
|
1993
|
+
for (const unsub of unsubsByShip.get(shipId) ?? []) unsub();
|
|
1994
|
+
unsubsByShip.delete(shipId);
|
|
1995
|
+
for (const key of [...pending.keys()]) {
|
|
1996
|
+
if (pending.get(key)?.shipId === shipId) pending.delete(key);
|
|
1997
|
+
}
|
|
1998
|
+
for (const key of [...engineLimits.keys()]) {
|
|
1999
|
+
if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
|
|
2000
|
+
}
|
|
2001
|
+
for (const key of [...agentEngines.keys()]) {
|
|
2002
|
+
if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
|
|
2003
|
+
}
|
|
2004
|
+
approved.delete(shipId);
|
|
2005
|
+
warnedUnapproved.delete(shipId);
|
|
2006
|
+
needsRefill.delete(shipId);
|
|
2007
|
+
config2 = forgetShip(config2, shipId);
|
|
2008
|
+
try {
|
|
2009
|
+
saveConfig(config2);
|
|
2010
|
+
} catch (e) {
|
|
2011
|
+
log2(`Could not rewrite the config after leaving Ship ${shipId}: ${e instanceof Error ? e.message : e}`);
|
|
2012
|
+
}
|
|
2013
|
+
log2(
|
|
2014
|
+
`Ship ${shipId}: ${why}. Deleted its runner key, its assignment and its queued work from this machine. To serve it again, run \`lumi-runner login\` here and have a captain approve this machine in the browser.`
|
|
1969
2015
|
);
|
|
2016
|
+
if (currentJobShip === shipId && sessionAbort) {
|
|
2017
|
+
log2(
|
|
2018
|
+
`Stopping the job it was running \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put that job back on the queue itself.`
|
|
2019
|
+
);
|
|
2020
|
+
sessionAbort.abort();
|
|
2021
|
+
}
|
|
2022
|
+
if (serving.size === 0) {
|
|
2023
|
+
log2(
|
|
2024
|
+
"This machine now serves no Ships. Staying up and idle \u2014 run `lumi-runner login` to connect it again, or `lumi-runner uninstall` to retire it."
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
1970
2027
|
}
|
|
1971
2028
|
const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
|
|
1972
2029
|
let working = false;
|
|
@@ -2057,9 +2114,7 @@ async function startDaemon() {
|
|
|
2057
2114
|
}
|
|
2058
2115
|
}
|
|
2059
2116
|
async function loadSecrets(shipId) {
|
|
2060
|
-
|
|
2061
|
-
secretsOk.set(shipId, missingSecretsFor(DEFAULT_ENGINE_ID, secrets).length === 0);
|
|
2062
|
-
return secrets;
|
|
2117
|
+
return loadRunnerSecrets(sess(shipId).fb.db, shipId);
|
|
2063
2118
|
}
|
|
2064
2119
|
async function processJob(shipId, queuedJob) {
|
|
2065
2120
|
const job = await claim(shipId, queuedJob);
|
|
@@ -2095,6 +2150,7 @@ async function startDaemon() {
|
|
|
2095
2150
|
...job.workflowName ? { workflowName: job.workflowName } : {},
|
|
2096
2151
|
startedAt: Date.now()
|
|
2097
2152
|
};
|
|
2153
|
+
currentJobShip = shipId;
|
|
2098
2154
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2099
2155
|
void heartbeat();
|
|
2100
2156
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
@@ -2197,7 +2253,11 @@ async function startDaemon() {
|
|
|
2197
2253
|
wake?.release();
|
|
2198
2254
|
sessionAbort = null;
|
|
2199
2255
|
try {
|
|
2200
|
-
if (
|
|
2256
|
+
if (!serving.has(shipId)) {
|
|
2257
|
+
log2(
|
|
2258
|
+
`Job ${job.id} stopped \u2014 Ship ${shipId} removed this machine mid-run. The Ship has put the job back on its own queue; nothing more is written from here.`
|
|
2259
|
+
);
|
|
2260
|
+
} else if (shuttingDown) {
|
|
2201
2261
|
await releaseJob(
|
|
2202
2262
|
sess(shipId).fb.db,
|
|
2203
2263
|
shipId,
|
|
@@ -2248,10 +2308,11 @@ async function startDaemon() {
|
|
|
2248
2308
|
log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
2249
2309
|
}
|
|
2250
2310
|
currentJob = null;
|
|
2251
|
-
|
|
2311
|
+
currentJobShip = null;
|
|
2312
|
+
if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
|
|
2252
2313
|
if (!shuttingDown) void heartbeat();
|
|
2253
2314
|
}
|
|
2254
|
-
await Promise.all(
|
|
2315
|
+
await Promise.all([...serving].map((shipId) => loadSecrets(shipId).catch(() => null)));
|
|
2255
2316
|
await heartbeat();
|
|
2256
2317
|
const heartbeatTimer = setInterval(() => {
|
|
2257
2318
|
void heartbeat();
|
|
@@ -2264,7 +2325,7 @@ async function startDaemon() {
|
|
|
2264
2325
|
shuttingDown = true;
|
|
2265
2326
|
clearInterval(heartbeatTimer);
|
|
2266
2327
|
if (limitTimer) clearTimeout(limitTimer);
|
|
2267
|
-
|
|
2328
|
+
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
2268
2329
|
if (sessionAbort) {
|
|
2269
2330
|
log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
|
|
2270
2331
|
notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
|
|
@@ -2280,7 +2341,7 @@ async function startDaemon() {
|
|
|
2280
2341
|
}
|
|
2281
2342
|
const now = Date.now();
|
|
2282
2343
|
try {
|
|
2283
|
-
for (const shipId of
|
|
2344
|
+
for (const shipId of serving) {
|
|
2284
2345
|
await setDoc2(
|
|
2285
2346
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
2286
2347
|
{ status: "offline", lastSeenAt: now, currentJob: deleteField() },
|
|
@@ -2293,7 +2354,7 @@ async function startDaemon() {
|
|
|
2293
2354
|
};
|
|
2294
2355
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2295
2356
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2296
|
-
log2(`Runner online \u2014 watching ${
|
|
2357
|
+
log2(`Runner online \u2014 watching ${serving.size} Ship(s), MCP: ${mcpUrl(config2)}`);
|
|
2297
2358
|
await new Promise(() => {
|
|
2298
2359
|
});
|
|
2299
2360
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
|
|
6
6
|
"//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",
|