@@ -1353,6 +1353,11 @@ var readOnlyRequiredTables = []string{
13531353 "recall_query_exposures" ,
13541354 "recall_extract_generations" ,
13551355 "recall_extract_progress" ,
1356+ "artifact_export_queue" ,
1357+ "artifact_publications" ,
1358+ "artifact_publication_revisions" ,
1359+ "artifact_checkpoint_heads" ,
1360+ "artifact_checkpoint_floors" ,
13561361}
13571362
13581363var (
@@ -2120,16 +2125,142 @@ func repairLegacySchemaBeforeInit(w *writerHandle) error {
21202125 return nil
21212126}
21222127
2128+ // artifactSessionQueueTriggerDropsSQL and artifactSessionQueueTriggerCreatesSQL
2129+ // together keep the three sessions-table triggers that populate
2130+ // artifact_export_queue upgradable across releases. They are applied here
2131+ // rather than in schema.sql because the CREATE bodies reference columns added
2132+ // by applySchemaColumnMigrations; running them at schema-init time would fire
2133+ // "no such column" errors against a legacy archive before those columns
2134+ // exist.
2135+ //
2136+ // The drops run BEFORE applySchemaColumnMigrations and the creates run AFTER:
2137+ // a trigger left over from a previous release must not still be attached to
2138+ // the sessions table while column migrations run, because a future
2139+ // migration that rebuilds the table (rather than a plain ALTER TABLE ADD
2140+ // COLUMN) would fail against a trigger body referencing columns mid-rebuild.
2141+ // Splitting the DDL this way keeps the table trigger-free for the duration
2142+ // of the migration step regardless of what a later migration needs to do.
2143+ //
2144+ // Every trigger additionally gates on the presence of an artifact origin
2145+ // (pg_sync_state key artifact_origin_id) so that archives which have never
2146+ // created or adopted an artifact origin never populate the export queue.
2147+ const artifactSessionQueueTriggerDropsSQL = `
2148+ DROP TRIGGER IF EXISTS artifact_sessions_insert_queue;
2149+ DROP TRIGGER IF EXISTS artifact_sessions_update_queue;
2150+ DROP TRIGGER IF EXISTS artifact_sessions_delete_queue;
2151+ `
2152+
2153+ const artifactSessionQueueTriggerCreatesSQL = `
2154+ CREATE TRIGGER IF NOT EXISTS artifact_sessions_insert_queue
2155+ AFTER INSERT ON sessions WHEN NEW.machine = 'local' AND EXISTS (
2156+ SELECT 1 FROM pg_sync_state WHERE key = 'artifact_origin_id'
2157+ ) BEGIN
2158+ INSERT INTO artifact_export_queue(session_id) VALUES (NEW.id)
2159+ ON CONFLICT(session_id) DO UPDATE SET
2160+ enqueued_at = CASE WHEN pending = 0
2161+ THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE enqueued_at END,
2162+ generation = generation + 1,
2163+ pending = 1;
2164+ END;
2165+
2166+ CREATE TRIGGER IF NOT EXISTS artifact_sessions_update_queue
2167+ AFTER UPDATE ON sessions
2168+ WHEN (OLD.machine = 'local' OR NEW.machine = 'local') AND EXISTS (
2169+ SELECT 1 FROM pg_sync_state WHERE key = 'artifact_origin_id'
2170+ ) AND (
2171+ OLD.project IS NOT NEW.project OR
2172+ OLD.machine IS NOT NEW.machine OR
2173+ OLD.agent IS NOT NEW.agent OR
2174+ OLD.agent_label IS NOT NEW.agent_label OR
2175+ OLD.entrypoint IS NOT NEW.entrypoint OR
2176+ OLD.first_message IS NOT NEW.first_message OR
2177+ OLD.display_name IS NOT NEW.display_name OR
2178+ OLD.session_name IS NOT NEW.session_name OR
2179+ OLD.started_at IS NOT NEW.started_at OR
2180+ OLD.ended_at IS NOT NEW.ended_at OR
2181+ OLD.message_count IS NOT NEW.message_count OR
2182+ OLD.user_message_count IS NOT NEW.user_message_count OR
2183+ OLD.transcript_revision IS NOT NEW.transcript_revision OR
2184+ OLD.parent_session_id IS NOT NEW.parent_session_id OR
2185+ OLD.relationship_type IS NOT NEW.relationship_type OR
2186+ OLD.total_output_tokens IS NOT NEW.total_output_tokens OR
2187+ OLD.peak_context_tokens IS NOT NEW.peak_context_tokens OR
2188+ OLD.has_total_output_tokens IS NOT NEW.has_total_output_tokens OR
2189+ OLD.has_peak_context_tokens IS NOT NEW.has_peak_context_tokens OR
2190+ OLD.is_automated IS NOT NEW.is_automated OR
2191+ OLD.tool_failure_signal_count IS NOT NEW.tool_failure_signal_count OR
2192+ OLD.tool_retry_count IS NOT NEW.tool_retry_count OR
2193+ OLD.edit_churn_count IS NOT NEW.edit_churn_count OR
2194+ OLD.consecutive_failure_max IS NOT NEW.consecutive_failure_max OR
2195+ OLD.outcome IS NOT NEW.outcome OR
2196+ OLD.outcome_confidence IS NOT NEW.outcome_confidence OR
2197+ OLD.ended_with_role IS NOT NEW.ended_with_role OR
2198+ OLD.final_failure_streak IS NOT NEW.final_failure_streak OR
2199+ OLD.signals_pending_since IS NOT NEW.signals_pending_since OR
2200+ OLD.compaction_count IS NOT NEW.compaction_count OR
2201+ OLD.mid_task_compaction_count IS NOT NEW.mid_task_compaction_count OR
2202+ OLD.context_pressure_max IS NOT NEW.context_pressure_max OR
2203+ OLD.health_score IS NOT NEW.health_score OR
2204+ OLD.health_grade IS NOT NEW.health_grade OR
2205+ OLD.has_tool_calls IS NOT NEW.has_tool_calls OR
2206+ OLD.has_context_data IS NOT NEW.has_context_data OR
2207+ OLD.quality_signal_version IS NOT NEW.quality_signal_version OR
2208+ OLD.short_prompt_count IS NOT NEW.short_prompt_count OR
2209+ OLD.unstructured_start IS NOT NEW.unstructured_start OR
2210+ OLD.missing_success_criteria_count IS NOT NEW.missing_success_criteria_count OR
2211+ OLD.missing_verification_count IS NOT NEW.missing_verification_count OR
2212+ OLD.duplicate_prompt_count IS NOT NEW.duplicate_prompt_count OR
2213+ OLD.no_code_context_count IS NOT NEW.no_code_context_count OR
2214+ OLD.runaway_tool_loop_count IS NOT NEW.runaway_tool_loop_count OR
2215+ OLD.data_version IS NOT NEW.data_version OR
2216+ OLD.cwd IS NOT NEW.cwd OR
2217+ OLD.git_branch IS NOT NEW.git_branch OR
2218+ OLD.source_session_id IS NOT NEW.source_session_id OR
2219+ OLD.source_version IS NOT NEW.source_version OR
2220+ OLD.transcript_fidelity IS NOT NEW.transcript_fidelity OR
2221+ OLD.parser_malformed_lines IS NOT NEW.parser_malformed_lines OR
2222+ OLD.is_truncated IS NOT NEW.is_truncated OR
2223+ OLD.deleted_at IS NOT NEW.deleted_at OR
2224+ OLD.created_at IS NOT NEW.created_at OR
2225+ OLD.termination_status IS NOT NEW.termination_status
2226+ ) BEGIN
2227+ INSERT INTO artifact_export_queue(session_id) VALUES (NEW.id)
2228+ ON CONFLICT(session_id) DO UPDATE SET
2229+ enqueued_at = CASE WHEN pending = 0
2230+ THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE enqueued_at END,
2231+ generation = generation + 1,
2232+ pending = 1;
2233+ END;
2234+
2235+ CREATE TRIGGER IF NOT EXISTS artifact_sessions_delete_queue
2236+ BEFORE DELETE ON sessions WHEN OLD.machine = 'local' AND EXISTS (
2237+ SELECT 1 FROM pg_sync_state WHERE key = 'artifact_origin_id'
2238+ ) BEGIN
2239+ INSERT INTO artifact_export_queue(session_id) VALUES (OLD.id)
2240+ ON CONFLICT(session_id) DO UPDATE SET
2241+ enqueued_at = CASE WHEN pending = 0
2242+ THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE enqueued_at END,
2243+ generation = generation + 1,
2244+ pending = 1;
2245+ END;
2246+ `
2247+
21232248// migrateColumns adds columns introduced by this branch to databases created
21242249// by older releases, then runs the data repairs required by a normal writable
21252250// startup. Schema-only callers use applySchemaColumnMigrations directly.
21262251func (db * DB ) migrateColumns () error {
21272252 db .mu .Lock ()
21282253 defer db .mu .Unlock ()
21292254 w := db .getWriter ()
2255+ if _ , err := w .Exec (artifactSessionQueueTriggerDropsSQL ); err != nil {
2256+ return fmt .Errorf ("dropping artifact session queue triggers: %w" , err )
2257+ }
21302258 if err := applySchemaColumnMigrations (w .QueryRow , w .Exec ); err != nil {
21312259 return err
21322260 }
2261+ if _ , err := w .Exec (artifactSessionQueueTriggerCreatesSQL ); err != nil {
2262+ return fmt .Errorf ("installing artifact session queue triggers: %w" , err )
2263+ }
21332264 if err := installSyncMarkerSchemaLocked (w ); err != nil {
21342265 return err
21352266 }
@@ -2308,6 +2439,23 @@ func (db *DB) migrateColumns() error {
23082439 return nil
23092440}
23102441
2442+ // BootstrapArtifactExportQueue enqueues every live locally-owned session
2443+ // once. Called when the artifact origin is first created or adopted; the
2444+ // queue triggers and enqueue hooks are origin-gated, so rows written
2445+ // before the origin existed are captured here exactly once.
2446+ func (db * DB ) BootstrapArtifactExportQueue () error {
2447+ db .mu .Lock ()
2448+ defer db .mu .Unlock ()
2449+ _ , err := db .getWriter ().Exec (`
2450+ INSERT OR IGNORE INTO artifact_export_queue(session_id)
2451+ SELECT id FROM sessions
2452+ WHERE machine = 'local' AND deleted_at IS NULL` )
2453+ if err != nil {
2454+ return fmt .Errorf ("bootstrapping artifact export queue: %w" , err )
2455+ }
2456+ return nil
2457+ }
2458+
23112459// syncMarkerSchemaSQL creates the sync_marker index and the triggers that
23122460// keep it equal to the max of created_at, local_modified_at, ended_at,
23132461// started_at, and file_mtime, normalized to ms-precision UTC text. This is
@@ -2860,6 +3008,7 @@ func (db *DB) backfillMessageTokenCoverageLocked(
28603008 }
28613009 defer stmt .Close ()
28623010
3011+ sessions := make (map [string ]struct {})
28633012 for _ , candidate := range candidates {
28643013 if _ , err := stmt .Exec (
28653014 candidate .hasContext , candidate .hasOutput , candidate .id ,
@@ -2869,6 +3018,12 @@ func (db *DB) backfillMessageTokenCoverageLocked(
28693018 candidate .id , err ,
28703019 )
28713020 }
3021+ sessions [candidate .sessionID ] = struct {}{}
3022+ }
3023+ for sessionID := range sessions {
3024+ if err := enqueueArtifactExportTx (tx , sessionID ); err != nil {
3025+ return 0 , err
3026+ }
28723027 }
28733028 if err := tx .Commit (); err != nil {
28743029 return 0 , fmt .Errorf (
@@ -2883,7 +3038,7 @@ func (db *DB) messageTokenCoverageBackfillCandidatesLocked(
28833038 w * writerHandle ,
28843039) ([]messageTokenCoverageBackfillCandidate , error ) {
28853040 rows , err := w .Query (
2886- `SELECT id, token_usage, context_tokens, output_tokens,
3041+ `SELECT id, session_id, token_usage, context_tokens, output_tokens,
28873042 has_context_tokens, has_output_tokens
28883043 FROM messages
28893044 WHERE (has_context_tokens = 0 OR has_output_tokens = 0)
@@ -2901,11 +3056,12 @@ func (db *DB) messageTokenCoverageBackfillCandidatesLocked(
29013056 var candidates []messageTokenCoverageBackfillCandidate
29023057 for rows .Next () {
29033058 var id int64
3059+ var sessionID string
29043060 var tokenUsage string
29053061 var contextTokens , outputTokens int
29063062 var hasContextTokens , hasOutputTokens bool
29073063 if err := rows .Scan (
2908- & id , & tokenUsage , & contextTokens ,
3064+ & id , & sessionID , & tokenUsage , & contextTokens ,
29093065 & outputTokens , & hasContextTokens ,
29103066 & hasOutputTokens ,
29113067 ); err != nil {
@@ -2923,6 +3079,7 @@ func (db *DB) messageTokenCoverageBackfillCandidatesLocked(
29233079 }
29243080 candidates = append (candidates , messageTokenCoverageBackfillCandidate {
29253081 id : id ,
3082+ sessionID : sessionID ,
29263083 hasContext : hasContext ,
29273084 hasOutput : hasOutput ,
29283085 })
@@ -2935,6 +3092,7 @@ func (db *DB) messageTokenCoverageBackfillCandidatesLocked(
29353092
29363093type messageTokenCoverageBackfillCandidate struct {
29373094 id int64
3095+ sessionID string
29383096 hasContext bool
29393097 hasOutput bool
29403098}
0 commit comments