From 374585ac3fcd8f8c52a9d68089dcf2c06051b41c Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Mon, 3 Aug 2026 23:46:53 +0200 Subject: [PATCH] Support dev instances without Slack --- .codex/skills/dev-instance/SKILL.md | 43 ++++++++++----- package.json | 1 + scripts/dev/cli.ts | 71 ++++++++++++++++-------- scripts/dev/commands/doctor.ts | 81 +++++++++++++++------------- scripts/dev/lib/types.ts | 3 ++ scripts/dev/supervisor/main.ts | 84 +++++++++++++++++++---------- scripts/dev/supervisor/specs.ts | 14 +++-- test/dev-cli-lib.test.ts | 23 ++++++++ 8 files changed, 215 insertions(+), 105 deletions(-) diff --git a/.codex/skills/dev-instance/SKILL.md b/.codex/skills/dev-instance/SKILL.md index 6f1c1b65..742d909f 100644 --- a/.codex/skills/dev-instance/SKILL.md +++ b/.codex/skills/dev-instance/SKILL.md @@ -1,12 +1,12 @@ --- name: dev-instance -description: Run the current worktree as a production-shaped local dev instance — core, Slack, web UI, admin, portal, on a real Pi LLM + Postgres — reachable in Slack as your own bot. Each developer uses their own set of Slack apps from their own machine's pool store, so many worktrees (yours and a teammate's) can run reachable at once without colliding. Use when asked to /dev-instance, "spin this up so I can QA it in Slack", or "let me test your branch end to end". +description: Run the current worktree as a production-shaped local dev instance — core, optional Slack, web UI, admin, portal, on a real Pi LLM + Postgres. Each developer can use Slack apps from their own machine's pool store, while browser-only instances need no Slack configuration. Use when asked to /dev-instance, "spin this up so I can QA it", or "let me test your branch end to end". --- # dev-instance `dev-instance` runs the current worktree as a full, production-shaped stack on your -machine and makes it reachable in Slack as one of _your_ bots. It is the way to QA a +machine, with Slack enabled by default and optional for browser-only QA. It is the way to QA a branch end to end: real LLM turns, a real sandbox, a real local Postgres (empty by default; opt in to prod data), and the real Slack/web/admin surfaces. @@ -15,6 +15,7 @@ every command accepts `--json` for machine-readable output): ```bash bash scripts/dev-instance.sh up +bash scripts/dev-instance.sh up --no-slack bash scripts/dev-instance.sh status bash scripts/dev-instance.sh down bash scripts/dev-instance.sh doctor @@ -23,17 +24,19 @@ bash scripts/dev-instance.sh restart [child] bash scripts/dev-instance.sh logs [child] [-f] ``` -`npm run dev-instance`, `npm run dev-instance:status`, `npm run dev-instance:down`, and +`npm run dev-instance`, `npm run dev-instance:no-slack`, `npm run dev-instance:status`, `npm run dev-instance:down`, and `npm run dev-instance:doctor` are equivalent. The Codex-visible skill copy lives at `.codex/skills/dev-instance/SKILL.md`; keep the two skill descriptions equivalent. ## What `up` Starts `up` claims one free Slack app slot from **this machine's** pool store (see "Slack reach" -below), then spawns a **per-slot supervisor daemon** that owns the production-shaped stack: +below), then spawns a **per-slot supervisor daemon** that owns the production-shaped stack. +With `--no-slack` or `DEV_INSTANCE_NO_SLACK=1`, it claims only a local port slot and needs +no pool configuration: - core API + workers -- Slack Socket Mode plugin (connected as the claimed app's bot) +- Slack Socket Mode plugin when Slack is enabled (connected as the claimed app's bot) - web UI surface - admin surface - portal front door proxying `/web-ui/` and `/admin/` @@ -42,7 +45,7 @@ The supervisor restarts crashed children with backoff, waits for a port to actua before respawning (no more EADDRINUSE), health-probes everything every 10s, and writes a heartbeat so slot reclaim can tell "actively in use" from "abandoned". -**`up` only prints success after proving the bot is reachable**: the Slack socket must be +When Slack is enabled, **`up` only prints success after proving the bot is reachable**: the Slack socket must be the app's _only_ connection (`num_connections == 1`, read from the hello frame) and — when the slot has a `CANARY_CHANNEL` — a posted canary message must arrive back over that same socket. If another machine/worktree holds a connection to the app (the classic "boots LIVE @@ -52,12 +55,27 @@ means a stale Slack app; `up` flags and rotates past that too. **Re-running `up` on a live instance is a reload, not a no-op**: it re-reads your shell env, dev.env, and `.env`, diffs against what the children are running, and does a rolling -restart + re-verification when anything changed (`--force` to restart regardless, +restart plus Slack re-verification when enabled and anything changed (`--force` to restart regardless, `--rotate` to move to a different Slack app). Open the portal URL printed by the CLI. Direct web/admin URLs are also printed for debugging, but the portal URL is the prod-like path. +## Browser-only mode + +Use browser-only mode when Slack is outside the test scope: + +```bash +bash scripts/dev-instance.sh up --no-slack +bash scripts/dev-instance.sh doctor --no-slack +``` + +This starts the same core, local Postgres, sandbox, web UI, admin, portal, supervisor, +health checks, reloads, and teardown flow. It omits Slack credentials, Socket Mode, +canaries, exclusivity checks, and the pool requirement. `DEV_INSTANCE_NO_SLACK=1` is the +environment equivalent. Switching an existing worktree between modes requires `down` +followed by `up` with the desired mode. + ## Sandbox: local Docker by default The agent's `execute` sandbox runs as a **local Docker container** (`SANDBOX_BACKEND=local`) @@ -146,14 +164,15 @@ DEV_INSTANCE_ALLOW_MOCK=1 bash scripts/dev-instance.sh up DEV_INSTANCE_ALLOW_MEMORY=1 bash scripts/dev-instance.sh up DEV_INSTANCE_WATCH=0 bash scripts/dev-instance.sh up DEV_INSTANCE_RECLAIM_STALE=0 bash scripts/dev-instance.sh up +DEV_INSTANCE_NO_SLACK=1 bash scripts/dev-instance.sh up ``` ## Env Discovery The launcher reads values from, in priority order: exported shell env, the machine-global `~/.config/qm/dev.env`, your login shell (for a model credential exported there), and this -worktree's `.env` (seeded from the main checkout in linked worktrees). Slack pool tokens -default to `~/.config/qm/slack-pool`. +worktree's `.env` (seeded from the main checkout in linked worktrees). When Slack is enabled, +pool tokens default to `~/.config/qm/slack-pool`. When a cloud sandbox backend is configured it also validates that provider's access at startup, refreshes a stale provider token from the provider CLI's own logged-in session @@ -162,15 +181,15 @@ self-API calls can reach your local core. None of that runs on the default local ## After Startup -Report the slot, portal URL, Slack handle, and log directory. To test Slack-specific +Report the slot, portal URL, log directory, and Slack handle when enabled. To test Slack-specific behavior, DM the printed `@` (on Alice's machine that's one of `@bot1 … @bot10`) in `example.slack.com`; for admin and web behavior, open the printed portal URL. Tear down with `bash scripts/dev-instance.sh down` when QA is finished. ## Troubleshooting -**Start with `dev doctor` (or `doctor --json`).** It runs the checks that used to take a -debugging session by hand — socket exclusivity (`num_connections`), a live canary +**Start with `dev doctor` (or `doctor --json`; pass `--no-slack` before the first browser-only boot).** It runs the checks that used to take a +debugging session by hand — socket exclusivity (`num_connections`) and a live canary when Slack is enabled, round trip, env/git drift since boot, per-child health and restart counts, stale leases, port squatters, machine-wide token orphans, Docker daemon — and prints a ranked diagnosis with a remedy per finding. `doctor --fix` applies the safe ones (child restarts). diff --git a/package.json b/package.json index 17bc5dfc..1cdc313d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start": "node --env-file-if-exists=.env src/index.ts", "dev": "SHUTDOWN_DRAIN_MS=2000 node --env-file-if-exists=.env --watch src/index.ts", "dev-instance": "bash scripts/dev-instance.sh up", + "dev-instance:no-slack": "bash scripts/dev-instance.sh up --no-slack", "dev-instance:status": "bash scripts/dev-instance.sh status", "dev-instance:down": "bash scripts/dev-instance.sh down", "worker": "node --env-file-if-exists=.env src/runs/worker-main.ts", diff --git a/scripts/dev/cli.ts b/scripts/dev/cli.ts index 3e45ee54..0ed2780f 100644 --- a/scripts/dev/cli.ts +++ b/scripts/dev/cli.ts @@ -58,6 +58,7 @@ function parseCli() { follow: { type: "boolean", short: "f", default: false }, fix: { type: "boolean", default: false }, sandbox: { type: "string", default: "auto" }, + "no-slack": { type: "boolean", default: false }, "no-watch": { type: "boolean", default: false }, org: { type: "string" }, }, @@ -74,13 +75,13 @@ const command = positionals[0] ?? "up"; const store = poolStore(); const commandOptions: Record = { - up: ["json", "force", "strict", "rotate", "sandbox", "no-watch", "org"], + up: ["json", "force", "strict", "rotate", "sandbox", "no-slack", "no-watch", "org"], down: ["json"], status: ["json"], restart: ["json"], canary: ["json"], logs: ["follow"], - doctor: ["json", "fix"], + doctor: ["json", "fix", "no-slack"], }; const devServiceNames = [...CHILD_ORDER, "web-ui"]; @@ -115,6 +116,7 @@ function emitJson(payload: unknown): void { } const orgId = opts.org ?? process.env.DEV_INSTANCE_ORG_ID ?? "acme"; +const slackEnabled = !opts["no-slack"] && process.env.DEV_INSTANCE_NO_SLACK !== "1"; const devCallerEnv = (): Record => ({ ...callerEnvSnapshot(), DEV_INSTANCE_ORG_ID: orgId }); async function legacyTeardown(lease: LeaseInfo): Promise { @@ -175,6 +177,15 @@ function claimNext(exclude: Set): string | null { return null; } +function claimNextLocal(exclude: Set): string | null { + for (let i = 1; i <= 99; i++) { + const slot = `pool${i}`; + if (exclude.has(slot)) continue; + if (claimSlotLock(slot, store)) return slot; + } + return null; +} + function renderPhase(e: BootPhaseEvent): void { if (opts.json || e.event !== "phase") return; let mark = "…"; @@ -188,7 +199,7 @@ function renderPhase(e: BootPhaseEvent): void { async function bootOnSlot(slot: string, worktree: string, branch: string): Promise { const ports = slotPorts(slot); const lock = lockDir(slot, store); - const tokens = slotTokens(slot, store); + const tokens = slackEnabled ? slotTokens(slot, store) : null; writeFileSync( join(lock, "meta"), @@ -200,6 +211,7 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi `web_port=${ports.web}`, `admin_port=${ports.admin}`, `portal_port=${ports.portal}`, + `slack=${slackEnabled ? "1" : "0"}`, "booting=1", `owner_pid=${process.pid}`, `created_epoch=${nowEpoch()}`, @@ -208,11 +220,13 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi ].join("\n"), ); - const swept = await sweepSlackTokenOrphans(tokens.appToken, new Set(), (m) => out(m)); - if (swept.swept.length) out(`swept ${swept.swept.length} orphaned process(es) holding ${slot}'s Slack app token`); + if (tokens) { + const swept = await sweepSlackTokenOrphans(tokens.appToken, new Set(), (m) => out(m)); + if (swept.swept.length) out(`swept ${swept.swept.length} orphaned process(es) holding ${slot}'s Slack app token`); + } const callerEnv = devCallerEnv(); - const canaryChannel = tokens.canaryChannel || callerEnv.DEV_INSTANCE_CANARY_CHANNEL || ""; + const canaryChannel = tokens?.canaryChannel || callerEnv.DEV_INSTANCE_CANARY_CHANNEL || ""; writeFileSync( join(lock, "boot-spec.json"), JSON.stringify( @@ -225,6 +239,7 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi sandbox: opts.sandbox as "local" | "sprites" | "auto", canaryChannel, strict: opts.strict, + slack: slackEnabled, }, null, 2, @@ -256,7 +271,9 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi }; } - out(`booting on slot ${slot} (@${tokens.handle || `agent-${slot}`})...`); + out( + tokens ? `booting on slot ${slot} (@${tokens.handle || `agent-${slot}`})...` : `booting on local slot ${slot}...`, + ); let result: BootResult | null = null; await streamBootEvents(sock, (e) => { renderPhase(e); @@ -270,15 +287,18 @@ function printSuccess(result: BootResult, branch: string): void { const lock = lockDir(result.slot, store); const meta = readMeta(lock); out(""); + const hasSlack = result.slackEnabled !== false; out( - `[ok] dev instance up -- slot ${result.slot} (VERIFIED: socket exclusive${result.canary ? `, canary ${result.canary.rttMs}ms round trip` : ", delivery unverified -- no canary channel"})`, + hasSlack + ? `[ok] dev instance up -- slot ${result.slot} (VERIFIED: socket exclusive${result.canary ? `, canary ${result.canary.rttMs}ms round trip` : ", delivery unverified -- no canary channel"})` + : `[ok] dev instance up -- slot ${result.slot} (Slack disabled)`, ); out(` branch : ${branch}`); out(` portal : http://localhost:${ports.portal} -> prod-style front door: the assistant at / and /admin`); out( ` core : http://localhost:${ports.core} (org=${orgId}, session_store=${meta.session_store}, run_store=${meta.run_store})`, ); - out(` slack : @${result.handle} -> mention it in example.slack.com to test`); + if (hasSlack) out(` slack : @${result.handle} -> mention it in example.slack.com to test`); out(` web : http://localhost:${ports.portal}/ (direct: http://localhost:${ports.web})`); out(` admin : http://localhost:${ports.portal}/admin/ (direct: http://localhost:${ports.admin})`); out(` logs : ${lock}/{core,web,admin,portal,supervisor}.log`); @@ -327,9 +347,10 @@ async function cmdUp(): Promise { const excluded = new Set(); const waitMax = Number(process.env.DEV_INSTANCE_WAIT || 120); for (let attempt = 1; attempt <= 3; attempt++) { - let slot = claimNext(excluded); - if (!slot && (await reclaimReclaimable())) slot = claimNext(excluded); - if (!slot && waitMax > 0 && attempt === 1) { + const claim = (): string | null => (slackEnabled ? claimNext(excluded) : claimNextLocal(excluded)); + let slot = claim(); + if (!slot && (await reclaimReclaimable())) slot = claim(); + if (!slot && slackEnabled && waitMax > 0 && attempt === 1) { out(""); out(`all pool apps are in use by other worktrees -- waiting up to ${waitMax}s for a free slot.`); out(` this is normal contention, not an error. held now: ${takenSummary(store)}`); @@ -338,14 +359,16 @@ async function cmdUp(): Promise { await sleep(5000); waited += 5; await reapStale(); - if (await reclaimReclaimable()) slot = claimNext(excluded); - if (!slot) slot = claimNext(excluded); + if (await reclaimReclaimable()) slot = claim(); + if (!slot) slot = claim(); } } if (!slot) { emitJson({ ok: false, reason: "no free pool slot", held: takenSummary(store) }); out( - `no free pool app. Another worktree holds each one -- 'dev down' one of them, add a poolN.env, or raise DEV_INSTANCE_WAIT.`, + slackEnabled + ? `no free pool app. Another worktree holds each one -- 'dev down' one of them, add a poolN.env, or raise DEV_INSTANCE_WAIT.` + : `no free local slot. Another worktree holds every slot -- 'dev down' one of them and retry.`, ); return EXIT.noFreeSlot; } @@ -404,7 +427,7 @@ async function cmdDown(): Promise { return EXIT.ok; } const slot = mine.slot; - const tokens = slotTokens(slot, store); + const tokens = mine.meta.slack === "0" ? null : slotTokens(slot, store); await teardownLease(mine); const residue: string[] = []; for (const [name, port] of Object.entries(slotPorts(slot))) { @@ -412,7 +435,7 @@ async function cmdDown(): Promise { const holders = portHolders(port); if (holders.length) residue.push(`port ${port} (${name}) still held by pid(s) ${holders.join(",")}`); } - const swept = await sweepSlackTokenOrphans(tokens.appToken, new Set(), (m) => out(m)); + const swept = tokens ? await sweepSlackTokenOrphans(tokens.appToken, new Set(), (m) => out(m)) : { swept: [] }; if (residue.length) { emitJson({ ok: false, slot, residue }); out(`[!] down completed with residue:\n ${residue.join("\n ")}`); @@ -435,7 +458,11 @@ async function cmdStatus(): Promise { } })(); const rows: Record[] = []; - for (const slot of listSlots(store)) { + const leases = listLeases(store); + const slots = [...new Set([...listSlots(store), ...leases.map((lease) => lease.slot)])].sort( + (a, b) => Number(a.slice(4)) - Number(b.slice(4)), + ); + for (const slot of slots) { const lock = lockDir(slot, store); const ports = slotPorts(slot); const flag = readSlotFlag(slot, store); @@ -443,7 +470,7 @@ async function cmdStatus(): Promise { rows.push({ slot, state: flag && slotFlagged(slot, store) ? `flagged(${flag.reason})` : "free", ports }); continue; } - const lease = listLeases(store).find((l) => l.slot === slot); + const lease = leases.find((l) => l.slot === slot); if (!lease) continue; const sock = resolveSocketPath(lock); if (await supervisorReachable(sock)) { @@ -511,7 +538,7 @@ async function cmdStatus(): Promise { } const taken = rows.filter((r) => r.state !== "free" && !String(r.state).startsWith("flagged")).length; console.log(""); - console.log(`${taken} taken / ${rows.length - taken} free / ${rows.length} pool apps total`); + console.log(`${taken} taken / ${rows.length - taken} free / ${rows.length} slots total`); console.log("live = supervised + verified. Reclaim never touches a slot with a fresh supervisor heartbeat."); return EXIT.ok; } @@ -597,10 +624,10 @@ async function main(): Promise { case "logs": return await cmdLogs(); case "doctor": - return await runDoctor({ json: opts.json, fix: opts.fix, store }); + return await runDoctor({ json: opts.json, fix: opts.fix, store, slack: slackEnabled }); default: console.error( - "usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|auto] [--no-watch] [--org id] [--fix]", + "usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|auto] [--no-slack] [--no-watch] [--org id] [--fix]", ); return EXIT.usage; } diff --git a/scripts/dev/commands/doctor.ts b/scripts/dev/commands/doctor.ts index 8bdbbf70..292d8c06 100644 --- a/scripts/dev/commands/doctor.ts +++ b/scripts/dev/commands/doctor.ts @@ -18,7 +18,7 @@ interface Check { autoFixable?: boolean; } -export async function runDoctor(opts: { json: boolean; fix: boolean; store: string }): Promise { +export async function runDoctor(opts: { json: boolean; fix: boolean; store: string; slack: boolean }): Promise { const checks: Check[] = []; const worktree = (() => { try { @@ -28,16 +28,21 @@ export async function runDoctor(opts: { json: boolean; fix: boolean; store: stri } })(); + const mine = worktree ? myLease(worktree, opts.store) : null; + const slackExpected = opts.slack && mine?.meta.slack !== "0"; const slots = listSlots(opts.store); + let poolDetail = "Slack disabled; no pool slot required"; + if (slackExpected) + poolDetail = slots.length ? `${slots.length} pool slot(s) configured` : "no poolN.env files in the pool store"; checks.push({ id: "pool", - ok: slots.length > 0, - severity: "critical", - detail: slots.length ? `${slots.length} pool slot(s) configured` : "no poolN.env files in the pool store", - remedy: slots.length ? undefined : "add poolN.env files (see the dev-instance skill runbook)", + ok: !slackExpected || slots.length > 0, + severity: slackExpected ? "critical" : "info", + detail: poolDetail, + remedy: slackExpected && !slots.length ? "add poolN.env files (see the dev-instance skill runbook)" : undefined, }); - for (const slot of slots) { + for (const slot of slackExpected ? slots : []) { const flag = readSlotFlag(slot, opts.store); if (flag && slotFlagged(slot, opts.store)) { checks.push({ @@ -53,7 +58,6 @@ export async function runDoctor(opts: { json: boolean; fix: boolean; store: stri } } - const mine = worktree ? myLease(worktree, opts.store) : null; if (!mine) { checks.push({ id: "lease", ok: true, severity: "info", detail: "no dev instance for this worktree" }); } else { @@ -83,37 +87,40 @@ export async function runDoctor(opts: { json: boolean; fix: boolean; store: stri autoFixable: child?.state !== "healthy", }); } - const slack = status.children.core?.slack; - const conns = slack?.numConnections ?? null; - checks.push({ - id: "slack-socket", - ok: conns === 1, - severity: "critical", - detail: - conns === null - ? "num_connections unknown (introspection tap degraded)" - : `num_connections=${conns}${slack?.helloHost ? ` (hello host ${slack.helloHost})` : ""}`, - remedy: conns !== null && conns > 1 ? "another live connection is stealing events: dev up --rotate" : undefined, - }); - const canary = (await supervisorRequest(sock, "POST", "/canary", {}, 40_000)).body as { - ok: boolean; - rttMs?: number; - reason?: string; - }; - const unconfigured = !canary.ok && /no canary channel configured/.test(canary.reason ?? ""); - let eventDeliveryRemedy: string | undefined; - if (!canary.ok) { - eventDeliveryRemedy = unconfigured - ? "set CANARY_CHANNEL in the slot env (a channel the bot is in), then dev down && dev up" - : "events are not arriving: dev up --rotate (stolen/stale app), or check the slack log"; + if (status.slackEnabled) { + const slack = status.children.core?.slack; + const conns = slack?.numConnections ?? null; + checks.push({ + id: "slack-socket", + ok: conns === 1, + severity: "critical", + detail: + conns === null + ? "num_connections unknown (introspection tap degraded)" + : `num_connections=${conns}${slack?.helloHost ? ` (hello host ${slack.helloHost})` : ""}`, + remedy: + conns !== null && conns > 1 ? "another live connection is stealing events: dev up --rotate" : undefined, + }); + const canary = (await supervisorRequest(sock, "POST", "/canary", {}, 40_000)).body as { + ok: boolean; + rttMs?: number; + reason?: string; + }; + const unconfigured = !canary.ok && /no canary channel configured/.test(canary.reason ?? ""); + let eventDeliveryRemedy: string | undefined; + if (!canary.ok) { + eventDeliveryRemedy = unconfigured + ? "set CANARY_CHANNEL in the slot env (a channel the bot is in), then dev down && dev up" + : "events are not arriving: dev up --rotate (stolen/stale app), or check the slack log"; + } + checks.push({ + id: "event-delivery", + ok: canary.ok, + severity: unconfigured ? "warn" : "critical", + detail: canary.ok ? `canary round trip ${canary.rttMs}ms` : `canary failed: ${canary.reason}`, + remedy: eventDeliveryRemedy, + }); } - checks.push({ - id: "event-delivery", - ok: canary.ok, - severity: unconfigured ? "warn" : "critical", - detail: canary.ok ? `canary round trip ${canary.rttMs}ms` : `canary failed: ${canary.reason}`, - remedy: eventDeliveryRemedy, - }); const gitNow = gitHead(worktree); checks.push({ id: "git-drift", diff --git a/scripts/dev/lib/types.ts b/scripts/dev/lib/types.ts index 27cf7132..b424eb2f 100644 --- a/scripts/dev/lib/types.ts +++ b/scripts/dev/lib/types.ts @@ -68,6 +68,7 @@ export interface BootPhaseEvent { export interface BootResult { ok: boolean; + slackEnabled?: boolean; reason?: string; slot: string; handle?: string; @@ -94,6 +95,7 @@ export interface StatusReport { sandbox: { backend: string; detail: string }; durability: { sessionStore: string; runStore: string; databaseUrl: boolean }; harness: string; + slackEnabled: boolean; watch: boolean; turnsLive: boolean; publicApiUrl: string | null; @@ -109,6 +111,7 @@ export interface BootSpec { sandbox: "local" | "sprites" | "auto"; canaryChannel?: string; strict: boolean; + slack?: boolean; } export interface LeaseInfo { diff --git a/scripts/dev/supervisor/main.ts b/scripts/dev/supervisor/main.ts index 5eedb307..691831b9 100644 --- a/scripts/dev/supervisor/main.ts +++ b/scripts/dev/supervisor/main.ts @@ -104,7 +104,10 @@ function readBootSpec(): BootSpec { return JSON.parse(readFileSync(join(lock, "boot-spec.json"), "utf8")) as BootSpec; } +const slackOn = (spec: BootSpec): boolean => spec.slack !== false; + async function resolveCanaryChannel(spec: BootSpec): Promise { + if (!slackOn(spec)) return; if (spec.canaryChannel) { canaryChannel = spec.canaryChannel; canaryChannelSource = "configured"; @@ -258,6 +261,7 @@ async function verifySlack(spec: BootSpec): Promise<{ ok: boolean; result: Parti } function writeLegacyMeta(booting: boolean): void { + const slackEnabled = specInputs?.slack !== undefined || slackOn(readBootSpec()); const branch = specInputs ? currentBranch(worktree) : "?"; const meta: Record = { slot, @@ -272,6 +276,7 @@ function writeLegacyMeta(booting: boolean): void { session_store: durability.sessionStore, run_store: durability.runStore, watch: watch ? "1" : "0", + slack: slackEnabled ? "1" : "0", created_epoch: String(startedAt), created: new Date(startedAt * 1000).toISOString().replace("T", " ").slice(0, 19), }; @@ -390,7 +395,7 @@ async function assembleAndPrepare(spec: BootSpec): Promise { if (!portalDevPrincipal) portalDevPrincipal = assembled.env.USER || "dev-admin"; log(`portal auth: localhost bypass signs in as ${portalDevPrincipal}`); - const tokens = slotTokens(slot, store); + const tokens = slackOn(spec) ? slotTokens(slot, store) : null; return { worktree, @@ -398,7 +403,7 @@ async function assembleAndPrepare(spec: BootSpec): Promise { baseEnv: assembled.env, watch: spec.watch, webUiBasePath: spec.callerEnv.DEV_INSTANCE_WEB_UI_BASE || "/", - slack: { botToken: tokens.botToken, appToken: tokens.appToken }, + ...(tokens ? { slack: { botToken: tokens.botToken, appToken: tokens.appToken } } : {}), sessionStore, runStore, databaseUrl, @@ -454,29 +459,34 @@ async function boot(): Promise { process.exit(EXIT.childFailed); } phase("verify", "start"); - await resolveCanaryChannel(spec); - const verified = await verifySlack(spec); - if (!verified.ok) { - bootResult = { ok: false, slot, ...verified.result } as BootResult; - phase("verify", "fail", bootResult.reason); - finishBoot(); - await teardown(`verification failed: ${bootResult.reason}`); - process.exit(bootResult.reason === "slot-stolen" ? EXIT.slotStolen : EXIT.verificationFailed); + let verified: { ok: boolean; result: Partial } = { ok: true, result: {} }; + if (slackOn(spec)) { + await resolveCanaryChannel(spec); + verified = await verifySlack(spec); + if (!verified.ok) { + bootResult = { ok: false, slackEnabled: true, slot, ...verified.result } as BootResult; + phase("verify", "fail", bootResult.reason); + finishBoot(); + await teardown(`verification failed: ${bootResult.reason}`); + process.exit(bootResult.reason === "slot-stolen" ? EXIT.slotStolen : EXIT.verificationFailed); + } + phase( + "verify", + "ok", + verified.result.canary + ? `canary ${verified.result.canary.rttMs}ms, connections=1` + : "socket verified (no canary channel)", + ); + } else { + phase("verify", "ok", "Slack disabled"); } - phase( - "verify", - "ok", - verified.result.canary - ? `canary ${verified.result.canary.rttMs}ms, connections=1` - : "socket verified (no canary channel)", - ); bootedAt = nowEpoch(); - bootResult = { ok: true, slot, handle, ...verified.result } as BootResult; + bootResult = { ok: true, slackEnabled: slackOn(spec), slot, handle, ...verified.result } as BootResult; writeLegacyMeta(false); persistState(); finishBoot(); startLoops(); - log(`live -- @${handle} on slot ${slot}`); + log(slackOn(spec) ? `live -- @${handle} on slot ${slot}` : `live -- Slack disabled on slot ${slot}`); } catch (err) { bootResult = { ok: false, reason: errMessage(err), slot }; phase(phaseName, "fail", errMessage(err)); @@ -516,7 +526,7 @@ function startLoops(): void { log(`${name} healthy again`); } } - const slackHealth = await fetchSlackHealth(); + const slackHealth = slackOn(readBootSpec()) ? await fetchSlackHealth() : null; if (slackHealth) { if (slackHealth.lastActivityAt) { lastSlackActivitySec = Math.max(lastSlackActivitySec, Math.floor(slackHealth.lastActivityAt / 1000)); @@ -531,7 +541,7 @@ function startLoops(): void { }, HEALTH_INTERVAL_MS); health.unref(); - if (CANARY_INTERVAL_MS > 0) { + if (CANARY_INTERVAL_MS > 0 && slackOn(readBootSpec())) { const canary = setInterval(async () => { if (!canaryChannel) await resolveCanaryChannel(readBootSpec()); if (!canaryChannel) return; @@ -546,8 +556,9 @@ function startLoops(): void { const lastActivity = Math.max(bootedAt ?? startedAt, lastSlackActivitySec, lastControlAt); const idleSec = nowEpoch() - lastActivity; if (idleSec > IDLE_HOURS * 3600) { + const activity = slackOn(readBootSpec()) ? "Slack events or control actions" : "control actions"; log( - `idle self-teardown: no Slack events or control actions for ${Math.floor(idleSec / 3600)}h (limit ${IDLE_HOURS}h; set DEV_INSTANCE_IDLE_HOURS=0 to disable)`, + `idle self-teardown: no ${activity} for ${Math.floor(idleSec / 3600)}h (limit ${IDLE_HOURS}h; set DEV_INSTANCE_IDLE_HOURS=0 to disable)`, ); await shutdownSelf(true); } @@ -662,8 +673,9 @@ async function reload(body: Record): Promise | undefined) ?? spec.callerEnv; const force = body.force === true; const dryRun = body.dryRun === true; - const freshCanary = - slotTokens(slot, store).canaryChannel || callerEnv.DEV_INSTANCE_CANARY_CHANNEL || spec.canaryChannel || ""; + const freshCanary = slackOn(spec) + ? slotTokens(slot, store).canaryChannel || callerEnv.DEV_INSTANCE_CANARY_CHANNEL || spec.canaryChannel || "" + : ""; const newSpec: BootSpec = { ...spec, callerEnv, canaryChannel: freshCanary }; if (dryRun) { const assembled = await assembleEnv({ @@ -719,13 +731,25 @@ async function reload(body: Record): Promise } = { ok: true, result: {} }; + if (slackOn(newSpec)) { + await resolveCanaryChannel(newSpec); + verified = await verifySlack(newSpec); + if (!verified.ok) return { ok: false, reason: verified.result.reason, ...verified.result }; + } bootedAt = nowEpoch(); writeLegacyMeta(false); persistState(); - return { ok: true, noop: false, envSha: newEnvSha, gitSha: newGitSha, bootId, handle, ...verified.result }; + return { + ok: true, + noop: false, + slackEnabled: slackOn(newSpec), + envSha: newEnvSha, + gitSha: newGitSha, + bootId, + handle, + ...verified.result, + }; } async function statusReport(): Promise { @@ -733,7 +757,8 @@ async function statusReport(): Promise { for (const [name, child] of children) { childStatuses[name] = child.status(); } - const slackHealth = await fetchSlackHealth(); + const slackEnabled = slackOn(readBootSpec()); + const slackHealth = slackEnabled ? await fetchSlackHealth() : null; if (slackHealth && childStatuses.core) childStatuses.core.slack = slackHealth; return { slot, @@ -754,6 +779,7 @@ async function statusReport(): Promise { databaseUrl: Boolean(durability.databaseUrl), }, harness, + slackEnabled, watch, turnsLive: harness !== "mock", publicApiUrl: sandbox?.publicApiUrl ?? null, diff --git a/scripts/dev/supervisor/specs.ts b/scripts/dev/supervisor/specs.ts index c1d308bd..33c0b364 100644 --- a/scripts/dev/supervisor/specs.ts +++ b/scripts/dev/supervisor/specs.ts @@ -7,7 +7,7 @@ export interface SpecInputs { baseEnv: Record; watch: boolean; webUiBasePath: string; - slack: { botToken: string; appToken: string }; + slack?: { botToken: string; appToken: string }; sessionStore: string; runStore: string; databaseUrl: string; @@ -37,11 +37,15 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] { ...(i.databaseUrl ? { DATABASE_URL: i.databaseUrl } : {}), ...(i.adminGrantsSeed ? { ADMIN_GRANTS: i.adminGrantsSeed } : {}), PUBLIC_WEB_URL: `http://localhost:${i.ports.portal}`, - SLACK_BOT_TOKEN: i.slack.botToken, - SLACK_APP_TOKEN: i.slack.appToken, + ...(i.slack + ? { + SLACK_BOT_TOKEN: i.slack.botToken, + SLACK_APP_TOKEN: i.slack.appToken, + DEV_INTROSPECTION: "1", + DEV_HEALTH_PORT: String(i.ports.slackHealth), + } + : {}), CORE_ORG_ID: orgId, - DEV_INTROSPECTION: "1", - DEV_HEALTH_PORT: String(i.ports.slackHealth), SHUTDOWN_DRAIN_MS: "2000", }, port: i.ports.core, diff --git a/test/dev-cli-lib.test.ts b/test/dev-cli-lib.test.ts index d34267e6..753b4e14 100644 --- a/test/dev-cli-lib.test.ts +++ b/test/dev-cli-lib.test.ts @@ -369,6 +369,29 @@ test("supervised children share the selected dev org", () => { assert.equal(buildChildSpecs(inputs).find((spec) => spec.name === "core")!.env.ORG_ID, "acme"); }); +test("supervised children omit Slack wiring when Slack is disabled", () => { + const inputs: SpecInputs = { + worktree: "/tmp/worktree", + ports: slotPorts("pool1"), + baseEnv: {}, + watch: false, + webUiBasePath: "/", + sessionStore: "memory", + runStore: "memory", + databaseUrl: "", + adminGrantsSeed: "", + coreSigningSecret: "", + portalSessionSecret: "secret", + portalDevPrincipal: "U1", + sandboxEnv: {}, + }; + const core = buildChildSpecs(inputs).find((spec) => spec.name === "core")!; + assert.equal(core.env.SLACK_BOT_TOKEN, undefined); + assert.equal(core.env.SLACK_APP_TOKEN, undefined); + assert.equal(core.env.DEV_INTROSPECTION, undefined); + assert.equal(core.env.DEV_HEALTH_PORT, undefined); +}); + test("formatAge renders the bash-compatible shapes", () => { assert.equal(formatAge(42), "42s"); assert.equal(formatAge(150), "2m");