Skip to content

Commit 7f44c27

Browse files
committed
fix gascity active session backfill
1 parent 87005e1 commit 7f44c27

13 files changed

Lines changed: 854 additions & 76 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ To capture agent-attributed transcripts from a gascity supervisor (separate
7777
from the proxy capture above), attach a city to the same daemon:
7878

7979
```bash
80-
npx collectivus gascity attach hyptown --api-url http://127.0.0.1:8372
80+
npx collectivus gascity attach hyptown
8181
npx collectivus query sql "select gascity_template, count(*) as parts from gascity_messages group by 1 order by parts desc"
8282
```
8383

@@ -335,7 +335,7 @@ store, so `ctvs query gascity_messages` is always reading what the daemon has
335335
flushed up to the moment of the call.
336336

337337
```bash
338-
ctvs gascity attach hyptown --api-url http://127.0.0.1:8372
338+
ctvs gascity attach hyptown
339339
ctvs gascity list
340340
ctvs query schema gascity_messages --format markdown
341341
ctvs query sql "select gascity_template, count(*) from gascity_messages group by 1"

src/cli/gascity.js

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ The daemon is reloaded by sending SIGHUP. Without a running daemon attach/detach
5050
still mutate the config so the next \`ctvs install\` picks the new entries up.
5151
`
5252

53+
const DEFAULT_GASCITY_API_URL = 'http://127.0.0.1:8372'
54+
5355
/**
5456
* Top-level dispatch for `ctvs gascity <sub>`.
5557
*
@@ -87,11 +89,11 @@ const ATTACH_USAGE = `Usage:
8789
8890
Adds a [[gascity]] entry to the collectivus config and signals the running
8991
daemon (SIGHUP) to start capturing. When <city-name-or-path> is a directory
90-
the city name and api_url are inferred from its city.toml; otherwise --api-url
91-
is required.
92+
the city name and api_url are inferred from its city.toml. Otherwise the city
93+
name is used as given and api_url defaults to ${DEFAULT_GASCITY_API_URL}.
9294
9395
Options:
94-
--api-url <url> Supervisor base URL (e.g. http://127.0.0.1:8372)
96+
--api-url <url> Supervisor base URL (default: ${DEFAULT_GASCITY_API_URL})
9597
--config <path> collectivus config to edit (default: ~/.hyp/collectivus.json)
9698
--no-wait Don't block on the first lifecycle event
9799
--help, -h Show this help
@@ -278,7 +280,7 @@ async function writeConfigObject(configPath, obj) {
278280
* - If `target` is a directory containing `city.toml`, parse that to
279281
* derive the name + api_url. We use a tiny inline TOML reader that
280282
* handles only the fields we need (name + api).
281-
* - Otherwise treat `target` as the city name; require `--api-url`.
283+
* - Otherwise treat `target` as the city name and use the default api_url.
282284
*
283285
* @param {string} target
284286
* @param {string | undefined} apiUrl
@@ -311,16 +313,10 @@ export async function resolveCityEntry(target, apiUrl) {
311313
if (!inferredName) {
312314
throw new Error(`${cityToml} did not provide a string \`name\``)
313315
}
314-
const finalApiUrl = apiUrl ?? inferredApi ?? await discoverApiUrl(target)
315-
if (!finalApiUrl) {
316-
throw new Error('could not infer api_url from the city directory; pass --api-url')
317-
}
316+
const finalApiUrl = apiUrl ?? inferredApi ?? await discoverApiUrl(target) ?? DEFAULT_GASCITY_API_URL
318317
return { name: inferredName, api_url: finalApiUrl }
319318
}
320-
if (!apiUrl) {
321-
throw new Error('--api-url is required when the target is a city name (no city.toml at that path)')
322-
}
323-
return { name: target, api_url: apiUrl }
319+
return { name: target, api_url: apiUrl ?? DEFAULT_GASCITY_API_URL }
324320
}
325321

326322
/**
@@ -915,9 +911,11 @@ export async function runBackfill(argv, hooks) {
915911
}
916912
} catch (err) {
917913
stderr.write(`error: backfill failed: ${formatError(err)}\n`)
914+
await dispatcher.drain().catch(swallow)
918915
await writer.stop().catch(swallow)
919916
return 1
920917
}
918+
await dispatcher.drain()
921919
await writer.stop()
922920
stdout.write(
923921
`Backfill complete: ${result.sessionsAttempted} attempted, ` +
@@ -1019,7 +1017,7 @@ export async function runStatus(argv, hooks) {
10191017
const cities = []
10201018
if (state) {
10211019
await Promise.all(state.cities.map(async (c) => {
1022-
const reachable = await probeReachable(fetchFn, c.api_url)
1020+
const reachable = await probeReachable(fetchFn, c.api_url, c.name)
10231021
/** @type {(typeof cities)[number]} */
10241022
const entry = {
10251023
name: c.name,
@@ -1061,21 +1059,24 @@ export async function runStatus(argv, hooks) {
10611059
}
10621060

10631061
/**
1064-
* GET `<api_url>/v0/health` (or `/health`, or `/`) with a short timeout.
1065-
* Returns true on a 2xx response. We don't care which endpoint the
1062+
* GET the per-city status endpoint first, then fall back to generic health
1063+
* probes. Returns true on a 2xx response. We don't care which endpoint the
10661064
* supervisor exposes — anything that 200s tells us "the port is live and
10671065
* answering". Any error or non-2xx means we render "unreachable" and let
10681066
* the operator decide.
10691067
*
10701068
* @param {typeof fetch} fetchFn
10711069
* @param {string} apiUrl
1070+
* @param {string} cityName
10721071
* @returns {Promise<boolean>}
10731072
*/
1074-
async function probeReachable(fetchFn, apiUrl) {
1073+
async function probeReachable(fetchFn, apiUrl, cityName) {
1074+
const baseUrl = apiUrl.replace(/\/+$/, '')
10751075
const candidates = [
1076-
`${apiUrl.replace(/\/+$/, '')}/v0/health`,
1077-
`${apiUrl.replace(/\/+$/, '')}/health`,
1078-
`${apiUrl.replace(/\/+$/, '')}/`,
1076+
`${baseUrl}/v0/city/${encodeURIComponent(cityName)}/status`,
1077+
`${baseUrl}/v0/health`,
1078+
`${baseUrl}/health`,
1079+
`${baseUrl}/`,
10791080
]
10801081
for (const url of candidates) {
10811082
/** @type {AbortController} */

src/cli/init.js

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -402,22 +402,8 @@ async function askManualGascityCities(args) {
402402
try {
403403
entry = await resolveGascityCityEntry(resolvedTarget, undefined)
404404
} catch (err) {
405-
const message = formatError(err)
406-
if (!/--api-url is required|could not infer api_url/.test(message)) {
407-
stderr.write(` ${message}\n`)
408-
continue
409-
}
410-
const apiUrl = (await prompt('Supervisor API URL: ')).trim()
411-
if (!apiUrl) {
412-
stderr.write(' supervisor API URL is required\n')
413-
continue
414-
}
415-
try {
416-
entry = await resolveGascityCityEntry(resolvedTarget, apiUrl)
417-
} catch (apiErr) {
418-
stderr.write(` ${formatError(apiErr)}\n`)
419-
continue
420-
}
405+
stderr.write(` ${formatError(err)}\n`)
406+
continue
421407
}
422408

423409
upsertGascityCity(cities, entry)

src/gascity/backfill.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,23 @@ function extractFrameArray(body) {
186186
if (Array.isArray(body)) return body
187187
if (body && typeof body === 'object') {
188188
const obj = /** @type {Record<string, unknown>} */ (body)
189+
if (Array.isArray(obj.messages)) return wrapProviderFrames(obj.messages, obj.provider)
189190
if (Array.isArray(obj.frames)) return obj.frames
190191
if (Array.isArray(obj.transcript)) return obj.transcript
191192
}
192193
return []
193194
}
194195

196+
/**
197+
* @param {unknown[]} frames
198+
* @param {unknown} provider
199+
* @returns {unknown[]}
200+
*/
201+
function wrapProviderFrames(frames, provider) {
202+
if (typeof provider !== 'string') return frames
203+
return frames.map((frame) => ({ provider, frame }))
204+
}
205+
195206
/**
196207
* @param {unknown} err
197208
* @returns {string}

src/gascity/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export async function startGascitySource(opts) {
120120
stop: async () => {
121121
await Promise.all(Array.from(subscribers.values()).map((s) => s.stop()))
122122
subscribers.clear()
123+
await dispatcher.drain()
123124
await writer.stop()
124125
await stateWriter.stop()
125126
},

src/gascity/normalizer_dispatcher.js

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export class NormalizerDispatcher {
3737
this.writer = opts.writer
3838
/** @type {NormalizerFn} */
3939
this.passthrough = passthroughNormalize
40+
/** @type {Set<Promise<void>>} */
41+
this.pendingAppends = new Set()
4042
this.register('claude', claudeStub)
4143
this.register('codex', codexStub)
4244
}
@@ -66,6 +68,19 @@ export class NormalizerDispatcher {
6668
this.writer = writer
6769
}
6870

71+
/**
72+
* Wait for every writer append that dispatch has handed off so far. Live
73+
* streaming can let appends run in the background, but short-lived paths
74+
* like `ctvs gascity backfill` must drain them before stopping the writer.
75+
*
76+
* @returns {Promise<void>}
77+
*/
78+
async drain() {
79+
while (this.pendingAppends.size > 0) {
80+
await Promise.allSettled(Array.from(this.pendingAppends))
81+
}
82+
}
83+
6984
/**
7085
* Resolve `provider` from the frame envelope and dispatch. The supervisor's
7186
* `format=raw` envelope wraps each provider frame in `{ provider, frame }`
@@ -83,32 +98,42 @@ export class NormalizerDispatcher {
8398
* @returns {NormalizedRow[]}
8499
*/
85100
dispatch(envelope, ctx) {
86-
const provider = resolveProvider(envelope) ?? 'unknown'
87-
const fn = this.registry.get(provider) ?? this.passthrough
88-
/** @type {NormalizedRow[] | undefined | void} */
89-
let rows
90-
try {
91-
rows = fn(envelope, ctx)
92-
} catch (err) {
93-
this.stderr.write(
94-
`[gascity] normalizer error provider=${provider} session=${ctx.sessionId} err=${formatError(err)}\n`
95-
)
96-
return []
97-
}
98-
const out = Array.isArray(rows) ? rows : []
99-
if (out.length > 0 && this.writer) {
100-
// Writer.append is async but we don't block dispatch — appending only
101-
// buffers and any triggered flush failures land on the writer's own
102-
// error path. We surface a top-level "writer rejected" only if append
103-
// itself throws synchronously (defensive — current ParquetWriter
104-
// returns a promise unconditionally).
105-
this.writer.append(ctx, out).catch((err) => {
101+
/** @type {NormalizedRow[]} */
102+
const allRows = []
103+
for (const unit of expandDispatchUnits(envelope)) {
104+
const provider = unit.provider ?? resolveProvider(unit.frame) ?? 'unknown'
105+
const registered = this.registry.get(provider)
106+
const fn = registered ?? this.passthrough
107+
const input = registered !== undefined ? unit.frame : unit.passthroughEnvelope ?? unit.frame
108+
/** @type {NormalizedRow[] | undefined | void} */
109+
let rows
110+
try {
111+
rows = fn(input, ctx)
112+
} catch (err) {
106113
this.stderr.write(
107-
`[gascity] writer_append_failed provider=${provider} session=${ctx.sessionId} err=${formatError(err)}\n`
114+
`[gascity] normalizer error provider=${provider} session=${ctx.sessionId} err=${formatError(err)}\n`
108115
)
109-
})
116+
continue
117+
}
118+
const out = Array.isArray(rows) ? rows : []
119+
if (out.length === 0) continue
120+
allRows.push(...out)
121+
if (this.writer) {
122+
// Writer.append is async but we don't block dispatch — appending only
123+
// buffers and any triggered flush failures land on the writer's own
124+
// error path. We surface a top-level "writer rejected" only if append
125+
// itself throws synchronously (defensive — current ParquetWriter
126+
// returns a promise unconditionally).
127+
const pending = this.writer.append(ctx, out).catch((err) => {
128+
this.stderr.write(
129+
`[gascity] writer_append_failed provider=${provider} session=${ctx.sessionId} err=${formatError(err)}\n`
130+
)
131+
})
132+
this.pendingAppends.add(pending)
133+
pending.finally(() => this.pendingAppends.delete(pending))
134+
}
110135
}
111-
return out
136+
return allRows
112137
}
113138
}
114139

@@ -136,6 +161,63 @@ export function resolveProvider(envelope) {
136161
return undefined
137162
}
138163

164+
/**
165+
* @typedef {{
166+
* frame: unknown,
167+
* provider?: string,
168+
* passthroughEnvelope?: unknown,
169+
* }} DispatchUnit
170+
*/
171+
172+
/**
173+
* The supervisor can send a provider frame directly, wrap a frame as
174+
* `{ provider, frame }`, or return transcript snapshots as
175+
* `{ provider, messages: [...] }`. Provider normalizers want the inner frame;
176+
* passthrough wants a provider-bearing envelope when one exists.
177+
*
178+
* @param {unknown} envelope
179+
* @returns {DispatchUnit[]}
180+
*/
181+
function expandDispatchUnits(envelope) {
182+
if (envelope === null || typeof envelope !== 'object') return [{ frame: envelope }]
183+
const obj = /** @type {Record<string, unknown>} */ (envelope)
184+
const provider = typeof obj.provider === 'string' ? obj.provider : undefined
185+
for (const key of ['messages', 'frames', 'transcript']) {
186+
const nested = obj[key]
187+
if (Array.isArray(nested)) {
188+
return nested.map((frame) => dispatchUnit(frame, provider))
189+
}
190+
}
191+
if (obj.frame !== null && typeof obj.frame === 'object' && !Array.isArray(obj.frame)) {
192+
/** @type {DispatchUnit} */
193+
const unit = {
194+
frame: obj.frame,
195+
passthroughEnvelope: envelope,
196+
}
197+
if (provider !== undefined) unit.provider = provider
198+
return [unit]
199+
}
200+
/** @type {DispatchUnit} */
201+
const unit = { frame: envelope }
202+
if (provider !== undefined) unit.provider = provider
203+
return [unit]
204+
}
205+
206+
/**
207+
* @param {unknown} frame
208+
* @param {string | undefined} provider
209+
* @returns {DispatchUnit}
210+
*/
211+
function dispatchUnit(frame, provider) {
212+
/** @type {DispatchUnit} */
213+
const unit = { frame }
214+
if (provider !== undefined) {
215+
unit.provider = provider
216+
unit.passthroughEnvelope = { provider, frame }
217+
}
218+
return unit
219+
}
220+
139221
/**
140222
* Bead-1 stub for the `claude` slot. Bead 2 replaces it via
141223
* `registerProductionNormalizers` in `./normalizers/index.js`; until that

src/gascity/paths.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ export function parquetPartitionDir(root, date, city) {
7676
export function parquetPartPath(root, date, city, sessionId, counter) {
7777
return path.join(
7878
parquetPartitionDir(root, date, city),
79-
`part-${sessionId}-${counter}.parquet`
79+
`part-${encodeURIComponent(sessionId)}-${counter}.parquet`
8080
)
8181
}

0 commit comments

Comments
 (0)