Stale reload and focus responses overwrite newer state
This commit is contained in:
@@ -4,8 +4,6 @@
|
|||||||
`CheckResponse` reads open state outside the write transaction and `Respond` updates by id alone (`internal/agents/respond.go:26–34`, `:93`; `internal/store/inbox.go:140`), so two concurrent answers both land, or a withdrawal is overwritten. Condition the update on `state='open'`, check RowsAffected, roll back the batch on conflict and return 409.
|
`CheckResponse` reads open state outside the write transaction and `Respond` updates by id alone (`internal/agents/respond.go:26–34`, `:93`; `internal/store/inbox.go:140`), so two concurrent answers both land, or a withdrawal is overwritten. Condition the update on `state='open'`, check RowsAffected, roll back the batch on conflict and return 409.
|
||||||
- A malformed file image URL breaks transcript rendering
|
- A malformed file image URL breaks transcript rendering
|
||||||
`decodeURI` throws on e.g. `file:///tmp/100%.png` (`web/src/markdown.ts:347`), and `renderAll` appends events before mounting the composer (`web/src/task.ts:608–609`), so the rest of the transcript and the composer don't render. Catch the decode error and render the plain fallback link.
|
`decodeURI` throws on e.g. `file:///tmp/100%.png` (`web/src/markdown.ts:347`), and `renderAll` appends events before mounting the composer (`web/src/task.ts:608–609`), so the rest of the transcript and the composer don't render. Catch the decode error and render the plain fallback link.
|
||||||
- Stale reload and focus responses overwrite newer state
|
|
||||||
`loadTasks`, `loadSessions`, `loadInbox` and `setFocus` apply every response, even a late one (`web/src/state.ts:158–179`, `:203–215`), so clicking through tabs quickly can snap the pane back. Keep a request sequence per resource key and for focus, and apply only the latest response.
|
|
||||||
- operations.md describes two incompatible event-stream designs
|
- operations.md describes two incompatible event-stream designs
|
||||||
`docs/operations.md:320–324` says selecting tasks opens dedicated SSE streams, which is stale against `web/src/state.ts:309–340`, `:401–414`, and says only `agent.delta` is transient though `admin.health` is too. Rewrite the Events section to match the current two-stream feed design, and qualify the transaction/event guarantee at `docs/operations.md:7` for internal bookkeeping writes.
|
`docs/operations.md:320–324` says selecting tasks opens dedicated SSE streams, which is stale against `web/src/state.ts:309–340`, `:401–414`, and says only `agent.delta` is transient though `admin.health` is too. Rewrite the Events section to match the current two-stream feed design, and qualify the transaction/event guarantee at `docs/operations.md:7` for internal bookkeeping writes.
|
||||||
|
|
||||||
|
|||||||
Vendored
+35
-4
@@ -112,8 +112,18 @@ export async function loadProjects() {
|
|||||||
await Promise.all(state.projects.map((p) => loadTasks(p.id)));
|
await Promise.all(state.projects.map((p) => loadTasks(p.id)));
|
||||||
notify("projects");
|
notify("projects");
|
||||||
}
|
}
|
||||||
|
const requestSeqs = new Map();
|
||||||
|
function claimRequest(key) {
|
||||||
|
const seq = (requestSeqs.get(key) ?? 0) + 1;
|
||||||
|
requestSeqs.set(key, seq);
|
||||||
|
return () => requestSeqs.get(key) === seq;
|
||||||
|
}
|
||||||
export async function loadTasks(projectId) {
|
export async function loadTasks(projectId) {
|
||||||
|
const latest = claimRequest(`tasks:${projectId}`);
|
||||||
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.tasks.set(projectId, tasks);
|
state.tasks.set(projectId, tasks);
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.status === "active") {
|
if (t.status === "active") {
|
||||||
@@ -125,11 +135,21 @@ export async function loadTasks(projectId) {
|
|||||||
notify("tasks");
|
notify("tasks");
|
||||||
}
|
}
|
||||||
export async function loadSessions(taskId) {
|
export async function loadSessions(taskId) {
|
||||||
state.sessions.set(taskId, await get("/api/tasks/{taskId}/sessions", { path: { taskId } }));
|
const latest = claimRequest(`sessions:${taskId}`);
|
||||||
|
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.sessions.set(taskId, sessions);
|
||||||
notify("sessions", taskId);
|
notify("sessions", taskId);
|
||||||
}
|
}
|
||||||
export async function loadInbox() {
|
export async function loadInbox() {
|
||||||
state.inbox = await get("/api/inbox");
|
const latest = claimRequest("inbox");
|
||||||
|
const inbox = await get("/api/inbox");
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.inbox = inbox;
|
||||||
notify("inbox");
|
notify("inbox");
|
||||||
}
|
}
|
||||||
export async function loadLeadSessions() {
|
export async function loadLeadSessions() {
|
||||||
@@ -137,7 +157,12 @@ export async function loadLeadSessions() {
|
|||||||
notify("lead.sessions");
|
notify("lead.sessions");
|
||||||
}
|
}
|
||||||
export async function loadFocus() {
|
export async function loadFocus() {
|
||||||
applyFocus(await get("/api/me/focus"));
|
const latest = claimRequest("focus");
|
||||||
|
const focus = await get("/api/me/focus");
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFocus(focus);
|
||||||
}
|
}
|
||||||
export function applyFocus(f) {
|
export function applyFocus(f) {
|
||||||
state.focus = f;
|
state.focus = f;
|
||||||
@@ -160,8 +185,13 @@ export async function setFocus(change) {
|
|||||||
if (projectId) {
|
if (projectId) {
|
||||||
next.project_id = projectId;
|
next.project_id = projectId;
|
||||||
}
|
}
|
||||||
|
const latest = claimRequest("focus");
|
||||||
applyFocus(next);
|
applyFocus(next);
|
||||||
applyFocus(await put("/api/me/focus", { body: next }));
|
const focus = await put("/api/me/focus", { body: next });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFocus(focus);
|
||||||
}
|
}
|
||||||
export function focusTask(taskId, tab = "transcript") {
|
export function focusTask(taskId, tab = "transcript") {
|
||||||
setFocus({ task_id: taskId, tab }).catch((err) => console.error("set focus", err));
|
setFocus({ task_id: taskId, tab }).catch((err) => console.error("set focus", err));
|
||||||
@@ -403,6 +433,7 @@ export function handleEvent(ev) {
|
|||||||
break;
|
break;
|
||||||
case "focus.updated":
|
case "focus.updated":
|
||||||
if (ev.actor !== state.me?.login) {
|
if (ev.actor !== state.me?.login) {
|
||||||
|
claimRequest("focus");
|
||||||
applyFocus(ev.payload);
|
applyFocus(ev.payload);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+37
-4
@@ -155,8 +155,20 @@ export async function loadProjects(): Promise<void> {
|
|||||||
notify("projects");
|
notify("projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSeqs = new Map<string, number>();
|
||||||
|
|
||||||
|
function claimRequest(key: string): () => boolean {
|
||||||
|
const seq = (requestSeqs.get(key) ?? 0) + 1;
|
||||||
|
requestSeqs.set(key, seq);
|
||||||
|
return () => requestSeqs.get(key) === seq;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTasks(projectId: string): Promise<void> {
|
export async function loadTasks(projectId: string): Promise<void> {
|
||||||
|
const latest = claimRequest(`tasks:${projectId}`);
|
||||||
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.tasks.set(projectId, tasks);
|
state.tasks.set(projectId, tasks);
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.status === "active") {
|
if (t.status === "active") {
|
||||||
@@ -169,12 +181,22 @@ export async function loadTasks(projectId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSessions(taskId: string): Promise<void> {
|
export async function loadSessions(taskId: string): Promise<void> {
|
||||||
state.sessions.set(taskId, await get("/api/tasks/{taskId}/sessions", { path: { taskId } }));
|
const latest = claimRequest(`sessions:${taskId}`);
|
||||||
|
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.sessions.set(taskId, sessions);
|
||||||
notify("sessions", taskId);
|
notify("sessions", taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadInbox(): Promise<void> {
|
export async function loadInbox(): Promise<void> {
|
||||||
state.inbox = await get("/api/inbox");
|
const latest = claimRequest("inbox");
|
||||||
|
const inbox = await get("/api/inbox");
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.inbox = inbox;
|
||||||
notify("inbox");
|
notify("inbox");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +206,12 @@ export async function loadLeadSessions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFocus(): Promise<void> {
|
export async function loadFocus(): Promise<void> {
|
||||||
applyFocus(await get("/api/me/focus"));
|
const latest = claimRequest("focus");
|
||||||
|
const focus = await get("/api/me/focus");
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFocus(focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFocus(f: Focus): void {
|
export function applyFocus(f: Focus): void {
|
||||||
@@ -210,8 +237,13 @@ export async function setFocus(change: { task_id?: string | null; project_id?: s
|
|||||||
if (projectId) {
|
if (projectId) {
|
||||||
next.project_id = projectId;
|
next.project_id = projectId;
|
||||||
}
|
}
|
||||||
|
const latest = claimRequest("focus");
|
||||||
applyFocus(next);
|
applyFocus(next);
|
||||||
applyFocus(await put("/api/me/focus", { body: next }));
|
const focus = await put("/api/me/focus", { body: next });
|
||||||
|
if (!latest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFocus(focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusTask(taskId: string, tab: TaskTab = "transcript"): void {
|
export function focusTask(taskId: string, tab: TaskTab = "transcript"): void {
|
||||||
@@ -477,6 +509,7 @@ export function handleEvent(ev: ByreEvent): void {
|
|||||||
break;
|
break;
|
||||||
case "focus.updated":
|
case "focus.updated":
|
||||||
if (ev.actor !== state.me?.login) {
|
if (ev.actor !== state.me?.login) {
|
||||||
|
claimRequest("focus");
|
||||||
applyFocus(ev.payload as unknown as Focus);
|
applyFocus(ev.payload as unknown as Focus);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user