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..869da576 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 withSlack = !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,22 @@ function claimNext(exclude: Set): string | null { return null; } +// Slackless instances need only a port/lock slot, not a provisioned Slack app. +// Prefer slot numbers with no poolN.env so a browser-only instance never squats +// a slot a Slack-enabled worktree could use; fall back to configured ones. +const MAX_PORT_SLOTS = 16; // slotPorts spaces port families 16 apart + +function claimPortSlot(exclude: Set): string | null { + const configured = new Set(listSlots(store)); + const all = Array.from({ length: MAX_PORT_SLOTS }, (_, i) => `pool${i + 1}`); + const ordered = [...all.filter((s) => !configured.has(s)), ...all.filter((s) => configured.has(s))]; + for (const slot of ordered) { + 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 +206,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 = withSlack ? slotTokens(slot, store) : null; writeFileSync( join(lock, "meta"), @@ -200,6 +218,7 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi `web_port=${ports.web}`, `admin_port=${ports.admin}`, `portal_port=${ports.portal}`, + `slack=${withSlack ? "1" : "0"}`, "booting=1", `owner_pid=${process.pid}`, `created_epoch=${nowEpoch()}`, @@ -208,11 +227,15 @@ 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 = tokens?.appToken + ? await sweepSlackTokenOrphans(tokens.appToken, new Set(), (m) => out(m)) + : { swept: [] as string[] }; + 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 +248,7 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi sandbox: opts.sandbox as "local" | "sprites" | "auto", canaryChannel, strict: opts.strict, + slack: withSlack, }, null, 2, @@ -256,7 +280,11 @@ 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 slot ${slot} (Slack off -- browser only)...`, + ); let result: BootResult | null = null; await streamBootEvents(sock, (e) => { renderPhase(e); @@ -270,15 +298,18 @@ function printSuccess(result: BootResult, branch: string): void { const lock = lockDir(result.slot, store); const meta = readMeta(lock); out(""); + const slackLive = 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"})`, + slackLive + ? `[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} (browser only -- Slack off)`, ); 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 (slackLive) 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`); @@ -326,26 +357,33 @@ async function cmdUp(): Promise { const excluded = new Set(); const waitMax = Number(process.env.DEV_INSTANCE_WAIT || 120); + const claim = (): string | null => (withSlack ? claimNext(excluded) : claimPortSlot(excluded)); for (let attempt = 1; attempt <= 3; attempt++) { - let slot = claimNext(excluded); - if (!slot && (await reclaimReclaimable())) slot = claimNext(excluded); + let slot = claim(); + if (!slot && (await reclaimReclaimable())) slot = claim(); if (!slot && 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( + withSlack + ? `all pool apps are in use by other worktrees -- waiting up to ${waitMax}s for a free slot.` + : `all local slots are in use by other worktrees -- waiting up to ${waitMax}s for a free one.`, + ); out(` this is normal contention, not an error. held now: ${takenSummary(store)}`); let waited = 0; while (waited < waitMax && !slot) { 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.`, + withSlack + ? `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. 'dev down' another worktree or raise DEV_INSTANCE_WAIT.`, ); return EXIT.noFreeSlot; } @@ -404,7 +442,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 +450,9 @@ 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: [] as string[] }; if (residue.length) { emitJson({ ok: false, slot, residue }); out(`[!] down completed with residue:\n ${residue.join("\n ")}`); @@ -435,7 +475,11 @@ async function cmdStatus(): Promise { } })(); const rows: Record[] = []; - for (const slot of listSlots(store)) { + const leases = listLeases(store); + const known = [...new Set([...listSlots(store), ...leases.map((l) => l.slot)])].sort( + (a, b) => Number(a.replace(/^pool/, "")) - Number(b.replace(/^pool/, "")), + ); + for (const slot of known) { const lock = lockDir(slot, store); const ports = slotPorts(slot); const flag = readSlotFlag(slot, store); @@ -443,7 +487,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 +555,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 +641,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: withSlack }); 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..7c760e85 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,23 @@ export async function runDoctor(opts: { json: boolean; fix: boolean; store: stri } })(); + const mine = worktree ? myLease(worktree, opts.store) : null; + // A browser-only instance (--no-slack, or a live lease booted that way) needs no pool app. + const needsPool = opts.slack && mine?.meta.slack !== "0"; const slots = listSlots(opts.store); + let poolDetail = "Slack off -- no pool slot needed"; + if (needsPool) { + 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: !needsPool || slots.length > 0, + severity: needsPool ? "critical" : "info", + detail: poolDetail, + remedy: needsPool && !slots.length ? "add poolN.env files (see the dev-instance skill runbook)" : undefined, }); - for (const slot of slots) { + for (const slot of needsPool ? slots : []) { const flag = readSlotFlag(slot, opts.store); if (flag && slotFlagged(slot, opts.store)) { checks.push({ @@ -53,7 +60,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 +89,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 !== false) { + 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..cd057da8 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"; @@ -272,6 +275,7 @@ function writeLegacyMeta(booting: boolean): void { session_store: durability.sessionStore, run_store: durability.runStore, watch: watch ? "1" : "0", + slack: slackOn(readBootSpec()) ? "1" : "0", created_epoch: String(startedAt), created: new Date(startedAt * 1000).toISOString().replace("T", " ").slice(0, 19), }; @@ -390,7 +394,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 +402,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 +458,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 off -- nothing to verify"); } - 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 -- browser only (Slack off) on slot ${slot}`); } catch (err) { bootResult = { ok: false, reason: errMessage(err), slot }; phase(phaseName, "fail", errMessage(err)); @@ -516,7 +525,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 +540,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; @@ -662,8 +671,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 +729,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 +755,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 +777,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..d466b8ea 100644 --- a/test/dev-cli-lib.test.ts +++ b/test/dev-cli-lib.test.ts @@ -369,6 +369,30 @@ test("supervised children share the selected dev org", () => { assert.equal(buildChildSpecs(inputs).find((spec) => spec.name === "core")!.env.ORG_ID, "acme"); }); +test("child specs omit Slack env when no Slack tokens are supplied", () => { + 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); + assert.equal(core.env.CORE_ORG_ID, "acme"); +}); + test("formatAge renders the bash-compatible shapes", () => { assert.equal(formatAge(42), "42s"); assert.equal(formatAge(150), "2m");