Skip to content

Commit fe21900

Browse files
philcunliffeclaude
andauthored
feat(gascity): ctvs gascity CLI subcommands + SIGHUP local reload (co-276c.5) (#109)
Adds the `ctvs gascity` subcommand surface (attach/detach/list/backfill/status) and wires it into a minimal SIGHUP-driven control channel for the standalone daemon. Per-city diff lets attach/detach apply without retiring unrelated cities. Runtime state file backs `list` and `status` without an IPC round trip. - src/runtime/{paths,pid_file}.js: PID file lifecycle (write at boot, remove on shutdown, kill -0 staleness probe). - src/gascity/runtime_state.js: GascityRuntimeStateWriter (debounced, atomic tmp+rename) and readRuntimeState(); plumbed through subscriber + worker so list/status reflect live capture progress. - src/cli.js: SIGHUP wiring re-reads local config, runs existing applyDiff, plus a new applyGascitySectionDiff that calls listener.applyCityDiff() for in-place per-city changes (no thundering retire-all on attach/detach). - src/gascity/index.js: startGascitySource returns an enriched listener with applyCityDiff(newCities), tracks subscribers by name; hot reload preserves unchanged-city state. - src/gascity/supervisor_subscriber.js: stop({removeFromState}) lets a hot-reload removal drop a city from the runtime snapshot; otherwise daemon shutdown leaves it. - src/cli/gascity.js: subcommands. - attach: edit JSON config, infer name/api_url from city.toml when given a path, send SIGHUP via PID file, optionally block on lifecycle event (10s). - detach: remove entry, SIGHUP, daemon retires sessions and drops from state. - list: read runtime state, render table or --json. - backfill: walk cursors directly via backfillSession; --since (cursor age), --all (include retired), idempotent via writer dedup set. - status: per-city reachability probe + lifecycle/session/frame summary, --json for scripts. Tests: +110 tests across runtime/pid_file, gascity/runtime_state, cli/gascity, cli/local_reload (PID file, SIGHUP-driven gascity-section reload). Full suite: 1476/1476 pass; typecheck clean; lint matches baseline (0 errors). Out-of-scope per bead spec: catalog registration + skill doc updates (bead 6). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40dde1a commit fe21900

16 files changed

Lines changed: 3344 additions & 44 deletions

bin/cli.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import process from 'node:process'
44

5-
const SUBCOMMANDS = new Set(['install', 'uninstall', 'attach', 'detach', 'status', 'config', 'admin', 'invite', 'export', 'query', 'collect', 'rendezvous', 'join', 'skills', 'claude-hook', 'init'])
5+
const SUBCOMMANDS = new Set(['install', 'uninstall', 'attach', 'detach', 'status', 'config', 'admin', 'invite', 'export', 'query', 'collect', 'rendezvous', 'join', 'skills', 'claude-hook', 'init', 'gascity'])
66

77
const argv = process.argv.slice(2)
88
const subcommand = argv[0]
@@ -108,6 +108,10 @@ async function loadSubcommand(name) {
108108
const { runInitSubcommand } = await import('../src/cli/init.js')
109109
return runInitSubcommand
110110
}
111+
case 'gascity': {
112+
const { runGascity } = await import('../src/cli/gascity.js')
113+
return runGascity
114+
}
111115
default:
112116
throw new Error(`unknown subcommand: ${name}`)
113117
}

src/cli.js

Lines changed: 228 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { IdentityClient } from './gateway/identity.js'
99
import { OutboxSink, defaultOutboxDir } from './gateway/outbox_sink.js'
1010
import { Proxy } from './proxy.js'
1111
import { Recorder } from './recorder.js'
12+
import { defaultPidFilePath } from './runtime/paths.js'
13+
import { removePidFile, writePidFile } from './runtime/pid_file.js'
1214
import { ControlPlane } from './server/control_plane.js'
1315
import { defaultSinkDir as defaultIngestSinkDir } from './server/ingest.js'
1416
import { FileSink } from './sinks/file.js'
@@ -29,7 +31,7 @@ const PARQUET_PARTITION_DIMENSIONS = ['gateway_id', 'signal']
2931
/**
3032
* @import { Server } from 'node:http'
3133
* @import { CollectivusConfig, ListenerFactory, StartedListener } from './types.js'
32-
* @import { ConfigResult, ErrorResult, HotReloadWiring, ParseResult } from './cli/types.d.ts'
34+
* @import { ConfigResult, ErrorResult, HotReloadWiring, LocalReloadWiring, ParseResult } from './cli/types.d.ts'
3335
* @import { ConfigChangedEvent } from './gateway/types.d.ts'
3436
* @import { IngestSignal } from './server/types.d.ts'
3537
*/
@@ -52,6 +54,7 @@ Commands:
5254
ctvs detach [--client claude|codex|all]
5355
Restore Claude Code and/or Codex config
5456
ctvs status Report daemon, config, recordings, attach state
57+
ctvs gascity <subcommand> [...] Manage gascity supervisor capture sources
5558
ctvs export --config <path|url> [...] Convert recorded JSONL to Parquet
5659
ctvs query <command> [...] Query local recordings through Parquet cache
5760
ctvs collect <file.jsonl> --name <name> Add external JSONL as a query table
@@ -177,9 +180,11 @@ function isHttpUrl(value) {
177180
* stdout?: { write: (s: string) => void },
178181
* stderr?: { write: (s: string) => void },
179182
* onShutdownRequested?: (handler: (signal: string) => void) => void,
183+
* onSighupRequested?: (handler: () => void) => void,
180184
* isTTY?: boolean,
181185
* runInit?: () => Promise<number>,
182186
* identityPersistedPath?: string,
187+
* pidFilePath?: string,
183188
* }} [hooks]
184189
* @returns {Promise<number>}
185190
*/
@@ -235,12 +240,23 @@ export async function run(argv, env, hooks = {}) {
235240
return 0
236241
}
237242

238-
return runWithConfig(config, env, {
243+
/** @type {Parameters<typeof runWithConfig>[2]} */
244+
const childHooks = {
239245
stdout,
240246
stderr,
241247
onShutdownRequested,
242248
identityPersistedPath: hooks.identityPersistedPath,
243-
})
249+
}
250+
if (hooks.onSighupRequested !== undefined) childHooks.onSighupRequested = hooks.onSighupRequested
251+
if (hooks.pidFilePath !== undefined) childHooks.pidFilePath = hooks.pidFilePath
252+
// Only wire SIGHUP-driven reload when the daemon was started from a
253+
// re-readable config source. `--config-env` configs live in process env,
254+
// so a SIGHUP can't pick up new values without a restart anyway; the
255+
// gateway-mode reload path (configClient) covers `--config-endpoint`.
256+
if (parsed.configPath !== undefined && !isHttpUrl(parsed.configPath)) {
257+
childHooks.localConfigPath = parsed.configPath
258+
}
259+
return runWithConfig(config, env, childHooks)
244260
}
245261

246262
/**
@@ -266,8 +282,18 @@ function loadConfigFromEnv(envName, env, opts) {
266282
* stdout?: { write: (s: string) => void },
267283
* stderr?: { write: (s: string) => void },
268284
* onShutdownRequested?: (handler: (signal: string) => void) => void,
285+
* onSighupRequested?: (handler: () => void) => void,
269286
* identityPersistedPath?: string,
287+
* localConfigPath?: string,
288+
* pidFilePath?: string,
270289
* }} [hooks]
290+
* `localConfigPath` (when set) enables SIGHUP-driven re-read of the local
291+
* config file — the standard Unix "reload without restart" pattern. Used
292+
* by `ctvs gascity attach/detach` to push a config edit live without
293+
* bouncing the whole daemon.
294+
* `pidFilePath` overrides where the daemon writes its PID; the default is
295+
* `~/.collectivus/runtime/collectivus.pid`. The CLI reads it to find a
296+
* live daemon for SIGHUP.
271297
* @returns {Promise<number>}
272298
*/
273299
export async function runWithConfig(config, env, hooks = {}) {
@@ -354,7 +380,31 @@ export async function runWithConfig(config, env, hooks = {}) {
354380
const hotReload = configClient
355381
? { initialConfig: config, configClient, factoryBuilder }
356382
: undefined
357-
return runLifecycle(factoryBuilder(config), stdout, stderr, onShutdownRequested, hotReload)
383+
384+
/** @type {Parameters<typeof runLifecycle>[5]} */
385+
const extras = {}
386+
if (hooks.localConfigPath !== undefined && !hotReload) {
387+
const { localConfigPath } = hooks
388+
/** @type {LocalReloadWiring} */
389+
const localReload = {
390+
initialConfig: config,
391+
factoryBuilder,
392+
reload: async () => {
393+
const reloaded = await loadConfigAsync(localConfigPath, { stderr })
394+
return resolveRuntimeSecrets(reloaded, env ?? {})
395+
},
396+
}
397+
extras.localReload = localReload
398+
if (hooks.onSighupRequested !== undefined) extras.onSighupRequested = hooks.onSighupRequested
399+
}
400+
// Only the standalone daemon writes a PID file. Gateway/server roles
401+
// typically run under a supervisor that already tracks PIDs (launchd,
402+
// systemd, k8s) and an out-of-band PID file would just go stale.
403+
if (config.role !== 'gateway' && config.role !== 'server') {
404+
extras.pidFile = { path: hooks.pidFilePath ?? defaultPidFilePath() }
405+
}
406+
407+
return runLifecycle(factoryBuilder(config), stdout, stderr, onShutdownRequested, hotReload, extras)
358408
}
359409

360410
/**
@@ -670,9 +720,24 @@ function buildSelfUpdateFactory(ctx) {
670720
* @param {HotReloadWiring} [hotReload] When supplied, subscribe to
671721
* `configClient.on('config-changed')` and route each event through
672722
* `applyDiff`, mutating the running registry in place.
723+
* @param {{
724+
* localReload?: LocalReloadWiring,
725+
* pidFile?: { path: string },
726+
* onSighupRequested?: (handler: () => void) => void,
727+
* }} [extras]
728+
* `localReload` enables SIGHUP-driven config reread for the standalone
729+
* daemon (used by `ctvs gascity attach/detach`). The handler re-reads the
730+
* config from disk via the supplied callback, then runs the same diff/apply
731+
* chain as the gateway hot-reload path. Gascity is special-cased to mutate
732+
* its existing listener in place via `applyCityDiff` rather than tearing
733+
* the whole source down.
734+
* `pidFile` writes the daemon's PID at startup and removes it on shutdown
735+
* so the CLI can find a live daemon for SIGHUP.
736+
* `onSighupRequested` is overridable for tests; production wires
737+
* `process.on('SIGHUP')`.
673738
* @returns {Promise<number>}
674739
*/
675-
async function runLifecycle(factories, stdout, stderr, onShutdownRequested, hotReload) {
740+
async function runLifecycle(factories, stdout, stderr, onShutdownRequested, hotReload, extras = {}) {
676741
if (factories.size === 0) {
677742
stderr.write('error: no listeners configured\n')
678743
return 1
@@ -701,35 +766,192 @@ async function runLifecycle(factories, stdout, stderr, onShutdownRequested, hotR
701766
}
702767
}
703768

769+
if (extras.pidFile) {
770+
try {
771+
await writePidFile(extras.pidFile.path)
772+
} catch (err) {
773+
stderr.write(`warning: failed to write pid file ${extras.pidFile.path}: ${formatError(err)}\n`)
774+
}
775+
}
776+
704777
// Serialize hot-reload applications onto a single chain so concurrent
705778
// `'config-changed'` emits (in practice the ConfigClient ticks
706779
// sequentially, but defensive serialization keeps the invariant local)
707780
// can't interleave their stop/start operations and leak a listener.
708781
/** @type {Promise<void>} */
709782
let reloadChain = Promise.resolve()
783+
/** @type {CollectivusConfig | undefined} */
784+
let currentCfg = hotReload ? hotReload.initialConfig : extras.localReload?.initialConfig
710785
if (hotReload) {
711-
let currentCfg = hotReload.initialConfig
712786
hotReload.configClient.on('config-changed', (/** @type {ConfigChangedEvent} */ event) => {
713787
reloadChain = reloadChain.then(async () => {
714788
const newCfg = event.newConfig
789+
if (currentCfg === undefined) currentCfg = newCfg
715790
const diff = diffConfig(currentCfg, newCfg)
716791
await applyDiff(diff, currentCfg, newCfg, started, hotReload.factoryBuilder, { stdout, stderr })
792+
await applyGascitySectionDiff(currentCfg, newCfg, started, hotReload.factoryBuilder, { stdout, stderr })
717793
currentCfg = newCfg
718794
}).catch((err) => {
719795
stderr.write(`hot reload: unexpected error: ${formatError(err)}\n`)
720796
})
721797
})
722798
}
723799

800+
if (extras.localReload) {
801+
const { localReload } = extras
802+
const onSighupRequested = extras.onSighupRequested ?? defaultSighupWiring
803+
onSighupRequested(() => {
804+
reloadChain = reloadChain.then(async () => {
805+
/** @type {CollectivusConfig} */
806+
let newCfg
807+
try {
808+
newCfg = await localReload.reload()
809+
} catch (err) {
810+
stderr.write(`local reload: failed to re-read config: ${formatError(err)}\n`)
811+
return
812+
}
813+
if (currentCfg === undefined) currentCfg = newCfg
814+
stdout.write('local reload: applying config\n')
815+
const diff = diffConfig(currentCfg, newCfg)
816+
await applyDiff(diff, currentCfg, newCfg, started, localReload.factoryBuilder, { stdout, stderr })
817+
await applyGascitySectionDiff(currentCfg, newCfg, started, localReload.factoryBuilder, { stdout, stderr })
818+
currentCfg = newCfg
819+
}).catch((err) => {
820+
stderr.write(`local reload: unexpected error: ${formatError(err)}\n`)
821+
})
822+
})
823+
}
824+
724825
await shutdownPromise
725826
// Drain any in-flight reload so its stop() lands before stopAll() races
726827
// it. The chain only does start/stop work, bounded and short.
727828
await reloadChain
728829
await stopAll(started, stderr)
830+
if (extras.pidFile) {
831+
try {
832+
await removePidFile(extras.pidFile.path)
833+
} catch (err) {
834+
stderr.write(`warning: failed to remove pid file ${extras.pidFile.path}: ${formatError(err)}\n`)
835+
}
836+
}
729837
stdout.write('Shutdown complete.\n')
730838
return 0
731839
}
732840

841+
/**
842+
* Reload-time gascity diff.
843+
*
844+
* The standard `applyDiff` only handles otel/proxy/sink/upload — those
845+
* sections share a "stop the old, start the new" pattern. The gascity
846+
* source is different: each city carries an SSE connection plus a set of
847+
* active session workers, and we don't want a config edit that touches
848+
* one city (e.g. `ctvs gascity attach city2`) to retire the live workers
849+
* for unchanged cities. Instead we mutate the listener in place by
850+
* calling `applyCityDiff(newCities)` on the existing instance.
851+
*
852+
* Three transitions to handle:
853+
* - oldGascity defined, newGascity defined → applyCityDiff
854+
* - oldGascity undefined, newGascity defined → spin up via factory
855+
* - oldGascity defined, newGascity undefined → stop and remove
856+
*
857+
* @param {CollectivusConfig} oldCfg
858+
* @param {CollectivusConfig} newCfg
859+
* @param {Map<string, StartedListener>} registry
860+
* @param {(cfg: CollectivusConfig) => Map<string, ListenerFactory>} factoryBuilder
861+
* @param {{ stdout: { write(s: string): void }, stderr: { write(s: string): void } }} log
862+
* @returns {Promise<void>}
863+
*/
864+
async function applyGascitySectionDiff(oldCfg, newCfg, registry, factoryBuilder, log) {
865+
const oldGascity = oldCfg.gascity
866+
const newGascity = newCfg.gascity
867+
if (oldGascity === undefined && newGascity === undefined) return
868+
if (gascityArraysEqual(oldGascity ?? [], newGascity ?? [])) return
869+
const existing = /** @type {(import('./types.js').StartedListener & { applyCityDiff?: (c: import('./gascity/types.d.ts').GascityCityConfig[]) => Promise<void> }) | undefined} */ (
870+
registry.get('gascity')
871+
)
872+
873+
if (existing && newGascity !== undefined && typeof existing.applyCityDiff === 'function') {
874+
try {
875+
await existing.applyCityDiff(newGascity)
876+
log.stdout.write(`local reload: gascity diff applied (${newGascity.length} ${newGascity.length === 1 ? 'city' : 'cities'})\n`)
877+
} catch (err) {
878+
log.stderr.write(`local reload: gascity applyCityDiff failed: ${formatError(err)}\n`)
879+
}
880+
return
881+
}
882+
if (!existing && newGascity !== undefined) {
883+
const factory = factoryBuilder(newCfg).get('gascity')
884+
if (!factory) return
885+
try {
886+
const listener = await factory()
887+
registry.set('gascity', listener)
888+
log.stdout.write(`local reload: gascity started — ${listener.description}\n`)
889+
} catch (err) {
890+
log.stderr.write(`local reload: failed to start gascity: ${formatError(err)}\n`)
891+
}
892+
return
893+
}
894+
if (existing && newGascity === undefined) {
895+
try {
896+
await existing.stop()
897+
registry.delete('gascity')
898+
log.stdout.write('local reload: gascity stopped\n')
899+
} catch (err) {
900+
log.stderr.write(`local reload: failed to stop gascity: ${formatError(err)}\n`)
901+
}
902+
}
903+
}
904+
905+
/**
906+
* Deep equality on two `gascity` arrays. Order matters because `[[gascity]]`
907+
* entries are compared positionally — a swap is a config-level change
908+
* intentionally, even when the set of names is identical.
909+
*
910+
* @param {readonly import('./gascity/types.d.ts').GascityCityConfig[]} a
911+
* @param {readonly import('./gascity/types.d.ts').GascityCityConfig[]} b
912+
* @returns {boolean}
913+
*/
914+
function gascityArraysEqual(a, b) {
915+
if (a.length !== b.length) return false
916+
for (let i = 0; i < a.length; i++) {
917+
const ca = a[i]
918+
const cb = b[i]
919+
if (ca.name !== cb.name) return false
920+
if (ca.api_url !== cb.api_url) return false
921+
if (!stringListEqual(ca.include_templates, cb.include_templates)) return false
922+
if (!stringListEqual(ca.exclude_templates, cb.exclude_templates)) return false
923+
}
924+
return true
925+
}
926+
927+
/**
928+
* @param {string[] | undefined} a
929+
* @param {string[] | undefined} b
930+
* @returns {boolean}
931+
*/
932+
function stringListEqual(a, b) {
933+
if (a === undefined && b === undefined) return true
934+
if (a === undefined || b === undefined) return false
935+
if (a.length !== b.length) return false
936+
for (let i = 0; i < a.length; i++) {
937+
if (a[i] !== b[i]) return false
938+
}
939+
return true
940+
}
941+
942+
/**
943+
* Wire SIGHUP to the supplied handler. SIGHUP is the standard Unix signal
944+
* for "re-read config without restarting" — using it here means the
945+
* `ctvs gascity attach/detach` CLI just sends one signal and the daemon
946+
* handles the rest.
947+
*
948+
* @param {() => void} handler
949+
* @returns {void}
950+
*/
951+
function defaultSighupWiring(handler) {
952+
process.on('SIGHUP', handler)
953+
}
954+
733955
/**
734956
* @param {Map<string, StartedListener>} started
735957
* @param {{ write: (s: string) => void }} stderr

0 commit comments

Comments
 (0)