Skip to content

Commit 90c7e48

Browse files
wesmmaphew
andcommitted
feat(db): add artifact publication ledger and origin-gated export queue
Add the export-side artifact publication schema: export queue, publication ledger, publication revisions, checkpoint heads and floors, with session triggers that enqueue owned-session changes. Triggers are origin-gated (they fire only once an artifact origin exists in pg_sync_state) and are installed in code after column migrations — trigger drops run before migrations so no trigger references sessions columns while they run. Child-only mutations (messages, usage events, findings) enqueue through Go hooks: the session batch writer samples the queue generation around the batch and enqueues exactly once when the row triggers did not fire, the standalone usage-event replace enqueues directly, and the token coverage backfill enqueues per touched session. Queue bootstrap is an explicit BootstrapArtifactExportQueue call for origin creation instead of a migrate-time backfill. CopySyncStateFrom now carries artifact publication state across a full resync: artifact sync-state keys, the export queue (merge preserves generations and pending flags), publications, revisions, checkpoint heads (with column probes for older archives) and floors. Co-authored-by: maphew <maphew@gmail.com>
1 parent 7d9f1c5 commit 90c7e48

10 files changed

Lines changed: 2082 additions & 26 deletions

internal/db/artifact_publication.go

Lines changed: 631 additions & 0 deletions
Large diffs are not rendered by default.

internal/db/artifact_publication_test.go

Lines changed: 1076 additions & 0 deletions
Large diffs are not rendered by default.

internal/db/db.go

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

13581363
var (
@@ -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.
21262251
func (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

29363093
type messageTokenCoverageBackfillCandidate struct {
29373094
id int64
3095+
sessionID string
29383096
hasContext bool
29393097
hasOutput bool
29403098
}

internal/db/db_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5560,10 +5560,15 @@ func TestCopySyncStateFrom_OnlyCopiesDurablePGKeys(t *testing.T) {
55605560
srcDB := testDBAtPath(t, srcPath, "src")
55615561
require.NoError(t, srcDB.SetSyncState("pg_push_marker_id", "marker-123"),
55625562
"seed source marker")
5563+
require.NoError(t, srcDB.SetSyncState("artifact_origin_id", "laptop-a1b2c3"),
5564+
"seed source artifact origin")
55635565
require.NoError(t, srcDB.SetSyncState("last_sync_started_at", "old-start"),
55645566
"seed source started")
55655567
require.NoError(t, srcDB.SetSyncState("last_sync_finished_at", "old-finish"),
55665568
"seed source finished")
5569+
require.NoError(t, srcDB.UpsertSession(Session{
5570+
ID: "queued-session", Project: "p", Machine: "local", Agent: "claude",
5571+
}), "seed source queued session")
55675572
require.NoError(t, srcDB.Close(), "Close src")
55685573

55695574
dstPath := filepath.Join(dir, "dst.db")
@@ -5581,6 +5586,14 @@ func TestCopySyncStateFrom_OnlyCopiesDurablePGKeys(t *testing.T) {
55815586
require.NoError(t, err, "GetSyncState pg_push_marker_id")
55825587
assert.Equal(t, "marker-123", gotMarker)
55835588

5589+
gotOrigin, err := dstDB.GetSyncState("artifact_origin_id")
5590+
require.NoError(t, err, "GetSyncState artifact_origin_id")
5591+
assert.Equal(t, "laptop-a1b2c3", gotOrigin,
5592+
"artifact_% sync-state keys must survive the copy")
5593+
5594+
assert.Contains(t, artifactExportQueueIDs(t, dstDB), "queued-session",
5595+
"artifact export queue rows must survive the copy")
5596+
55845597
gotStarted, err := dstDB.GetSyncState("last_sync_started_at")
55855598
require.NoError(t, err, "GetSyncState last_sync_started_at")
55865599
assert.Equal(t, "new-start", gotStarted)
@@ -7376,10 +7389,13 @@ func TestMigration_TerminationStatusColumn(t *testing.T) {
73767389
requireNoError(t, err, "raw open")
73777390

73787391
// SQLite supports DROP COLUMN as of 3.35; the in-tree driver is
7379-
// recent enough. Drop the index first since SQLite blocks
7380-
// dropping a column referenced by an index.
7392+
// recent enough. Drop the index and the trigger that references the
7393+
// column first since SQLite blocks dropping a column referenced by an
7394+
// index or a trigger.
73817395
_, err = conn.Exec(`DROP INDEX IF EXISTS idx_sessions_termination_status`)
73827396
requireNoError(t, err, "drop termination_status index")
7397+
_, err = conn.Exec(`DROP TRIGGER IF EXISTS artifact_sessions_update_queue`)
7398+
requireNoError(t, err, "drop artifact_sessions_update_queue trigger")
73837399
_, err = conn.Exec(`ALTER TABLE sessions DROP COLUMN termination_status`)
73847400
requireNoError(t, err, "drop termination_status column")
73857401

0 commit comments

Comments
 (0)