@tea-agent/loop-agent 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/executors/model-routing.js +14 -4
- package/dist/governance/manifest-types.js +34 -7
- package/dist/worker/console/chat/model-resolver.js +114 -34
- package/dist/worker/console/chat/workspace-landing.js +58 -22
- package/dist/worker/console/doctor.js +1 -0
- package/dist/worker/console/night-aux-ticker.js +5 -0
- package/dist/worker/console/pi-readiness.js +26 -17
- package/dist/worker/console/server.js +2 -0
- package/dist/worker/console/static/assets/index-3R-GT3a_.js +29 -0
- package/dist/worker/console/static/assets/index-B0EQt_yq.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +140 -0
- package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
- package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
- package/dist/worker/console/static-src/chat-view-types.js +2 -0
- package/dist/worker/console/static-src/night/night-types.js +24 -0
- package/dist/worker/console/static-src/night/useNightBoard.js +94 -0
- package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
- package/dist/worker/console/static-src/operator-chat/format.js +71 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
- package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
- package/dist/worker/console/static-src/operator-chat/types.js +1 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
- package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
- package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
- package/dist/worker/observability/read-model.js +106 -0
- package/dist/worker/observe/routes.js +19 -1
- package/dist/worker/observe/static/api.js +42 -3
- package/dist/worker/observe/static/app.js +4 -0
- package/dist/worker/observe/static/constants.js +10 -0
- package/dist/worker/observe/static/custom-select.js +567 -0
- package/dist/worker/observe/static/index.html +47 -6
- package/dist/worker/observe/static/router.js +54 -1
- package/dist/worker/observe/static/state.js +20 -0
- package/dist/worker/observe/static/styles.css +618 -30
- package/dist/worker/observe/static/views/dag-inspector.js +20 -17
- package/dist/worker/observe/static/views/dag.js +136 -59
- package/dist/worker/observe/static/views/dags.js +877 -0
- package/dist/worker/observe/static/views/dashboard.js +15 -4
- package/dist/workflows/dag/init-hybrid.js +58 -29
- package/docs/templates/agent-worker-production-readiness-checklist.md +26 -24
- package/docs/templates/harness.schema.json +5 -0
- package/harness.json +4 -4
- package/package.json +5 -4
- package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
- package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { formatModelRef, mergeSessionRuntime, runtimeSelectionFromRecord, sessionRuntimeMatches, } from "../../chat/runtime-selection.js";
|
|
3
|
+
import { confirmationToken } from "./format.js";
|
|
4
|
+
import { buildToolCatalog } from "./tools-catalog.js";
|
|
5
|
+
/** Capabilities catalog, model / thinking-level switcher, runtime-context
|
|
6
|
+
* popover and the outside-click dismissal for both transient menus. */
|
|
7
|
+
export function useRuntimeControls(params) {
|
|
8
|
+
const { origin, session, setSession, refs, setError } = params;
|
|
9
|
+
const [capabilities, setCapabilities] = useState(null);
|
|
10
|
+
const [models, setModels] = useState([]);
|
|
11
|
+
const [thinkingLevels, setThinkingLevels] = useState([]);
|
|
12
|
+
const [runtimeContext, setRuntimeContext] = useState(null);
|
|
13
|
+
const [toolsOpen, setToolsOpen] = useState(false);
|
|
14
|
+
const [runtimeContextOpen, setRuntimeContextOpen] = useState(false);
|
|
15
|
+
const toolsMenuRef = useRef(null);
|
|
16
|
+
const runtimeContextMenuRef = useRef(null);
|
|
17
|
+
const loadCapabilities = useCallback(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/capabilities`);
|
|
20
|
+
if (!res.ok)
|
|
21
|
+
return;
|
|
22
|
+
const body = (await res.json());
|
|
23
|
+
setCapabilities(body);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// capabilities optional; Chat still works without them
|
|
27
|
+
}
|
|
28
|
+
}, [origin]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
void loadCapabilities();
|
|
31
|
+
}, [loadCapabilities]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!session) {
|
|
34
|
+
setModels([]);
|
|
35
|
+
setRuntimeContext(null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const sessionId = session.sessionId;
|
|
39
|
+
const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
|
|
40
|
+
void fetch(`${base}/models`, { credentials: "include" })
|
|
41
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
42
|
+
.then((body) => {
|
|
43
|
+
// Guard against a stale response landing after the user switched/
|
|
44
|
+
// created/forked a session: only adopt models for the session we
|
|
45
|
+
// actually requested them for.
|
|
46
|
+
if (!body || refs.sessionRef.current?.sessionId !== sessionId)
|
|
47
|
+
return;
|
|
48
|
+
setModels(body.models ?? []);
|
|
49
|
+
setThinkingLevels(body.thinkingLevels ?? []);
|
|
50
|
+
});
|
|
51
|
+
void fetch(`${base}/runtime-context`, { credentials: "include" })
|
|
52
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
53
|
+
.then((body) => {
|
|
54
|
+
// Stale-response guard: a slow runtime-context reply for session A
|
|
55
|
+
// must not overwrite the context/session state of the now-current
|
|
56
|
+
// session B.
|
|
57
|
+
if (!body?.context || refs.sessionRef.current?.sessionId !== sessionId)
|
|
58
|
+
return;
|
|
59
|
+
setRuntimeContext(body.context);
|
|
60
|
+
const ctx = body.context;
|
|
61
|
+
const patch = {
|
|
62
|
+
model: ctx.model,
|
|
63
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
64
|
+
};
|
|
65
|
+
setSession((current) => {
|
|
66
|
+
if (!current || current.sessionId !== sessionId)
|
|
67
|
+
return current;
|
|
68
|
+
if (sessionRuntimeMatches(current, patch))
|
|
69
|
+
return current;
|
|
70
|
+
const merged = mergeSessionRuntime(current, patch);
|
|
71
|
+
refs.sessionRef.current = merged;
|
|
72
|
+
return merged;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}, [origin, session?.sessionId, refs, setSession]);
|
|
76
|
+
const switchRuntime = useCallback(async (body) => {
|
|
77
|
+
if (!session)
|
|
78
|
+
return;
|
|
79
|
+
// Capture the target session so a slow model/thinking response cannot
|
|
80
|
+
// write its result into a different (newly created/forked/switched)
|
|
81
|
+
// session the user has since moved to.
|
|
82
|
+
const targetSessionId = session.sessionId;
|
|
83
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
credentials: "include",
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json",
|
|
88
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
// Only surface the failure for the session that still owns it.
|
|
94
|
+
if (refs.sessionRef.current?.sessionId === targetSessionId)
|
|
95
|
+
setError("模型或思考等级切换失败");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const payload = (await res.json().catch(() => ({})));
|
|
99
|
+
if (!payload.session)
|
|
100
|
+
return;
|
|
101
|
+
// Drop the result entirely if the active session changed mid-flight.
|
|
102
|
+
if (refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
103
|
+
return;
|
|
104
|
+
setSession((current) => {
|
|
105
|
+
if (!current || current.sessionId !== targetSessionId)
|
|
106
|
+
return current;
|
|
107
|
+
const patch = runtimeSelectionFromRecord(payload.session);
|
|
108
|
+
if (sessionRuntimeMatches(current, patch))
|
|
109
|
+
return current;
|
|
110
|
+
const merged = mergeSessionRuntime(current, patch);
|
|
111
|
+
refs.sessionRef.current = merged;
|
|
112
|
+
return merged;
|
|
113
|
+
});
|
|
114
|
+
setRuntimeContext((current) => current
|
|
115
|
+
? {
|
|
116
|
+
...current,
|
|
117
|
+
...(payload.session.modelProvider && payload.session.modelId
|
|
118
|
+
? {
|
|
119
|
+
model: {
|
|
120
|
+
provider: payload.session.modelProvider,
|
|
121
|
+
modelId: payload.session.modelId,
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
...(payload.session.thinkingLevel
|
|
126
|
+
? { thinkingLevel: payload.session.thinkingLevel }
|
|
127
|
+
: body.thinkingLevel
|
|
128
|
+
? { thinkingLevel: body.thinkingLevel }
|
|
129
|
+
: {}),
|
|
130
|
+
}
|
|
131
|
+
: current);
|
|
132
|
+
}, [origin, session, refs, setSession, setError]);
|
|
133
|
+
// Close tools catalog on outside click / Escape.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!toolsOpen)
|
|
136
|
+
return;
|
|
137
|
+
const onPointer = (e) => {
|
|
138
|
+
const root = toolsMenuRef.current;
|
|
139
|
+
if (!root)
|
|
140
|
+
return;
|
|
141
|
+
if (e.target instanceof Node && !root.contains(e.target)) {
|
|
142
|
+
setToolsOpen(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const onKey = (e) => {
|
|
146
|
+
if (e.key === "Escape")
|
|
147
|
+
setToolsOpen(false);
|
|
148
|
+
};
|
|
149
|
+
document.addEventListener("mousedown", onPointer);
|
|
150
|
+
document.addEventListener("keydown", onKey);
|
|
151
|
+
return () => {
|
|
152
|
+
document.removeEventListener("mousedown", onPointer);
|
|
153
|
+
document.removeEventListener("keydown", onKey);
|
|
154
|
+
};
|
|
155
|
+
}, [toolsOpen]);
|
|
156
|
+
// Close runtime-context popover on outside click / Escape.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!runtimeContextOpen)
|
|
159
|
+
return;
|
|
160
|
+
const onPointer = (e) => {
|
|
161
|
+
const root = runtimeContextMenuRef.current;
|
|
162
|
+
if (!root)
|
|
163
|
+
return;
|
|
164
|
+
if (e.target instanceof Node && !root.contains(e.target)) {
|
|
165
|
+
setRuntimeContextOpen(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const onKey = (e) => {
|
|
169
|
+
if (e.key === "Escape")
|
|
170
|
+
setRuntimeContextOpen(false);
|
|
171
|
+
};
|
|
172
|
+
document.addEventListener("mousedown", onPointer);
|
|
173
|
+
document.addEventListener("keydown", onKey);
|
|
174
|
+
return () => {
|
|
175
|
+
document.removeEventListener("mousedown", onPointer);
|
|
176
|
+
document.removeEventListener("keydown", onKey);
|
|
177
|
+
};
|
|
178
|
+
}, [runtimeContextOpen]);
|
|
179
|
+
const toolCatalog = useMemo(() => buildToolCatalog(capabilities), [
|
|
180
|
+
capabilities,
|
|
181
|
+
]);
|
|
182
|
+
const activeModel = session?.model ?? runtimeContext?.model;
|
|
183
|
+
const activeModelRef = activeModel
|
|
184
|
+
? formatModelRef(activeModel.provider, activeModel.modelId)
|
|
185
|
+
: "";
|
|
186
|
+
const activeThinkingLevel = session?.thinkingLevel ?? runtimeContext?.thinkingLevel ?? "";
|
|
187
|
+
const activeModelListed = !activeModelRef ||
|
|
188
|
+
models.some((model) => formatModelRef(model.provider, model.id) === activeModelRef);
|
|
189
|
+
return {
|
|
190
|
+
capabilities,
|
|
191
|
+
models,
|
|
192
|
+
thinkingLevels,
|
|
193
|
+
runtimeContext,
|
|
194
|
+
toolsOpen,
|
|
195
|
+
setToolsOpen,
|
|
196
|
+
runtimeContextOpen,
|
|
197
|
+
setRuntimeContextOpen,
|
|
198
|
+
toolsMenuRef,
|
|
199
|
+
runtimeContextMenuRef,
|
|
200
|
+
switchRuntime,
|
|
201
|
+
toolCatalog,
|
|
202
|
+
activeModel,
|
|
203
|
+
activeModelRef,
|
|
204
|
+
activeThinkingLevel,
|
|
205
|
+
activeModelListed,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -43,6 +43,11 @@ export function resolveLegacyTask(tasks, taskId) {
|
|
|
43
43
|
}
|
|
44
44
|
export const TASK_RUN_HISTORY_DEFAULT_LIMIT = 20;
|
|
45
45
|
export const TASK_RUN_HISTORY_MAX_LIMIT = 100;
|
|
46
|
+
/** Default / allowed page sizes for GET /api/dag-runs history list. */
|
|
47
|
+
export const DAG_RUN_HISTORY_DEFAULT_PAGE = 1;
|
|
48
|
+
export const DAG_RUN_HISTORY_DEFAULT_PAGE_SIZE = 20;
|
|
49
|
+
export const DAG_RUN_HISTORY_MAX_PAGE_SIZE = 100;
|
|
50
|
+
export const DAG_RUN_HISTORY_ALLOWED_PAGE_SIZES = [20, 50, 100];
|
|
46
51
|
const ACTIVE_TASK_STATUSES = new Set(["running", "pending"]);
|
|
47
52
|
const ACTIVE_BATCH_STATUSES = new Set(["running", "pending"]);
|
|
48
53
|
export async function buildGlobalSnapshot(options) {
|
|
@@ -227,6 +232,107 @@ export function clampTaskRunHistoryLimit(limit) {
|
|
|
227
232
|
}
|
|
228
233
|
return Math.min(Math.floor(limit), TASK_RUN_HISTORY_MAX_LIMIT);
|
|
229
234
|
}
|
|
235
|
+
export function clampDagRunHistoryPageSize(pageSize) {
|
|
236
|
+
if (pageSize === undefined || !Number.isFinite(pageSize) || pageSize <= 0) {
|
|
237
|
+
return DAG_RUN_HISTORY_DEFAULT_PAGE_SIZE;
|
|
238
|
+
}
|
|
239
|
+
const floored = Math.floor(pageSize);
|
|
240
|
+
if (DAG_RUN_HISTORY_ALLOWED_PAGE_SIZES.includes(floored)) {
|
|
241
|
+
return floored;
|
|
242
|
+
}
|
|
243
|
+
// Nearest allowed size, prefer smaller when equidistant, then clamp to max.
|
|
244
|
+
let best = DAG_RUN_HISTORY_DEFAULT_PAGE_SIZE;
|
|
245
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
246
|
+
for (const allowed of DAG_RUN_HISTORY_ALLOWED_PAGE_SIZES) {
|
|
247
|
+
const dist = Math.abs(allowed - floored);
|
|
248
|
+
if (dist < bestDist || (dist === bestDist && allowed < best)) {
|
|
249
|
+
best = allowed;
|
|
250
|
+
bestDist = dist;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return Math.min(best, DAG_RUN_HISTORY_MAX_PAGE_SIZE);
|
|
254
|
+
}
|
|
255
|
+
export function clampDagRunHistoryPage(page, totalPages) {
|
|
256
|
+
const safeTotal = Math.max(1, Math.floor(totalPages) || 1);
|
|
257
|
+
if (page === undefined || !Number.isFinite(page) || page <= 0) {
|
|
258
|
+
return DAG_RUN_HISTORY_DEFAULT_PAGE;
|
|
259
|
+
}
|
|
260
|
+
return Math.min(Math.floor(page), safeTotal);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Formal DAG history membership (AC-004):
|
|
264
|
+
* exclude executionMode or lifecycle dry-run/init-only;
|
|
265
|
+
* keep missing executionMode when lifecycle is not a non-execution mode.
|
|
266
|
+
*/
|
|
267
|
+
export function isFormalDagRunHistoryCandidate(dag) {
|
|
268
|
+
const executionMode = (dag.executionMode ?? "").toLowerCase();
|
|
269
|
+
if (NON_EXECUTION_MODES.has(executionMode))
|
|
270
|
+
return false;
|
|
271
|
+
const lifecycle = (dag.lifecycle ?? "").toLowerCase();
|
|
272
|
+
if (NON_EXECUTION_MODES.has(lifecycle))
|
|
273
|
+
return false;
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
function dagRunHistorySortKey(dag) {
|
|
277
|
+
return `${dag.startedAt ?? dag.finishedAt ?? ""}\0${dag.dagRunId}`;
|
|
278
|
+
}
|
|
279
|
+
function projectDagRunHistoryProgress(dag) {
|
|
280
|
+
const nodes = dag.nodes ?? [];
|
|
281
|
+
let finished = 0;
|
|
282
|
+
for (const node of nodes) {
|
|
283
|
+
const status = (node.status ?? "").toLowerCase();
|
|
284
|
+
if (status === "finished" ||
|
|
285
|
+
status === "completed" ||
|
|
286
|
+
status === "succeeded" ||
|
|
287
|
+
status === "done" ||
|
|
288
|
+
status === "error" ||
|
|
289
|
+
status === "failed" ||
|
|
290
|
+
status === "skipped" ||
|
|
291
|
+
status === "partial_failed") {
|
|
292
|
+
finished += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { finished, total: nodes.length };
|
|
296
|
+
}
|
|
297
|
+
function toDagRunHistoryItem(dag) {
|
|
298
|
+
return {
|
|
299
|
+
dagRunId: dag.dagRunId,
|
|
300
|
+
...(dag.title ? { title: dag.title } : {}),
|
|
301
|
+
...(dag.status ? { status: dag.status } : {}),
|
|
302
|
+
...(dag.effectiveStatus ? { effectiveStatus: dag.effectiveStatus } : {}),
|
|
303
|
+
...(dag.lifecycle ? { lifecycle: dag.lifecycle } : {}),
|
|
304
|
+
...(dag.executionMode ? { executionMode: dag.executionMode } : {}),
|
|
305
|
+
...(dag.startedAt ? { startedAt: dag.startedAt } : {}),
|
|
306
|
+
...(dag.finishedAt ? { finishedAt: dag.finishedAt } : {}),
|
|
307
|
+
...(dag.durationMs !== undefined ? { durationMs: dag.durationMs } : {}),
|
|
308
|
+
progress: projectDagRunHistoryProgress(dag),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Paginated lightweight formal DAG run history from an in-memory GlobalSnapshot.
|
|
313
|
+
* Filters dry-run/init-only; sorts newest first; never embeds nodes/edges/previews.
|
|
314
|
+
*/
|
|
315
|
+
export function listDagRunHistoryFromSnapshot(snapshot, options = {}) {
|
|
316
|
+
const pageSize = clampDagRunHistoryPageSize(options.pageSize);
|
|
317
|
+
const formal = (snapshot.dagRuns ?? [])
|
|
318
|
+
.filter(isFormalDagRunHistoryCandidate)
|
|
319
|
+
.slice()
|
|
320
|
+
.sort((a, b) => dagRunHistorySortKey(b).localeCompare(dagRunHistorySortKey(a)));
|
|
321
|
+
const total = formal.length;
|
|
322
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
|
323
|
+
const page = clampDagRunHistoryPage(options.page, totalPages);
|
|
324
|
+
const start = (page - 1) * pageSize;
|
|
325
|
+
const runs = formal.slice(start, start + pageSize).map(toDagRunHistoryItem);
|
|
326
|
+
return {
|
|
327
|
+
schemaVersion: 1,
|
|
328
|
+
generatedAt: snapshot.generatedAt,
|
|
329
|
+
page,
|
|
330
|
+
pageSize,
|
|
331
|
+
total,
|
|
332
|
+
totalPages,
|
|
333
|
+
runs,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
230
336
|
function emptySnapshot(repoRoot, now) {
|
|
231
337
|
let taskPoolPresent = false;
|
|
232
338
|
try {
|
|
@@ -7,7 +7,7 @@ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
|
7
7
|
import { parseWorkerEventLine } from "../observability/events.js";
|
|
8
8
|
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
9
9
|
import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, } from "../observability/event-history.js";
|
|
10
|
-
import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
|
|
10
|
+
import { buildGlobalSnapshot, clampDagRunHistoryPageSize, clampTaskRunHistoryLimit, listDagRunHistoryFromSnapshot, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
|
|
11
11
|
import { loadDagRunExecutionTrajectory } from "../observability/dag-execution-trajectory.js";
|
|
12
12
|
import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
|
|
13
13
|
import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
|
|
@@ -115,6 +115,13 @@ export const ROUTES = [
|
|
|
115
115
|
pattern: /^\/api\/dag-runs\/([^/]+)$/,
|
|
116
116
|
handler: handleDagRunById,
|
|
117
117
|
},
|
|
118
|
+
// List route must be registered after /api/dag-runs/:id so detail keeps priority;
|
|
119
|
+
// matchObserveGetRoute scans in order and this path has no capture group.
|
|
120
|
+
{
|
|
121
|
+
method: "GET",
|
|
122
|
+
pattern: /^\/api\/dag-runs$/,
|
|
123
|
+
handler: handleDagRuns,
|
|
124
|
+
},
|
|
118
125
|
{ method: "GET", pattern: /^\/api\/artifacts$/, handler: handleArtifactRead },
|
|
119
126
|
{
|
|
120
127
|
method: "GET",
|
|
@@ -507,6 +514,17 @@ async function handleRunArtifacts(_req, res, match, ctx) {
|
|
|
507
514
|
}
|
|
508
515
|
sendJson(res, 200, { artifacts });
|
|
509
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* GET /api/dag-runs?page=&pageSize=
|
|
519
|
+
* Lightweight formal DAG history page (schemaVersion 1).
|
|
520
|
+
*/
|
|
521
|
+
async function handleDagRuns(_req, res, match, ctx) {
|
|
522
|
+
const snapshot = await getSnapshot(ctx);
|
|
523
|
+
const page = parsePositiveInt(match.query.get("page"), 1);
|
|
524
|
+
const pageSize = clampDagRunHistoryPageSize(parsePositiveInt(match.query.get("pageSize"), 0) || undefined);
|
|
525
|
+
const body = listDagRunHistoryFromSnapshot(snapshot, { page, pageSize });
|
|
526
|
+
sendJson(res, 200, body);
|
|
527
|
+
}
|
|
510
528
|
export async function handleDagRunById(_req, res, match, ctx) {
|
|
511
529
|
const snapshot = await getSnapshot(ctx);
|
|
512
530
|
const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === match.params.id);
|
|
@@ -13,10 +13,12 @@ export async function fetchJson(url) {
|
|
|
13
13
|
/**
|
|
14
14
|
* Read-only JSON fetch that preserves HTTP status so callers can distinguish
|
|
15
15
|
* 404 (missing) from 409 (ambiguous task identity). Body is parsed best-effort.
|
|
16
|
+
* Optional `signal` enables AbortController cancellation for formal navigation.
|
|
16
17
|
*/
|
|
17
|
-
export async function fetchJsonResult(url) {
|
|
18
|
+
export async function fetchJsonResult(url, options = {}) {
|
|
19
|
+
const signal = options?.signal;
|
|
18
20
|
try {
|
|
19
|
-
const res = await fetch(url);
|
|
21
|
+
const res = await fetch(url, signal ? { signal } : undefined);
|
|
20
22
|
let body = null;
|
|
21
23
|
try {
|
|
22
24
|
body = await res.json();
|
|
@@ -24,11 +26,48 @@ export async function fetchJsonResult(url) {
|
|
|
24
26
|
body = null;
|
|
25
27
|
}
|
|
26
28
|
return { ok: res.ok, status: res.status, body };
|
|
27
|
-
} catch {
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (
|
|
31
|
+
(err && (err.name === "AbortError" || err.code === 20)) ||
|
|
32
|
+
(signal && signal.aborted)
|
|
33
|
+
) {
|
|
34
|
+
return { ok: false, status: 0, body: null, aborted: true };
|
|
35
|
+
}
|
|
28
36
|
return { ok: false, status: 0, body: null };
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Formal DAG history page (real request path). page/pageSize are query params.
|
|
42
|
+
* Callers should discard late responses via request generation tokens and may
|
|
43
|
+
* pass AbortSignal to cancel superseded formal navigation requests.
|
|
44
|
+
*/
|
|
45
|
+
export async function fetchDagRunHistory(page, pageSize, options = {}) {
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
if (page != null) params.set("page", String(page));
|
|
48
|
+
if (pageSize != null) params.set("pageSize", String(pageSize));
|
|
49
|
+
const qs = params.toString();
|
|
50
|
+
const url = qs ? `/api/dag-runs?${qs}` : "/api/dag-runs";
|
|
51
|
+
return fetchJsonResult(url, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safe user-facing error summary for DAG history (AC-015).
|
|
56
|
+
* Only local trusted fixed copy — never renders server error/message/detail
|
|
57
|
+
* or absolute local paths (Windows or POSIX).
|
|
58
|
+
*/
|
|
59
|
+
export function summarizeFetchError(result) {
|
|
60
|
+
if (!result) return "加载失败。";
|
|
61
|
+
if (result.status === 0) return "网络错误,请检查 Observe 服务是否可用。";
|
|
62
|
+
if (typeof result.status === "number" && result.status >= 500) {
|
|
63
|
+
return "服务暂时不可用,请稍后重试。";
|
|
64
|
+
}
|
|
65
|
+
if (typeof result.status === "number" && result.status >= 400) {
|
|
66
|
+
return "请求失败,请稍后重试。";
|
|
67
|
+
}
|
|
68
|
+
return "加载失败。";
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
/**
|
|
33
72
|
* Fetch a dag node input projection. Preserves HTTP status so callers can
|
|
34
73
|
* distinguish missing runs, unavailable nodes, and transport errors.
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import { renderFailures } from "./views/failures.js";
|
|
50
50
|
import { startPoolView } from "./views/pool.js";
|
|
51
51
|
import { startDashboardPolling } from "./views/dashboard.js";
|
|
52
|
+
import { startDagsView } from "./views/dags.js";
|
|
52
53
|
import { renderFeatureDetail } from "./views/feature.js";
|
|
53
54
|
import { renderBatch } from "./views/batch.js";
|
|
54
55
|
import { startTaskPolling } from "./views/task.js";
|
|
@@ -119,6 +120,8 @@ function route() {
|
|
|
119
120
|
const r = parseRoute();
|
|
120
121
|
if (r.view === "dashboard") {
|
|
121
122
|
startDashboardPolling(r.scrollTo);
|
|
123
|
+
} else if (r.view === "dags") {
|
|
124
|
+
void startDagsView();
|
|
122
125
|
} else if (r.view === "batch") {
|
|
123
126
|
void renderBatch(r.batchRunId);
|
|
124
127
|
} else if (r.view === "run") {
|
|
@@ -146,6 +149,7 @@ if (typeof window !== "undefined") {
|
|
|
146
149
|
if (!isPageVisible()) return;
|
|
147
150
|
const r = parseRoute();
|
|
148
151
|
if (r.view === "dashboard") startDashboardPolling();
|
|
152
|
+
// History page is manual-refresh only (no 5s dashboard polling).
|
|
149
153
|
if (r.view === "run") startRunPolling(r.workerRunId, false);
|
|
150
154
|
if (r.view === "dag") startDagPolling(r.dagRunId, false);
|
|
151
155
|
if (r.view === "pool" && uiState.poolAutoRefresh) startPoolView();
|
|
@@ -21,6 +21,8 @@ export const OUTPUT_FOLLOW_LATEST_PX = 48;
|
|
|
21
21
|
export const UI_TEXT = {
|
|
22
22
|
dashboard: "总览",
|
|
23
23
|
dagRuns: "DAG 运行",
|
|
24
|
+
dagHistory: "DAG 运行历史",
|
|
25
|
+
viewAllDags: "查看全部 →",
|
|
24
26
|
failures: "异常 Task",
|
|
25
27
|
nightJobs: "夜间任务",
|
|
26
28
|
batchDetail: "批次详情",
|
|
@@ -31,6 +33,14 @@ export const UI_TEXT = {
|
|
|
31
33
|
batches: "Worker 批次",
|
|
32
34
|
noActiveDags: "当前没有进行中的 DAG,启动后将出现在这里。",
|
|
33
35
|
noRecentDags: "暂无 DAG 运行记录。",
|
|
36
|
+
noFormalDagHistory: "暂无正式 DAG 运行记录",
|
|
37
|
+
noFormalDagHistoryHint:
|
|
38
|
+
"dry-run / init-only 预检记录不会出现在此列表中。",
|
|
39
|
+
dagHistoryRefreshFailed: "刷新失败,当前展示上一次结果",
|
|
40
|
+
dagHistoryCopied: "已复制",
|
|
41
|
+
dagHistoryCopiedAnnounce: "已复制运行 ID",
|
|
42
|
+
dagHistoryCopyFailed: "复制失败,请手动选择运行 ID",
|
|
43
|
+
dagHistoryCopyTooltip: "复制运行 ID",
|
|
34
44
|
noBatches: "暂无 Worker 批次,运行后将出现在这里。",
|
|
35
45
|
noNodes: "暂无节点。",
|
|
36
46
|
noOutput: "暂无节点输出。",
|