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
31 changes: 23 additions & 8 deletions src-tauri/crates/project-management/src/orchestrator/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ use super::state_machine;
use crate::projects::io::orchestrator_view;
use core_types::session::PENDING_SESSION_PLACEHOLDER;

fn emit_data_changed(app: &tauri::AppHandle) {
let ts = chrono::Utc::now().to_rfc3339();
let _ = app.emit(DATA_CHANGED_EVENT, &ts);
fn emit_data_changed(app: &tauri::AppHandle, project_slug: &str, work_item_id: &str) {
let _ = app.emit(
DATA_CHANGED_EVENT,
serde_json::json!({
"project_slug": project_slug,
"work_item_id": work_item_id,
"source": "orchestrator",
}),
);
}

#[derive(Debug, Clone, Serialize)]
Expand All @@ -39,6 +45,7 @@ pub async fn orchestrator_start(
work_item_id: String,
app: tauri::AppHandle,
) -> Result<String, String> {
let event_project_slug = project_slug.clone();
let result = tokio::task::spawn_blocking(move || {
projects_io::update_work_item_atomic(
&project_slug,
Expand Down Expand Up @@ -87,7 +94,7 @@ pub async fn orchestrator_start(
.await
.map_err(|err| err.to_string())??;

emit_data_changed(&app);
emit_data_changed(&app, &event_project_slug, &result);
Ok(result)
}

Expand All @@ -98,6 +105,8 @@ pub async fn orchestrator_cancel(
work_item_id: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let event_project_slug = project_slug.clone();
let event_work_item_id = work_item_id.clone();
tokio::task::spawn_blocking(move || -> Result<(), String> {
state_machine::mutate_work_item(&project_slug, &work_item_id, |frontmatter| {
state_machine::cancel(frontmatter);
Expand All @@ -108,7 +117,7 @@ pub async fn orchestrator_cancel(
.await
.map_err(|err| err.to_string())??;

emit_data_changed(&app);
emit_data_changed(&app, &event_project_slug, &event_work_item_id);
Ok(())
}

Expand All @@ -119,6 +128,8 @@ pub async fn orchestrator_retry(
work_item_id: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let event_project_slug = project_slug.clone();
let event_work_item_id = work_item_id.clone();
tokio::task::spawn_blocking(move || {
let state = orchestrator_view::read_orchestrator_state(&project_slug, &work_item_id)?
.ok_or("No orchestrator state")?;
Expand Down Expand Up @@ -163,7 +174,7 @@ pub async fn orchestrator_retry(
.await
.map_err(|err| err.to_string())??;

emit_data_changed(&app);
emit_data_changed(&app, &event_project_slug, &event_work_item_id);
Ok(())
}

Expand Down Expand Up @@ -226,13 +237,15 @@ pub async fn orchestrator_create_follow_up(
review_feedback: String,
app: tauri::AppHandle,
) -> Result<String, String> {
let event_project_slug = project_slug.clone();
let event_parent_short_id = parent_short_id.clone();
let result = tokio::task::spawn_blocking(move || {
super::follow_up::create_follow_up(&project_slug, &parent_short_id, &review_feedback)
})
.await
.map_err(|err| err.to_string())??;

emit_data_changed(&app);
emit_data_changed(&app, &event_project_slug, &event_parent_short_id);
Ok(result)
}

Expand Down Expand Up @@ -273,6 +286,8 @@ pub async fn orchestrator_set_pr(
pr_status: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let event_project_slug = project_slug.clone();
let event_work_item_id = work_item_id.clone();
tokio::task::spawn_blocking(move || {
let status = match pr_status.as_str() {
"open" => PrStatus::Open,
Expand All @@ -291,6 +306,6 @@ pub async fn orchestrator_set_pr(
.await
.map_err(|err| err.to_string())??;

emit_data_changed(&app);
emit_data_changed(&app, &event_project_slug, &event_work_item_id);
Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ pub async fn project_read_work_item(
.map_err(|err| format!("Task join error: {}", err))?
}

/// Read one work item with its labels, members, project, and milestone already
/// resolved. Detail surfaces should prefer this over reading the entire project
/// collection and filtering it in JavaScript.
#[tauri::command]
pub async fn project_read_work_item_enriched(
project_slug: String,
short_id: String,
org_id: Option<String>,
) -> Result<EnrichedWorkItem, String> {
tokio::task::spawn_blocking(move || {
io::read_work_item_enriched_scoped(&project_slug, &short_id, org_id.as_deref())
})
.await
.map_err(|err| format!("Task join error: {}", err))?
}

#[tauri::command]
pub async fn work_item_read_standalone_items(
org_id: Option<String>,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/handler_list.inc
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ project_management::projects::commands::project_read_work_items_enriched,
project_management::projects::commands::project_read_workspace_work_items_data,
project_management::projects::commands::project_read_work_items_view_data,
project_management::projects::commands::project_read_work_item,
project_management::projects::commands::project_read_work_item_enriched,
project_management::projects::commands::work_item_read_standalone_items,
project_management::projects::commands::work_item_read_standalone_item,
project_management::projects::commands::project_write_work_item,
Expand Down
35 changes: 35 additions & 0 deletions src/api/http/project/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,39 @@ describe("project read cache invalidation fencing", () => {
);
expect(fetcher).toHaveBeenCalledTimes(2);
});

it("does not restart an unrelated project read after scoped invalidation", async () => {
let resolveStaleAlpha: ((value: string) => void) | undefined;
let resolveBeta: ((value: string) => void) | undefined;
const alphaFetcher = vi
.fn<() => Promise<string>>()
.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveStaleAlpha = resolve;
})
)
.mockResolvedValue("alpha-fresh");
const betaFetcher = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveBeta = resolve;
})
);

const alphaBeforeMutation = cachedRead("alpha:workitems", alphaFetcher);
const betaBeforeMutation = cachedRead("beta:workitems", betaFetcher);
await Promise.resolve();
invalidateCache("alpha");
const alphaAfterMutation = cachedRead("alpha:workitems", alphaFetcher);

resolveBeta?.("beta-current");
await expect(betaBeforeMutation).resolves.toBe("beta-current");
expect(betaFetcher).toHaveBeenCalledTimes(1);

await expect(alphaAfterMutation).resolves.toBe("alpha-fresh");
resolveStaleAlpha?.("alpha-stale");
await expect(alphaBeforeMutation).resolves.toBe("alpha-fresh");
expect(alphaFetcher).toHaveBeenCalledTimes(2);
});
});
64 changes: 56 additions & 8 deletions src/api/http/project/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
* - Entries expire after `CACHE_TTL_MS` (2 seconds).
* - In-flight promises are shared (request deduplication).
* - `invalidateCache(slug)` drops every key starting with `${slug}:`;
* `invalidateCache()` flushes the whole cache (used by the global
* `orgii-data-changed` listener since the event doesn't carry a slug).
* `invalidateCache()` flushes the whole cache for legacy/unscoped
* `orgii-data-changed` events.
* - Max 50 entries with FIFO eviction.
*/

Expand All @@ -32,7 +32,31 @@ const inflight = new Map<string, Promise<unknown>>();
* still resolve later, repopulate the cache with its pre-mutation snapshot,
* and hand that stale snapshot to an open Work Item detail.
*/
let invalidationGeneration = 0;
let globalInvalidationGeneration = 0;
const scopedInvalidationGenerations = new Map<string, number>();
const activeReadsByScope = new Map<string, number>();

function cacheScope(cacheKey: string): string {
const separatorIndex = cacheKey.indexOf(":");
return separatorIndex >= 0 ? cacheKey.slice(0, separatorIndex) : cacheKey;
}

function beginScopedRead(scope: string): void {
activeReadsByScope.set(scope, (activeReadsByScope.get(scope) ?? 0) + 1);
}

function endScopedRead(scope: string): void {
const remaining = (activeReadsByScope.get(scope) ?? 1) - 1;
if (remaining > 0) {
activeReadsByScope.set(scope, remaining);
return;
}
activeReadsByScope.delete(scope);
// A scoped generation only fences requests that crossed its mutation.
// Once all requests in that scope have settled, retaining it has no value
// and would turn project slugs into an unbounded registry.
scopedInvalidationGenerations.delete(scope);
}

function evictIfNeeded(): void {
if (cache.size < MAX_ENTRIES) return;
Expand All @@ -55,10 +79,24 @@ export async function cachedRead<T>(
return pending as Promise<T>;
}

const requestGeneration = invalidationGeneration;
const promise = fetcher()
const scope = cacheScope(cacheKey);
const requestGlobalGeneration = globalInvalidationGeneration;
const requestScopedGeneration = scopedInvalidationGenerations.get(scope) ?? 0;
beginScopedRead(scope);
let fetchPromise: Promise<T>;
try {
fetchPromise = fetcher();
} catch (error) {
endScopedRead(scope);
throw error;
}
const promise = fetchPromise
.then(async (result): Promise<T> => {
if (requestGeneration !== invalidationGeneration) {
if (
requestGlobalGeneration !== globalInvalidationGeneration ||
requestScopedGeneration !==
(scopedInvalidationGenerations.get(scope) ?? 0)
) {
// This request crossed a mutation boundary. Never expose/cache its
// stale snapshot; converge the original waiter onto the post-change
// read (or its already-running shared Promise) instead.
Expand All @@ -75,6 +113,9 @@ export async function cachedRead<T>(
// under the same key after invalidation.
if (inflight.get(cacheKey) === promise) inflight.delete(cacheKey);
throw err;
})
.finally(() => {
endScopedRead(scope);
});

inflight.set(cacheKey, promise);
Expand All @@ -83,15 +124,22 @@ export async function cachedRead<T>(

/**
* Drop cached entries scoped to `slug`. Pass no argument to flush the
* whole cache (used by the project-data-changed event listener).
* whole cache when a project-data-changed event has no safe scope.
*/
export function invalidateCache(slug?: string): void {
invalidationGeneration += 1;
if (!slug) {
globalInvalidationGeneration += 1;
scopedInvalidationGenerations.clear();
cache.clear();
inflight.clear();
return;
}
if ((activeReadsByScope.get(slug) ?? 0) > 0) {
scopedInvalidationGenerations.set(
slug,
(scopedInvalidationGenerations.get(slug) ?? 0) + 1
);
}
const prefix = `${slug}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
Expand Down
17 changes: 17 additions & 0 deletions src/api/http/project/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,23 @@ export async function readWorkItem(
});
}

export async function readWorkItemEnriched(
projectSlug: string,
shortId: string,
options?: ProjectScopeOptions
): Promise<EnrichedWorkItem> {
const scopeSegment = scopeCacheSegment(options);
return cachedRead(
`${projectSlug}:workitem-enriched:${shortId}:${scopeSegment}`,
() =>
invoke<EnrichedWorkItem>("project_read_work_item_enriched", {
projectSlug,
shortId,
...scopeInvokePayload(options),
})
);
}

export async function readStandaloneWorkItems(
options?: WorkItemsReadOptions
): Promise<WorkItemData[]> {
Expand Down
1 change: 1 addition & 0 deletions src/api/http/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const projectApi = {
writeMembers: client.writeMembers,
// Work items
readWorkItem: client.readWorkItem,
readWorkItemEnriched: client.readWorkItemEnriched,
readStandaloneWorkItem: client.readStandaloneWorkItem,
readStandaloneWorkItems: client.readStandaloneWorkItems,
readWorkItems: client.readWorkItems,
Expand Down
11 changes: 8 additions & 3 deletions src/engines/ChatPanel/panels/ProjectPanelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,14 @@ export const ProjectPanelView: React.FC<ProjectPanelViewProps> = ({
}, [loadProjectWorkItems]);

useProjectDataChanged(
useCallback(() => {
void loadProjectWorkItems();
}, [loadProjectWorkItems])
useCallback(
(change) => {
if (!change?.projectSlug || change.projectSlug === projectSlug) {
void loadProjectWorkItems();
}
},
[loadProjectWorkItems, projectSlug]
)
);

useEffect(() => {
Expand Down
Loading
Loading