Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ pub struct CommitTimelineItem {
pub session_id: Option<String>,
pub session_status: Option<String>,
pub completion_reason: Option<String>,
/// Kind of the git pipeline behind this row's session. Only set on pending
/// rows: it is how the frontend tells a queued/running rebase or squash
/// apart from a plain commit session, since the subject is a display label
/// an agent-pushed ACP title can replace mid-pipeline.
pub pipeline_kind: Option<store::PipelineKind>,
/// Whether this commit was authored by the current git user.
pub is_own_commit: bool,
}
Expand Down Expand Up @@ -1175,6 +1180,7 @@ async fn get_repo_default_branch_timeline(
session_id: None,
session_status: None,
completion_reason: None,
pipeline_kind: None,
is_own_commit: false,
})
})
Expand Down
53 changes: 43 additions & 10 deletions apps/staged/src-tauri/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ fn parse_commit_lines(
session_id: resolved.session_id,
session_status: resolved.status,
completion_reason: resolved.completion_reason,
pipeline_kind: None,
is_own_commit: false, // set later by build_branch_timeline
});
}
Expand Down Expand Up @@ -268,6 +269,7 @@ fn map_local_commits(
session_id: resolved.session_id,
session_status: resolved.status,
completion_reason: resolved.completion_reason,
pipeline_kind: None,
is_own_commit: false, // set later by build_branch_timeline
}
})
Expand Down Expand Up @@ -465,21 +467,17 @@ fn build_branch_timeline(store: &Arc<Store>, branch_id: &str) -> Result<BranchTi
for dc in db_commits {
if dc.sha.is_none() {
let resolved = store.resolve_session_status(dc.session_id.as_deref());
let session = resolved
.session_id
.as_deref()
.and_then(|sid| store.get_session(sid).ok().flatten());

commits.push(CommitTimelineItem {
id: Some(dc.id.clone()),
sha: String::new(),
short_sha: String::new(),
subject: non_empty_acp_title(&resolved)
.or_else(|| {
resolved.session_id.as_deref().and_then(|sid| {
store
.get_session(sid)
.ok()
.flatten()
.map(|s| s.prompt.clone())
})
})
.or_else(|| session.as_ref().map(|s| s.prompt.clone()))
.unwrap_or_else(|| "Pending commit".to_string()),
author: String::new(),
author_email: String::new(),
Expand All @@ -489,6 +487,7 @@ fn build_branch_timeline(store: &Arc<Store>, branch_id: &str) -> Result<BranchTi
session_id: resolved.session_id,
session_status: resolved.status,
completion_reason: resolved.completion_reason,
pipeline_kind: session.and_then(|s| s.pipeline).and_then(|p| p.kind),
is_own_commit: true, // pending commits are always the current user's
});
}
Expand Down Expand Up @@ -1440,7 +1439,8 @@ mod tests {
use super::*;
use crate::git::Span;
use crate::store::models::{
Branch, Comment, Commit, Note, Project, ReviewScope, Session, SessionStatus, Workdir,
Branch, Comment, Commit, Note, PipelineExecution, PipelineKind, Project, ReviewScope,
Session, SessionStatus, Workdir,
};
use crate::test_utils::TempGitRepo;
use std::fs;
Expand Down Expand Up @@ -1752,6 +1752,38 @@ mod tests {
assert_eq!(subject_of(&untitled_commit.id), "add more tests");
}

/// The pending row of a queued rebase pipeline carries the pipeline kind,
/// so the frontend can withhold the Rebase button without matching on the
/// subject (which an agent-pushed ACP title can replace mid-pipeline).
#[test]
fn build_branch_timeline_pending_commit_carries_pipeline_kind() {
let (repo, _visible_sha, _stale_sha) = repo_with_visible_and_stale_commit();
let (store, branch) = store_with_branch(&repo);

let mut rebase = Session::new_queued("Rebase branch");
rebase.pipeline = Some(PipelineExecution::from_steps(&[]).with_kind(PipelineKind::Rebase));
store.create_session(&rebase).unwrap();
let plain = running_session(&store, "add more tests", None);
let rebase_commit = Commit::new_pending(&branch.id).with_session(&rebase.id);
let plain_commit = Commit::new_pending(&branch.id).with_session(&plain.id);
store.create_commit(&rebase_commit).unwrap();
store.create_commit(&plain_commit).unwrap();

let timeline = build_branch_timeline(&store, &branch.id).unwrap();

let kind_of = |commit_id: &str| {
timeline
.commits
.iter()
.find(|c| c.id.as_deref() == Some(commit_id))
.unwrap()
.pipeline_kind
.clone()
};
assert_eq!(kind_of(&rebase_commit.id), Some(PipelineKind::Rebase));
assert_eq!(kind_of(&plain_commit.id), None);
}

#[test]
fn build_branch_timeline_running_note_shows_acp_title() {
let (repo, _visible_sha, _stale_sha) = repo_with_visible_and_stale_commit();
Expand Down Expand Up @@ -1880,6 +1912,7 @@ mod tests {
session_id: None,
session_status: None,
completion_reason: None,
pipeline_kind: None,
is_own_commit: false,
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
session_id: None,
session_status: None,
completion_reason: None,
pipeline_kind: None,
is_own_commit: false,
})
})
Expand Down
42 changes: 41 additions & 1 deletion apps/staged/src/lib/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createStore, set } from 'idb-keyval';

// Mock transport — web mode (isTauri = false) with controllable invokeCommand
const mockInvoke = vi.fn();
Expand All @@ -16,8 +17,8 @@ import {
invalidateCacheByCommand,
markAllStale,
clearAllCache,
CACHE_SCHEMA_VERSION,
_cacheKey,
_CACHE_SCHEMA_VERSION,
_MAX_CACHE_ENTRIES,
_evictIfNeeded,
} from './cache';
Expand Down Expand Up @@ -51,6 +52,45 @@ describe('cacheKey', () => {
});
});

describe('schema version', () => {
/** Write an entry the way a build one schema version behind would have. */
async function writePreviousVersionEntry(command: string, data: unknown): Promise<void> {
const key = _cacheKey(command);
await set(
key,
{ key, data, fetchedAt: Date.now(), schemaVersion: CACHE_SCHEMA_VERSION - 1 },
createStore('staged-cache', 'responses')
);
}

it('never yields an entry written under a previous schema version', async () => {
await writePreviousVersionEntry('cmd', 'old-shape');
mockInvoke.mockResolvedValue('new-shape');

const results = [];
for await (const r of cachedInvoke('cmd', undefined, { ttl: 60_000 })) {
results.push(r);
}

// A within-TTL entry would normally short-circuit the fetch entirely; the
// version mismatch has to demote it to a miss, or the deploy that changed
// the payload serves the old shape with no network correction.
expect(results).toEqual([
{ data: 'new-shape', source: 'network', fetchedAt: expect.any(Number) },
]);
});

it('treats a previous-version entry as a miss in cachedCommand', async () => {
await writePreviousVersionEntry('cmd', 'old-shape');
mockInvoke.mockResolvedValue('new-shape');

await expect(cachedCommand('cmd', undefined, { ttl: 60_000 })).resolves.toEqual({
data: 'new-shape',
revalidating: null,
});
});
});

describe('cachedInvoke', () => {
it('yields only network result on cache miss', async () => {
mockInvoke.mockResolvedValue({ items: [1, 2] });
Expand Down
17 changes: 15 additions & 2 deletions apps/staged/src/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { get, set, del, keys, entries, clear, createStore } from 'idb-keyval';
import { invokeCommand, isTauri } from './transport';

const CACHE_SCHEMA_VERSION = 1;
/**
* Stamped on every persisted cache entry and checked on read. Bump it whenever
* the shape of any cached command response changes — otherwise the first load
* after a deploy serves entries written by the previous build, and a field the
* new UI depends on reads as `undefined` (e.g. `CommitTimelineItem.pipelineKind`,
* whose absence silently re-enables the Rebase button mid-rebase).
*
* Entries that fail the check read as misses and sit inert in IndexedDB until
* they're overwritten or LRU-evicted. The cost of a bump is one cold-cache boot
* per client.
*
* The timeline boot snapshot in `commands.ts` is versioned by this same
* constant, so one bump covers both layers that survive a deploy.
*/
export const CACHE_SCHEMA_VERSION = 2;
const MAX_CACHE_ENTRIES = 200;

/**
Expand Down Expand Up @@ -369,7 +383,6 @@ export async function clearAllCache(): Promise<void> {
// Exported for testing
export {
cacheKey as _cacheKey,
CACHE_SCHEMA_VERSION as _CACHE_SCHEMA_VERSION,
MAX_CACHE_ENTRIES as _MAX_CACHE_ENTRIES,
evictIfNeeded as _evictIfNeeded,
};
112 changes: 112 additions & 0 deletions apps/staged/src/lib/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ describe('cached mutation command wrappers', () => {
invokeCommand,
}));
vi.doMock('./cache', () => ({
CACHE_SCHEMA_VERSION: 1,
cachedCommand,
cachedInvoke: vi.fn(),
invalidateCache,
Expand Down Expand Up @@ -632,3 +633,114 @@ describe('cached mutation command wrappers', () => {
});
});
});

describe('timeline boot snapshot', () => {
/** Deliberately not the real value — the snapshot must track whatever the shared constant says. */
const SCHEMA_VERSION = 7;

const stale = {
commits: [{ sha: 'pending-1', subject: 'Rebase onto main', pipelineKind: 'rebase' }],
notes: [],
reviews: [],
images: [],
};
const fetched = {
commits: [{ sha: 'abc123', subject: 'Fix the thing' }],
notes: [],
reviews: [],
images: [],
};

let storage: Record<string, string>;
let cachedInvoke: ReturnType<typeof vi.fn>;
let snapshotKey: string;

beforeEach(async () => {
vi.resetModules();
storage = {};
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage[key] ?? null,
setItem: (key: string, value: string) => {
storage[key] = value;
},
removeItem: (key: string) => {
delete storage[key];
},
});
cachedInvoke = vi.fn(async function* () {
yield { data: fetched, source: 'network', fetchedAt: Date.now() };
});

vi.doMock('./transport', () => ({ isTauri: false, invokeCommand: vi.fn() }));
vi.doMock('./cache', () => ({
CACHE_SCHEMA_VERSION: SCHEMA_VERSION,
cachedCommand: vi.fn(),
cachedInvoke,
invalidateCache: vi.fn(),
invalidateCacheByCommand: vi.fn(),
invalidateCacheByArgs: vi.fn(),
}));

({
SNAPSHOT_KEYS: { timelines: snapshotKey },
} = await import('./shared/webSnapshot'));
});

afterEach(() => {
vi.doUnmock('./transport');
vi.doUnmock('./cache');
vi.unstubAllGlobals();
});

it('discards a snapshot written before the payload carried a version', async () => {
// Pre-versioning format: a bare branchId -> entry record.
storage[snapshotKey] = JSON.stringify({
'branch-1': { timeline: stale, fetchedAt: Date.now() },
});

const { getBranchTimelineWithRevalidation } = await import('./commands');
const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1');

expect(cached).toBeNull();
await expect(fresh).resolves.toEqual(fetched);
expect(storage[snapshotKey]).toBeUndefined();
});

it('discards a snapshot stamped by a previous build', async () => {
storage[snapshotKey] = JSON.stringify({
schemaVersion: SCHEMA_VERSION - 1,
timelines: { 'branch-1': { timeline: stale, fetchedAt: Date.now() } },
});

const { getBranchTimelineWithRevalidation } = await import('./commands');
const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1');

expect(cached).toBeNull();
await expect(fresh).resolves.toEqual(fetched);
expect(storage[snapshotKey]).toBeUndefined();
});

it('round-trips the timeline cache through a snapshot on the current version', async () => {
const beforeReload = await import('./commands');
await expect(beforeReload.getBranchTimeline('branch-1')).resolves.toEqual(fetched);
beforeReload.persistTimelineSnapshot();

expect(JSON.parse(storage[snapshotKey])).toEqual({
schemaVersion: SCHEMA_VERSION,
timelines: { 'branch-1': { timeline: fetched, fetchedAt: expect.any(Number) } },
});

cachedInvoke.mockClear();
vi.resetModules();
const afterReload = await import('./commands');

// A just-seeded entry is inside TIMELINE_FRESH_MS, so this read is served
// with no fetch behind it — the window a mismatched payload shape would
// otherwise reach the UI through uncorrected.
expect(afterReload.getBranchTimelineWithRevalidation('branch-1')).toEqual({
cached: fetched,
fresh: null,
});
expect(cachedInvoke).not.toHaveBeenCalled();
});
});
Loading