Skip to content

Commit c571dc2

Browse files
authored
capture gascity event bus (#125)
1 parent fe41372 commit c571dc2

20 files changed

Lines changed: 1481 additions & 114 deletions

src/cli/init.js

Lines changed: 160 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const IDENTITY_SECRET_BYTES = 32
5555
const DEFAULT_UPLOAD_REGION = 'us-east-1'
5656
const DEFAULT_UPLOAD_PREFIX = 'collectivus'
5757
const DEFAULT_UPLOAD_TIME = '00:10'
58+
const DEFAULT_GASCITY_SUPERVISOR_PORT = 8372
59+
const DEFAULT_GASCITY_SUPERVISOR_API_URL = `http://127.0.0.1:${DEFAULT_GASCITY_SUPERVISOR_PORT}`
60+
const GASCITY_DISCOVERY_TIMEOUT_MS = 5000
5861
/** @type {readonly import('../types.js').UploadSignal[]} */
5962
const ALLOWED_UPLOAD_SIGNALS = ['logs', 'traces', 'metrics', 'proxy']
6063
const DEFAULT_UPLOAD_SIGNALS_INPUT = ALLOWED_UPLOAD_SIGNALS.join(',')
@@ -151,15 +154,16 @@ export async function runInit(hooks = {}) {
151154
runInstall: hooks.runInstall,
152155
runGascityBackfill: hooks.runGascityBackfill,
153156
hasGcBinary: hooks.hasGcBinary,
157+
fetchFn: hooks.fetchFn,
154158
})
155159
}
156160

157161
/**
158162
* Minimal standalone walkthrough. Lets the operator choose which local
159163
* capture sources to enable, then asks only the details those sources need.
160164
* Proxy defaults to 127.0.0.1:8787 forwarding to Anthropic; OTLP defaults to
161-
* 127.0.0.1:4318; gascity tries to discover city roots from the current
162-
* workspace before falling back to manual city/API entry.
165+
* 127.0.0.1:4318; gascity asks for the local supervisor port and reads the
166+
* registered city list from that supervisor.
163167
*
164168
* @param {{
165169
* stdout: { write: (s: string) => void },
@@ -176,6 +180,7 @@ export async function runInit(hooks = {}) {
176180
* runInstall?: (args: string[], hooks?: InstallHooks) => Promise<number>,
177181
* runGascityBackfill?: (args: string[], hooks?: { stdout?: { write: (s: string) => void }, stderr?: { write: (s: string) => void } }) => Promise<number>,
178182
* hasGcBinary?: () => boolean | Promise<boolean>,
183+
* fetchFn?: typeof fetch,
179184
* }} args
180185
* @returns {Promise<number>}
181186
*/
@@ -221,7 +226,7 @@ async function runSingleUserFlow(args) {
221226
}
222227

223228
if (hasGascity) {
224-
gascityCities = await askGascityCities({ stdout, stderr, prompt, cwd })
229+
gascityCities = await askGascityCities({ stdout, stderr, prompt, cwd, fetchFn: args.fetchFn })
225230
config.gascity = gascityCities
226231
}
227232

@@ -427,33 +432,48 @@ function commandExistsOnPath(name) {
427432
* stderr: { write: (s: string) => void },
428433
* prompt: (q: string) => Promise<string>,
429434
* cwd: string,
435+
* fetchFn?: typeof fetch,
430436
* }} args
431437
* @returns {Promise<import('../gascity/types.d.ts').GascityCityConfig[]>}
432438
*/
433439
async function askGascityCities(args) {
434440
const { stdout, stderr, prompt, cwd } = args
435441
stdout.write('\nGas city supervisor capture\n')
436-
stdout.write('Enter a city root or a parent directory. Press Enter to scan the current directory.\n')
437-
const searchAns = (await prompt(`Gas city search path [${cwd}]: `)).trim()
438-
const searchRoot = searchAns === '' ? cwd : path.resolve(cwd, searchAns)
439-
const discovered = await discoverGascityCityEntries(searchRoot)
442+
stdout.write('Collectivus reads the registered city list from the local gc supervisor.\n')
443+
444+
const apiUrl = await askGascitySupervisorApiUrl({ stderr, prompt })
445+
/** @type {Array<{ name: string, api_url: string, path?: string, running?: boolean }>} */
446+
let discovered = []
447+
try {
448+
discovered = await fetchSupervisorGascityCities({
449+
apiUrl,
450+
fetchFn: args.fetchFn ?? globalThis.fetch,
451+
})
452+
} catch (err) {
453+
stderr.write(` Could not read gas cities from ${apiUrl}: ${formatError(err)}\n`)
454+
}
455+
440456
if (discovered.length > 0) {
441-
stdout.write('\nDiscovered gas city supervisors:\n')
457+
stdout.write('\nDiscovered gas cities:\n')
442458
for (const city of discovered) {
443-
stdout.write(` - ${city.name} (${city.api_url})\n`)
459+
const state = city.running === undefined ? '' : city.running ? ' running' : ' stopped'
460+
const location = city.path === undefined ? '' : ` ${city.path}`
461+
stdout.write(` - ${city.name}${state}${location}\n`)
444462
}
445463
const addAns = (await prompt(`Add ${discovered.length === 1 ? 'this city' : 'these cities'}? [Y/n]: `)).trim()
446464
if (isYes(addAns)) {
447465
return askManualGascityCities({
448466
stdout, stderr, prompt, cwd,
449-
defaultTarget: searchRoot,
450-
initial: discovered,
467+
defaultApiUrl: apiUrl,
468+
initial: discovered.map(function(city) {
469+
return { name: city.name, api_url: city.api_url }
470+
}),
451471
})
452472
}
453473
} else {
454-
stdout.write('No gas city supervisors were discovered from that path.\n')
474+
stdout.write('No gas cities were reported by that supervisor.\n')
455475
}
456-
return askManualGascityCities({ stdout, stderr, prompt, cwd, defaultTarget: searchRoot, initial: [] })
476+
return askManualGascityCities({ stdout, stderr, prompt, cwd, defaultApiUrl: apiUrl, initial: [] })
457477
}
458478

459479
/**
@@ -462,13 +482,13 @@ async function askGascityCities(args) {
462482
* stderr: { write: (s: string) => void },
463483
* prompt: (q: string) => Promise<string>,
464484
* cwd: string,
465-
* defaultTarget: string,
485+
* defaultApiUrl: string,
466486
* initial: import('../gascity/types.d.ts').GascityCityConfig[],
467487
* }} args
468488
* @returns {Promise<import('../gascity/types.d.ts').GascityCityConfig[]>}
469489
*/
470490
async function askManualGascityCities(args) {
471-
const { stdout, stderr, prompt, cwd, defaultTarget } = args
491+
const { stdout, stderr, prompt, cwd, defaultApiUrl } = args
472492
/** @type {import('../gascity/types.d.ts').GascityCityConfig[]} */
473493
const cities = dedupeGascityCities(args.initial)
474494

@@ -478,21 +498,18 @@ async function askManualGascityCities(args) {
478498
if (!/^y(es)?$/i.test(more)) return cities
479499
}
480500

481-
const targetDefault = cities.length === 0 ? defaultTarget : ''
482-
const question = targetDefault
483-
? `Gas city directory or name [${targetDefault}]: `
484-
: 'Gas city directory or name: '
501+
const question = 'Gas city name or directory: '
485502
const targetAns = (await prompt(question)).trim()
486-
const target = targetAns === '' ? targetDefault : targetAns
503+
const target = targetAns
487504
if (!target) {
488-
stderr.write(' city directory or name is required\n')
505+
stderr.write(' city name or directory is required\n')
489506
continue
490507
}
491508

492509
const resolvedTarget = resolvePromptPath(cwd, target)
493510
let entry
494511
try {
495-
entry = await resolveGascityCityEntry(resolvedTarget, undefined)
512+
entry = await resolveGascityCityEntry(resolvedTarget, isDirectory(resolvedTarget) ? undefined : defaultApiUrl)
496513
} catch (err) {
497514
stderr.write(` ${formatError(err)}\n`)
498515
continue
@@ -504,55 +521,142 @@ async function askManualGascityCities(args) {
504521
}
505522

506523
/**
507-
* @param {string} root
508-
* @returns {Promise<import('../gascity/types.d.ts').GascityCityConfig[]>}
524+
* @param {{
525+
* stderr: { write: (s: string) => void },
526+
* prompt: (q: string) => Promise<string>,
527+
* }} args
528+
* @returns {Promise<string>}
509529
*/
510-
async function discoverGascityCityEntries(root) {
511-
const dirs = discoverGascityCityDirs(root)
512-
/** @type {import('../gascity/types.d.ts').GascityCityConfig[]} */
513-
const entries = []
514-
for (const dir of dirs) {
515-
try {
516-
const entry = await resolveGascityCityEntry(dir, undefined)
517-
upsertGascityCity(entries, entry)
518-
} catch {
519-
// A city.toml without an API hint can still be added manually below.
520-
}
530+
async function askGascitySupervisorApiUrl(args) {
531+
const { stderr, prompt } = args
532+
for (;;) {
533+
const ans = (await prompt(`Gas city supervisor port [${DEFAULT_GASCITY_SUPERVISOR_PORT}]: `)).trim()
534+
const apiUrl = parseSupervisorApiUrl(ans)
535+
if (apiUrl !== undefined) return apiUrl
536+
stderr.write(' supervisor port must be a number from 1 to 65535\n')
521537
}
522-
entries.sort((a, b) => a.name.localeCompare(b.name))
523-
return entries
524538
}
525539

526540
/**
527-
* @param {string} root
528-
* @returns {string[]}
541+
* @param {{
542+
* apiUrl: string,
543+
* fetchFn: typeof fetch,
544+
* }} args
545+
* @returns {Promise<Array<{ name: string, api_url: string, path?: string, running?: boolean }>>}
529546
*/
530-
function discoverGascityCityDirs(root) {
531-
/** @type {string[]} */
532-
const dirs = []
533-
if (hasCityToml(root)) dirs.push(root)
534-
let children
547+
async function fetchSupervisorGascityCities(args) {
548+
const url = `${args.apiUrl.replace(/\/+$/, '')}/v0/cities`
549+
const controller = new AbortController()
550+
const timer = setTimeout(function() { controller.abort() }, GASCITY_DISCOVERY_TIMEOUT_MS)
551+
/** @type {Response} */
552+
let response
535553
try {
536-
children = fs.readdirSync(root, { withFileTypes: true })
537-
} catch {
538-
return dirs
554+
response = await args.fetchFn(url, { signal: controller.signal })
555+
} finally {
556+
clearTimeout(timer)
539557
}
540-
for (const child of children) {
541-
if (!child.isDirectory()) continue
542-
const childDir = path.join(root, child.name)
543-
if (hasCityToml(childDir)) dirs.push(childDir)
558+
if (!response.ok) {
559+
await response.body?.cancel().catch(function() {})
560+
throw new Error(`HTTP ${response.status}`)
544561
}
545-
dirs.sort()
546-
return dirs
562+
/** @type {unknown} */
563+
const body = await response.json()
564+
return parseSupervisorGascityCities(body, args.apiUrl)
547565
}
548566

549567
/**
550-
* @param {string} dir
568+
* @param {unknown} body
569+
* @param {string} apiUrl
570+
* @returns {Array<{ name: string, api_url: string, path?: string, running?: boolean }>}
571+
*/
572+
function parseSupervisorGascityCities(body, apiUrl) {
573+
const items = supervisorCityItems(body)
574+
/** @type {Array<{ name: string, api_url: string, path?: string, running?: boolean }>} */
575+
const cities = []
576+
for (const item of items) {
577+
if (item === null || typeof item !== 'object') continue
578+
const name = pickString(item, 'name') ?? pickString(item, 'city')
579+
if (name === undefined || name.length === 0) continue
580+
/** @type {{ name: string, api_url: string, path?: string, running?: boolean }} */
581+
const entry = { name, api_url: apiUrl }
582+
const cityPath = pickString(item, 'path')
583+
if (cityPath !== undefined) entry.path = cityPath
584+
const running = pickBoolean(item, 'running')
585+
if (running !== undefined) entry.running = running
586+
cities.push(entry)
587+
}
588+
cities.sort(function(a, b) { return a.name.localeCompare(b.name) })
589+
return cities
590+
}
591+
592+
/**
593+
* @param {unknown} body
594+
* @returns {unknown[]}
595+
*/
596+
function supervisorCityItems(body) {
597+
if (Array.isArray(body)) return body
598+
if (body === null || typeof body !== 'object') return []
599+
const obj = /** @type {Record<string, unknown>} */ (body)
600+
if (Array.isArray(obj.items)) return obj.items
601+
if (Array.isArray(obj.cities)) return obj.cities
602+
return []
603+
}
604+
605+
/**
606+
* @param {unknown} raw
607+
* @returns {string | undefined}
608+
*/
609+
function parseSupervisorApiUrl(raw) {
610+
const input = String(raw).trim()
611+
if (input === '') return DEFAULT_GASCITY_SUPERVISOR_API_URL
612+
const portOnly = /^:?\d+$/.test(input) ? input.replace(/^:/, '') : undefined
613+
if (portOnly !== undefined) {
614+
const port = Number(portOnly)
615+
if (Number.isInteger(port) && port >= 1 && port <= 65535) {
616+
return `http://127.0.0.1:${port}`
617+
}
618+
return undefined
619+
}
620+
try {
621+
const url = new URL(input.includes('://') ? input : `http://${input}`)
622+
if ((url.protocol === 'http:' || url.protocol === 'https:') && url.port !== '') {
623+
return url.origin
624+
}
625+
} catch {
626+
return undefined
627+
}
628+
return undefined
629+
}
630+
631+
/**
632+
* @param {unknown} obj
633+
* @param {string} key
634+
* @returns {string | undefined}
635+
*/
636+
function pickString(obj, key) {
637+
if (obj === null || typeof obj !== 'object') return undefined
638+
const value = /** @type {Record<string, unknown>} */ (obj)[key]
639+
return typeof value === 'string' ? value : undefined
640+
}
641+
642+
/**
643+
* @param {unknown} obj
644+
* @param {string} key
645+
* @returns {boolean | undefined}
646+
*/
647+
function pickBoolean(obj, key) {
648+
if (obj === null || typeof obj !== 'object') return undefined
649+
const value = /** @type {Record<string, unknown>} */ (obj)[key]
650+
return typeof value === 'boolean' ? value : undefined
651+
}
652+
653+
/**
654+
* @param {string} target
551655
* @returns {boolean}
552656
*/
553-
function hasCityToml(dir) {
657+
function isDirectory(target) {
554658
try {
555-
return fs.statSync(path.join(dir, 'city.toml')).isFile()
659+
return fs.statSync(target).isDirectory()
556660
} catch {
557661
return false
558662
}

src/cli/init_presets/gascity_skill.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
---
22
name: ctvs-gascity
3-
description: Query the gascity event log, session-reconciler segments, and `gascity_messages` agent transcripts. Use when the user asks about gc agents, beads, orders, mail, sessions, session-reconciler decisions, agent tool calls, or LLM token usage by rig/template.
3+
description: Query gascity event logs, session-reconciler segments, and captured agent transcripts. Use when the user asks about gc agents, beads, orders, mail, sessions, session-reconciler decisions, agent tool calls, or LLM token usage by rig/template.
44
---
55

66
# Gascity Query
77

8-
This workspace has been registered with the `ctvs query` cache via `ctvs init gascity`. Three tables are available alongside the global built-in datasets:
8+
This workspace has been registered with the `ctvs query` cache via `ctvs init gascity`. Four gascity-oriented tables are available alongside the global built-in datasets:
99

1010
- **`events`** — one row per gascity event from `.gc/events.jsonl` (bead lifecycle, order execution, mail, sessions, controller events). Single source file, registered as a collection.
1111
- **`session_segments`** — one row per tracepoint from `.gc/runtime/session-reconciler-trace/segments/**/*.jsonl` (baseline, decision, mutation, operation records per session reconciler cycle). Glob-backed collection; many source files, one cache partition each.
1212
- **`gascity_messages`** — one row per content block from gascity-captured agent sessions (text, thinking, tool_use, tool_result, attachment). Captured by the `ctvs gascity` source from the supervisor REST API; provider-native frames preserved verbatim in `raw_frame`. Built-in dataset (no `ctvs init gascity` needed) — partitioned at `~/.collectivus/sink/gascity_messages/date=<YYYY-MM-DD>/city=<name>/`.
13+
- **`gascity_events`** — one row per supervisor/city event bus item captured from `/v0/events`, `/v0/events/stream`, `/v0/city/{city}/events`, and `/v0/city/{city}/events/stream`. Built-in dataset — partitioned at `~/.collectivus/sink/gascity_events/date=<YYYY-MM-DD>/event_scope=<scope>/city=<name>/`.
1314

1415
Refer to the global [`collectivus-query`](../collectivus-query/SKILL.md) skill for cache freshness rules, `--format` options, and the wire-level `proxy_messages` dataset.
1516

@@ -18,6 +19,7 @@ Refer to the global [`collectivus-query`](../collectivus-query/SKILL.md) skill f
1819
Each table identifies the originating agent through a different column. There is no `cwd` on `events` or `session_segments`; on `gascity_messages` the `cwd` is the agent's working directory at frame time.
1920

2021
- `events.actor` — e.g. `hypcity-overrides.mayor`, `hypcity-overrides.refinery`, `hypcity-overrides.deacon`.
22+
- `gascity_events.actor` / `gascity_events.subject` — supervisor-scope rows may also carry `city`; city-scope rows carry the configured `city`.
2123
- `session_segments.template` — e.g. `hypcity-overrides.mayor` (city-scoped) or `collectivus/hypcity-overrides.polecat` (rig-scoped: `<rig>/<pack>.<agent>`).
2224
- `gascity_messages.gascity_template` — same shape as `session_segments.template`. Pair with `gascity_rig` and `gascity_alias` for finer cuts. The provider-side session id is `gascity_session_id` / `provider_session_id`.
2325

@@ -156,6 +158,7 @@ ORDER BY source, idx;
156158
## When to use which source
157159

158160
- Use **`gascity_messages`** when you want: agent identity (`gascity_template` / `gascity_rig`), structured content blocks, tool calls + arguments + results in one table, per-frame token usage with cache breakdown, no need for HTTP wire detail.
161+
- Use **`gascity_events`** when you want: supervisor event bus activity as observed by Collectivus, including stream events and snapshot backfill, without relying on the workspace-local `.gc/events.jsonl` collection.
159162
- Use **`proxy_messages`** when you want: HTTP retry visibility, request timing, response status codes, end-user attribution via the Anthropic `user_id`, conversation-grain dedup of replayed history.
160163
- Use **both** (UNION ALL or FULL OUTER JOIN) for cross-source aggregations, sanity checks, or to recover content that one source missed (e.g., gascity captured an in-process supervisor frame the proxy never saw).
161164

@@ -165,9 +168,9 @@ ORDER BY source, idx;
165168

166169
`events` is append-only single-file; mtime/size changes trigger re-materialization on refresh.
167170

168-
`gascity_messages` is **always fresh** — the daemon writes Parquet directly into the sink (no JSONL stage, no `.meta.json` sidecar), so query-time discovery picks up every part-file the writer has flushed. `ctvs query refresh --all gascity_messages` is a documented no-op (it lists existing partitions as already-fresh). To pull in newly-flushed rows simply rerun the query.
171+
`gascity_messages` and `gascity_events` are **always fresh** — the daemon writes directly into the sink, so query-time discovery picks up every flushed part-file or event JSONL. `ctvs query refresh --all gascity_messages` and `ctvs query refresh --all gascity_events` are documented no-ops. To pull in newly-flushed rows simply rerun the query.
169172

170-
Full schemas: `ctvs query schema events --format markdown`, `ctvs query schema session_segments --format markdown`, `ctvs query schema gascity_messages --format markdown`. Catalog: `ctvs query catalog --format markdown`.
173+
Full schemas: `ctvs query schema events --format markdown`, `ctvs query schema session_segments --format markdown`, `ctvs query schema gascity_messages --format markdown`, `ctvs query schema gascity_events --format markdown`. Catalog: `ctvs query catalog --format markdown`.
171174

172175
## Refresh cost
173176

0 commit comments

Comments
 (0)