@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4
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/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- package/src/ui/server/__tests__/path-containment.test.ts +0 -54
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deriveProgress } from "../../../core/job-view.ts";
|
|
1
2
|
import { deriveJobStatusFromTasks, normalizeJobStatus, normalizeTaskState } from "../../../config/statuses";
|
|
2
3
|
import type {
|
|
3
4
|
AllowedActions,
|
|
@@ -19,6 +20,9 @@ const EMPTY_COSTS: CostsSummary = {
|
|
|
19
20
|
totalCost: 0,
|
|
20
21
|
totalInputCost: 0,
|
|
21
22
|
totalOutputCost: 0,
|
|
23
|
+
estimatedCost: 0,
|
|
24
|
+
hasEstimated: false,
|
|
25
|
+
unavailableCost: 0,
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -50,10 +54,14 @@ function normalizeGate(value: unknown): GateInfo | null | undefined {
|
|
|
50
54
|
? value["artifacts"].filter((artifact): artifact is string => typeof artifact === "string")
|
|
51
55
|
: undefined;
|
|
52
56
|
|
|
57
|
+
const rawKind = value["kind"];
|
|
58
|
+
const kind = rawKind === "approval" || rawKind === "control_invalid" ? rawKind : undefined;
|
|
59
|
+
|
|
53
60
|
return {
|
|
54
61
|
afterTask: typeof value["afterTask"] === "string" ? value["afterTask"] : "",
|
|
55
62
|
message: typeof value["message"] === "string" ? value["message"] : "",
|
|
56
63
|
requestedAt: typeof value["requestedAt"] === "string" ? value["requestedAt"] : "",
|
|
64
|
+
...(kind ? { kind } : {}),
|
|
57
65
|
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
|
|
58
66
|
};
|
|
59
67
|
}
|
|
@@ -108,6 +116,14 @@ function getWarnings(rawTasks: unknown): string[] {
|
|
|
108
116
|
return ["Unsupported task collection shape"];
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
function isErrorShape(value: unknown): value is { code: string; message: string; path?: string } {
|
|
120
|
+
if (!isRecord(value)) return false;
|
|
121
|
+
if (typeof value["code"] !== "string") return false;
|
|
122
|
+
if (typeof value["message"] !== "string") return false;
|
|
123
|
+
if ("path" in value && typeof value["path"] !== "string") return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
function normalizeCostsSummary(costs: unknown): CostsSummary {
|
|
112
128
|
if (!isRecord(costs)) return EMPTY_COSTS;
|
|
113
129
|
return {
|
|
@@ -117,6 +133,9 @@ function normalizeCostsSummary(costs: unknown): CostsSummary {
|
|
|
117
133
|
totalCost: toNumber(costs["totalCost"]),
|
|
118
134
|
totalInputCost: toNumber(costs["totalInputCost"]),
|
|
119
135
|
totalOutputCost: toNumber(costs["totalOutputCost"]),
|
|
136
|
+
estimatedCost: toNumber(costs["estimatedCost"]),
|
|
137
|
+
hasEstimated: typeof costs["hasEstimated"] === "boolean" ? costs["hasEstimated"] : false,
|
|
138
|
+
unavailableCost: toNumber(costs["unavailableCost"]),
|
|
120
139
|
};
|
|
121
140
|
}
|
|
122
141
|
|
|
@@ -124,13 +143,24 @@ function normalizeCostBreakdown(costs: unknown): Record<string, TaskCostBreakdow
|
|
|
124
143
|
if (!isRecord(costs)) return undefined;
|
|
125
144
|
const entries = Object.entries(costs).flatMap(([name, value]) => {
|
|
126
145
|
if (!isRecord(value)) return [];
|
|
127
|
-
|
|
146
|
+
const entry: TaskCostBreakdown = {
|
|
128
147
|
inputTokens: toNumber(value["inputTokens"]),
|
|
129
148
|
outputTokens: toNumber(value["outputTokens"]),
|
|
130
149
|
inputCost: toNumber(value["inputCost"]),
|
|
131
150
|
outputCost: toNumber(value["outputCost"]),
|
|
132
151
|
totalCost: toNumber(value["totalCost"]),
|
|
133
|
-
}
|
|
152
|
+
};
|
|
153
|
+
const rawSource = value["source"];
|
|
154
|
+
if (rawSource === "reported" || rawSource === "estimated" || rawSource === "unavailable" || rawSource === "zero") {
|
|
155
|
+
entry.source = rawSource;
|
|
156
|
+
}
|
|
157
|
+
if (typeof value["failed"] === "boolean") {
|
|
158
|
+
entry.failed = value["failed"];
|
|
159
|
+
}
|
|
160
|
+
if (typeof value["costEstimated"] === "boolean") {
|
|
161
|
+
entry.costEstimated = value["costEstimated"];
|
|
162
|
+
}
|
|
163
|
+
return [[name, entry] as const];
|
|
134
164
|
});
|
|
135
165
|
return Object.fromEntries(entries);
|
|
136
166
|
}
|
|
@@ -159,20 +189,13 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
|
|
|
159
189
|
const rawTasks = apiJob["tasks"] ?? apiJob["tasksStatus"];
|
|
160
190
|
const tasks = normalizeTasks(rawTasks);
|
|
161
191
|
const taskList = Object.values(tasks);
|
|
162
|
-
const doneCount = taskList.filter((task) => task.state === "done").length;
|
|
163
|
-
const completedCount = taskList.filter((task) => isCompletedTaskState(task.state)).length;
|
|
164
192
|
const rawPipelineConfig = isRecord(apiJob["pipelineConfig"]) ? apiJob["pipelineConfig"] : null;
|
|
165
|
-
const
|
|
166
|
-
? rawPipelineConfig["tasks"] as unknown[]
|
|
193
|
+
const configuredCount = rawPipelineConfig && Array.isArray(rawPipelineConfig["tasks"])
|
|
194
|
+
? (rawPipelineConfig["tasks"] as unknown[]).length
|
|
167
195
|
: null;
|
|
168
|
-
const
|
|
196
|
+
const derived = deriveProgress(taskList, configuredCount);
|
|
169
197
|
const inferredStatus = deriveJobStatusFromTasks(taskList);
|
|
170
198
|
const status = normalizeJobStatus(apiJob["status"] ?? inferredStatus);
|
|
171
|
-
const progress = pipelineTaskArray
|
|
172
|
-
? (taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100)))
|
|
173
|
-
: typeof apiJob["progress"] === "number"
|
|
174
|
-
? apiJob["progress"]
|
|
175
|
-
: taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100));
|
|
176
199
|
|
|
177
200
|
return {
|
|
178
201
|
id: typeof apiJob["id"] === "string" ? apiJob["id"] : String(apiJob["jobId"] ?? ""),
|
|
@@ -181,10 +204,10 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
|
|
|
181
204
|
? apiJob["name"]
|
|
182
205
|
: typeof apiJob["title"] === "string" ? apiJob["title"] : "",
|
|
183
206
|
status,
|
|
184
|
-
progress,
|
|
185
|
-
taskCount,
|
|
186
|
-
doneCount,
|
|
187
|
-
completedCount,
|
|
207
|
+
progress: derived.progress,
|
|
208
|
+
taskCount: derived.taskCount,
|
|
209
|
+
doneCount: derived.doneCount,
|
|
210
|
+
completedCount: derived.completedCount,
|
|
188
211
|
location: typeof apiJob["location"] === "string" ? apiJob["location"] : "current",
|
|
189
212
|
tasks,
|
|
190
213
|
current: normalizeCurrent(apiJob["current"]),
|
|
@@ -202,6 +225,8 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
|
|
|
202
225
|
totalTokens: toNumber(apiJob["totalTokens"], normalizeCostsSummary(apiJob["costsSummary"]).totalTokens),
|
|
203
226
|
displayCategory: getDisplayCategory(status),
|
|
204
227
|
__warnings: getWarnings(rawTasks),
|
|
228
|
+
...(typeof apiJob["readable"] === "boolean" ? { readable: apiJob["readable"] } : {}),
|
|
229
|
+
...(isErrorShape(apiJob["error"]) ? { error: apiJob["error"] } : {}),
|
|
205
230
|
};
|
|
206
231
|
}
|
|
207
232
|
|
package/src/ui/client/api.ts
CHANGED
|
@@ -201,14 +201,22 @@ export async function decideGate(
|
|
|
201
201
|
) as Promise<GateDecisionResponse>;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
export async function
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
export async function getJson<T>(
|
|
205
|
+
url: string,
|
|
206
|
+
opts?: {
|
|
207
|
+
signal?: AbortSignal;
|
|
208
|
+
validate?: (data: unknown) => data is T;
|
|
209
|
+
malformedMessage?: string;
|
|
210
|
+
},
|
|
211
|
+
): Promise<T> {
|
|
212
|
+
const signal = opts?.signal;
|
|
213
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
214
|
+
|
|
207
215
|
let response: Response;
|
|
208
216
|
let payload: unknown;
|
|
209
217
|
|
|
210
218
|
try {
|
|
211
|
-
response = await fetch(
|
|
219
|
+
response = await fetch(url, { signal });
|
|
212
220
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
213
221
|
payload = await parseJson(response);
|
|
214
222
|
} catch (error) {
|
|
@@ -227,19 +235,34 @@ export async function fetchConcurrencyStatus(
|
|
|
227
235
|
);
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
238
|
+
if (!isRecord(payload) || payload["ok"] !== true) {
|
|
239
|
+
throw {
|
|
240
|
+
code: "malformed_response",
|
|
241
|
+
message: opts?.malformedMessage ?? "Malformed response",
|
|
242
|
+
status: response.status,
|
|
243
|
+
} satisfies ApiError;
|
|
236
244
|
}
|
|
237
245
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
const data = payload["data"];
|
|
247
|
+
if (data === undefined || (opts?.validate && !opts.validate(data))) {
|
|
248
|
+
throw {
|
|
249
|
+
code: "malformed_response",
|
|
250
|
+
message: opts?.malformedMessage ?? "Malformed response",
|
|
251
|
+
status: response.status,
|
|
252
|
+
} satisfies ApiError;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return data as T;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function fetchConcurrencyStatus(
|
|
259
|
+
signal?: AbortSignal,
|
|
260
|
+
): Promise<JobConcurrencyApiStatus> {
|
|
261
|
+
return getJson<JobConcurrencyApiStatus>("/api/concurrency", {
|
|
262
|
+
signal,
|
|
263
|
+
validate: isJobConcurrencyApiStatus,
|
|
264
|
+
malformedMessage: "Malformed concurrency status response",
|
|
265
|
+
});
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
function isJobConcurrencyApiStatus(value: unknown): value is JobConcurrencyApiStatus {
|
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { getJson } from "./api";
|
|
2
|
+
import type { ApiError, BootstrapOptions, SseEventType } from "./types";
|
|
2
3
|
|
|
3
4
|
const DEFAULT_STATE_URL = "/api/state";
|
|
4
5
|
const DEFAULT_SSE_URL = "/api/events";
|
|
5
|
-
const BOOTSTRAP_EVENTS: SseEventType[] = [
|
|
6
|
-
"state",
|
|
7
|
-
"
|
|
6
|
+
const BOOTSTRAP_EVENTS: readonly SseEventType[] = [
|
|
7
|
+
"state:change",
|
|
8
|
+
"state:summary",
|
|
8
9
|
"job:created",
|
|
10
|
+
"job:updated",
|
|
9
11
|
"job:removed",
|
|
10
12
|
"heartbeat",
|
|
11
|
-
"
|
|
13
|
+
"seed:uploaded",
|
|
12
14
|
];
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
function isApiError(value: unknown): value is ApiError {
|
|
17
|
+
return (
|
|
18
|
+
typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
typeof (value as { code?: unknown }).code === "string" &&
|
|
21
|
+
typeof (value as { message?: unknown }).message === "string"
|
|
22
|
+
);
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
async function loadSnapshot(stateUrl: string): Promise<unknown> {
|
|
23
26
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
return await getJson<unknown>(stateUrl);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (isApiError(error)) {
|
|
30
|
+
console.warn("bootstrap: failed to load initial state", error);
|
|
31
|
+
}
|
|
27
32
|
return null;
|
|
28
33
|
}
|
|
29
34
|
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
|
2
2
|
|
|
3
|
+
import { parseSseEvent, type SseEvent } from "../../../config/sse-events.ts";
|
|
3
4
|
import { adaptJobDetail } from "../adapters/job-adapter";
|
|
5
|
+
import { getJson } from "../api";
|
|
6
|
+
import { nextLoadState, type LoadState } from "../load-state";
|
|
7
|
+
import { applyDetailEvent } from "../reducers/job-events.ts";
|
|
4
8
|
import type {
|
|
9
|
+
ApiError,
|
|
5
10
|
ConnectionStatus,
|
|
6
11
|
NormalizedJobDetail,
|
|
7
|
-
NormalizedTask,
|
|
8
|
-
SseJobEvent,
|
|
9
12
|
UseJobDetailWithUpdatesResult,
|
|
10
13
|
} from "../types";
|
|
11
14
|
|
|
15
|
+
export { applyDetailEvent };
|
|
16
|
+
|
|
12
17
|
export const REFRESH_DEBOUNCE_MS = 200;
|
|
13
18
|
export const POLL_INTERVAL_MS = 3_000;
|
|
14
19
|
|
|
@@ -16,18 +21,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
16
21
|
return typeof value === "object" && value !== null;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
export function extractJobDetail(payload: unknown): Record<string, unknown> | null {
|
|
20
|
-
if (isRecord(payload) && isRecord(payload["data"])) return payload["data"];
|
|
21
|
-
if (isRecord(payload)) return payload;
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getPipelineTaskCount(detail: NormalizedJobDetail): number | null {
|
|
26
|
-
const config = detail.pipelineConfig;
|
|
27
|
-
if (config && Array.isArray(config["tasks"])) return config["tasks"].length;
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
function getPipelineTaskNames(detail: NormalizedJobDetail): string[] {
|
|
32
25
|
const config = detail.pipelineConfig;
|
|
33
26
|
if (!config || !Array.isArray(config["tasks"])) return Object.keys(detail.tasks);
|
|
@@ -46,12 +39,7 @@ function sameTaskSet(left: string[], right: string[]): boolean {
|
|
|
46
39
|
return left.every((taskName) => rightSet.has(taskName));
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event:
|
|
50
|
-
if (event.type === "task:updated") {
|
|
51
|
-
const taskName = typeof event.data["taskName"] === "string" ? event.data["taskName"] : null;
|
|
52
|
-
return taskName !== null && detail.tasks[taskName] === undefined;
|
|
53
|
-
}
|
|
54
|
-
|
|
42
|
+
export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event: SseEvent): boolean {
|
|
55
43
|
if (event.type !== "job:updated") return false;
|
|
56
44
|
const rawTasks = event.data["tasks"];
|
|
57
45
|
if (!isRecord(rawTasks)) return false;
|
|
@@ -61,21 +49,6 @@ export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event
|
|
|
61
49
|
return !sameTaskSet(getPipelineTaskNames(detail), incomingTaskNames);
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
function recomputeProgress(detail: NormalizedJobDetail): NormalizedJobDetail {
|
|
65
|
-
const tasks = Object.values(detail.tasks);
|
|
66
|
-
const doneCount = tasks.filter((task) => task.state === "done").length;
|
|
67
|
-
const completedCount = tasks.filter((task) => task.state === "done" || task.state === "skipped").length;
|
|
68
|
-
const taskCount = getPipelineTaskCount(detail) ?? tasks.length;
|
|
69
|
-
return {
|
|
70
|
-
...detail,
|
|
71
|
-
doneCount,
|
|
72
|
-
completedCount,
|
|
73
|
-
taskCount,
|
|
74
|
-
progress: taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100)),
|
|
75
|
-
updatedAt: new Date().toISOString(),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
52
|
function escapeRegExp(value: string): string {
|
|
80
53
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
54
|
}
|
|
@@ -85,67 +58,72 @@ export function matchesJobTasksStatusPath(path: string, jobId: string): boolean
|
|
|
85
58
|
return pattern.test(path);
|
|
86
59
|
}
|
|
87
60
|
|
|
88
|
-
export
|
|
89
|
-
|
|
90
|
-
event:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
61
|
+
export type DetailMessageAction =
|
|
62
|
+
| { kind: "refetch" }
|
|
63
|
+
| { kind: "apply"; event: SseEvent }
|
|
64
|
+
| { kind: "skip" };
|
|
65
|
+
|
|
66
|
+
export function handleDetailSseMessage(
|
|
67
|
+
type: string,
|
|
68
|
+
rawData: string,
|
|
69
|
+
jobId: string,
|
|
70
|
+
): DetailMessageAction {
|
|
71
|
+
let parsedData: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsedData = JSON.parse(rawData);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.warn("useJobDetailWithUpdates: failed to parse SSE payload", error);
|
|
76
|
+
return { kind: "refetch" };
|
|
97
77
|
}
|
|
98
78
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const nextTask: NormalizedTask = {
|
|
106
|
-
...currentTask,
|
|
107
|
-
...(isRecord(event.data["task"]) ? adaptJobDetail({
|
|
108
|
-
...detail,
|
|
109
|
-
tasks: {
|
|
110
|
-
[taskName]: event.data["task"],
|
|
111
|
-
},
|
|
112
|
-
}).tasks[taskName] ?? currentTask : currentTask),
|
|
113
|
-
};
|
|
79
|
+
const event = parseSseEvent(type, parsedData);
|
|
80
|
+
if (event === null) {
|
|
81
|
+
console.warn(`useJobDetailWithUpdates: dropping invalid SSE event of type "${type}"`);
|
|
82
|
+
return { kind: "refetch" };
|
|
83
|
+
}
|
|
114
84
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
[taskName]: nextTask,
|
|
120
|
-
},
|
|
121
|
-
});
|
|
85
|
+
if (event.type === "state:change") {
|
|
86
|
+
return matchesJobTasksStatusPath(event.data.path, jobId)
|
|
87
|
+
? { kind: "refetch" }
|
|
88
|
+
: { kind: "skip" };
|
|
122
89
|
}
|
|
123
90
|
|
|
124
|
-
return
|
|
91
|
+
return { kind: "apply", event };
|
|
125
92
|
}
|
|
126
93
|
|
|
127
|
-
async function fetchJobDetail(jobId: string, signal: AbortSignal): Promise<NormalizedJobDetail> {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
94
|
+
export async function fetchJobDetail(jobId: string, signal: AbortSignal): Promise<NormalizedJobDetail> {
|
|
95
|
+
const raw = await getJson<Record<string, unknown>>(`/api/jobs/${jobId}`, {
|
|
96
|
+
signal,
|
|
97
|
+
validate: isRecord,
|
|
98
|
+
malformedMessage: "Malformed job detail response",
|
|
99
|
+
});
|
|
100
|
+
return adaptJobDetail(raw);
|
|
131
101
|
}
|
|
132
102
|
|
|
133
103
|
export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesResult {
|
|
134
104
|
const [data, setData] = useState<NormalizedJobDetail | null>(null);
|
|
135
105
|
const [loading, setLoading] = useState(true);
|
|
136
|
-
const [error, setError] = useState<
|
|
106
|
+
const [error, setError] = useState<ApiError | null>(null);
|
|
137
107
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("disconnected");
|
|
138
108
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
139
109
|
const [isHydrated, setIsHydrated] = useState(false);
|
|
140
110
|
const [isTransitioning, startTransition] = useTransition();
|
|
141
111
|
const mountedRef = useRef(true);
|
|
142
112
|
const dataRef = useRef<NormalizedJobDetail | null>(null);
|
|
113
|
+
const errorRef = useRef<ApiError | null>(null);
|
|
143
114
|
const hydratedRef = useRef(false);
|
|
144
115
|
const abortRef = useRef<AbortController | null>(null);
|
|
145
|
-
const queueRef = useRef<
|
|
116
|
+
const queueRef = useRef<SseEvent[]>([]);
|
|
146
117
|
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
147
118
|
const refetchingRef = useRef(false);
|
|
148
119
|
|
|
120
|
+
const apply = (next: LoadState<NormalizedJobDetail>) => {
|
|
121
|
+
dataRef.current = next.data;
|
|
122
|
+
errorRef.current = next.error;
|
|
123
|
+
setData(next.data);
|
|
124
|
+
setError(next.error);
|
|
125
|
+
};
|
|
126
|
+
|
|
149
127
|
const load = useCallback(() => {
|
|
150
128
|
abortRef.current?.abort();
|
|
151
129
|
const controller = new AbortController();
|
|
@@ -160,16 +138,20 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
160
138
|
if (!mountedRef.current) return;
|
|
161
139
|
const replayed = queueRef.current.reduce(applyDetailEvent, detail);
|
|
162
140
|
queueRef.current = [];
|
|
163
|
-
|
|
141
|
+
apply(nextLoadState(
|
|
142
|
+
{ data: dataRef.current, error: errorRef.current },
|
|
143
|
+
{ ok: true, data: replayed },
|
|
144
|
+
));
|
|
164
145
|
hydratedRef.current = true;
|
|
165
|
-
setData(replayed);
|
|
166
|
-
setError(null);
|
|
167
146
|
setIsHydrated(true);
|
|
168
147
|
})
|
|
169
|
-
.catch((fetchError) => {
|
|
148
|
+
.catch((fetchError: unknown) => {
|
|
170
149
|
if (fetchError instanceof DOMException && fetchError.name === "AbortError") return;
|
|
171
150
|
if (!mountedRef.current) return;
|
|
172
|
-
|
|
151
|
+
apply(nextLoadState(
|
|
152
|
+
{ data: dataRef.current, error: errorRef.current },
|
|
153
|
+
{ ok: false, error: fetchError as ApiError },
|
|
154
|
+
));
|
|
173
155
|
})
|
|
174
156
|
.finally(() => {
|
|
175
157
|
if (!mountedRef.current) return;
|
|
@@ -202,31 +184,21 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
202
184
|
};
|
|
203
185
|
|
|
204
186
|
const onMessage = (event: MessageEvent<string>) => {
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
payload = {
|
|
208
|
-
type: event.type as SseJobEvent["type"],
|
|
209
|
-
data: JSON.parse(event.data) as Record<string, unknown>,
|
|
210
|
-
};
|
|
211
|
-
} catch (parseError) {
|
|
212
|
-
console.warn("Failed to parse job detail SSE payload", parseError);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
187
|
+
const result = handleDetailSseMessage(event.type, event.data, jobId);
|
|
215
188
|
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
if (matchesJobTasksStatusPath(path, jobId)) {
|
|
219
|
-
scheduleRefetch();
|
|
220
|
-
}
|
|
189
|
+
if (result.kind === "refetch") {
|
|
190
|
+
scheduleRefetch();
|
|
221
191
|
return;
|
|
222
192
|
}
|
|
223
193
|
|
|
194
|
+
if (result.kind === "skip") return;
|
|
195
|
+
|
|
224
196
|
if (!hydratedRef.current || refetchingRef.current || dataRef.current === null) {
|
|
225
|
-
queueRef.current.push(
|
|
197
|
+
queueRef.current.push(result.event);
|
|
226
198
|
return;
|
|
227
199
|
}
|
|
228
200
|
|
|
229
|
-
if (shouldRefetchDetailForTaskSet(dataRef.current,
|
|
201
|
+
if (shouldRefetchDetailForTaskSet(dataRef.current, result.event)) {
|
|
230
202
|
scheduleRefetch();
|
|
231
203
|
return;
|
|
232
204
|
}
|
|
@@ -234,7 +206,7 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
234
206
|
startTransition(() => {
|
|
235
207
|
setData((current) => {
|
|
236
208
|
if (current === null) return current;
|
|
237
|
-
const next = applyDetailEvent(current,
|
|
209
|
+
const next = applyDetailEvent(current, result.event);
|
|
238
210
|
dataRef.current = next;
|
|
239
211
|
return next;
|
|
240
212
|
});
|
|
@@ -242,7 +214,6 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
242
214
|
};
|
|
243
215
|
|
|
244
216
|
source.addEventListener("job:updated", onMessage as EventListener);
|
|
245
|
-
source.addEventListener("task:updated", onMessage as EventListener);
|
|
246
217
|
source.addEventListener("state:change", onMessage as EventListener);
|
|
247
218
|
|
|
248
219
|
return () => {
|
|
@@ -265,6 +236,10 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
265
236
|
return () => clearInterval(interval);
|
|
266
237
|
}, [data?.status, load]);
|
|
267
238
|
|
|
239
|
+
const refetch = useCallback(() => {
|
|
240
|
+
load();
|
|
241
|
+
}, [load]);
|
|
242
|
+
|
|
268
243
|
return {
|
|
269
244
|
data,
|
|
270
245
|
loading,
|
|
@@ -273,5 +248,6 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
|
|
|
273
248
|
isRefreshing,
|
|
274
249
|
isTransitioning,
|
|
275
250
|
isHydrated,
|
|
251
|
+
refetch,
|
|
276
252
|
};
|
|
277
253
|
}
|
|
@@ -1,39 +1,17 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { adaptJobSummary } from "../adapters/job-adapter";
|
|
4
|
+
import { getJson } from "../api";
|
|
5
|
+
import { nextLoadState, type LoadState } from "../load-state";
|
|
4
6
|
import type { ApiError, NormalizedJobSummary, UseJobListResult } from "../types";
|
|
5
7
|
|
|
6
8
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
9
|
return typeof value === "object" && value !== null;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
function toApiError(error: unknown): ApiError {
|
|
11
|
-
if (isRecord(error) && typeof error["code"] === "string" && typeof error["message"] === "string") {
|
|
12
|
-
return {
|
|
13
|
-
code: error["code"] as ApiError["code"],
|
|
14
|
-
message: error["message"],
|
|
15
|
-
status: typeof error["status"] === "number" ? error["status"] : undefined,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
code: "unknown_error",
|
|
21
|
-
message: error instanceof Error ? error.message : "Failed to load jobs",
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function extractJobList(payload: unknown): Record<string, unknown>[] {
|
|
26
|
-
if (Array.isArray(payload)) return payload.filter(isRecord);
|
|
27
|
-
if (isRecord(payload) && payload["ok"] === true && Array.isArray(payload["data"])) {
|
|
28
|
-
return payload["data"].filter(isRecord);
|
|
29
|
-
}
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
12
|
export async function fetchJobList(signal?: AbortSignal): Promise<NormalizedJobSummary[]> {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
return extractJobList(payload).map(adaptJobSummary);
|
|
13
|
+
const raw = await getJson<unknown[]>("/api/jobs", { signal, validate: Array.isArray });
|
|
14
|
+
return raw.filter(isRecord).map(adaptJobSummary);
|
|
37
15
|
}
|
|
38
16
|
|
|
39
17
|
export function useJobList(): UseJobListResult {
|
|
@@ -41,6 +19,15 @@ export function useJobList(): UseJobListResult {
|
|
|
41
19
|
const [data, setData] = useState<NormalizedJobSummary[] | null>(null);
|
|
42
20
|
const [error, setError] = useState<ApiError | null>(null);
|
|
43
21
|
const abortRef = useRef<AbortController | null>(null);
|
|
22
|
+
const dataRef = useRef<NormalizedJobSummary[] | null>(null);
|
|
23
|
+
const errorRef = useRef<ApiError | null>(null);
|
|
24
|
+
|
|
25
|
+
const apply = (next: LoadState<NormalizedJobSummary[]>) => {
|
|
26
|
+
dataRef.current = next.data;
|
|
27
|
+
errorRef.current = next.error;
|
|
28
|
+
setData(next.data);
|
|
29
|
+
setError(next.error);
|
|
30
|
+
};
|
|
44
31
|
|
|
45
32
|
const load = useCallback((cancelPrevious: boolean) => {
|
|
46
33
|
if (cancelPrevious) abortRef.current?.abort();
|
|
@@ -50,14 +37,22 @@ export function useJobList(): UseJobListResult {
|
|
|
50
37
|
|
|
51
38
|
void fetchJobList(controller.signal)
|
|
52
39
|
.then((jobs) => {
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
if (abortRef.current !== controller) return;
|
|
41
|
+
apply(nextLoadState(
|
|
42
|
+
{ data: dataRef.current, error: errorRef.current },
|
|
43
|
+
{ ok: true, data: jobs },
|
|
44
|
+
));
|
|
55
45
|
})
|
|
56
|
-
.catch((fetchError) => {
|
|
46
|
+
.catch((fetchError: unknown) => {
|
|
57
47
|
if (fetchError instanceof DOMException && fetchError.name === "AbortError") return;
|
|
58
|
-
|
|
48
|
+
if (abortRef.current !== controller) return;
|
|
49
|
+
apply(nextLoadState(
|
|
50
|
+
{ data: dataRef.current, error: errorRef.current },
|
|
51
|
+
{ ok: false, error: fetchError as ApiError },
|
|
52
|
+
));
|
|
59
53
|
})
|
|
60
54
|
.finally(() => {
|
|
55
|
+
if (abortRef.current !== controller) return;
|
|
61
56
|
setLoading(false);
|
|
62
57
|
});
|
|
63
58
|
}, []);
|
|
@@ -72,4 +67,4 @@ export function useJobList(): UseJobListResult {
|
|
|
72
67
|
}, [load]);
|
|
73
68
|
|
|
74
69
|
return { loading, data, error, refetch };
|
|
75
|
-
}
|
|
70
|
+
}
|