Skip to content

Commit 1bec8c0

Browse files
committed
Add file-targeted query refresh
1 parent 946b0df commit 1bec8c0

10 files changed

Lines changed: 246 additions & 32 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,8 @@ and [provider fields](https://developers.openai.com/codex/config-reference#model
529529
auto-refresh its Parquet cache unless you ask for that explicitly.
530530

531531
```bash
532-
ctvs query refresh --config collectivus.json
532+
ctvs query refresh /path/to/gw1/logs/2026-05-11.jsonl --config collectivus.json
533+
ctvs query refresh --all logs --config collectivus.json
533534
ctvs query logs --config collectivus.json --since 1h
534535
ctvs query traces slow --config collectivus.json --limit 20
535536
ctvs query metrics series latency.ms --config collectivus.json
@@ -549,9 +550,11 @@ Freshness is treated asymmetrically (since v1.7.0):
549550
| --- | --- |
550551
| `fresh` | Query proceeds silently. |
551552
| `stale` (Parquet exists, may be outdated) | Query proceeds; a `warning: querying stale data; …` line is written to stderr. Stdout is unchanged. |
552-
| `missing` (no Parquet at all) | Query exits with the exact `ctvs query refresh …` command to run. |
553+
| `missing` (no Parquet at all) | Query exits with the exact file-targeted `ctvs query refresh …` command to run when the source file is known. |
553554

554-
Use `--refresh always` to force a refresh before the query runs. Use
555+
Use `ctvs query refresh <file.jsonl>` to refresh selected source files, or
556+
`ctvs query refresh --all [dataset]` when you explicitly want the broader
557+
walk. Use `--refresh always` to force a refresh before the query runs. Use
555558
`--strict-freshness` to restore the pre-1.7 behavior where stale partitions
556559
are a hard error (useful in CI / scheduled jobs that must never read
557560
outdated data).

skills/collectivus-query/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ Use `ctvs query` to inspect local Collectivus recordings. It reads local JSONL r
1212
1. Run `ctvs query doctor` or `ctvs query status` first to verify the recording root and cache state.
1313
2. If the command cannot find the intended config, discover the service config once with `ctvs status`, a LaunchAgent/systemd unit, or the user, then reuse `--config <path>` only for that setup.
1414
3. Cache freshness is handled asymmetrically:
15-
- **Stale partitions are queried by default** and the CLI prints a `warning: querying stale data; …` line to stderr. Read stderr alongside stdout, and surface any stale warning to the user so they know the data may be outdated (and can rerun with `--refresh always` to update).
15+
- **Stale partitions are queried by default** and the CLI prints a `warning: querying stale data; …` line to stderr. Read stderr alongside stdout, and surface any stale warning to the user so they know the data may be outdated. Prefer the file-targeted `ctvs query refresh <file.jsonl>` command the CLI prints when updating cache data; use `--refresh always` only when the query should refresh before it runs.
1616
- **Missing partitions still error.** Run the exact `ctvs query refresh …` command the CLI prints, or rerun the target query with `--refresh always`.
17+
- Broad manual refreshes are explicit: `ctvs query refresh --all [dataset]`. Do not run a broad refresh when the printed file-targeted command is enough.
1718
- Pass `--strict-freshness` only when the user explicitly needs the pre-1.7 strict mode (e.g., scheduled checks that must never read stale data); it turns stale partitions back into a hard error.
1819
4. Prefer structured output for analysis: use `--format json` for follow-up reasoning, `--format markdown` when showing a table to the user, and `--limit` to keep output bounded.
1920
5. Use high-level query commands before custom SQL. Switch to `ctvs query sql` only when the built-in commands cannot answer the question.
@@ -32,6 +33,8 @@ ctvs query metrics series <metric-name> --format json
3233
ctvs query proxy get <conversation-id> --format json
3334
ctvs query proxy stats --format json
3435
ctvs query errors --since 24h --format json
36+
ctvs query refresh <file.jsonl>
37+
ctvs query refresh --all logs
3538
ctvs collect <file.jsonl> --name <name>
3639
ctvs collect --glob '<pattern>' --name <name>
3740
```

skills/collectivus-query/references/query-cli.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Commands default to `~/.hyp/collectivus.json`. If the running gateway or OTEL co
3838
- `--limit <n>`: Maximum rows to render. Default `100`, maximum `1000`.
3939
- `--format <fmt>`: `table`, `json`, `jsonl`, or `markdown`.
4040
- `--refresh <mode>`: `never` or `always`. Default `never`.
41+
- `--all`: Refresh all matching source files for `ctvs query refresh`.
42+
- `--force`: Rebuild fresh cache partitions too for `ctvs query refresh`.
4143
- `--strict-freshness`: Treat stale partitions as a hard error (pre-1.7 behavior). Off by default.
4244

4345
## Commands
@@ -46,7 +48,8 @@ Commands default to `~/.hyp/collectivus.json`. If the running gateway or OTEL co
4648
- `ctvs query status`: Inspect source partitions and cache freshness.
4749
- `ctvs query catalog`: List logical datasets, columns, source partitions, and cached row counts.
4850
- `ctvs query schema <dataset>`: Print static schema for a logical dataset.
49-
- `ctvs query refresh [dataset] [--force]`: Materialize local JSONL into query-cache Parquet.
51+
- `ctvs query refresh <file.jsonl>... [--force]`: Materialize selected JSONL source files into query-cache Parquet.
52+
- `ctvs query refresh --all [dataset] [--force]`: Materialize all matching JSONL source files into query-cache Parquet.
5053
- `ctvs query sample <dataset>`: Show sample rows.
5154
- `ctvs query sql <select-sql>`: Run read-only SQL over logical datasets.
5255
- `ctvs query logs [count|tail]`: List logs, count logs, or tail live JSONL without requiring cache.
@@ -68,7 +71,7 @@ ctvs collect --glob '/path/to/segments/**/*.jsonl' --name segments
6871
ctvs query sql "select * from random_log" --format json
6972
```
7073

71-
`ctvs collect` stores the absolute source path (or glob) and immediately refreshes the Parquet cache. If the source file changes later, normal query freshness rules apply: stale cached data is queryable with a stderr warning, `--strict-freshness` turns that into an error, and `--refresh always` refreshes before running the query.
74+
`ctvs collect` stores the absolute source path (or glob) and immediately refreshes the Parquet cache. If the source file changes later, normal query freshness rules apply: stale cached data is queryable with a stderr warning, `--strict-freshness` turns that into an error, and `ctvs query refresh <file.jsonl>` refreshes selected files. Use `--refresh always` to refresh before running the query.
7275

7376
With `--glob`, one logical table is backed by many source files: each matched file becomes its own cache partition under `.collectivus-query/parquet/collections/<table>/source=<hash>/data.parquet`, and only files whose mtime/size changed re-materialize on refresh. Files that no longer match the glob are pruned from the cache on the next refresh. Inside SQL, use `_ctvs_source_path` to see which file a row came from.
7477

src/cli/init_presets/gascity_skill.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ ORDER BY source, idx;
161161

162162
## Freshness
163163

164-
`session_segments` is glob-backed: new segment files only appear in the cache after a refresh. Run `ctvs query refresh session_segments` to pick up new segments, or use `--refresh always` on any query. Deleted segment files are pruned from the cache on the next refresh.
164+
`session_segments` is glob-backed: new segment files only appear in the cache after a refresh. Run `ctvs query refresh <segment-file.jsonl>` for selected segment files, `ctvs query refresh --all session_segments` to pick up every matching segment, or use `--refresh always` on any query. Deleted segment files are pruned from the cache on the next `--all` refresh.
165165

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

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 gascity_messages` is a documented no-op (it lists existing partitions as already-fresh). To pull in newly-flushed rows simply rerun the query.
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.
169169

170170
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`.
171171

@@ -176,5 +176,5 @@ Refreshing isn't free. `events.jsonl` and the `session_segments/**/*.jsonl` file
176176
Recommended workflow:
177177

178178
1. Run `ctvs query status` first. The summary shows which date ranges are already cached and which are stale; cheap queries against a covered range never need a refresh.
179-
2. Only invoke `--refresh always` or `ctvs query refresh <dataset>` when a needed date range is missing or `status` reports staleness in the window you care about.
179+
2. Only invoke `--refresh always`, `ctvs query refresh <file.jsonl>`, or `ctvs query refresh --all <dataset>` when a needed date range is missing or `status` reports staleness in the window you care about.
180180
3. Do not reflexively pass `--refresh always` "just in case". Stale-data queries print a warning to stderr (the default behavior); reading that warning is cheaper than re-materializing the cache. Treat refresh as a deliberate step, not a default.

src/cli/query.js

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import path from 'node:path'
23
import process from 'node:process'
34
import { ConfigError, loadConfigAsync as defaultLoadConfig } from '../config.js'
45
import { defaultConfigPath } from './common.js'
@@ -47,7 +48,8 @@ Commands:
4748
status Inspect JSONL sources and query-cache freshness
4849
catalog List logical datasets and cached row counts
4950
schema <dataset> Print the static logical schema
50-
refresh [dataset] [--force] Materialize local JSONL into query-cache Parquet
51+
refresh <file.jsonl>... Materialize selected JSONL source files into Parquet
52+
refresh --all [dataset] Materialize all matching JSONL into query-cache Parquet
5153
sql <select-sql> Run read-only SELECT SQL over logical datasets
5254
sample <dataset> Show sample rows
5355
doctor Check query prerequisites
@@ -75,6 +77,8 @@ Shared options:
7577
--refresh <mode> never or always (default: never).
7678
Stale partitions query with a stderr warning;
7779
missing partitions always error.
80+
--all Refresh all matching source files (refresh command only)
81+
--force Rebuild fresh cache partitions too (refresh command only)
7882
--strict-freshness Treat stale partitions as errors (pre-1.7 behavior)
7983
--help, -h Show this help`
8084

@@ -191,6 +195,7 @@ export async function runQuery(argv, hooks = {}) {
191195
* limit: number,
192196
* format: QueryFormat,
193197
* refresh: QueryRefreshMode,
198+
* all: boolean,
194199
* force: boolean,
195200
* strictFreshness: boolean,
196201
* error?: string,
@@ -205,12 +210,14 @@ export function parseQueryArgs(argv) {
205210
limit: DEFAULT_LIMIT,
206211
format: 'table',
207212
refresh: 'never',
213+
all: false,
208214
force: false,
209215
strictFreshness: false,
210216
}
211217
for (let i = 0; i < argv.length; i++) {
212218
const arg = argv[i]
213219
if (arg === '--help' || arg === '-h') { out.help = true; return out }
220+
if (arg === '--all') { out.all = true; continue }
214221
if (arg === '--force') { out.force = true; continue }
215222
if (arg === '--strict-freshness') { out.strictFreshness = true; continue }
216223
/**
@@ -393,7 +400,7 @@ function handleSchema(paths, parsed, stdout, stderr) {
393400
}
394401
const meta = readAnyCollectionMeta(paths.parquetDir, collection)
395402
if (!meta) {
396-
stderr.write(`error: query cache is missing for ${collection.table}. Run: ${refreshCommand(parsed)}\n`)
403+
stderr.write(`error: query cache is missing for ${collection.table}. Run: ${refreshCommand(parsed, undefined, { ...baseScope(parsed), datasets: [collection.table] })}\n`)
397404
return 1
398405
}
399406
const rows = meta.columns.map((column) => ({
@@ -414,11 +421,31 @@ function handleSchema(paths, parsed, stdout, stderr) {
414421
* @returns {Promise<number>}
415422
*/
416423
async function handleRefresh(paths, parsed, stdout, stderr) {
417-
const scope = scopeWithOptionalDataset(paths, parsed, parsed.positionals[1])
418424
if (!paths.parquetEnabled || !paths.parquetDir) {
419425
stderr.write('error: query parquet cache is disabled; pass --parquet-dir to refresh explicitly\n')
420426
return 1
421427
}
428+
const targets = parsed.positionals.slice(1)
429+
/** @type {QueryScope} */
430+
let scope
431+
if (parsed.all) {
432+
if (targets.length > 1) {
433+
stderr.write('error: refresh --all accepts at most one dataset\n')
434+
return 2
435+
}
436+
scope = scopeWithOptionalDataset(paths, parsed, targets[0])
437+
} else {
438+
if (targets.length === 0) {
439+
stderr.write('error: refresh requires one or more JSONL files; pass --all to refresh all matching sources\n')
440+
return 2
441+
}
442+
const datasetTarget = targets.length === 1 ? resolveQueryTable(paths, targets[0]) : undefined
443+
if (datasetTarget) {
444+
stderr.write(`error: refresh ${targets[0]} targets a dataset; pass --all to refresh all ${datasetTarget} sources\n`)
445+
return 2
446+
}
447+
scope = refreshScopeForSourceFiles(paths, parsed, targets)
448+
}
422449
const result = await refreshAllCaches({ paths, scope, force: parsed.force, stdout })
423450
if (result.written === 0 && result.skipped === 0 && result.failures === 0) {
424451
stdout.write(`No JSONL files matched in ${paths.recordingRoot}.\n`)
@@ -793,7 +820,7 @@ async function ensureCacheReady(paths, scope, parsed) {
793820
const detail = `${partitionLabel(first)}: missing${first.reason ? ` (${first.reason})` : ''}`
794821
return {
795822
ok: false,
796-
message: `error: query cache is missing for ${detail}. Run: ${refreshCommand(parsed)}`,
823+
message: `error: query cache is missing for ${detail}. Run: ${refreshCommand(parsed, missing, scope)}`,
797824
}
798825
}
799826

@@ -803,14 +830,14 @@ async function ensureCacheReady(paths, scope, parsed) {
803830
const detail = `${partitionLabel(first)}: stale${first.reason ? ` (${first.reason})` : ''}`
804831
return {
805832
ok: false,
806-
message: `error: query cache is stale for ${detail} (--strict-freshness set). Run: ${refreshCommand(parsed)}`,
833+
message: `error: query cache is stale for ${detail} (--strict-freshness set). Run: ${refreshCommand(parsed, stale, scope)}`,
807834
}
808835
}
809836
const summary = stale.slice(0, 3).map((state) => `${partitionLabel(state)}${state.reason ? ` (${state.reason})` : ''}`).join(', ')
810837
const more = stale.length > 3 ? `, +${stale.length - 3} more` : ''
811838
return {
812839
ok: true,
813-
warnings: [`warning: querying stale data; ${stale.length} partition(s) outdated [${summary}${more}] — run '${refreshCommand(parsed)}' to update`],
840+
warnings: [`warning: querying stale data; ${stale.length} partition(s) outdated [${summary}${more}] — run '${refreshCommand(parsed, stale, scope)}' to update`],
814841
}
815842
}
816843

@@ -819,17 +846,50 @@ async function ensureCacheReady(paths, scope, parsed) {
819846

820847
/**
821848
* @param {ReturnType<typeof parseQueryArgs>} parsed
849+
* @param {Array<{ partition: unknown }>} [states]
850+
* @param {QueryScope} [scope]
822851
* @returns {string}
823852
*/
824-
function refreshCommand(parsed) {
853+
function refreshCommand(parsed, states, scope) {
825854
const parts = ['ctvs', 'query', 'refresh']
855+
const sourcePaths = states ? refreshableSourcePaths(states) : []
856+
if (sourcePaths.length > 0 && sourcePaths.length <= 5) {
857+
parts.push(...sourcePaths.map(shellQuote))
858+
} else {
859+
parts.push('--all')
860+
const datasets = scope?.datasets ?? (scope?.dataset ? [scope.dataset] : undefined)
861+
if (datasets?.length === 1) parts.push(datasets[0])
862+
}
826863
if (parsed.configPath) parts.push('--config', shellQuote(parsed.configPath))
827864
if (parsed.parquetDir) parts.push('--parquet-dir', shellQuote(parsed.parquetDir))
828865
if (parsed.gatewayId) parts.push('--gateway-id', shellQuote(parsed.gatewayId))
829866
if (parsed.date) parts.push('--date', parsed.date)
830867
return parts.join(' ')
831868
}
832869

870+
/**
871+
* @param {Array<{ partition: unknown }>} states
872+
* @returns {string[]}
873+
*/
874+
function refreshableSourcePaths(states) {
875+
const seen = new Set()
876+
/** @type {string[]} */
877+
const out = []
878+
for (const state of states) {
879+
const { partition: rawPartition } = state
880+
const partition = /** @type {Record<string, unknown>} */ (rawPartition)
881+
const sourcePath = partition.jsonlPath
882+
if (typeof sourcePath !== 'string') continue
883+
if (!sourcePath.endsWith('.jsonl')) continue
884+
if (!fs.existsSync(sourcePath)) continue
885+
const abs = path.resolve(sourcePath)
886+
if (seen.has(abs)) continue
887+
seen.add(abs)
888+
out.push(abs)
889+
}
890+
return out
891+
}
892+
833893
/**
834894
* @param {QueryPaths} paths
835895
* @param {ReturnType<typeof parseQueryArgs>} parsed
@@ -1097,6 +1157,53 @@ function scopeWithOptionalDataset(paths, parsed, rawDataset) {
10971157
return scope
10981158
}
10991159

1160+
/**
1161+
* @param {QueryPaths} paths
1162+
* @param {ReturnType<typeof parseQueryArgs>} parsed
1163+
* @param {string[]} targets
1164+
* @returns {QueryScope}
1165+
*/
1166+
function refreshScopeForSourceFiles(paths, parsed, targets) {
1167+
/** @type {string[]} */
1168+
const sourcePaths = []
1169+
const seenTargets = new Set()
1170+
for (const target of targets) {
1171+
const abs = path.resolve(target)
1172+
if (seenTargets.has(abs)) continue
1173+
seenTargets.add(abs)
1174+
sourcePaths.push(abs)
1175+
}
1176+
1177+
const scope = { ...baseScope(parsed), sourcePaths }
1178+
const matched = new Set()
1179+
const datasets = new Set()
1180+
for (const source of discoverSourceFiles(paths.recordingRoot, scope)) {
1181+
matched.add(path.resolve(source.jsonlPath))
1182+
datasets.add(datasetForSourceSignal(source.signal))
1183+
}
1184+
for (const partition of expectedCollectionPartitions(paths, scope)) {
1185+
matched.add(path.resolve(partition.jsonlPath))
1186+
datasets.add(partition.table)
1187+
}
1188+
1189+
const unknown = sourcePaths.filter((sourcePath) => !matched.has(sourcePath))
1190+
if (unknown.length > 0) {
1191+
throw new Error(`refresh source file is not a known recording or collection source: ${unknown[0]}`)
1192+
}
1193+
if (datasets.size === 0) {
1194+
throw new Error('no refreshable source files matched')
1195+
}
1196+
return { ...scope, datasets: [...datasets] }
1197+
}
1198+
1199+
/**
1200+
* @param {import('../query/types.js').SourceFile['signal']} signal
1201+
* @returns {string}
1202+
*/
1203+
function datasetForSourceSignal(signal) {
1204+
return signal === 'proxy' ? 'proxy_messages' : signal
1205+
}
1206+
11001207
/**
11011208
* @param {QueryPaths} paths
11021209
* @param {string} raw

src/query/collections.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,11 @@ export function expectedCollectionPartitions(paths, scope) {
277277
if (!paths.parquetDir) return []
278278
const manifest = readCollectionsManifest(paths.recordingRoot)
279279
const wanted = collectionTablesForScope(manifest, scope)
280-
const parquetDir = paths.parquetDir
281-
return wanted.flatMap((collection) => collectionPartitionsFor(parquetDir, collection))
280+
const { parquetDir } = paths
281+
const sourcePaths = sourcePathFilter(scope)
282+
const partitions = wanted.flatMap((collection) => collectionPartitionsFor(parquetDir, collection))
283+
if (!sourcePaths) return partitions
284+
return partitions.filter((partition) => sourcePaths.has(path.resolve(partition.jsonlPath)))
282285
}
283286

284287
/**
@@ -540,6 +543,7 @@ export function readCollectionCacheMeta(metaPath) {
540543
*/
541544
function pruneOrphanCollectionPartitions(paths, scope, stdout) {
542545
if (!paths.parquetDir) return
546+
if (scope.sourcePaths && scope.sourcePaths.length > 0) return
543547
const manifest = readCollectionsManifest(paths.recordingRoot)
544548
const wanted = collectionTablesForScope(manifest, scope)
545549
for (const collection of wanted) {
@@ -894,6 +898,15 @@ function collectionTablesForScope(manifest, scope) {
894898
return all.filter((collection) => wanted.has(collection.table))
895899
}
896900

901+
/**
902+
* @param {QueryScope} scope
903+
* @returns {Set<string> | undefined}
904+
*/
905+
function sourcePathFilter(scope) {
906+
if (!scope.sourcePaths) return undefined
907+
return new Set(scope.sourcePaths.map((sourcePath) => path.resolve(sourcePath)))
908+
}
909+
897910
/**
898911
* @returns {CollectionsManifest}
899912
*/

0 commit comments

Comments
 (0)