@lumi.ai/runner 0.2.0 → 0.3.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/dist/cli.js +249 -214
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/daemon.ts
|
|
7
|
-
import
|
|
7
|
+
import os3 from "node:os";
|
|
8
8
|
import {
|
|
9
9
|
collection as collection5,
|
|
10
10
|
deleteField,
|
|
11
11
|
doc as doc7,
|
|
12
12
|
getDoc as getDoc5,
|
|
13
|
+
getDocs as getDocs3,
|
|
13
14
|
onSnapshot as onSnapshot2,
|
|
14
15
|
orderBy as orderBy3,
|
|
15
16
|
query as query3,
|
|
@@ -120,6 +121,17 @@ var COLLECTIONS = {
|
|
|
120
121
|
ships: "ships",
|
|
121
122
|
/** `ships/{shipId}/members/{uid}` — the humans of a Ship (captain | member). */
|
|
122
123
|
members: "members",
|
|
124
|
+
/**
|
|
125
|
+
* `ships/{shipId}/invites/{inviteId}` — humans asked aboard but not yet arrived (PRD §15.24).
|
|
126
|
+
*
|
|
127
|
+
* Its OWN collection rather than a pending row inside `members`, and that is not tidiness:
|
|
128
|
+
* `members` is load-bearing in five places that all assume every document in it is a person who
|
|
129
|
+
* exists. `isMember()`/`isCaptain()` in firestore.rules key on the doc EXISTING, `captainUids()`
|
|
130
|
+
* fans notifications out to `role == 'captain'`, the "my Ships" collection-group query filters on
|
|
131
|
+
* `uid`, and the purge reads the member list to find `users/{uid}/runners`. A pending row would
|
|
132
|
+
* have to lie to all four.
|
|
133
|
+
*/
|
|
134
|
+
invites: "invites",
|
|
123
135
|
/** `ships/{shipId}/tasks/{taskId}` — the board's units of work. */
|
|
124
136
|
tasks: "tasks",
|
|
125
137
|
/** `ships/{shipId}/tasks/{taskId}/activity/{eventId}` — a task's feed (comments + status). */
|
|
@@ -170,7 +182,15 @@ var COLLECTIONS = {
|
|
|
170
182
|
/** Top-level `githubInstallations/{installationId}` — installation→Ship index (backend only). */
|
|
171
183
|
githubInstallations: "githubInstallations",
|
|
172
184
|
/** Top-level `runnerLoginCodes/{userCode}` — CLI device-login handshakes (backend only). */
|
|
173
|
-
runnerLoginCodes: "runnerLoginCodes"
|
|
185
|
+
runnerLoginCodes: "runnerLoginCodes",
|
|
186
|
+
/**
|
|
187
|
+
* Top-level `shipDeletions/{shipId}` — one tombstone per Ship being purged (backend only).
|
|
188
|
+
*
|
|
189
|
+
* OUTSIDE the Ship subtree on purpose: `recursiveDelete` deletes the root document even when
|
|
190
|
+
* descendant deletes failed, so a marker stored on the Ship would be destroyed by exactly the
|
|
191
|
+
* partial failure it exists to survive.
|
|
192
|
+
*/
|
|
193
|
+
shipDeletions: "shipDeletions"
|
|
174
194
|
};
|
|
175
195
|
|
|
176
196
|
// ../shared/dist/engineLimit.js
|
|
@@ -366,7 +386,16 @@ var SECRET_DOCS = {
|
|
|
366
386
|
/** Captain-provided runner credentials (this file's shape). */
|
|
367
387
|
runner: "runner",
|
|
368
388
|
/** `{ tokenHash }` for the Workspace MCP bearer — backend-only, never client-readable. */
|
|
369
|
-
mcp: "mcp"
|
|
389
|
+
mcp: "mcp",
|
|
390
|
+
/**
|
|
391
|
+
* `{ runnerId, keyHash }` for THIS Ship's daemon credential (PRD §8.1) — backend-only.
|
|
392
|
+
*
|
|
393
|
+
* One document, not one per machine, because a Ship runs one daemon: issuing a key is
|
|
394
|
+
* therefore also how the previous one is revoked, exactly like rotating the MCP bearer.
|
|
395
|
+
* Only `docId == 'runner'` is released by packages/crew/firestore.rules, so this falls
|
|
396
|
+
* through to deny for every client without needing a rule of its own.
|
|
397
|
+
*/
|
|
398
|
+
runnerKey: "runnerKey"
|
|
370
399
|
};
|
|
371
400
|
|
|
372
401
|
// ../shared/dist/task.js
|
|
@@ -415,6 +444,7 @@ var DEFAULT_SHIP_SETTINGS = {
|
|
|
415
444
|
// The creation wizard offers the captain's own browser zone, which is what most Ships get.
|
|
416
445
|
timezone: "UTC"
|
|
417
446
|
};
|
|
447
|
+
var SHIP_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
418
448
|
|
|
419
449
|
// ../shared/dist/usage.js
|
|
420
450
|
var EMPTY_USAGE_TOTALS = {
|
|
@@ -502,10 +532,9 @@ function mcpUrl(config2) {
|
|
|
502
532
|
}
|
|
503
533
|
|
|
504
534
|
// src/version.ts
|
|
505
|
-
var RUNNER_VERSION = true ? "0.
|
|
535
|
+
var RUNNER_VERSION = true ? "0.3.0" : "0.0.0-dev";
|
|
506
536
|
|
|
507
537
|
// src/auth.ts
|
|
508
|
-
import os2 from "node:os";
|
|
509
538
|
import { signInWithCustomToken } from "firebase/auth";
|
|
510
539
|
|
|
511
540
|
// src/callables.ts
|
|
@@ -549,59 +578,39 @@ async function request(baseUrl, name, data, headers) {
|
|
|
549
578
|
}
|
|
550
579
|
|
|
551
580
|
// src/auth.ts
|
|
552
|
-
var authEmulator = () => process.env.FIREBASE_AUTH_EMULATOR_HOST;
|
|
553
|
-
function securetokenUrl(apiKey) {
|
|
554
|
-
const emu = authEmulator();
|
|
555
|
-
return emu ? `http://${emu}/securetoken.googleapis.com/v1/token?key=${apiKey}` : `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
|
|
556
|
-
}
|
|
557
|
-
async function login(fb, config2, customToken) {
|
|
558
|
-
const cred = await signInWithCustomToken(fb.auth, customToken);
|
|
559
|
-
config2.refreshToken = cred.user.refreshToken;
|
|
560
|
-
saveConfig(config2);
|
|
561
|
-
return cred.user;
|
|
562
|
-
}
|
|
563
581
|
var AuthError = class extends Error {
|
|
564
582
|
};
|
|
565
|
-
async function
|
|
566
|
-
const
|
|
567
|
-
if (!
|
|
568
|
-
throw new AuthError(
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
method: "POST",
|
|
572
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
573
|
-
body: new URLSearchParams({
|
|
574
|
-
grant_type: "refresh_token",
|
|
575
|
-
refresh_token: config2.refreshToken
|
|
576
|
-
})
|
|
577
|
-
});
|
|
578
|
-
if (!tokenRes.ok) {
|
|
579
|
-
throw new AuthError(`Session refresh failed (${tokenRes.status}). Run \`lumi-runner login\` again.`);
|
|
583
|
+
async function signInToShip(fb, config2, shipId) {
|
|
584
|
+
const key = config2.shipKeys?.[shipId];
|
|
585
|
+
if (!key) {
|
|
586
|
+
throw new AuthError(
|
|
587
|
+
`No runner key for Ship ${shipId}. Run \`lumi-runner login\` and pick it, or ask a captain to approve this machine on that Ship's Daemons page.`
|
|
588
|
+
);
|
|
580
589
|
}
|
|
581
|
-
|
|
582
|
-
let mint;
|
|
590
|
+
let session;
|
|
583
591
|
try {
|
|
584
|
-
|
|
585
|
-
runnerId: config2.runnerId,
|
|
586
|
-
hostname: os2.hostname(),
|
|
587
|
-
version: RUNNER_VERSION
|
|
588
|
-
});
|
|
592
|
+
session = await callPublicFunction(functionsBaseUrl(config2), "exchangeRunnerKey", { key });
|
|
589
593
|
} catch (e) {
|
|
590
|
-
throw new AuthError(
|
|
594
|
+
throw new AuthError(
|
|
595
|
+
`Ship ${shipId}: ${e instanceof Error ? e.message : String(e)}`
|
|
596
|
+
);
|
|
591
597
|
}
|
|
592
|
-
const cred = await signInWithCustomToken(fb.auth,
|
|
593
|
-
config2.runnerId = mint.runnerId;
|
|
594
|
-
config2.refreshToken = cred.user.refreshToken;
|
|
595
|
-
saveConfig(config2);
|
|
598
|
+
const cred = await signInWithCustomToken(fb.auth, session.customToken);
|
|
596
599
|
return cred.user;
|
|
597
600
|
}
|
|
598
|
-
async function
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
601
|
+
async function openShipSessions(config2, build) {
|
|
602
|
+
const sessions = [];
|
|
603
|
+
const failures = [];
|
|
604
|
+
for (const shipId of config2.ships) {
|
|
605
|
+
const fb = build(shipId);
|
|
606
|
+
try {
|
|
607
|
+
const user = await signInToShip(fb, config2, shipId);
|
|
608
|
+
sessions.push({ shipId, fb, user });
|
|
609
|
+
} catch (e) {
|
|
610
|
+
failures.push({ shipId, message: e instanceof Error ? e.message : String(e) });
|
|
611
|
+
}
|
|
604
612
|
}
|
|
613
|
+
return { sessions, failures };
|
|
605
614
|
}
|
|
606
615
|
|
|
607
616
|
// src/firebase.ts
|
|
@@ -613,15 +622,16 @@ import {
|
|
|
613
622
|
terminate
|
|
614
623
|
} from "firebase/firestore";
|
|
615
624
|
import { getStorage, connectStorageEmulator } from "firebase/storage";
|
|
616
|
-
function initFirebase(config2) {
|
|
617
|
-
const
|
|
625
|
+
function initFirebase(config2, name) {
|
|
626
|
+
const options = {
|
|
618
627
|
apiKey: config2.apiKey,
|
|
619
628
|
projectId: config2.projectId,
|
|
620
629
|
authDomain: `${config2.projectId}.firebaseapp.com`,
|
|
621
630
|
// The modern default bucket (post-2024 Firebase projects) — matches what the functions
|
|
622
631
|
// runtime's admin getStorage().bucket() resolves for getTranscript.
|
|
623
632
|
storageBucket: `${config2.projectId}.firebasestorage.app`
|
|
624
|
-
}
|
|
633
|
+
};
|
|
634
|
+
const app = name ? initializeApp(options, name) : initializeApp(options);
|
|
625
635
|
const auth = getAuth(app);
|
|
626
636
|
const db = getFirestore(app, CREW_DATABASE_ID);
|
|
627
637
|
const storage = getStorage(app);
|
|
@@ -1225,7 +1235,7 @@ Note: a chat session has no task, so it is never granted write access \u2014 you
|
|
|
1225
1235
|
// src/engines/claude.ts
|
|
1226
1236
|
import { spawn as spawn3 } from "node:child_process";
|
|
1227
1237
|
import fs3 from "node:fs";
|
|
1228
|
-
import
|
|
1238
|
+
import os2 from "node:os";
|
|
1229
1239
|
import path3 from "node:path";
|
|
1230
1240
|
function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
1231
1241
|
const result = resultEvent ?? {};
|
|
@@ -1278,8 +1288,8 @@ function allowedTools(agent) {
|
|
|
1278
1288
|
return [...new Set(tools)];
|
|
1279
1289
|
}
|
|
1280
1290
|
function createSessionDirs(jobId) {
|
|
1281
|
-
const workdir = fs3.mkdtempSync(path3.join(
|
|
1282
|
-
const configDir2 = fs3.mkdtempSync(path3.join(
|
|
1291
|
+
const workdir = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-job-${jobId}-`));
|
|
1292
|
+
const configDir2 = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-cfg-${jobId}-`));
|
|
1283
1293
|
return { workdir, configDir: configDir2, mcpConfigPath: path3.join(configDir2, "mcp.json") };
|
|
1284
1294
|
}
|
|
1285
1295
|
function buildMcpConfig(input) {
|
|
@@ -1748,9 +1758,25 @@ async function startDaemon() {
|
|
|
1748
1758
|
console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
|
|
1749
1759
|
process.exit(1);
|
|
1750
1760
|
}
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
|
|
1761
|
+
const { sessions, failures } = await openShipSessions(
|
|
1762
|
+
config2,
|
|
1763
|
+
(shipId) => initFirebase(config2, `ship-${shipId}`)
|
|
1764
|
+
);
|
|
1765
|
+
for (const f of failures) console.error(f.message);
|
|
1766
|
+
if (sessions.length === 0) {
|
|
1767
|
+
console.error("No Ship could be signed in to. Run: lumi-runner login");
|
|
1768
|
+
process.exit(1);
|
|
1769
|
+
}
|
|
1770
|
+
const byShip = new Map(sessions.map((s) => [s.shipId, s]));
|
|
1771
|
+
const sess = (shipId) => {
|
|
1772
|
+
const s = byShip.get(shipId);
|
|
1773
|
+
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
1774
|
+
return s;
|
|
1775
|
+
};
|
|
1776
|
+
const liveShips = sessions.map((s) => s.shipId);
|
|
1777
|
+
console.log(
|
|
1778
|
+
`Runner ${config2.runnerId} serving ${liveShips.length} Ship(s): ${liveShips.join(", ")}`
|
|
1779
|
+
);
|
|
1754
1780
|
const logLines = [];
|
|
1755
1781
|
const log2 = (line) => {
|
|
1756
1782
|
const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
|
|
@@ -1763,53 +1789,21 @@ async function startDaemon() {
|
|
|
1763
1789
|
const startedAt = Date.now();
|
|
1764
1790
|
let currentJob = null;
|
|
1765
1791
|
const secretsOk = /* @__PURE__ */ new Map();
|
|
1766
|
-
const enrolled = /* @__PURE__ */ new Set();
|
|
1767
1792
|
const approved = /* @__PURE__ */ new Map();
|
|
1768
1793
|
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
1769
|
-
const shipRunnerRef = (shipId) => doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
1770
|
-
|
|
1771
|
-
if (enrolled.has(shipId)) return;
|
|
1772
|
-
const snap = await getDoc5(shipRunnerRef(shipId));
|
|
1773
|
-
if (!snap.exists()) {
|
|
1774
|
-
await setDoc2(shipRunnerRef(shipId), {
|
|
1775
|
-
ownerUserId: user.uid,
|
|
1776
|
-
hostname: os4.hostname(),
|
|
1777
|
-
version: RUNNER_VERSION,
|
|
1778
|
-
status: "online",
|
|
1779
|
-
lastSeenAt: Date.now(),
|
|
1780
|
-
startedAt,
|
|
1781
|
-
keyPresent: secretsOk.get(shipId) ?? false,
|
|
1782
|
-
currentJob: null,
|
|
1783
|
-
lastLogLines: [],
|
|
1784
|
-
approved: false
|
|
1785
|
-
});
|
|
1786
|
-
}
|
|
1787
|
-
enrolled.add(shipId);
|
|
1788
|
-
}
|
|
1789
|
-
let becameEligible = false;
|
|
1790
|
-
let tokenStale = false;
|
|
1794
|
+
const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
1795
|
+
const needsRefill = /* @__PURE__ */ new Set();
|
|
1791
1796
|
let beating = false;
|
|
1792
1797
|
async function heartbeat() {
|
|
1793
1798
|
if (beating) return;
|
|
1794
1799
|
beating = true;
|
|
1795
1800
|
const now = Date.now();
|
|
1796
1801
|
try {
|
|
1797
|
-
|
|
1798
|
-
doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
|
|
1799
|
-
{
|
|
1800
|
-
hostname: os4.hostname(),
|
|
1801
|
-
version: RUNNER_VERSION,
|
|
1802
|
-
status: "online",
|
|
1803
|
-
lastSeenAt: now,
|
|
1804
|
-
ships: config2.ships
|
|
1805
|
-
}
|
|
1806
|
-
);
|
|
1807
|
-
for (const shipId of config2.ships) {
|
|
1808
|
-
await ensureEnrolled(shipId);
|
|
1802
|
+
for (const shipId of liveShips) {
|
|
1809
1803
|
await setDoc2(
|
|
1810
1804
|
shipRunnerRef(shipId),
|
|
1811
1805
|
{
|
|
1812
|
-
hostname:
|
|
1806
|
+
hostname: os3.hostname(),
|
|
1813
1807
|
version: RUNNER_VERSION,
|
|
1814
1808
|
status: "online",
|
|
1815
1809
|
lastSeenAt: now,
|
|
@@ -1822,19 +1816,17 @@ async function startDaemon() {
|
|
|
1822
1816
|
);
|
|
1823
1817
|
const snap = await getDoc5(shipRunnerRef(shipId));
|
|
1824
1818
|
const isApproved = snap.data()?.approved === true;
|
|
1825
|
-
const seen = approved.has(shipId);
|
|
1826
1819
|
const wasApproved = approved.get(shipId) === true;
|
|
1827
1820
|
approved.set(shipId, isApproved);
|
|
1828
1821
|
if (!isApproved && !warnedUnapproved.has(shipId)) {
|
|
1829
1822
|
warnedUnapproved.add(shipId);
|
|
1830
1823
|
log2(
|
|
1831
|
-
`Ship ${shipId}:
|
|
1824
|
+
`Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
|
|
1832
1825
|
);
|
|
1833
1826
|
}
|
|
1834
1827
|
if (isApproved && !wasApproved) {
|
|
1835
1828
|
warnedUnapproved.delete(shipId);
|
|
1836
|
-
|
|
1837
|
-
if (seen) tokenStale = true;
|
|
1829
|
+
needsRefill.add(shipId);
|
|
1838
1830
|
}
|
|
1839
1831
|
}
|
|
1840
1832
|
} catch (e) {
|
|
@@ -1842,17 +1834,27 @@ async function startDaemon() {
|
|
|
1842
1834
|
} finally {
|
|
1843
1835
|
beating = false;
|
|
1844
1836
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1837
|
+
for (const shipId of [...needsRefill]) {
|
|
1838
|
+
needsRefill.delete(shipId);
|
|
1839
|
+
if (approved.get(shipId) !== true) continue;
|
|
1847
1840
|
try {
|
|
1848
|
-
|
|
1849
|
-
|
|
1841
|
+
const snap = await getDocs3(
|
|
1842
|
+
query3(
|
|
1843
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
1844
|
+
where3("status", "==", "queued"),
|
|
1845
|
+
orderBy3("createdAt", "asc")
|
|
1846
|
+
)
|
|
1847
|
+
);
|
|
1848
|
+
for (const d of snap.docs) {
|
|
1849
|
+
pending.set(`${shipId}/${d.id}`, {
|
|
1850
|
+
shipId,
|
|
1851
|
+
job: { id: d.id, ...d.data() }
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
|
|
1850
1855
|
} catch (e) {
|
|
1851
|
-
console.error(
|
|
1856
|
+
console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
1852
1857
|
}
|
|
1853
|
-
}
|
|
1854
|
-
if (becameEligible) {
|
|
1855
|
-
becameEligible = false;
|
|
1856
1858
|
poke();
|
|
1857
1859
|
}
|
|
1858
1860
|
}
|
|
@@ -1860,9 +1862,9 @@ async function startDaemon() {
|
|
|
1860
1862
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
1861
1863
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
1862
1864
|
const unsubs = [];
|
|
1863
|
-
for (const shipId of
|
|
1865
|
+
for (const shipId of liveShips) {
|
|
1864
1866
|
const q = query3(
|
|
1865
|
-
collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
1867
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
1866
1868
|
where3("status", "==", "queued"),
|
|
1867
1869
|
orderBy3("createdAt", "asc")
|
|
1868
1870
|
);
|
|
@@ -1886,7 +1888,7 @@ async function startDaemon() {
|
|
|
1886
1888
|
);
|
|
1887
1889
|
unsubs.push(
|
|
1888
1890
|
onSnapshot2(
|
|
1889
|
-
collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
1891
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
1890
1892
|
(snap) => {
|
|
1891
1893
|
for (const d of snap.docs) {
|
|
1892
1894
|
agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
|
|
@@ -1898,7 +1900,7 @@ async function startDaemon() {
|
|
|
1898
1900
|
);
|
|
1899
1901
|
unsubs.push(
|
|
1900
1902
|
subscribeEngineLimits(
|
|
1901
|
-
fb.db,
|
|
1903
|
+
sess(shipId).fb.db,
|
|
1902
1904
|
shipId,
|
|
1903
1905
|
(docs) => {
|
|
1904
1906
|
for (const key of [...engineLimits.keys()]) {
|
|
@@ -1950,7 +1952,7 @@ async function startDaemon() {
|
|
|
1950
1952
|
log2(
|
|
1951
1953
|
`${getEngine(entry.engineId).label} usage window on Ship ${entry.shipId} has reset \u2014 resuming.`
|
|
1952
1954
|
);
|
|
1953
|
-
void clearEngineLimit(fb.db, entry.shipId, entry.engineId).catch(() => {
|
|
1955
|
+
void clearEngineLimit(sess(entry.shipId).fb.db, entry.shipId, entry.engineId).catch(() => {
|
|
1954
1956
|
});
|
|
1955
1957
|
}
|
|
1956
1958
|
armLimitTimer();
|
|
@@ -1980,10 +1982,10 @@ async function startDaemon() {
|
|
|
1980
1982
|
}
|
|
1981
1983
|
}
|
|
1982
1984
|
async function claim(shipId, job) {
|
|
1983
|
-
const jobRef = doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
|
|
1985
|
+
const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
|
|
1984
1986
|
try {
|
|
1985
1987
|
let claimed = null;
|
|
1986
|
-
await runTransaction2(fb.db, async (tx) => {
|
|
1988
|
+
await runTransaction2(sess(shipId).fb.db, async (tx) => {
|
|
1987
1989
|
const snap = await tx.get(jobRef);
|
|
1988
1990
|
if (!snap.exists() || snap.data().status !== "queued") {
|
|
1989
1991
|
claimed = null;
|
|
@@ -1996,18 +1998,19 @@ async function startDaemon() {
|
|
|
1996
1998
|
return claimed;
|
|
1997
1999
|
} catch (e) {
|
|
1998
2000
|
log2(`claim failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
2001
|
+
needsRefill.add(shipId);
|
|
1999
2002
|
return null;
|
|
2000
2003
|
}
|
|
2001
2004
|
}
|
|
2002
2005
|
async function setAgentStatus(shipId, agentId, status) {
|
|
2003
2006
|
try {
|
|
2004
|
-
await updateDoc2(doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
|
|
2007
|
+
await updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
|
|
2005
2008
|
} catch (e) {
|
|
2006
2009
|
log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
|
|
2007
2010
|
}
|
|
2008
2011
|
}
|
|
2009
2012
|
async function loadSecrets(shipId) {
|
|
2010
|
-
const secrets = await loadRunnerSecrets(fb.db, shipId);
|
|
2013
|
+
const secrets = await loadRunnerSecrets(sess(shipId).fb.db, shipId);
|
|
2011
2014
|
secretsOk.set(shipId, missingSecretsFor(DEFAULT_ENGINE_ID, secrets).length === 0);
|
|
2012
2015
|
return secrets;
|
|
2013
2016
|
}
|
|
@@ -2017,7 +2020,7 @@ async function startDaemon() {
|
|
|
2017
2020
|
const target = jobTarget(job);
|
|
2018
2021
|
if (!target) {
|
|
2019
2022
|
log2(`Job ${job.id} names neither a task nor a chat (or both) \u2014 failing it.`);
|
|
2020
|
-
await finalizeJob(fb.db, shipId, job, {
|
|
2023
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, {
|
|
2021
2024
|
status: "failed",
|
|
2022
2025
|
usage: {
|
|
2023
2026
|
engine: DEFAULT_ENGINE_ID,
|
|
@@ -2071,7 +2074,7 @@ async function startDaemon() {
|
|
|
2071
2074
|
let statuses = DEFAULT_TASK_STATUSES;
|
|
2072
2075
|
try {
|
|
2073
2076
|
const secrets = await loadSecrets(shipId);
|
|
2074
|
-
const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(fb.db, shipId, { ...job, taskId: target.taskId }) };
|
|
2077
|
+
const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }) };
|
|
2075
2078
|
if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
|
|
2076
2079
|
log2(
|
|
2077
2080
|
`Playbook ${job.workflowId} is missing, disabled or empty \u2014 running a generic session.`
|
|
@@ -2088,10 +2091,10 @@ async function startDaemon() {
|
|
|
2088
2091
|
);
|
|
2089
2092
|
}
|
|
2090
2093
|
const prompt = packed.kind === "chat" ? buildChatPrompt(packed.ctx) : buildPrompt(packed.ctx, job.reason);
|
|
2091
|
-
const idToken = await user.getIdToken();
|
|
2094
|
+
const idToken = await sess(shipId).user.getIdToken();
|
|
2092
2095
|
try {
|
|
2093
2096
|
const gh = await resolveGithubToken({
|
|
2094
|
-
db: fb.db,
|
|
2097
|
+
db: sess(shipId).fb.db,
|
|
2095
2098
|
config: config2,
|
|
2096
2099
|
idToken,
|
|
2097
2100
|
shipId,
|
|
@@ -2149,7 +2152,7 @@ async function startDaemon() {
|
|
|
2149
2152
|
try {
|
|
2150
2153
|
if (shuttingDown) {
|
|
2151
2154
|
await releaseJob(
|
|
2152
|
-
fb.db,
|
|
2155
|
+
sess(shipId).fb.db,
|
|
2153
2156
|
shipId,
|
|
2154
2157
|
job,
|
|
2155
2158
|
"Runner shut down mid-job \u2014 released back to the queue without consuming a retry."
|
|
@@ -2158,9 +2161,9 @@ async function startDaemon() {
|
|
|
2158
2161
|
} else if (sessionLimit) {
|
|
2159
2162
|
const engineLabel = getEngine(engineId).label;
|
|
2160
2163
|
const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
|
|
2161
|
-
await noteEngineLimit(fb.db, shipId, engineId, sessionLimit, config2.runnerId);
|
|
2164
|
+
await noteEngineLimit(sess(shipId).fb.db, shipId, engineId, sessionLimit, config2.runnerId);
|
|
2162
2165
|
await releaseJob(
|
|
2163
|
-
fb.db,
|
|
2166
|
+
sess(shipId).fb.db,
|
|
2164
2167
|
shipId,
|
|
2165
2168
|
job,
|
|
2166
2169
|
`${engineLabel} usage limit reached \u2014 released without consuming a retry; resumes ${resetsAt}.`
|
|
@@ -2173,8 +2176,8 @@ async function startDaemon() {
|
|
|
2173
2176
|
`${engineLabel} usage limit reached \u2014 work resumes automatically.`
|
|
2174
2177
|
);
|
|
2175
2178
|
} else if (!failure) {
|
|
2176
|
-
const transcriptPath = await uploadTranscript(fb.storage, shipId, job.id, transcript);
|
|
2177
|
-
await finalizeJob(fb.db, shipId, job, { status: "done", usage, transcriptPath });
|
|
2179
|
+
const transcriptPath = await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript);
|
|
2180
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, { status: "done", usage, transcriptPath });
|
|
2178
2181
|
log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
|
|
2179
2182
|
notify(
|
|
2180
2183
|
"Crew job finished",
|
|
@@ -2182,14 +2185,14 @@ async function startDaemon() {
|
|
|
2182
2185
|
);
|
|
2183
2186
|
} else if (!terminal && job.attempt < MAX_ATTEMPTS) {
|
|
2184
2187
|
log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
|
|
2185
|
-
await requeueForRetry(fb.db, shipId, job, failure);
|
|
2188
|
+
await requeueForRetry(sess(shipId).fb.db, shipId, job, failure);
|
|
2186
2189
|
} else {
|
|
2187
|
-
const transcriptPath = transcript ? await uploadTranscript(fb.storage, shipId, job.id, transcript) : "";
|
|
2188
|
-
await finalizeJob(fb.db, shipId, job, { status: "failed", usage, transcriptPath, error: failure });
|
|
2190
|
+
const transcriptPath = transcript ? await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript) : "";
|
|
2191
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, { status: "failed", usage, transcriptPath, error: failure });
|
|
2189
2192
|
if (target.kind === "chat") {
|
|
2190
|
-
await markChatFailed(fb.db, shipId, { ...job, chatId: target.chatId }, failure);
|
|
2193
|
+
await markChatFailed(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, failure);
|
|
2191
2194
|
} else {
|
|
2192
|
-
await markTaskFailed(fb.db, shipId, { ...job, taskId: target.taskId }, failure, statuses);
|
|
2195
|
+
await markTaskFailed(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }, failure, statuses);
|
|
2193
2196
|
}
|
|
2194
2197
|
log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
|
|
2195
2198
|
notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
|
|
@@ -2201,7 +2204,7 @@ async function startDaemon() {
|
|
|
2201
2204
|
await setAgentStatus(shipId, job.agentId, "idle");
|
|
2202
2205
|
if (!shuttingDown) void heartbeat();
|
|
2203
2206
|
}
|
|
2204
|
-
await Promise.all(
|
|
2207
|
+
await Promise.all(liveShips.map((shipId) => loadSecrets(shipId).catch(() => null)));
|
|
2205
2208
|
await heartbeat();
|
|
2206
2209
|
const heartbeatTimer = setInterval(() => {
|
|
2207
2210
|
void heartbeat();
|
|
@@ -2230,15 +2233,10 @@ async function startDaemon() {
|
|
|
2230
2233
|
}
|
|
2231
2234
|
const now = Date.now();
|
|
2232
2235
|
try {
|
|
2233
|
-
|
|
2234
|
-
doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
|
|
2235
|
-
{ status: "offline", lastSeenAt: now },
|
|
2236
|
-
{ merge: true }
|
|
2237
|
-
);
|
|
2238
|
-
for (const shipId of config2.ships) {
|
|
2236
|
+
for (const shipId of liveShips) {
|
|
2239
2237
|
await setDoc2(
|
|
2240
|
-
doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
2241
|
-
{
|
|
2238
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
2239
|
+
{ status: "offline", lastSeenAt: now, currentJob: deleteField() },
|
|
2242
2240
|
{ merge: true }
|
|
2243
2241
|
);
|
|
2244
2242
|
}
|
|
@@ -2248,7 +2246,7 @@ async function startDaemon() {
|
|
|
2248
2246
|
};
|
|
2249
2247
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2250
2248
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2251
|
-
log2(`Runner online \u2014 watching ${
|
|
2249
|
+
log2(`Runner online \u2014 watching ${liveShips.length} Ship(s), MCP: ${mcpUrl(config2)}`);
|
|
2252
2250
|
await new Promise(() => {
|
|
2253
2251
|
});
|
|
2254
2252
|
}
|
|
@@ -2426,12 +2424,12 @@ async function runConfigSet(key, value) {
|
|
|
2426
2424
|
|
|
2427
2425
|
// src/cli/commands/doctor.ts
|
|
2428
2426
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2429
|
-
import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as
|
|
2427
|
+
import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs4 } from "firebase/firestore";
|
|
2430
2428
|
|
|
2431
2429
|
// src/service.ts
|
|
2432
2430
|
import { spawnSync } from "node:child_process";
|
|
2433
2431
|
import fs4 from "node:fs";
|
|
2434
|
-
import
|
|
2432
|
+
import os4 from "node:os";
|
|
2435
2433
|
import path4 from "node:path";
|
|
2436
2434
|
import { fileURLToPath } from "node:url";
|
|
2437
2435
|
var SERVICE_LABEL = "com.lumi.runner";
|
|
@@ -2456,16 +2454,16 @@ function uid() {
|
|
|
2456
2454
|
return String(process.getuid?.() ?? 0);
|
|
2457
2455
|
}
|
|
2458
2456
|
function launchAgentPath() {
|
|
2459
|
-
return path4.join(
|
|
2457
|
+
return path4.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
2460
2458
|
}
|
|
2461
2459
|
function systemdUnitPath() {
|
|
2462
|
-
return path4.join(
|
|
2460
|
+
return path4.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
|
|
2463
2461
|
}
|
|
2464
2462
|
function legacyLaunchAgentPath() {
|
|
2465
|
-
return path4.join(
|
|
2463
|
+
return path4.join(os4.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
|
|
2466
2464
|
}
|
|
2467
2465
|
function legacySystemdUnitPath() {
|
|
2468
|
-
return path4.join(
|
|
2466
|
+
return path4.join(os4.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
|
|
2469
2467
|
}
|
|
2470
2468
|
function serviceEnv() {
|
|
2471
2469
|
const env = { PATH: process.env.PATH ?? "" };
|
|
@@ -2677,10 +2675,10 @@ function installService() {
|
|
|
2677
2675
|
if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
|
|
2678
2676
|
const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
|
|
2679
2677
|
if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
|
|
2680
|
-
const linger = run("loginctl", ["show-user",
|
|
2678
|
+
const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
|
|
2681
2679
|
if (!linger.out.includes("Linger=yes")) {
|
|
2682
2680
|
notes.push(
|
|
2683
|
-
`Run \`sudo loginctl enable-linger ${
|
|
2681
|
+
`Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
|
|
2684
2682
|
);
|
|
2685
2683
|
}
|
|
2686
2684
|
return { unitPath, notes };
|
|
@@ -2747,11 +2745,26 @@ function restartService() {
|
|
|
2747
2745
|
}
|
|
2748
2746
|
|
|
2749
2747
|
// src/cli/session.ts
|
|
2748
|
+
async function openShipSession(shipId) {
|
|
2749
|
+
const config2 = requireConfig();
|
|
2750
|
+
const fb = initFirebase(config2, `cli-${shipId}`);
|
|
2751
|
+
const user = await signInToShip(fb, config2, shipId);
|
|
2752
|
+
return { shipId, config: config2, fb, user };
|
|
2753
|
+
}
|
|
2750
2754
|
async function openSession() {
|
|
2751
2755
|
const config2 = requireConfig();
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2756
|
+
if (config2.ships.length === 0) {
|
|
2757
|
+
throw new AuthError("No Ships assigned. Run: lumi-runner ship add <shipId>");
|
|
2758
|
+
}
|
|
2759
|
+
let last;
|
|
2760
|
+
for (const shipId of config2.ships) {
|
|
2761
|
+
try {
|
|
2762
|
+
return await openShipSession(shipId);
|
|
2763
|
+
} catch (e) {
|
|
2764
|
+
last = e;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
throw last instanceof Error ? last : new AuthError("Could not sign in to any Ship.");
|
|
2755
2768
|
}
|
|
2756
2769
|
|
|
2757
2770
|
// src/cli/commands/doctor.ts
|
|
@@ -2818,8 +2831,7 @@ function serviceCheckFrom(status) {
|
|
|
2818
2831
|
function checkService() {
|
|
2819
2832
|
return serviceCheckFrom(serviceStatus());
|
|
2820
2833
|
}
|
|
2821
|
-
async function checkShips(
|
|
2822
|
-
const { config: config2, fb, user } = session;
|
|
2834
|
+
async function checkShips(config2) {
|
|
2823
2835
|
const checks = [];
|
|
2824
2836
|
const engines = /* @__PURE__ */ new Set();
|
|
2825
2837
|
let needsGithub = false;
|
|
@@ -2830,6 +2842,21 @@ async function checkShips(session) {
|
|
|
2830
2842
|
return { checks, engines, needsGithub };
|
|
2831
2843
|
}
|
|
2832
2844
|
for (const shipId of config2.ships) {
|
|
2845
|
+
let fb;
|
|
2846
|
+
try {
|
|
2847
|
+
({ fb } = await openShipSession(shipId));
|
|
2848
|
+
checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
|
|
2849
|
+
} catch (e) {
|
|
2850
|
+
checks.push(
|
|
2851
|
+
fail(
|
|
2852
|
+
`key:${shipId}`,
|
|
2853
|
+
`Ship ${shipId} \u2014 key`,
|
|
2854
|
+
e instanceof Error ? e.message : String(e),
|
|
2855
|
+
"Ask a captain to approve this machine on that Ship's Daemons page, then re-run `lumi-runner login`."
|
|
2856
|
+
)
|
|
2857
|
+
);
|
|
2858
|
+
continue;
|
|
2859
|
+
}
|
|
2833
2860
|
try {
|
|
2834
2861
|
const snap = await getDoc6(
|
|
2835
2862
|
doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
|
|
@@ -2839,8 +2866,8 @@ async function checkShips(session) {
|
|
|
2839
2866
|
warn(
|
|
2840
2867
|
`approval:${shipId}`,
|
|
2841
2868
|
`Ship ${shipId} \u2014 approval`,
|
|
2842
|
-
"This machine has
|
|
2843
|
-
"
|
|
2869
|
+
"This machine has no record on that Ship.",
|
|
2870
|
+
"Re-run `lumi-runner login` \u2014 approving is what enrols it."
|
|
2844
2871
|
)
|
|
2845
2872
|
);
|
|
2846
2873
|
} else if (snap.data().approved !== true) {
|
|
@@ -2848,17 +2875,8 @@ async function checkShips(session) {
|
|
|
2848
2875
|
fail(
|
|
2849
2876
|
`approval:${shipId}`,
|
|
2850
2877
|
`Ship ${shipId} \u2014 approval`,
|
|
2851
|
-
"
|
|
2852
|
-
`Approve this machine on the Ship's Daemons page.`
|
|
2853
|
-
)
|
|
2854
|
-
);
|
|
2855
|
-
} else if (snap.data().ownerUserId !== user.uid) {
|
|
2856
|
-
checks.push(
|
|
2857
|
-
fail(
|
|
2858
|
-
`approval:${shipId}`,
|
|
2859
|
-
`Ship ${shipId} \u2014 approval`,
|
|
2860
|
-
"The approval record belongs to another user.",
|
|
2861
|
-
"Re-run `lumi-runner login` on this machine."
|
|
2878
|
+
"Approval was withdrawn \u2014 no jobs will be claimed.",
|
|
2879
|
+
`Approve this machine again on the Ship's Daemons page.`
|
|
2862
2880
|
)
|
|
2863
2881
|
);
|
|
2864
2882
|
} else {
|
|
@@ -2870,13 +2888,13 @@ async function checkShips(session) {
|
|
|
2870
2888
|
`approval:${shipId}`,
|
|
2871
2889
|
`Ship ${shipId} \u2014 approval`,
|
|
2872
2890
|
`Could not read the approval record (${e instanceof Error ? e.message : e}).`,
|
|
2873
|
-
"
|
|
2891
|
+
"The key exchanged but the read was refused \u2014 report this."
|
|
2874
2892
|
)
|
|
2875
2893
|
);
|
|
2876
2894
|
}
|
|
2877
2895
|
let agents = [];
|
|
2878
2896
|
try {
|
|
2879
|
-
const snap = await
|
|
2897
|
+
const snap = await getDocs4(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
|
|
2880
2898
|
agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
2881
2899
|
} catch {
|
|
2882
2900
|
}
|
|
@@ -2920,26 +2938,14 @@ async function runDoctor() {
|
|
|
2920
2938
|
checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
|
|
2921
2939
|
const progress = spinner2();
|
|
2922
2940
|
progress.start("Running checks\u2026");
|
|
2923
|
-
let session = null;
|
|
2924
|
-
try {
|
|
2925
|
-
session = await openSession();
|
|
2926
|
-
checks.push(ok("session", "Session", `Signed in as ${session.user.uid}`));
|
|
2927
|
-
} catch (e) {
|
|
2928
|
-
checks.push(
|
|
2929
|
-
fail("session", "Session", e instanceof Error ? e.message : String(e), "Run `lumi-runner login`.")
|
|
2930
|
-
);
|
|
2931
|
-
}
|
|
2932
2941
|
const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
engines.clear();
|
|
2939
|
-
for (const id of shipResults.engines) engines.add(id);
|
|
2940
|
-
}
|
|
2941
|
-
needsGithub = shipResults.needsGithub;
|
|
2942
|
+
const shipResults = await checkShips(config2);
|
|
2943
|
+
checks.push(...shipResults.checks);
|
|
2944
|
+
if (shipResults.engines.size > 0) {
|
|
2945
|
+
engines.clear();
|
|
2946
|
+
for (const id of shipResults.engines) engines.add(id);
|
|
2942
2947
|
}
|
|
2948
|
+
const needsGithub = shipResults.needsGithub;
|
|
2943
2949
|
for (const engineId of engines) {
|
|
2944
2950
|
const health = await getDriver(engineId).healthCheck();
|
|
2945
2951
|
checks.push(
|
|
@@ -2993,7 +2999,7 @@ function report(checks) {
|
|
|
2993
2999
|
|
|
2994
3000
|
// src/cli/commands/login.ts
|
|
2995
3001
|
import { spawn as spawn4 } from "node:child_process";
|
|
2996
|
-
import
|
|
3002
|
+
import os5 from "node:os";
|
|
2997
3003
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
2998
3004
|
function openBrowser(url) {
|
|
2999
3005
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
@@ -3009,14 +3015,14 @@ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
3009
3015
|
async function runLogin(options) {
|
|
3010
3016
|
const moved = migrateLegacyDir();
|
|
3011
3017
|
if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
|
|
3012
|
-
return options.
|
|
3018
|
+
return options.key ? loginWithKey(options) : loginWithDeviceFlow(options);
|
|
3013
3019
|
}
|
|
3014
3020
|
async function loginWithDeviceFlow(options) {
|
|
3015
3021
|
const existingConfig = loadConfig();
|
|
3016
3022
|
const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
|
|
3017
3023
|
const baseUrl = functionsBaseUrlFor(projectId);
|
|
3018
3024
|
const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
|
|
3019
|
-
hostname:
|
|
3025
|
+
hostname: os5.hostname(),
|
|
3020
3026
|
// Offer the id this machine already has: re-logging in should keep its identity, and with
|
|
3021
3027
|
// it the captain approvals it has already earned.
|
|
3022
3028
|
...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {}
|
|
@@ -3062,7 +3068,10 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
3062
3068
|
apiKey: approved.apiKey || existingConfig?.apiKey || "",
|
|
3063
3069
|
projectId: approved.projectId || projectId,
|
|
3064
3070
|
runnerId: approved.runnerId,
|
|
3065
|
-
|
|
3071
|
+
// THE CREDENTIALS (PRD §8.1) — one Ship key per approved Ship, handed over on this one hop
|
|
3072
|
+
// and nowhere else. Merged over any we already held so that approving a SECOND Ship does not
|
|
3073
|
+
// silently revoke the first: this response only carries keys for what was just approved.
|
|
3074
|
+
shipKeys: { ...existingConfig?.shipKeys ?? {}, ...approved.shipKeys },
|
|
3066
3075
|
// What the human PICKED on the approval page. Their choice replaces whatever this machine
|
|
3067
3076
|
// was serving — that page is where the decision is now made.
|
|
3068
3077
|
ships: approved.selectedShips.length > 0 ? approved.selectedShips : existingConfig?.ships ?? [],
|
|
@@ -3074,34 +3083,62 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
3074
3083
|
);
|
|
3075
3084
|
}
|
|
3076
3085
|
const fb = initFirebase(config2);
|
|
3077
|
-
const
|
|
3078
|
-
|
|
3086
|
+
const first = approved.approvedShips[0];
|
|
3087
|
+
const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
|
|
3079
3088
|
saveConfig(config2);
|
|
3080
3089
|
return report2({
|
|
3081
3090
|
runnerId: config2.runnerId,
|
|
3082
|
-
uid:
|
|
3091
|
+
uid: uid2,
|
|
3083
3092
|
ships: config2.ships,
|
|
3084
3093
|
approvedShips: approved.approvedShips,
|
|
3085
3094
|
pendingShips: approved.pendingShips.filter((id) => config2.ships.includes(id))
|
|
3086
3095
|
});
|
|
3087
3096
|
}
|
|
3088
|
-
async function
|
|
3097
|
+
async function exchange(config2, shipId) {
|
|
3098
|
+
return callPublicFunction(
|
|
3099
|
+
functionsBaseUrlFor(config2.projectId),
|
|
3100
|
+
"exchangeRunnerKey",
|
|
3101
|
+
{ key: config2.shipKeys[shipId] }
|
|
3102
|
+
);
|
|
3103
|
+
}
|
|
3104
|
+
async function loginWithKey(options) {
|
|
3089
3105
|
if (!options.apiKey || !options.project) {
|
|
3090
|
-
throw new CliError("--
|
|
3106
|
+
throw new CliError("--key also needs --api-key and --project. Omit --key to use the browser flow.");
|
|
3107
|
+
}
|
|
3108
|
+
const parsed = parseKeyShipId(options.key);
|
|
3109
|
+
if (!parsed) {
|
|
3110
|
+
throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
|
|
3091
3111
|
}
|
|
3092
3112
|
const existing = loadConfig();
|
|
3093
3113
|
const config2 = {
|
|
3094
3114
|
...existing,
|
|
3095
3115
|
apiKey: options.apiKey,
|
|
3096
3116
|
projectId: options.project,
|
|
3097
|
-
runnerId: options.runnerId || existing?.runnerId || `runner-${
|
|
3098
|
-
|
|
3099
|
-
ships: existing?.ships ?? [],
|
|
3117
|
+
runnerId: options.runnerId || existing?.runnerId || `runner-${os5.hostname().toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
3118
|
+
shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
|
|
3119
|
+
ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
|
|
3100
3120
|
...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
|
|
3101
3121
|
};
|
|
3122
|
+
const session = await callPublicFunction(
|
|
3123
|
+
functionsBaseUrlFor(config2.projectId),
|
|
3124
|
+
"exchangeRunnerKey",
|
|
3125
|
+
{ key: options.key }
|
|
3126
|
+
);
|
|
3127
|
+
config2.runnerId = session.runnerId;
|
|
3102
3128
|
const fb = initFirebase(config2);
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3129
|
+
const cred = await signInWithCustomToken2(fb.auth, session.customToken);
|
|
3130
|
+
saveConfig(config2);
|
|
3131
|
+
return report2({
|
|
3132
|
+
runnerId: config2.runnerId,
|
|
3133
|
+
uid: cred.user.uid,
|
|
3134
|
+
ships: config2.ships,
|
|
3135
|
+
approvedShips: [parsed],
|
|
3136
|
+
pendingShips: []
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
function parseKeyShipId(key) {
|
|
3140
|
+
const parts = (key ?? "").split("_");
|
|
3141
|
+
return parts.length === 3 && parts[0] === "crewrunner" && parts[1] && parts[2] ? parts[1] : null;
|
|
3105
3142
|
}
|
|
3106
3143
|
function report2(result) {
|
|
3107
3144
|
if (isJson()) {
|
|
@@ -3234,10 +3271,10 @@ async function runUninstall(options) {
|
|
|
3234
3271
|
}
|
|
3235
3272
|
|
|
3236
3273
|
// src/cli/commands/ship.ts
|
|
3237
|
-
import { collectionGroup, doc as doc9, getDoc as getDoc7, getDocs as
|
|
3274
|
+
import { collectionGroup, doc as doc9, getDoc as getDoc7, getDocs as getDocs5, query as query4, where as where4 } from "firebase/firestore";
|
|
3238
3275
|
async function listMyShips() {
|
|
3239
3276
|
const { fb, user } = await openSession();
|
|
3240
|
-
const memberships = await
|
|
3277
|
+
const memberships = await getDocs5(
|
|
3241
3278
|
query4(collectionGroup(fb.db, COLLECTIONS.members), where4("uid", "==", user.uid))
|
|
3242
3279
|
);
|
|
3243
3280
|
const shipIds = memberships.docs.map((d) => d.ref.parent.parent?.id).filter((id) => !!id);
|
|
@@ -3314,8 +3351,8 @@ async function runSetup(options) {
|
|
|
3314
3351
|
const moved = migrateLegacyDir();
|
|
3315
3352
|
if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
|
|
3316
3353
|
const existing = loadConfig();
|
|
3317
|
-
const reuse = existing?.
|
|
3318
|
-
message: `This machine is already connected as ${existing
|
|
3354
|
+
const reuse = Object.keys(existing?.shipKeys ?? {}).length > 0 && await promptConfirm({
|
|
3355
|
+
message: `This machine is already connected as ${existing?.runnerId}. Keep its Ship keys?`,
|
|
3319
3356
|
initialValue: true,
|
|
3320
3357
|
yesFlagApplies: false
|
|
3321
3358
|
});
|
|
@@ -3362,7 +3399,7 @@ import {
|
|
|
3362
3399
|
doc as doc10,
|
|
3363
3400
|
getCountFromServer as getCountFromServer2,
|
|
3364
3401
|
getDoc as getDoc8,
|
|
3365
|
-
getDocs as
|
|
3402
|
+
getDocs as getDocs6,
|
|
3366
3403
|
query as query5,
|
|
3367
3404
|
where as where5
|
|
3368
3405
|
} from "firebase/firestore";
|
|
@@ -3396,7 +3433,7 @@ async function runStatus() {
|
|
|
3396
3433
|
const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
|
|
3397
3434
|
const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
|
|
3398
3435
|
const now = Date.now();
|
|
3399
|
-
const limitsSnap = await
|
|
3436
|
+
const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
|
|
3400
3437
|
const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
|
|
3401
3438
|
ships.push({
|
|
3402
3439
|
shipId,
|
|
@@ -3464,13 +3501,11 @@ function action(handler) {
|
|
|
3464
3501
|
try {
|
|
3465
3502
|
process.exitCode = await handler(...args);
|
|
3466
3503
|
} catch (error) {
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
process.exitCode = 1;
|
|
3473
|
-
}
|
|
3504
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3505
|
+
if (isJson()) process.stderr.write(`${JSON.stringify({ error: message })}
|
|
3506
|
+
`);
|
|
3507
|
+
else say.error(message);
|
|
3508
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
3474
3509
|
} finally {
|
|
3475
3510
|
await closeFirebase();
|
|
3476
3511
|
}
|
|
@@ -3479,12 +3514,12 @@ function action(handler) {
|
|
|
3479
3514
|
program.command("setup").description("Connect this machine and set it up to run jobs (interactive)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").action(action(
|
|
3480
3515
|
async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
|
|
3481
3516
|
));
|
|
3482
|
-
program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--
|
|
3517
|
+
program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--key <shipKey>", "skip the browser: paste a Ship key from the Daemons page (CI)").option("--api-key <key>", "Firebase web API key (only with --key)").option("--runner-id <id>", "reuse an existing runner id (only with --key)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
|
|
3483
3518
|
action(
|
|
3484
3519
|
async (options) => runLogin({
|
|
3485
3520
|
project: options.project,
|
|
3486
3521
|
noBrowser: options.browser === false,
|
|
3487
|
-
|
|
3522
|
+
key: options.key,
|
|
3488
3523
|
apiKey: options.apiKey,
|
|
3489
3524
|
runnerId: options.runnerId,
|
|
3490
3525
|
mcpUrl: options.mcpUrl
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.",
|