pi-harness-runtime 1.1.428 → 1.1.430
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/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/dist/index.js +1 -1
- package/packages/clipboard-plugin/dist/index.js.map +1 -1
- package/packages/clipboard-plugin/package.json +1 -2
- package/packages/clipboard-plugin/src/index.ts +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/dist/runtime-api.d.ts +61 -6
- package/packages/runtime/dist/runtime-api.d.ts.map +1 -1
- package/packages/runtime/dist/runtime-api.js +83 -30
- package/packages/runtime/dist/runtime-api.js.map +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/runtime/src/runtime-api.ts +168 -52
- package/packages/runtime/test/runtime-api.test.ts +169 -0
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/lib/runtime-imports.ts +90 -26
- package/scripts/typecheck-packages.mjs +1 -1
- package/scripts/verify-package-contents.ts +42 -0
- package/harness/task-graph.d.ts +0 -51
- package/packages/runtime/command-executor.ts +0 -523
- package/packages/runtime/index.ts +0 -50
- package/packages/runtime/local-runtime-agent.ts +0 -480
- package/packages/runtime/policy-engine.ts +0 -813
- package/packages/runtime/runtime-api.ts +0 -665
- package/packages/runtime/workspace-manager.ts +0 -591
- package/packages/types/src/ai-providers.js +0 -66
- package/packages/types/src/runtime-types.js +0 -3
|
@@ -25,9 +25,86 @@ import { createServer, type Server, type IncomingMessage } from "node:http";
|
|
|
25
25
|
import { WebSocketServer, WebSocket } from "ws";
|
|
26
26
|
import { URL } from "node:url";
|
|
27
27
|
import { EventEmitter } from "node:events";
|
|
28
|
-
import type {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
import type {
|
|
29
|
+
JobStatus,
|
|
30
|
+
RuntimeCheckpoint,
|
|
31
|
+
RuntimeEvent,
|
|
32
|
+
RuntimeTask,
|
|
33
|
+
} from "../../types/dist/runtime-types.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The outcome of a job transition, as reported by the registered machine.
|
|
37
|
+
*/
|
|
38
|
+
export interface JobTransitionResult {
|
|
39
|
+
success: boolean;
|
|
40
|
+
checkpoint?: RuntimeCheckpoint;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The job actions the API exposes.
|
|
46
|
+
*
|
|
47
|
+
* The states are the ones the job state machine actually has:
|
|
48
|
+
* `paused_quota` is its only paused state and a paused job returns to
|
|
49
|
+
* `running`. There is no plain `paused` status, so the API must not
|
|
50
|
+
* pretend there is one.
|
|
51
|
+
*/
|
|
52
|
+
const JOB_ACTIONS: Record<
|
|
53
|
+
string,
|
|
54
|
+
{ status: JobStatus; message: string; event: string }
|
|
55
|
+
> = {
|
|
56
|
+
start: { status: "running", message: "Job started", event: "job.started" },
|
|
57
|
+
pause: {
|
|
58
|
+
status: "paused_quota",
|
|
59
|
+
message: "Job paused",
|
|
60
|
+
event: "job.paused",
|
|
61
|
+
},
|
|
62
|
+
resume: { status: "running", message: "Job resumed", event: "job.resumed" },
|
|
63
|
+
cancel: {
|
|
64
|
+
status: "cancelled",
|
|
65
|
+
message: "Job cancelled",
|
|
66
|
+
event: "job.cancelled",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The job state machine the harness registers.
|
|
72
|
+
*
|
|
73
|
+
* The runtime API does not implement a job: it stores the machine the
|
|
74
|
+
* harness already runs and reports on it. The package therefore describes
|
|
75
|
+
* the shape it uses instead of importing the harness - the harness is the
|
|
76
|
+
* app root, a package may not reach into it, and the import also dragged
|
|
77
|
+
* the whole harness program into this package's compilation.
|
|
78
|
+
*/
|
|
79
|
+
export interface JobStateMachineLike {
|
|
80
|
+
getStatusSummary(): {
|
|
81
|
+
status: JobStatus;
|
|
82
|
+
isTerminal: boolean;
|
|
83
|
+
canResume: boolean;
|
|
84
|
+
} | null;
|
|
85
|
+
getCheckpoint(): RuntimeCheckpoint | null | Promise<RuntimeCheckpoint | null>;
|
|
86
|
+
resumeJob(jobId: string): Promise<JobTransitionResult>;
|
|
87
|
+
transition(
|
|
88
|
+
to: JobStatus,
|
|
89
|
+
data?: Partial<RuntimeCheckpoint>,
|
|
90
|
+
): Promise<JobTransitionResult>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The task graph the harness registers.
|
|
95
|
+
*/
|
|
96
|
+
export interface TaskGraphLike {
|
|
97
|
+
getAllTasks(): RuntimeTask[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The checkpoint manager the harness registers.
|
|
102
|
+
*/
|
|
103
|
+
export interface CheckpointManagerLike {
|
|
104
|
+
save(checkpoint: RuntimeCheckpoint): Promise<void>;
|
|
105
|
+
load(jobId: string): Promise<RuntimeCheckpoint | null>;
|
|
106
|
+
appendEvent(jobId: string, event: RuntimeEvent): Promise<void>;
|
|
107
|
+
}
|
|
31
108
|
|
|
32
109
|
export interface RuntimeApiConfig {
|
|
33
110
|
/** Port to listen on (default: 3849) */
|
|
@@ -107,9 +184,9 @@ export class RuntimeApi extends EventEmitter {
|
|
|
107
184
|
private wsClients: Set<WebSocket> = new Set();
|
|
108
185
|
private isRunning = false;
|
|
109
186
|
private startTime: number = 0;
|
|
110
|
-
private jobMachines: Map<string,
|
|
111
|
-
private taskGraphs: Map<string,
|
|
112
|
-
private checkpoints: Map<string,
|
|
187
|
+
private jobMachines: Map<string, JobStateMachineLike> = new Map();
|
|
188
|
+
private taskGraphs: Map<string, TaskGraphLike> = new Map();
|
|
189
|
+
private checkpoints: Map<string, CheckpointManagerLike> = new Map();
|
|
113
190
|
private requestCounter = 0;
|
|
114
191
|
|
|
115
192
|
constructor(config: RuntimeApiConfig = {}) {
|
|
@@ -118,6 +195,18 @@ export class RuntimeApi extends EventEmitter {
|
|
|
118
195
|
this.startTime = Date.now();
|
|
119
196
|
}
|
|
120
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The port the API listens on.
|
|
200
|
+
*
|
|
201
|
+
* The operating system picks the port when the API was started on port
|
|
202
|
+
* 0, so the running port is not always the configured one.
|
|
203
|
+
*/
|
|
204
|
+
getPort(): number | null {
|
|
205
|
+
const address = this.server?.address();
|
|
206
|
+
if (!address || typeof address === "string") return null;
|
|
207
|
+
return address.port;
|
|
208
|
+
}
|
|
209
|
+
|
|
121
210
|
/**
|
|
122
211
|
* Start the Runtime API server
|
|
123
212
|
*/
|
|
@@ -212,21 +301,24 @@ export class RuntimeApi extends EventEmitter {
|
|
|
212
301
|
/**
|
|
213
302
|
* Register a job machine
|
|
214
303
|
*/
|
|
215
|
-
registerJob(jobId: string, machine:
|
|
304
|
+
registerJob(jobId: string, machine: JobStateMachineLike): void {
|
|
216
305
|
this.jobMachines.set(jobId, machine);
|
|
217
306
|
}
|
|
218
307
|
|
|
219
308
|
/**
|
|
220
309
|
* Register a task graph
|
|
221
310
|
*/
|
|
222
|
-
registerTaskGraph(jobId: string, graph:
|
|
311
|
+
registerTaskGraph(jobId: string, graph: TaskGraphLike): void {
|
|
223
312
|
this.taskGraphs.set(jobId, graph);
|
|
224
313
|
}
|
|
225
314
|
|
|
226
315
|
/**
|
|
227
316
|
* Register a checkpoint manager
|
|
228
317
|
*/
|
|
229
|
-
registerCheckpointManager(
|
|
318
|
+
registerCheckpointManager(
|
|
319
|
+
jobId: string,
|
|
320
|
+
manager: CheckpointManagerLike,
|
|
321
|
+
): void {
|
|
230
322
|
this.checkpoints.set(jobId, manager);
|
|
231
323
|
}
|
|
232
324
|
|
|
@@ -315,22 +407,32 @@ export class RuntimeApi extends EventEmitter {
|
|
|
315
407
|
res: import("node:http").ServerResponse,
|
|
316
408
|
): void {
|
|
317
409
|
const origin = req.headers.origin;
|
|
318
|
-
|
|
410
|
+
if (!origin) {
|
|
411
|
+
// Only a browser request carries an Origin header, and only a
|
|
412
|
+
// browser request is affected by the CORS headers.
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
319
415
|
|
|
416
|
+
const allowedOrigins = this.config.corsOrigins ?? ["*"];
|
|
320
417
|
if (
|
|
321
|
-
allowedOrigins.includes("*")
|
|
322
|
-
|
|
418
|
+
!allowedOrigins.includes("*") &&
|
|
419
|
+
!allowedOrigins.includes(origin)
|
|
323
420
|
) {
|
|
324
|
-
|
|
325
|
-
res.setHeader(
|
|
326
|
-
"Access-Control-Allow-Methods",
|
|
327
|
-
"GET, POST, PUT, DELETE, OPTIONS",
|
|
328
|
-
);
|
|
329
|
-
res.setHeader(
|
|
330
|
-
"Access-Control-Allow-Headers",
|
|
331
|
-
"Content-Type, Authorization, X-Api-Key",
|
|
332
|
-
);
|
|
421
|
+
return;
|
|
333
422
|
}
|
|
423
|
+
|
|
424
|
+
// The request origin is reflected instead of the wildcard: the API
|
|
425
|
+
// never enables credentialed requests, and a reflected origin keeps
|
|
426
|
+
// the response usable for a browser client.
|
|
427
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
428
|
+
res.setHeader(
|
|
429
|
+
"Access-Control-Allow-Methods",
|
|
430
|
+
"GET, POST, PUT, DELETE, OPTIONS",
|
|
431
|
+
);
|
|
432
|
+
res.setHeader(
|
|
433
|
+
"Access-Control-Allow-Headers",
|
|
434
|
+
"Content-Type, Authorization, X-Api-Key",
|
|
435
|
+
);
|
|
334
436
|
}
|
|
335
437
|
|
|
336
438
|
private async handleRequest(
|
|
@@ -400,11 +502,7 @@ export class RuntimeApi extends EventEmitter {
|
|
|
400
502
|
const summary = machine.getStatusSummary();
|
|
401
503
|
if (summary) {
|
|
402
504
|
if (summary.status === "running") status.jobs.active++;
|
|
403
|
-
else if (
|
|
404
|
-
summary.status === "paused" ||
|
|
405
|
-
summary.status === "paused_quota"
|
|
406
|
-
)
|
|
407
|
-
status.jobs.paused++;
|
|
505
|
+
else if (summary.status === "paused_quota") status.jobs.paused++;
|
|
408
506
|
else if (summary.isTerminal) status.jobs.completed++;
|
|
409
507
|
}
|
|
410
508
|
}
|
|
@@ -498,7 +596,7 @@ export class RuntimeApi extends EventEmitter {
|
|
|
498
596
|
jobId: string,
|
|
499
597
|
method: string,
|
|
500
598
|
url: URL,
|
|
501
|
-
|
|
599
|
+
_req: IncomingMessage,
|
|
502
600
|
res: import("node:http").ServerResponse,
|
|
503
601
|
requestId: string,
|
|
504
602
|
): Promise<void> {
|
|
@@ -550,43 +648,61 @@ export class RuntimeApi extends EventEmitter {
|
|
|
550
648
|
const action = url.searchParams.get("action");
|
|
551
649
|
|
|
552
650
|
switch (method) {
|
|
553
|
-
case "POST":
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
this.
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
res,
|
|
563
|
-
200,
|
|
564
|
-
{ message: "Job paused", jobId, result },
|
|
565
|
-
requestId,
|
|
566
|
-
);
|
|
567
|
-
} else if (action === "resume") {
|
|
568
|
-
await machine.resumeJob(jobId);
|
|
569
|
-
this.broadcast("job.resumed", { jobId });
|
|
570
|
-
this.sendJson(res, 200, { message: "Job resumed", jobId }, requestId);
|
|
571
|
-
} else if (action === "cancel") {
|
|
572
|
-
const result = await machine.transition("cancelled");
|
|
573
|
-
this.broadcast("job.cancelled", { jobId });
|
|
651
|
+
case "POST": {
|
|
652
|
+
const target = JOB_ACTIONS[action ?? ""];
|
|
653
|
+
if (!target) {
|
|
654
|
+
this.sendJson(res, 400, { error: "Unknown action" }, requestId);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const result = await this.moveJob(machine, jobId, target.status);
|
|
659
|
+
if (!result.success) {
|
|
574
660
|
this.sendJson(
|
|
575
661
|
res,
|
|
576
|
-
|
|
577
|
-
{
|
|
662
|
+
409,
|
|
663
|
+
{
|
|
664
|
+
error: result.error ?? `Cannot ${target.message.toLowerCase()}`,
|
|
665
|
+
jobId,
|
|
666
|
+
},
|
|
578
667
|
requestId,
|
|
579
668
|
);
|
|
580
|
-
|
|
581
|
-
this.sendJson(res, 400, { error: "Unknown action" }, requestId);
|
|
669
|
+
break;
|
|
582
670
|
}
|
|
671
|
+
|
|
672
|
+
this.broadcast(target.event, { jobId });
|
|
673
|
+
this.sendJson(res, 200, { message: target.message, jobId }, requestId);
|
|
583
674
|
break;
|
|
675
|
+
}
|
|
584
676
|
|
|
585
677
|
default:
|
|
586
678
|
this.sendJson(res, 405, { error: "Method not allowed" }, requestId);
|
|
587
679
|
}
|
|
588
680
|
}
|
|
589
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Move a job to a new state.
|
|
684
|
+
*
|
|
685
|
+
* The machine keeps the active checkpoint in memory, so it has to be
|
|
686
|
+
* activated for the job before it can transition. The transition itself
|
|
687
|
+
* validates the move against the state machine's rules and reports the
|
|
688
|
+
* outcome, so a refused move never becomes a false success.
|
|
689
|
+
*/
|
|
690
|
+
private async moveJob(
|
|
691
|
+
machine: JobStateMachineLike,
|
|
692
|
+
jobId: string,
|
|
693
|
+
to: JobStatus,
|
|
694
|
+
): Promise<JobTransitionResult> {
|
|
695
|
+
const activated = await machine.resumeJob(jobId);
|
|
696
|
+
if (!activated.success) {
|
|
697
|
+
return {
|
|
698
|
+
success: false,
|
|
699
|
+
error: activated.error ?? `Job ${jobId} is not available`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return machine.transition(to);
|
|
704
|
+
}
|
|
705
|
+
|
|
590
706
|
private async handleGetQuota(
|
|
591
707
|
res: import("node:http").ServerResponse,
|
|
592
708
|
requestId: string,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime API tests — the job actions and the states they use.
|
|
3
|
+
*
|
|
4
|
+
* The API stores the job machine the harness registers and moves the job
|
|
5
|
+
* through the state machine. The states are the ones the state machine
|
|
6
|
+
* actually has: `paused_quota` is its only paused state, and there is no
|
|
7
|
+
* plain `paused` status.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
RuntimeApi,
|
|
13
|
+
type JobStateMachineLike,
|
|
14
|
+
type JobTransitionResult,
|
|
15
|
+
} from "../src/runtime-api.js";
|
|
16
|
+
import type {
|
|
17
|
+
JobStatus,
|
|
18
|
+
RuntimeCheckpoint,
|
|
19
|
+
} from "../../types/dist/runtime-types.js";
|
|
20
|
+
|
|
21
|
+
/** The states the fake machine accepts. */
|
|
22
|
+
const ACCEPTED: JobStatus[] = ["running", "paused_quota", "cancelled"];
|
|
23
|
+
|
|
24
|
+
/** The envelope every API response uses. */
|
|
25
|
+
interface ApiResponseBody<T> {
|
|
26
|
+
success: boolean;
|
|
27
|
+
data?: T;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the JSON body of a response. */
|
|
32
|
+
async function readJson<T>(res: Response): Promise<ApiResponseBody<T>> {
|
|
33
|
+
return (await res.json()) as ApiResponseBody<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A job machine that records the moves the API asks for. */
|
|
37
|
+
class FakeMachine implements JobStateMachineLike {
|
|
38
|
+
status: JobStatus = "running";
|
|
39
|
+
readonly moves: JobStatus[] = [];
|
|
40
|
+
|
|
41
|
+
getStatusSummary() {
|
|
42
|
+
return {
|
|
43
|
+
status: this.status,
|
|
44
|
+
isTerminal: this.status === "cancelled",
|
|
45
|
+
canResume: this.status === "paused_quota",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getCheckpoint(): RuntimeCheckpoint {
|
|
50
|
+
return {
|
|
51
|
+
version: 1,
|
|
52
|
+
jobId: "job-1",
|
|
53
|
+
status: this.status,
|
|
54
|
+
requirement: "do the thing",
|
|
55
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
56
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async resumeJob(): Promise<JobTransitionResult> {
|
|
61
|
+
return { success: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async transition(to: JobStatus): Promise<JobTransitionResult> {
|
|
65
|
+
this.moves.push(to);
|
|
66
|
+
if (!ACCEPTED.includes(to)) {
|
|
67
|
+
return { success: false, error: `Invalid transition to ${to}` };
|
|
68
|
+
}
|
|
69
|
+
this.status = to;
|
|
70
|
+
return { success: true };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("RuntimeApi job actions", () => {
|
|
75
|
+
let api: RuntimeApi;
|
|
76
|
+
let machine: FakeMachine;
|
|
77
|
+
let base: string;
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
api = new RuntimeApi({ port: 0, host: "127.0.0.1" });
|
|
81
|
+
machine = new FakeMachine();
|
|
82
|
+
api.registerJob("job-1", machine);
|
|
83
|
+
await api.start();
|
|
84
|
+
base = `http://127.0.0.1:${api.getPort()}`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
await api.stop();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("pauses a job into the state the machine has", async () => {
|
|
92
|
+
const res = await fetch(`${base}/jobs/job-1?action=pause`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
expect(machine.moves).toEqual(["paused_quota"]);
|
|
98
|
+
expect(machine.status).toBe("paused_quota");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resumes a paused job", async () => {
|
|
102
|
+
await fetch(`${base}/jobs/job-1?action=pause`, { method: "POST" });
|
|
103
|
+
machine.moves.length = 0;
|
|
104
|
+
|
|
105
|
+
const res = await fetch(`${base}/jobs/job-1?action=resume`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(machine.moves).toEqual(["running"]);
|
|
111
|
+
expect(machine.status).toBe("running");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("cancels a job", async () => {
|
|
115
|
+
const res = await fetch(`${base}/jobs/job-1?action=cancel`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
expect(machine.status).toBe("cancelled");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reports a refused move instead of a false success", async () => {
|
|
124
|
+
const refusing = new FakeMachine();
|
|
125
|
+
refusing.transition = async (to: JobStatus) => {
|
|
126
|
+
refusing.moves.push(to);
|
|
127
|
+
return { success: false, error: "the state machine refused the move" };
|
|
128
|
+
};
|
|
129
|
+
api.registerJob("job-2", refusing);
|
|
130
|
+
|
|
131
|
+
const res = await fetch(`${base}/jobs/job-2?action=pause`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(res.status).toBe(409);
|
|
136
|
+
const body = await readJson<never>(res);
|
|
137
|
+
expect(body.error).toBe("the state machine refused the move");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rejects an unknown action", async () => {
|
|
141
|
+
const res = await fetch(`${base}/jobs/job-1?action=explode`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res.status).toBe(400);
|
|
146
|
+
expect(machine.moves).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("counts a paused job as paused", async () => {
|
|
150
|
+
await fetch(`${base}/jobs/job-1?action=pause`, { method: "POST" });
|
|
151
|
+
|
|
152
|
+
const res = await fetch(`${base}/status`);
|
|
153
|
+
const body = await readJson<{
|
|
154
|
+
jobs: { total: number; active: number; paused: number; completed: number };
|
|
155
|
+
}>(res);
|
|
156
|
+
|
|
157
|
+
expect(body.data?.jobs.total).toBe(1);
|
|
158
|
+
expect(body.data?.jobs.paused).toBe(1);
|
|
159
|
+
expect(body.data?.jobs.active).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reports an unknown job", async () => {
|
|
163
|
+
const res = await fetch(`${base}/jobs/nope?action=pause`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(404);
|
|
168
|
+
});
|
|
169
|
+
});
|