@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,12 +1,28 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import { TaskAnalysisRepository } from "../../../task-analysis/repository";
|
|
4
|
+
import { resolveUnderRoot } from "../../../utils/path-containment";
|
|
3
5
|
import { sendJson } from "../utils/http-utils";
|
|
4
6
|
|
|
5
|
-
export async function handleTaskAnalysis(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
export async function handleTaskAnalysis(
|
|
8
|
+
_req: Request,
|
|
9
|
+
slug: string,
|
|
10
|
+
taskId: string,
|
|
11
|
+
root: string,
|
|
12
|
+
): Promise<Response> {
|
|
13
|
+
const validatedPath = resolveUnderRoot(
|
|
14
|
+
path.join(root, "pipeline-config"),
|
|
15
|
+
slug,
|
|
16
|
+
"tasks",
|
|
17
|
+
`${taskId}.analysis.json`,
|
|
18
|
+
);
|
|
19
|
+
if (validatedPath === null) {
|
|
20
|
+
return sendJson(400, { ok: false, code: "BAD_PATH", message: "invalid task-analysis path" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const analysis = await TaskAnalysisRepository.readFromPath(validatedPath);
|
|
24
|
+
if (analysis === null) {
|
|
9
25
|
return sendJson(404, { ok: false, code: "NOT_FOUND", message: "analysis file not found" });
|
|
10
26
|
}
|
|
11
|
-
return sendJson(200, { ok: true, data:
|
|
27
|
+
return sendJson(200, { ok: true, data: analysis });
|
|
12
28
|
}
|
|
@@ -3,12 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { sendJson } from "../utils/http-utils";
|
|
5
5
|
|
|
6
|
-
export async function handleTaskSave(req: Request): Promise<Response> {
|
|
6
|
+
export async function handleTaskSave(req: Request, root: string): Promise<Response> {
|
|
7
7
|
const body = (await req.json()) as Record<string, unknown>;
|
|
8
8
|
const slug = typeof body["slug"] === "string" ? body["slug"] : "";
|
|
9
9
|
const taskId = typeof body["taskId"] === "string" ? body["taskId"] : "";
|
|
10
10
|
const content = typeof body["content"] === "string" ? body["content"] : "";
|
|
11
|
-
const root = process.env["PO_ROOT"] ?? process.cwd();
|
|
12
11
|
const taskPath = path.join(root, "pipeline-config", slug, "tasks", `${taskId}.md`);
|
|
13
12
|
await mkdir(path.dirname(taskPath), { recursive: true });
|
|
14
13
|
await Bun.write(taskPath, content);
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
1
|
import { parseMultipartFormData, sendJson } from "../utils/http-utils";
|
|
5
2
|
import { extractSeedZip } from "../zip-utils";
|
|
6
3
|
import { createErrorResponse } from "../config-bridge";
|
|
7
4
|
import { Constants } from "../config-bridge-node";
|
|
8
|
-
import {
|
|
5
|
+
import { submitUploadedSeed } from "../../../core/job-submission.ts";
|
|
9
6
|
|
|
10
7
|
export interface SeedUploadResult {
|
|
11
8
|
seedObject: Record<string, unknown>;
|
|
12
9
|
artifacts?: Array<{ filename: string; content: Uint8Array }>;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
function nextJobId(): string {
|
|
16
|
-
return `job-${Date.now()}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
export async function normalizeSeedUpload(req: Request): Promise<SeedUploadResult> {
|
|
20
13
|
const contentType = req.headers.get("content-type") ?? "";
|
|
21
14
|
if (contentType.includes("application/json")) {
|
|
@@ -34,39 +27,11 @@ export async function handleSeedUploadDirect(
|
|
|
34
27
|
dataDir: string,
|
|
35
28
|
artifacts: Array<{ filename: string; content: Uint8Array }> = [],
|
|
36
29
|
): Promise<Response> {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Stage artifacts under staging/{jobId}/ (before the trigger file).
|
|
42
|
-
// The orchestrator owns current/{jobId}/ and moves staged artifacts into
|
|
43
|
-
// current/{jobId}/files/artifacts/ during promotion.
|
|
44
|
-
if (artifacts.length > 0) {
|
|
45
|
-
const stagingJobDir = path.join(pipelineData, "staging", jobId);
|
|
46
|
-
|
|
47
|
-
for (const artifact of artifacts) {
|
|
48
|
-
if (resolveWithin(stagingJobDir, artifact.filename) === null) {
|
|
49
|
-
return sendJson(400, createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, `unsafe artifact path: ${artifact.filename}`));
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
for (const artifact of artifacts) {
|
|
54
|
-
const resolved = resolveWithin(stagingJobDir, artifact.filename)!;
|
|
55
|
-
await mkdir(path.dirname(resolved), { recursive: true });
|
|
56
|
-
await Bun.write(resolved, artifact.content);
|
|
57
|
-
}
|
|
30
|
+
const result = await submitUploadedSeed({ root: dataDir, seedObject, artifacts });
|
|
31
|
+
if (!result.success) {
|
|
32
|
+
return sendJson(400, createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, result.message));
|
|
58
33
|
}
|
|
59
|
-
|
|
60
|
-
// Write the seed to pending/ as the trigger file for the orchestrator.
|
|
61
|
-
// The orchestrator watches pending/*.json and handles the full lifecycle:
|
|
62
|
-
// move to current/, write tasks-status.json, and spawn the pipeline runner.
|
|
63
|
-
await mkdir(pendingDir, { recursive: true });
|
|
64
|
-
await Bun.write(
|
|
65
|
-
path.join(pendingDir, `${jobId}-seed.json`),
|
|
66
|
-
JSON.stringify(seedObject, null, 2),
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
return sendJson(201, { ok: true, data: { jobId } });
|
|
34
|
+
return sendJson(201, { ok: true, data: { jobId: result.jobId } });
|
|
70
35
|
}
|
|
71
36
|
|
|
72
37
|
export async function handleSeedUpload(req: Request, dataDir: string): Promise<Response> {
|
package/src/ui/server/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { emitSseEvent, type SseEvent } from "../../config/sse-events.ts";
|
|
4
|
+
import { getPipelineDataDir, resolveWorkspaceRoot } from "../../config/paths";
|
|
5
|
+
import { getConfig, getOrchestratorConfig } from "../../core/config";
|
|
5
6
|
import { loadEnvironment } from "../../core/environment";
|
|
6
7
|
import { getConcurrencyRuntimePaths } from "../../core/job-concurrency";
|
|
7
8
|
import { createLogger } from "../../core/logger";
|
|
@@ -19,7 +20,8 @@ import { sseRegistry } from "./sse-registry";
|
|
|
19
20
|
const logger = createLogger("ui-server");
|
|
20
21
|
|
|
21
22
|
export interface ServerOptions {
|
|
22
|
-
|
|
23
|
+
/** Workspace root (not the `pipeline-data` subdir); omitted values fall back to `PO_ROOT` at boot. */
|
|
24
|
+
dataDir?: string;
|
|
23
25
|
port?: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -62,8 +64,8 @@ export async function initializeWatcher(dataDir: string): Promise<void> {
|
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
export function createServer(dataDir
|
|
66
|
-
const resolvedDataDir = dataDir
|
|
67
|
+
export function createServer(dataDir: string): { fetch: (req: Request) => Promise<Response> } {
|
|
68
|
+
const resolvedDataDir = dataDir;
|
|
67
69
|
const rawOrigins = process.env["PO_CORS_ORIGINS"];
|
|
68
70
|
const allowNull = process.env["PO_CORS_ALLOW_NULL_ORIGIN"];
|
|
69
71
|
const cors = parseCorsConfig(rawOrigins, allowNull === "1" || allowNull === "true");
|
|
@@ -72,18 +74,21 @@ export function createServer(dataDir?: string): { fetch: (req: Request) => Promi
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export async function startServer(options: ServerOptions): Promise<ServerHandle> {
|
|
75
|
-
|
|
76
|
-
throw new Error("PO_ROOT is required in non-test environments");
|
|
77
|
-
}
|
|
77
|
+
const root = resolveWorkspaceRoot(options.dataDir);
|
|
78
78
|
|
|
79
|
-
await loadEnvironment({ rootDir:
|
|
80
|
-
|
|
79
|
+
await loadEnvironment({ rootDir: root });
|
|
80
|
+
getConfig();
|
|
81
|
+
initPATHS(root);
|
|
81
82
|
|
|
82
83
|
const port = options.port ?? 4000;
|
|
83
|
-
const app = createServer(
|
|
84
|
+
const app = createServer(root);
|
|
84
85
|
|
|
85
86
|
let heartbeat: ReturnType<typeof setInterval> | null = setInterval(() => {
|
|
86
|
-
|
|
87
|
+
const event: SseEvent = {
|
|
88
|
+
type: "heartbeat",
|
|
89
|
+
data: { ok: true, timestamp: new Date().toISOString() },
|
|
90
|
+
};
|
|
91
|
+
emitSseEvent(sseRegistry, event);
|
|
87
92
|
}, 30_000);
|
|
88
93
|
|
|
89
94
|
let server: Bun.Server<undefined> | null = null;
|
|
@@ -112,7 +117,7 @@ export async function startServer(options: ServerOptions): Promise<ServerHandle>
|
|
|
112
117
|
]);
|
|
113
118
|
await startup;
|
|
114
119
|
try {
|
|
115
|
-
await initializeWatcher(
|
|
120
|
+
await initializeWatcher(root);
|
|
116
121
|
} catch (error) {
|
|
117
122
|
if (heartbeat) clearInterval(heartbeat);
|
|
118
123
|
heartbeat = null;
|
|
@@ -164,7 +169,7 @@ if (
|
|
|
164
169
|
process.argv[1] !== process.execPath &&
|
|
165
170
|
import.meta.url === Bun.pathToFileURL(process.argv[1]).href
|
|
166
171
|
) {
|
|
167
|
-
const dataDir = process.env["PO_ROOT"]
|
|
172
|
+
const dataDir = process.env["PO_ROOT"];
|
|
168
173
|
startServer({ dataDir }).catch((err) => {
|
|
169
174
|
console.error("Failed to start server:", err);
|
|
170
175
|
process.exit(1);
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
1
3
|
import type { ErrorEnvelope } from "./config-bridge";
|
|
2
4
|
import {
|
|
3
5
|
Constants,
|
|
4
6
|
createErrorResponse,
|
|
5
7
|
getTasksStatusPath,
|
|
6
8
|
isLocked,
|
|
9
|
+
resolvePipelinePaths,
|
|
7
10
|
validateJobId,
|
|
8
11
|
} from "./config-bridge-node";
|
|
9
12
|
import { getFileReadingStats, readFileWithRetry } from "./file-reader";
|
|
@@ -30,13 +33,22 @@ export interface JobReadingStats {
|
|
|
30
33
|
|
|
31
34
|
const READ_LOCATIONS = ["current", "complete"] as const;
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
function getTasksStatusPathForRoot(
|
|
37
|
+
jobId: string,
|
|
38
|
+
location: (typeof READ_LOCATIONS)[number],
|
|
39
|
+
root?: string,
|
|
40
|
+
): string {
|
|
41
|
+
if (root === undefined) return getTasksStatusPath(jobId, location);
|
|
42
|
+
return join(resolvePipelinePaths(root)[location], jobId, "tasks-status.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readJob(jobId: string, root?: string): Promise<JobReadResult> {
|
|
34
46
|
if (!validateJobId(jobId)) {
|
|
35
47
|
return { ...createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, "invalid job id"), jobId, location: "" };
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
for (const location of READ_LOCATIONS) {
|
|
39
|
-
const path =
|
|
51
|
+
const path = getTasksStatusPathForRoot(jobId, location, root);
|
|
40
52
|
const result = await readFileWithRetry(path);
|
|
41
53
|
if (!result.ok) {
|
|
42
54
|
if (result.code === Constants.ERROR_CODES.NOT_FOUND) continue;
|
package/src/ui/server/router.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import { resolveUnderRoot } from "../../utils/path-containment";
|
|
3
4
|
import { embeddedAssets } from "./embedded-assets";
|
|
5
|
+
import type { EmbeddedAssetEntry } from "./embedded-assets";
|
|
4
6
|
import { handleConcurrencyStatus } from "./endpoints/concurrency-endpoint";
|
|
5
7
|
import { handleCreatePipeline } from "./endpoints/create-pipeline-endpoint";
|
|
6
8
|
import { handleTaskFile, handleTaskFileList } from "./endpoints/file-endpoints";
|
|
@@ -38,6 +40,7 @@ interface Route {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
interface RouterOptions {
|
|
43
|
+
/** Workspace root (not the `pipeline-data` subdir). The CLI transport ensures this is set; the boot function asserts. */
|
|
41
44
|
dataDir: string;
|
|
42
45
|
distDir?: string;
|
|
43
46
|
cors?: CorsConfig;
|
|
@@ -49,6 +52,12 @@ function normalizeParams(groups: Record<string, string | undefined>): Record<str
|
|
|
49
52
|
);
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
function responseFromEmbedded(entry: EmbeddedAssetEntry): Response {
|
|
56
|
+
return new Response(Bun.file(entry.path), {
|
|
57
|
+
headers: { "Content-Type": entry.mime },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
export function createRouter(options: RouterOptions): {
|
|
53
62
|
addRoute(method: string, path: string, handler: RouteHandler): void;
|
|
54
63
|
handle(req: Request): Promise<Response>;
|
|
@@ -64,11 +73,14 @@ export function createRouter(options: RouterOptions): {
|
|
|
64
73
|
const serveAsset = async (pathname: string): Promise<Response | null> => {
|
|
65
74
|
const embedded = embeddedAssets[pathname];
|
|
66
75
|
if (embedded) {
|
|
67
|
-
return
|
|
76
|
+
return responseFromEmbedded(embedded);
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
const diskPath =
|
|
71
|
-
|
|
79
|
+
const diskPath = resolveUnderRoot(
|
|
80
|
+
distDir,
|
|
81
|
+
pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""),
|
|
82
|
+
);
|
|
83
|
+
if (diskPath !== null && (await Bun.file(diskPath).exists())) {
|
|
72
84
|
return new Response(Bun.file(diskPath), { headers: { "Content-Type": getMimeType(diskPath) } });
|
|
73
85
|
}
|
|
74
86
|
|
|
@@ -77,6 +89,11 @@ export function createRouter(options: RouterOptions): {
|
|
|
77
89
|
return new Response(Bun.file(indexPath), { headers: { "Content-Type": "text/html" } });
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
const shell = embeddedAssets["/"];
|
|
93
|
+
if (shell) {
|
|
94
|
+
return responseFromEmbedded(shell);
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
return null;
|
|
81
98
|
};
|
|
82
99
|
|
|
@@ -87,17 +104,17 @@ export function createRouter(options: RouterOptions): {
|
|
|
87
104
|
addRoute("POST", "/api/jobs/:jobId/stop", (req, params) => handleJobStop(req, params["jobId"]!, options.dataDir));
|
|
88
105
|
addRoute("POST", "/api/jobs/:jobId/rescan", (req, params) => handleJobRescan(req, params["jobId"]!, options.dataDir));
|
|
89
106
|
addRoute("POST", "/api/jobs/:jobId/tasks/:taskId/start", (req, params) => handleTaskStart(req, params["jobId"]!, params["taskId"]!, options.dataDir));
|
|
90
|
-
addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/files", (req, params) => handleTaskFileList(req, params["jobId"]!, params["taskId"]
|
|
91
|
-
addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/file", (req, params) => handleTaskFile(req, params["jobId"]!, params["taskId"]
|
|
107
|
+
addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/files", (req, params) => handleTaskFileList(req, params["jobId"]!, params["taskId"]!, options.dataDir));
|
|
108
|
+
addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/file", (req, params) => handleTaskFile(req, params["jobId"]!, params["taskId"]!, options.dataDir));
|
|
92
109
|
addRoute("GET", "/api/pipelines", () => handlePipelinesList());
|
|
93
110
|
addRoute("GET", "/api/pipelines/:slug", (_req, params) => handlePipelineTypeDetail(params["slug"]!));
|
|
94
|
-
addRoute("POST", "/api/pipelines", (req) => handleCreatePipeline(req));
|
|
95
|
-
addRoute("POST", "/api/pipelines/:slug/analyze", (req, params) => handlePipelineAnalysis(req, params["slug"]
|
|
111
|
+
addRoute("POST", "/api/pipelines", (req) => handleCreatePipeline(req, options.dataDir));
|
|
112
|
+
addRoute("POST", "/api/pipelines/:slug/analyze", (req, params) => handlePipelineAnalysis(req, params["slug"]!, options.dataDir));
|
|
96
113
|
addRoute("GET", "/api/pipelines/:slug/artifacts", (req, params) => handlePipelineArtifacts(req, params["slug"]!));
|
|
97
|
-
addRoute("GET", "/api/pipelines/:slug/tasks/:taskId/analysis", (req, params) => handleTaskAnalysis(req, params["slug"]!, params["taskId"]
|
|
98
|
-
addRoute("GET", "/api/pipelines/:slug/schemas/:filename", (req, params) => handleSchemaFile(req, params["slug"]!, params["filename"]
|
|
114
|
+
addRoute("GET", "/api/pipelines/:slug/tasks/:taskId/analysis", (req, params) => handleTaskAnalysis(req, params["slug"]!, params["taskId"]!, options.dataDir));
|
|
115
|
+
addRoute("GET", "/api/pipelines/:slug/schemas/:filename", (req, params) => handleSchemaFile(req, params["slug"]!, params["filename"]!, options.dataDir));
|
|
99
116
|
addRoute("POST", "/api/ai/task-plan", (req) => handleTaskPlan(req));
|
|
100
|
-
addRoute("POST", "/api/tasks/create", (req) => handleTaskSave(req));
|
|
117
|
+
addRoute("POST", "/api/tasks/create", (req) => handleTaskSave(req, options.dataDir));
|
|
101
118
|
addRoute("GET", "/api/state", () => handleApiState());
|
|
102
119
|
addRoute("GET", "/api/meta", () => handleMeta());
|
|
103
120
|
addRoute("GET", "/api/concurrency", () => handleConcurrencyStatus(options.dataDir));
|
|
@@ -150,6 +167,12 @@ export function createRouter(options: RouterOptions): {
|
|
|
150
167
|
return extraHeaders ? decorateHeaders(response, extraHeaders) : response;
|
|
151
168
|
}
|
|
152
169
|
|
|
170
|
+
if (url.pathname.startsWith("/api/")) {
|
|
171
|
+
const notFound = sendJson(404, { ok: false, code: "NOT_FOUND", message: "route not found" });
|
|
172
|
+
const extraHeaders = corsHeadersFor(origin, host, cors);
|
|
173
|
+
return extraHeaders ? decorateHeaders(notFound, extraHeaders) : notFound;
|
|
174
|
+
}
|
|
175
|
+
|
|
153
176
|
const asset = await serveAsset(url.pathname);
|
|
154
177
|
if (asset) {
|
|
155
178
|
const extraHeaders = corsHeadersFor(origin, host, cors);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emitSseEvent, type SseEvent } from "../../config/sse-events.ts";
|
|
1
2
|
import { createLogger } from "../../core/logger";
|
|
2
3
|
import { sseRegistry } from "./sse-registry";
|
|
3
4
|
|
|
@@ -20,6 +21,8 @@ function parseChange(change: ChangeLike): ChangeLike & { jobId?: string; lifecyc
|
|
|
20
21
|
return { ...change, lifecycle: match[1], jobId: match[2] };
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
type StateChangeData = Extract<SseEvent, { type: "state:change" }>["data"];
|
|
25
|
+
|
|
23
26
|
export function broadcastStateUpdate(currentState: StateLike): void {
|
|
24
27
|
try {
|
|
25
28
|
const prioritized = currentState.recentChanges.find((change) =>
|
|
@@ -27,11 +30,19 @@ export function broadcastStateUpdate(currentState: StateLike): void {
|
|
|
27
30
|
);
|
|
28
31
|
|
|
29
32
|
if (prioritized) {
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
const parsed = parseChange(prioritized);
|
|
34
|
+
if (typeof parsed.jobId === "string") {
|
|
35
|
+
const data: StateChangeData = { ...parsed, jobId: parsed.jobId };
|
|
36
|
+
emitSseEvent(sseRegistry, { type: "state:change", data });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
const event: SseEvent = {
|
|
42
|
+
type: "state:summary",
|
|
43
|
+
data: { changeCount: currentState.changeCount },
|
|
44
|
+
};
|
|
45
|
+
emitSseEvent(sseRegistry, event);
|
|
35
46
|
} catch (error) {
|
|
36
47
|
try {
|
|
37
48
|
logger.error("failed to broadcast state update", error);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { emitSseEvent, type JobWire, type SseEvent } from "../../config/sse-events.ts";
|
|
1
2
|
import { transformJobListForAPI } from "../state/transformers/list-transformer";
|
|
2
3
|
import { transformJobStatus } from "../state/transformers/status-transformer";
|
|
3
4
|
import { Constants } from "./config-bridge-node";
|
|
4
5
|
import { readJob, type JobReadResult } from "./job-reader";
|
|
5
6
|
import { sseRegistry, type SSERegistry } from "./sse-registry";
|
|
7
|
+
import { createLogger } from "../../core/logger";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger("ui-server-sse-enhancer");
|
|
6
10
|
|
|
7
11
|
export interface SSEEnhancerOptions {
|
|
8
12
|
readJobFn: (jobId: string) => Promise<JobReadResult>;
|
|
@@ -29,8 +33,14 @@ export function createSSEEnhancer(options: SSEEnhancerOptions): SSEEnhancer {
|
|
|
29
33
|
const transformed = transformJobStatus(result.data, jobId, result.location);
|
|
30
34
|
if (!transformed) return;
|
|
31
35
|
const [apiJob] = transformJobListForAPI([transformed], { includePipelineMetadata: true });
|
|
32
|
-
|
|
33
|
-
seen.
|
|
36
|
+
if (!apiJob) return;
|
|
37
|
+
const type: SseEvent["type"] = seen.has(jobId) ? "job:updated" : "job:created";
|
|
38
|
+
try {
|
|
39
|
+
emitSseEvent(options.sseRegistry, { type, data: apiJob as JobWire });
|
|
40
|
+
seen.add(jobId);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.error(`failed to emit ${type} for job ${jobId}`, error);
|
|
43
|
+
}
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
return {
|
|
@@ -19,7 +19,17 @@ describe("schema-loader", () => {
|
|
|
19
19
|
resetConfig();
|
|
20
20
|
await Bun.write(
|
|
21
21
|
registryPath,
|
|
22
|
-
JSON.stringify({
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
version: 1,
|
|
24
|
+
pipelines: {
|
|
25
|
+
demo: {
|
|
26
|
+
name: "Demo",
|
|
27
|
+
description: "demo pipeline",
|
|
28
|
+
configDir: "pipeline-config/demo",
|
|
29
|
+
tasksDir: "tasks/demo",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
23
33
|
);
|
|
24
34
|
await Bun.write(path.join(pipelineDir, "pipeline.json"), JSON.stringify({ slug: "demo" }));
|
|
25
35
|
});
|
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { initPATHS, resetPATHS } from "../../server/config-bridge-node";
|
|
3
8
|
import { buildSnapshotFromFilesystem, composeStateSnapshot } from "../snapshot";
|
|
9
|
+
import type { CanonicalJob, JobReadResult } from "../types";
|
|
10
|
+
|
|
11
|
+
const tempRoots: string[] = [];
|
|
12
|
+
|
|
13
|
+
async function makeTempRoot(): Promise<string> {
|
|
14
|
+
const root = await Bun.$`mktemp -d ${join(tmpdir(), "snapshot-XXXXXX")}`.text();
|
|
15
|
+
const trimmed = root.trim();
|
|
16
|
+
tempRoots.push(trimmed);
|
|
17
|
+
return trimmed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
resetPATHS();
|
|
22
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
23
|
+
});
|
|
4
24
|
|
|
5
25
|
describe("snapshot", () => {
|
|
6
26
|
it("composes default snapshots and extracts ids from variant fields", () => {
|
|
@@ -17,17 +37,22 @@ describe("snapshot", () => {
|
|
|
17
37
|
});
|
|
18
38
|
|
|
19
39
|
it("builds a sorted deduplicated filesystem snapshot from injected deps", async () => {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
location
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
const readLocations = ["current", "current", "complete"] as const;
|
|
41
|
+
let readIndex = 0;
|
|
42
|
+
const readJob = vi.fn(async (jobId: string) => {
|
|
43
|
+
const location = readLocations[readIndex++] ?? "current";
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
jobId,
|
|
47
|
+
location,
|
|
48
|
+
data: {
|
|
49
|
+
title: `${jobId}-${location}`,
|
|
50
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
51
|
+
updatedAt: location === "current" ? "2024-02-01T00:00:00.000Z" : "2024-01-15T00:00:00.000Z",
|
|
52
|
+
tasks: { a: { state: location === "current" ? "running" : "done" } },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
});
|
|
31
56
|
|
|
32
57
|
const snapshot = await buildSnapshotFromFilesystem({
|
|
33
58
|
listAllJobs: () => ({ current: ["job-1", "job-2"], complete: ["job-1"] }),
|
|
@@ -45,14 +70,45 @@ describe("snapshot", () => {
|
|
|
45
70
|
});
|
|
46
71
|
});
|
|
47
72
|
|
|
73
|
+
it("builds the default filesystem snapshot with the real job reader", async () => {
|
|
74
|
+
const root = await makeTempRoot();
|
|
75
|
+
initPATHS(root);
|
|
76
|
+
|
|
77
|
+
const jobDir = join(root, "pipeline-data", "current", "job-1");
|
|
78
|
+
await mkdir(jobDir, { recursive: true });
|
|
79
|
+
await writeFile(
|
|
80
|
+
join(jobDir, "tasks-status.json"),
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
jobId: "job-1",
|
|
83
|
+
name: "Job 1",
|
|
84
|
+
tasks: { research: { state: "done" } },
|
|
85
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
86
|
+
updatedAt: "2024-02-01T00:00:00.000Z",
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const snapshot = await buildSnapshotFromFilesystem({
|
|
91
|
+
now: () => new Date("2024-03-01T00:00:00.000Z"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(snapshot.jobs).toEqual([
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
jobId: "job-1",
|
|
97
|
+
title: "Job 1",
|
|
98
|
+
location: "current",
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
expect(snapshot.meta.lastUpdated).toBe("2024-03-01T00:00:00.000Z");
|
|
102
|
+
});
|
|
103
|
+
|
|
48
104
|
it("catches individual job read failures and continues with successful reads", async () => {
|
|
49
105
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
50
|
-
const readJob = vi.fn(async (jobId: string
|
|
106
|
+
const readJob = vi.fn(async (jobId: string) => {
|
|
51
107
|
if (jobId === "bad-job") throw new Error("disk read failed");
|
|
52
108
|
return {
|
|
53
109
|
ok: true,
|
|
54
110
|
jobId,
|
|
55
|
-
location,
|
|
111
|
+
location: "current",
|
|
56
112
|
data: { title: jobId, tasks: { a: { state: "done" } }, createdAt: "2024-01-01T00:00:00.000Z" },
|
|
57
113
|
};
|
|
58
114
|
});
|
|
@@ -81,4 +137,54 @@ describe("snapshot", () => {
|
|
|
81
137
|
}),
|
|
82
138
|
).rejects.toThrow(/unavailable/);
|
|
83
139
|
});
|
|
140
|
+
|
|
141
|
+
it("sorts snapshot jobs with failed (former error) priority first", async () => {
|
|
142
|
+
const statusByJobId: Record<string, CanonicalJob["status"]> = {
|
|
143
|
+
"job-failed": "failed",
|
|
144
|
+
"job-running": "running",
|
|
145
|
+
"job-complete": "complete",
|
|
146
|
+
"job-pending": "pending",
|
|
147
|
+
};
|
|
148
|
+
const transformMultipleJobs = vi.fn((results: JobReadResult[]): CanonicalJob[] =>
|
|
149
|
+
results.map((result) => ({
|
|
150
|
+
id: result.jobId,
|
|
151
|
+
jobId: result.jobId,
|
|
152
|
+
name: result.jobId,
|
|
153
|
+
title: result.jobId,
|
|
154
|
+
status: statusByJobId[result.jobId] ?? "pending",
|
|
155
|
+
progress: 0,
|
|
156
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
157
|
+
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
158
|
+
location: result.location,
|
|
159
|
+
tasks: {},
|
|
160
|
+
files: {},
|
|
161
|
+
costs: {},
|
|
162
|
+
})),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const snapshot = await buildSnapshotFromFilesystem({
|
|
166
|
+
listAllJobs: () => ({
|
|
167
|
+
current: ["job-pending", "job-running", "job-failed", "job-complete"],
|
|
168
|
+
complete: [],
|
|
169
|
+
}),
|
|
170
|
+
readJob: async (jobId) => ({ ok: true, jobId, location: "current", data: {} }),
|
|
171
|
+
transformMultipleJobs,
|
|
172
|
+
now: () => new Date("2024-03-01T00:00:00.000Z"),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(transformMultipleJobs).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(snapshot.jobs.map((job) => job.jobId)).toEqual([
|
|
177
|
+
"job-failed",
|
|
178
|
+
"job-running",
|
|
179
|
+
"job-complete",
|
|
180
|
+
"job-pending",
|
|
181
|
+
]);
|
|
182
|
+
expect(snapshot.jobs.map((job) => job.status)).toEqual([
|
|
183
|
+
"failed",
|
|
184
|
+
"running",
|
|
185
|
+
"complete",
|
|
186
|
+
"pending",
|
|
187
|
+
]);
|
|
188
|
+
expect(snapshot.jobs[0]?.jobId).toBe("job-failed");
|
|
189
|
+
});
|
|
84
190
|
});
|