Skip to content

Commit f820318

Browse files
committed
Fix data task runtime restore and datasource readiness
1 parent d51d199 commit f820318

8 files changed

Lines changed: 280 additions & 30 deletions

File tree

apps/web/src/app/data-tasks/__tests__/conversation-restore.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { ConfigApiError } from "../../../lib/config-api/types";
1919
import {
2020
createInitialLiveRun,
21+
deriveRunUsage,
2122
formatWorkspaceMetadataSummary,
2223
reconcileLiveRunArtifacts,
2324
reduceLiveRunEvent,
@@ -1331,6 +1332,93 @@ describe("hydrateLiveRunFromConversation run ordering", () => {
13311332
});
13321333

13331334
describe("hydrateLiveRunFromConversation", () => {
1335+
it("restores canceled message-only checkpoints as canceled live runs", () => {
1336+
const dto: SessionConversationDto = {
1337+
sessionId: "thread-1",
1338+
messages: [
1339+
{
1340+
id: "run-canceled:user",
1341+
runId: "run-canceled",
1342+
role: "user",
1343+
source: "client",
1344+
messageId: "frontend-user-canceled",
1345+
contentText: "Please write a long answer.",
1346+
position: 1,
1347+
createdAt: "2026-06-25T10:00:01Z",
1348+
},
1349+
{
1350+
id: "run-canceled:assistant:partial",
1351+
runId: "run-canceled",
1352+
role: "assistant",
1353+
source: "agent",
1354+
messageId: "assistant-partial",
1355+
contentText: "Partial answer before cancellation.",
1356+
position: 2,
1357+
createdAt: "2026-06-25T10:00:02Z",
1358+
},
1359+
],
1360+
runEventRefs: [{ runId: "run-canceled", eventCount: 12, firstSeq: 1, lastSeq: 12 }],
1361+
checkpoints: [
1362+
{
1363+
runId: "run-canceled",
1364+
status: "canceled",
1365+
messageStartPosition: 1,
1366+
messageEndPosition: 2,
1367+
firstEventSeq: 1,
1368+
lastEventSeq: 12,
1369+
startedAt: "2026-06-25T10:00:00Z",
1370+
finishedAt: "2026-06-25T10:00:03Z",
1371+
errorMessage: "user-requested",
1372+
},
1373+
],
1374+
toolCalls: [],
1375+
};
1376+
1377+
const run = hydrateLiveRunFromConversation(createInitialLiveRun(), dto);
1378+
expect(run.runStatus).toBe("canceled");
1379+
expect(run.runStartedAt).toBe(Date.parse("2026-06-25T10:00:00Z"));
1380+
expect(run.runFinishedAt).toBe(Date.parse("2026-06-25T10:00:03Z"));
1381+
expect(deriveRunUsage(run).durationMs).toBe(3000);
1382+
});
1383+
1384+
it("restores canceled tool checkpoints with checkpoint timing", () => {
1385+
const dto: SessionConversationDto = {
1386+
sessionId: "thread-1",
1387+
messages: [],
1388+
runEventRefs: [{ runId: "run-canceled", eventCount: 4, firstSeq: 1, lastSeq: 4 }],
1389+
checkpoints: [
1390+
{
1391+
runId: "run-canceled",
1392+
status: "canceled",
1393+
messageStartPosition: 1,
1394+
messageEndPosition: 2,
1395+
firstEventSeq: 1,
1396+
lastEventSeq: 4,
1397+
startedAt: "2026-06-25T10:00:00Z",
1398+
finishedAt: "2026-06-25T10:00:05Z",
1399+
errorMessage: "user-requested",
1400+
},
1401+
],
1402+
toolCalls: [
1403+
{
1404+
runId: "run-canceled",
1405+
toolCallId: "sql-canceled",
1406+
status: "completed",
1407+
toolName: "run_sql_readonly",
1408+
callEventSeq: 2,
1409+
resultEventSeq: 3,
1410+
resultPreview: JSON.stringify({ row_count: 3 }),
1411+
},
1412+
],
1413+
};
1414+
1415+
const run = hydrateLiveRunFromConversation(createInitialLiveRun(), dto);
1416+
expect(run.runStatus).toBe("canceled");
1417+
expect(run.runStartedAt).toBe(Date.parse("2026-06-25T10:00:00Z"));
1418+
expect(run.runFinishedAt).toBe(Date.parse("2026-06-25T10:00:05Z"));
1419+
expect(deriveRunUsage(run).durationMs).toBe(5000);
1420+
});
1421+
13341422
it("hydrates live run tool calls and links restored artifacts", () => {
13351423
const dto: SessionConversationDto = {
13361424
sessionId: "thread-1",

apps/web/src/app/data-tasks/__tests__/per-run-mentions.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ import {
2020
type WorkspaceConfigStore,
2121
} from "../data-task-state";
2222

23-
function item(id: string, name = id) {
24-
return { id, name, description: `${name} desc`, enabled: true };
23+
function item(
24+
id: string,
25+
name = id,
26+
status: "connected" | "failed" | "untested" = "connected",
27+
) {
28+
return { id, name, description: `${name} desc`, enabled: true, status };
2529
}
2630

2731
const store: WorkspaceConfigStore = {
@@ -179,6 +183,39 @@ describe("buildRunConfig", () => {
179183
expect(config.activeDatasourceId).toBe("db-orders");
180184
});
181185

186+
it("excludes unusable datasources from run config and active fallback", () => {
187+
const mixedStore: WorkspaceConfigStore = {
188+
...store,
189+
db: [
190+
item("db-failed", "Failed db", "failed"),
191+
item("db-default"),
192+
item("db-untested", "Untested db", "untested"),
193+
],
194+
};
195+
const selection = togglePerRunMention(
196+
emptyPerRunSelection(),
197+
"db",
198+
"db-failed",
199+
);
200+
const config = buildRunConfig(mixedStore, {
201+
activeLlmId: "llm-1",
202+
defaultDatasourceId: "db-failed",
203+
session,
204+
perRunSelection: selection,
205+
});
206+
expect(config.enabledDatasourceIds).toEqual(["db-default"]);
207+
expect(config.activeDatasourceId).toBe("db-default");
208+
expect(config.mentioned.db).toEqual([]);
209+
expect(
210+
resolveActiveDatasourceId(
211+
mixedStore,
212+
session,
213+
selection,
214+
"db-failed",
215+
),
216+
).toBe("db-default");
217+
});
218+
182219
it("splits file mentions into workspace fileIds and session pinnedPaths", () => {
183220
const files: FileMentionResource[] = [
184221
{

apps/web/src/app/data-tasks/__tests__/session-pane-ui.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
import type { WorkspaceConfigStore } from "../data-task-state";
1212
import { LEFT_PANEL_MAX_WIDTH } from "../workspace-layout";
1313

14-
function item(id: string, name = id) {
15-
return { id, name, description: `${name} desc`, enabled: true };
14+
function item(id: string, name = id, status: "connected" | "failed" | "untested" = "connected") {
15+
return { id, name, description: `${name} desc`, enabled: true, status };
1616
}
1717

1818
const workspaceConfig: WorkspaceConfigStore = {
@@ -127,6 +127,32 @@ describe("session pane ui conventions", () => {
127127
});
128128
});
129129

130+
it("counts only runnable data sources in the workspace rail", () => {
131+
const groups = getWorkspaceResourceNavGroups({
132+
workspaceConfig: {
133+
...workspaceConfig,
134+
db: [
135+
item("sqlite-orders", "Orders SQLite", "connected"),
136+
item("api-duckdb-demo", "API DuckDB Demo", "failed"),
137+
item("warehouse", "Warehouse", "untested"),
138+
],
139+
},
140+
workspaceFileCount: 0,
141+
activeConfigPanel: null,
142+
activeFilesPanel: false,
143+
capabilitiesReady: true,
144+
supportsFiles: true,
145+
supportsKnowledge: true,
146+
supportsMcp: true,
147+
supportsSkills: true,
148+
});
149+
150+
expect(groups[0]).toMatchObject({
151+
title: "Data Sources",
152+
summary: "1",
153+
});
154+
});
155+
130156
it("keeps sidebar status minimal for static configuration navigation", () => {
131157
const groups = getWorkspaceResourceNavGroups({
132158
workspaceConfig: { ...workspaceConfig, db: [] },

apps/web/src/app/data-tasks/conversation-restore.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,14 @@ function checkpointByRunId(
613613
return map;
614614
}
615615

616+
function timestampFromIso(value: string | undefined): number | undefined {
617+
if (!value) {
618+
return undefined;
619+
}
620+
const parsed = Date.parse(value);
621+
return Number.isFinite(parsed) ? parsed : undefined;
622+
}
623+
616624
function runStatusFromCheckpoint(
617625
checkpoint: ConversationCheckpointDto | undefined,
618626
): LiveRun["runStatus"] | undefined {
@@ -1273,11 +1281,25 @@ function applyConversationToolCall(state: LiveRun, toolCall: ConversationToolCal
12731281
function finalizeHydratedRunSegment(
12741282
state: LiveRun,
12751283
tools: ConversationToolCallDto[],
1284+
checkpoint: ConversationCheckpointDto | undefined,
12761285
): LiveRun {
1286+
const checkpointStatus = runStatusFromCheckpoint(checkpoint);
1287+
if (checkpointStatus === "running" || checkpointStatus === "queued") {
1288+
return state;
1289+
}
1290+
if (checkpointStatus === "failed") {
1291+
return reduceLiveRunEvent(state, {
1292+
type: "RUN_ERROR",
1293+
message: checkpoint?.errorMessage?.trim() || ORPHANED_RUN_ASSISTANT_PLACEHOLDER,
1294+
});
1295+
}
1296+
if (checkpointStatus === "canceled") {
1297+
return reduceLiveRunEvent(state, { type: "RUN_FINISHED", status: "canceled" });
1298+
}
12771299
if (tools.length === 0) {
12781300
return state;
12791301
}
1280-
if (isPendingCollaborationRunEnd(tools)) {
1302+
if (checkpointStatus === "suspended" || isPendingCollaborationRunEnd(tools)) {
12811303
let next = reduceLiveRunEvent(state, {
12821304
type: "STATE_DELTA",
12831305
delta: [{ op: "replace", path: "/runStatus", value: "suspended" }],
@@ -1291,9 +1313,9 @@ function finalizeHydratedRunSegment(
12911313
function finalizeMessageOnlyHydratedRunSegment(
12921314
state: LiveRun,
12931315
segment: RestorableRunSegment,
1316+
checkpoint: ConversationCheckpointDto | undefined,
12941317
dto: SessionConversationDto,
12951318
): LiveRun {
1296-
const checkpoint = checkpointByRunId(dto).get(segment.runId);
12971319
const checkpointStatus = runStatusFromCheckpoint(checkpoint);
12981320
if (checkpointStatus === "running" || checkpointStatus === "queued") {
12991321
return state;
@@ -1305,7 +1327,7 @@ function finalizeMessageOnlyHydratedRunSegment(
13051327
});
13061328
}
13071329
if (checkpointStatus === "canceled") {
1308-
return reduceLiveRunEvent(state, { type: "RUN_FINISHED" });
1330+
return reduceLiveRunEvent(state, { type: "RUN_FINISHED", status: "canceled" });
13091331
}
13101332
if (segment.hasUser && !segment.hasAssistant) {
13111333
return reduceLiveRunEvent(state, {
@@ -1316,6 +1338,22 @@ function finalizeMessageOnlyHydratedRunSegment(
13161338
return reduceLiveRunEvent(state, { type: "RUN_FINISHED" });
13171339
}
13181340

1341+
function applyHydratedRunCheckpointTiming(
1342+
state: LiveRun,
1343+
checkpoint: ConversationCheckpointDto | undefined,
1344+
): LiveRun {
1345+
const startedAt = timestampFromIso(checkpoint?.startedAt);
1346+
const finishedAt = timestampFromIso(checkpoint?.finishedAt);
1347+
if (startedAt === undefined && finishedAt === undefined) {
1348+
return state;
1349+
}
1350+
return {
1351+
...state,
1352+
...(startedAt !== undefined ? { runStartedAt: startedAt } : {}),
1353+
...(finishedAt !== undefined ? { runFinishedAt: finishedAt } : {}),
1354+
};
1355+
}
1356+
13191357
function startNextHydratedRunGroup(state: LiveRun, runId?: string): LiveRun {
13201358
if (state.runStatus === "suspended") {
13211359
const archived: LiveRun = {
@@ -1470,9 +1508,11 @@ export function hydrateLiveRunFromConversation(
14701508
}
14711509

14721510
const runSegments = collectRestorableRunSegments(dto);
1511+
const checkpoints = checkpointByRunId(dto);
14731512

14741513
let next = createInitialLiveRun();
14751514
for (const [index, segment] of runSegments.entries()) {
1515+
const checkpoint = checkpoints.get(segment.runId);
14761516
next =
14771517
index === 0
14781518
? reduceLiveRunEvent(next, { type: "RUN_STARTED", runId: segment.runId })
@@ -1486,8 +1526,9 @@ export function hydrateLiveRunFromConversation(
14861526

14871527
next =
14881528
segment.tools.length > 0
1489-
? finalizeHydratedRunSegment(next, segment.tools)
1490-
: finalizeMessageOnlyHydratedRunSegment(next, segment, dto);
1529+
? finalizeHydratedRunSegment(next, segment.tools, checkpoint)
1530+
: finalizeMessageOnlyHydratedRunSegment(next, segment, checkpoint, dto);
1531+
next = applyHydratedRunCheckpointTiming(next, checkpoint);
14911532
}
14921533

14931534
next = reconcileLiveRunArtifacts(mergePreservedLiveRunSessionData(state, next));

0 commit comments

Comments
 (0)