@velum-labs/routekit-harness-core 0.18.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel.d.ts +10 -1
- package/dist/channel.js +50 -26
- package/dist/effect/lifecycle.d.ts +10 -0
- package/dist/effect/lifecycle.js +20 -0
- package/dist/effect-api.d.ts +1 -0
- package/dist/effect-api.js +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +3 -0
- package/dist/index.d.ts +19 -16
- package/dist/index.js +9 -8
- package/dist/lifecycle-api.d.ts +2 -0
- package/dist/lifecycle-api.js +1 -0
- package/dist/lifecycle.d.ts +47 -0
- package/dist/lifecycle.js +138 -0
- package/dist/process.d.ts +6 -2
- package/dist/process.js +4 -1
- package/dist/test/effect-lifecycle.test.d.ts +1 -0
- package/dist/test/effect-lifecycle.test.js +33 -0
- package/dist/test/harness-core.test.js +60 -4
- package/dist/testing/contract-suite.js +39 -0
- package/dist/testing/mock-driver.js +61 -42
- package/package.json +12 -3
package/dist/channel.d.ts
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
* drivers (ACP, SSE) to bridge notification callbacks into the `sendTurn`
|
|
5
5
|
* async iterable.
|
|
6
6
|
*/
|
|
7
|
+
export type AsyncChannelOptions = {
|
|
8
|
+
/** Maximum number of values retained while the consumer is not reading. */
|
|
9
|
+
capacity?: number;
|
|
10
|
+
/** Overflow is explicit: fail the stream or retain only the newest values. */
|
|
11
|
+
overflow?: "error" | "drop-oldest";
|
|
12
|
+
/** Invoked when the consumer returns before the producer closes the stream. */
|
|
13
|
+
onConsumerReturn?: () => void;
|
|
14
|
+
};
|
|
7
15
|
export declare class AsyncChannel<T> {
|
|
8
16
|
#private;
|
|
9
|
-
|
|
17
|
+
constructor(options?: AsyncChannelOptions);
|
|
18
|
+
push(value: T): boolean;
|
|
10
19
|
close(error?: unknown): void;
|
|
11
20
|
[Symbol.asyncIterator](): AsyncGenerator<T>;
|
|
12
21
|
}
|
package/dist/channel.js
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A single-consumer async channel: producers `push` values (or `close`), the
|
|
3
|
-
* consumer drains them via `[Symbol.asyncIterator]`. Used by transport-driven
|
|
4
|
-
* drivers (ACP, SSE) to bridge notification callbacks into the `sendTurn`
|
|
5
|
-
* async iterable.
|
|
6
|
-
*/
|
|
7
1
|
export class AsyncChannel {
|
|
8
2
|
#queue = [];
|
|
3
|
+
#capacity;
|
|
4
|
+
#overflow;
|
|
5
|
+
#onConsumerReturn;
|
|
9
6
|
#waiter;
|
|
10
7
|
#closed = false;
|
|
11
8
|
#error;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.#capacity = options.capacity ?? 256;
|
|
11
|
+
if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1) {
|
|
12
|
+
throw new RangeError("AsyncChannel capacity must be a positive safe integer");
|
|
13
|
+
}
|
|
14
|
+
this.#overflow = options.overflow ?? "error";
|
|
15
|
+
this.#onConsumerReturn = options.onConsumerReturn;
|
|
16
|
+
}
|
|
12
17
|
push(value) {
|
|
13
18
|
if (this.#closed)
|
|
14
|
-
return;
|
|
19
|
+
return false;
|
|
15
20
|
if (this.#waiter !== undefined) {
|
|
16
21
|
const resolve = this.#waiter;
|
|
17
22
|
this.#waiter = undefined;
|
|
18
23
|
resolve({ value, done: false });
|
|
19
|
-
return;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (this.#queue.length >= this.#capacity) {
|
|
27
|
+
if (this.#overflow === "drop-oldest") {
|
|
28
|
+
this.#queue.shift();
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
this.close(new Error(`AsyncChannel capacity ${this.#capacity} exceeded`));
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
20
34
|
}
|
|
21
35
|
this.#queue.push(value);
|
|
36
|
+
return true;
|
|
22
37
|
}
|
|
23
38
|
close(error) {
|
|
24
39
|
if (this.#closed)
|
|
@@ -38,25 +53,34 @@ export class AsyncChannel {
|
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
55
|
async *[Symbol.asyncIterator]() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
let drained = false;
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (this.#queue.length > 0) {
|
|
60
|
+
yield this.#queue.shift();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (this.#closed) {
|
|
64
|
+
drained = true;
|
|
65
|
+
if (this.#error !== undefined)
|
|
66
|
+
throw this.#error;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const next = await new Promise((resolve) => {
|
|
70
|
+
this.#waiter = resolve;
|
|
71
|
+
});
|
|
72
|
+
if (next.done === true) {
|
|
73
|
+
drained = true;
|
|
74
|
+
if (this.#error !== undefined)
|
|
75
|
+
throw this.#error;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
yield next.value;
|
|
50
79
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (this.#error !== undefined)
|
|
56
|
-
throw this.#error;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
yield next.value;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (!drained && !this.#closed)
|
|
83
|
+
this.#onConsumerReturn?.();
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { SessionResourceRegistry, SingleFlightTurnController, type TurnLease } from "../lifecycle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Effect façade over harness turn and session lifetime.
|
|
5
|
+
*
|
|
6
|
+
* The one-live-turn rule stays on `SingleFlightTurnController`. Scope close
|
|
7
|
+
* disposes an incomplete lease, which aborts the native operation.
|
|
8
|
+
*/
|
|
9
|
+
export declare function scopedTurn(controller: SingleFlightTurnController, external?: AbortSignal): Effect.Effect<TurnLease, import("@velum-labs/routekit-runtime/effect").RouteKitFailure, import("effect/Scope").Scope>;
|
|
10
|
+
export declare function scopedSessionRegistry(registry: SessionResourceRegistry): Effect.Effect<SessionResourceRegistry, never, import("effect/Scope").Scope>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { toRouteKitFailure } from "@velum-labs/routekit-runtime/effect";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
/**
|
|
4
|
+
* Effect façade over harness turn and session lifetime.
|
|
5
|
+
*
|
|
6
|
+
* The one-live-turn rule stays on `SingleFlightTurnController`. Scope close
|
|
7
|
+
* disposes an incomplete lease, which aborts the native operation.
|
|
8
|
+
*/
|
|
9
|
+
export function scopedTurn(controller, external) {
|
|
10
|
+
return Effect.acquireRelease(Effect.try({
|
|
11
|
+
try: () => controller.start(external),
|
|
12
|
+
catch: (cause) => toRouteKitFailure(cause)
|
|
13
|
+
}), (lease) => Effect.sync(() => lease.dispose()));
|
|
14
|
+
}
|
|
15
|
+
export function scopedSessionRegistry(registry) {
|
|
16
|
+
return Effect.acquireRelease(Effect.succeed(registry), (owned) => Effect.tryPromise({
|
|
17
|
+
try: () => owned.dispose(),
|
|
18
|
+
catch: (cause) => toRouteKitFailure(cause)
|
|
19
|
+
}).pipe(Effect.ignore));
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { scopedSessionRegistry, scopedTurn } from "./effect/lifecycle.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { scopedSessionRegistry, scopedTurn } from "./effect/lifecycle.js";
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* these codes at the boundary where they occur; retryability and failover
|
|
4
4
|
* category are derived from the code, never hand-picked per call site.
|
|
5
5
|
*/
|
|
6
|
-
export declare const HARNESS_ERROR_CODES: readonly ["not_installed", "not_authenticated", "version_unsupported", "invalid_config", "session_closed", "protocol_parse", "timeout", "aborted", "provider_error"];
|
|
6
|
+
export declare const HARNESS_ERROR_CODES: readonly ["not_installed", "not_authenticated", "version_unsupported", "invalid_config", "session_closed", "session_busy", "protocol_parse", "timeout", "aborted", "provider_error"];
|
|
7
7
|
export type HarnessErrorCode = (typeof HARNESS_ERROR_CODES)[number];
|
|
8
8
|
/** The gateway's provider-failover taxonomy. */
|
|
9
9
|
export type HarnessErrorCategory = "transient" | "quota_exhausted" | "auth_permanent" | "context_overflow" | "unknown";
|
package/dist/errors.js
CHANGED
|
@@ -14,6 +14,8 @@ export const HARNESS_ERROR_CODES = [
|
|
|
14
14
|
"invalid_config",
|
|
15
15
|
/** The session existed but its process/connection has gone away. */
|
|
16
16
|
"session_closed",
|
|
17
|
+
/** A second turn was attempted while the session already had a live turn. */
|
|
18
|
+
"session_busy",
|
|
17
19
|
/** A wire payload failed schema/shape validation. */
|
|
18
20
|
"protocol_parse",
|
|
19
21
|
/** The run exceeded its deadline. */
|
|
@@ -41,6 +43,7 @@ function defaultCategoryFor(code) {
|
|
|
41
43
|
switch (code) {
|
|
42
44
|
case "timeout":
|
|
43
45
|
case "session_closed":
|
|
46
|
+
case "session_busy":
|
|
44
47
|
return "transient";
|
|
45
48
|
case "not_authenticated":
|
|
46
49
|
return "auth_permanent";
|
package/dist/index.d.ts
CHANGED
|
@@ -7,24 +7,27 @@
|
|
|
7
7
|
* registry. Drivers (tool-codex, tool-claude, tool-cursor, tool-opencode)
|
|
8
8
|
* implement this contract; orchestrators and launchers consume it.
|
|
9
9
|
*/
|
|
10
|
-
export { HARNESS_KINDS, isHarnessKind } from "./kinds.js";
|
|
11
|
-
export type { HarnessKind } from "./kinds.js";
|
|
12
|
-
export { HARNESS_ERROR_CODES, HarnessError, asHarnessError, isRetryable } from "./errors.js";
|
|
13
|
-
export type { HarnessErrorCategory, HarnessErrorCode } from "./errors.js";
|
|
14
|
-
export type { HarnessContentStream, HarnessEvent, HarnessEventRaw, HarnessEventType, HarnessItemType, HarnessRequestType, HarnessTokenUsage, HarnessTurnEndReason } from "./events.js";
|
|
15
|
-
export { DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, createDeferred, decideApproval } from "./approvals.js";
|
|
16
10
|
export type { ApprovalDecision, ApprovalPolicy, Deferred, PendingRequest } from "./approvals.js";
|
|
17
|
-
export {
|
|
18
|
-
export type {
|
|
11
|
+
export { createDeferred, DEFAULT_AUTOMATION_APPROVAL_POLICY, decideApproval, PendingRequests } from "./approvals.js";
|
|
12
|
+
export type { AsyncChannelOptions } from "./channel.js";
|
|
13
|
+
export { AsyncChannel } from "./channel.js";
|
|
19
14
|
export type { AnyHarnessDriver, DriverContext, HarnessDriver, HarnessInstance, ResumeCursor, SessionHandle, SessionTurnInput, StartSessionOptions } from "./contract.js";
|
|
20
|
-
export { DriverRegistry } from "./registry.js";
|
|
21
|
-
export { createCachedHarnessDriver, probeCliVersion, resolveDriverEnv } from "./driver-factory.js";
|
|
22
15
|
export type { CachedHarnessDriverInput, CliVersionProbeInput } from "./driver-factory.js";
|
|
23
|
-
export {
|
|
24
|
-
export {
|
|
16
|
+
export { createCachedHarnessDriver, probeCliVersion, resolveDriverEnv } from "./driver-factory.js";
|
|
17
|
+
export type { HarnessErrorCategory, HarnessErrorCode } from "./errors.js";
|
|
18
|
+
export { asHarnessError, HARNESS_ERROR_CODES, HarnessError, isRetryable } from "./errors.js";
|
|
19
|
+
export type { HarnessContentStream, HarnessEvent, HarnessEventRaw, HarnessEventType, HarnessItemType, HarnessRequestType, HarnessTokenUsage, HarnessTurnEndReason } from "./events.js";
|
|
20
|
+
export type { HarnessKind } from "./kinds.js";
|
|
21
|
+
export { HARNESS_KINDS, isHarnessKind } from "./kinds.js";
|
|
22
|
+
export type { TurnLease } from "./lifecycle.js";
|
|
23
|
+
export { ManagedSession, nowIso, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "./lifecycle.js";
|
|
25
24
|
export type { EventLogOptions } from "./logging.js";
|
|
26
|
-
export {
|
|
27
|
-
export type { ParsedStreamJson, ParseStreamJsonOptions, StreamJsonEmitterOptions, StreamJsonStepText } from "./stream-json.js";
|
|
28
|
-
export { DEFAULT_TMP_MANIFEST, createTrackedTmpDir, releaseTrackedTmpDir, sweepTrackedTmpDirs } from "./tmp-sweep.js";
|
|
29
|
-
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "./process.js";
|
|
25
|
+
export { EventLog } from "./logging.js";
|
|
30
26
|
export type { BuildChildEnvInput, CliCaptureOptions, CliCaptureResult, LoggedChild, LoggedSpawnOptions } from "./process.js";
|
|
27
|
+
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "./process.js";
|
|
28
|
+
export { DriverRegistry } from "./registry.js";
|
|
29
|
+
export type { HarnessAuthStatus, HarnessModelDescriptor, HarnessStatus } from "./status.js";
|
|
30
|
+
export { DEFAULT_STATUS_CACHE_DIR, readCachedStatus, statusSkipReason, writeCachedStatus } from "./status.js";
|
|
31
|
+
export type { ParsedStreamJson, ParseStreamJsonOptions, StreamJsonEmitterOptions, StreamJsonStepText } from "./stream-json.js";
|
|
32
|
+
export { asArray, asObject, asString, createStreamJsonStepEmitter, parseStreamJsonLine, parseStreamJsonTrajectory, STREAM_JSON_MAX_TEXT, STREAM_JSON_MAX_TOOL_INPUT, streamJsonResultContentText, stringifyStreamJsonValue, truncateStreamJsonText } from "./stream-json.js";
|
|
33
|
+
export { createTrackedTmpDir, DEFAULT_TMP_MANIFEST, releaseTrackedTmpDir, sweepTrackedTmpDirs } from "./tmp-sweep.js";
|
package/dist/index.js
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* registry. Drivers (tool-codex, tool-claude, tool-cursor, tool-opencode)
|
|
8
8
|
* implement this contract; orchestrators and launchers consume it.
|
|
9
9
|
*/
|
|
10
|
-
export {
|
|
11
|
-
export { HARNESS_ERROR_CODES, HarnessError, asHarnessError, isRetryable } from "./errors.js";
|
|
12
|
-
export { DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, createDeferred, decideApproval } from "./approvals.js";
|
|
13
|
-
export { DEFAULT_STATUS_CACHE_DIR, readCachedStatus, statusSkipReason, writeCachedStatus } from "./status.js";
|
|
14
|
-
export { DriverRegistry } from "./registry.js";
|
|
15
|
-
export { createCachedHarnessDriver, probeCliVersion, resolveDriverEnv } from "./driver-factory.js";
|
|
10
|
+
export { createDeferred, DEFAULT_AUTOMATION_APPROVAL_POLICY, decideApproval, PendingRequests } from "./approvals.js";
|
|
16
11
|
export { AsyncChannel } from "./channel.js";
|
|
12
|
+
export { createCachedHarnessDriver, probeCliVersion, resolveDriverEnv } from "./driver-factory.js";
|
|
13
|
+
export { asHarnessError, HARNESS_ERROR_CODES, HarnessError, isRetryable } from "./errors.js";
|
|
14
|
+
export { HARNESS_KINDS, isHarnessKind } from "./kinds.js";
|
|
15
|
+
export { ManagedSession, nowIso, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "./lifecycle.js";
|
|
17
16
|
export { EventLog } from "./logging.js";
|
|
18
|
-
export { asArray, asObject, asString, createStreamJsonStepEmitter, parseStreamJsonLine, parseStreamJsonTrajectory, streamJsonResultContentText, stringifyStreamJsonValue, STREAM_JSON_MAX_TEXT, STREAM_JSON_MAX_TOOL_INPUT, truncateStreamJsonText } from "./stream-json.js";
|
|
19
|
-
export { DEFAULT_TMP_MANIFEST, createTrackedTmpDir, releaseTrackedTmpDir, sweepTrackedTmpDirs } from "./tmp-sweep.js";
|
|
20
17
|
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "./process.js";
|
|
18
|
+
export { DriverRegistry } from "./registry.js";
|
|
19
|
+
export { DEFAULT_STATUS_CACHE_DIR, readCachedStatus, statusSkipReason, writeCachedStatus } from "./status.js";
|
|
20
|
+
export { asArray, asObject, asString, createStreamJsonStepEmitter, parseStreamJsonLine, parseStreamJsonTrajectory, STREAM_JSON_MAX_TEXT, STREAM_JSON_MAX_TOOL_INPUT, streamJsonResultContentText, stringifyStreamJsonValue, truncateStreamJsonText } from "./stream-json.js";
|
|
21
|
+
export { createTrackedTmpDir, DEFAULT_TMP_MANIFEST, releaseTrackedTmpDir, sweepTrackedTmpDirs } from "./tmp-sweep.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ManagedSession, nowIso, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "./lifecycle.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "./approvals.js";
|
|
2
|
+
import type { ResumeCursor, SessionHandle, SessionTurnInput } from "./contract.js";
|
|
3
|
+
import type { HarnessEvent } from "./events.js";
|
|
4
|
+
export declare function nowIso(): string;
|
|
5
|
+
export type TurnLease = {
|
|
6
|
+
readonly signal: AbortSignal;
|
|
7
|
+
complete(): void;
|
|
8
|
+
dispose(): void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Enforces the session contract's one-live-turn rule and gives every driver
|
|
12
|
+
* an internal abort signal. Disposing an incomplete lease aborts the native
|
|
13
|
+
* operation, which makes an async iterator's `return()` a real cancellation.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SingleFlightTurnController {
|
|
16
|
+
#private;
|
|
17
|
+
start(external?: AbortSignal): TurnLease;
|
|
18
|
+
interrupt(reason?: unknown): void;
|
|
19
|
+
get active(): boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A session wrapper that owns unregistering itself from its instance. `stop`
|
|
23
|
+
* is idempotent even when the underlying driver's stop operation rejects.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ManagedSession implements SessionHandle {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(session: SessionHandle, release: () => void);
|
|
28
|
+
get sessionId(): string;
|
|
29
|
+
sendTurn(input: SessionTurnInput): AsyncIterable<HarnessEvent>;
|
|
30
|
+
respondToRequest(requestId: string, decision: ApprovalDecision): Promise<void>;
|
|
31
|
+
interrupt(): Promise<void>;
|
|
32
|
+
resumeCursor(): ResumeCursor | undefined;
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Owns the live sessions created by one harness instance. Stopped sessions
|
|
37
|
+
* unregister immediately, while instance disposal settles every still-live
|
|
38
|
+
* session and aggregates failures only after all stop operations were tried.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SessionResourceRegistry {
|
|
41
|
+
#private;
|
|
42
|
+
manage(session: SessionHandle): ManagedSession;
|
|
43
|
+
get size(): number;
|
|
44
|
+
assertOpen(): void;
|
|
45
|
+
dispose(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export declare function resumeStringField(resume: ResumeCursor | undefined, kind: ResumeCursor["kind"], field: string): string | undefined;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { HarnessError } from "./errors.js";
|
|
2
|
+
export function nowIso() {
|
|
3
|
+
return new Date().toISOString();
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Enforces the session contract's one-live-turn rule and gives every driver
|
|
7
|
+
* an internal abort signal. Disposing an incomplete lease aborts the native
|
|
8
|
+
* operation, which makes an async iterator's `return()` a real cancellation.
|
|
9
|
+
*/
|
|
10
|
+
export class SingleFlightTurnController {
|
|
11
|
+
#active;
|
|
12
|
+
start(external) {
|
|
13
|
+
if (this.#active !== undefined) {
|
|
14
|
+
throw new HarnessError("session_busy", "a turn is already active for this session");
|
|
15
|
+
}
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const onAbort = () => controller.abort(external?.reason);
|
|
18
|
+
if (external?.aborted === true)
|
|
19
|
+
onAbort();
|
|
20
|
+
else
|
|
21
|
+
external?.addEventListener("abort", onAbort, { once: true });
|
|
22
|
+
const active = {
|
|
23
|
+
controller,
|
|
24
|
+
detachExternal: () => external?.removeEventListener("abort", onAbort)
|
|
25
|
+
};
|
|
26
|
+
this.#active = active;
|
|
27
|
+
let completed = false;
|
|
28
|
+
let disposed = false;
|
|
29
|
+
return {
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
complete: () => {
|
|
32
|
+
completed = true;
|
|
33
|
+
},
|
|
34
|
+
dispose: () => {
|
|
35
|
+
if (disposed)
|
|
36
|
+
return;
|
|
37
|
+
disposed = true;
|
|
38
|
+
if (!completed)
|
|
39
|
+
controller.abort(new Error("turn iterator closed"));
|
|
40
|
+
active.detachExternal();
|
|
41
|
+
if (this.#active === active)
|
|
42
|
+
this.#active = undefined;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
interrupt(reason = new Error("turn interrupted")) {
|
|
47
|
+
this.#active?.controller.abort(reason);
|
|
48
|
+
}
|
|
49
|
+
get active() {
|
|
50
|
+
return this.#active !== undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A session wrapper that owns unregistering itself from its instance. `stop`
|
|
55
|
+
* is idempotent even when the underlying driver's stop operation rejects.
|
|
56
|
+
*/
|
|
57
|
+
export class ManagedSession {
|
|
58
|
+
#session;
|
|
59
|
+
#release;
|
|
60
|
+
#stopPromise;
|
|
61
|
+
constructor(session, release) {
|
|
62
|
+
this.#session = session;
|
|
63
|
+
this.#release = release;
|
|
64
|
+
}
|
|
65
|
+
get sessionId() {
|
|
66
|
+
return this.#session.sessionId;
|
|
67
|
+
}
|
|
68
|
+
sendTurn(input) {
|
|
69
|
+
return this.#session.sendTurn(input);
|
|
70
|
+
}
|
|
71
|
+
respondToRequest(requestId, decision) {
|
|
72
|
+
return this.#session.respondToRequest(requestId, decision);
|
|
73
|
+
}
|
|
74
|
+
interrupt() {
|
|
75
|
+
return this.#session.interrupt();
|
|
76
|
+
}
|
|
77
|
+
resumeCursor() {
|
|
78
|
+
return this.#session.resumeCursor();
|
|
79
|
+
}
|
|
80
|
+
stop() {
|
|
81
|
+
this.#stopPromise ??= (async () => {
|
|
82
|
+
try {
|
|
83
|
+
await this.#session.stop();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
this.#release();
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
return this.#stopPromise;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Owns the live sessions created by one harness instance. Stopped sessions
|
|
94
|
+
* unregister immediately, while instance disposal settles every still-live
|
|
95
|
+
* session and aggregates failures only after all stop operations were tried.
|
|
96
|
+
*/
|
|
97
|
+
export class SessionResourceRegistry {
|
|
98
|
+
#sessions = new Set();
|
|
99
|
+
#disposed = false;
|
|
100
|
+
#disposePromise;
|
|
101
|
+
manage(session) {
|
|
102
|
+
if (this.#disposed) {
|
|
103
|
+
throw new HarnessError("session_closed", "harness instance is disposed");
|
|
104
|
+
}
|
|
105
|
+
let managed;
|
|
106
|
+
managed = new ManagedSession(session, () => this.#sessions.delete(managed));
|
|
107
|
+
this.#sessions.add(managed);
|
|
108
|
+
return managed;
|
|
109
|
+
}
|
|
110
|
+
get size() {
|
|
111
|
+
return this.#sessions.size;
|
|
112
|
+
}
|
|
113
|
+
assertOpen() {
|
|
114
|
+
if (this.#disposed) {
|
|
115
|
+
throw new HarnessError("session_closed", "harness instance is disposed");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
dispose() {
|
|
119
|
+
this.#disposed = true;
|
|
120
|
+
this.#disposePromise ??= (async () => {
|
|
121
|
+
const sessions = [...this.#sessions];
|
|
122
|
+
const results = await Promise.allSettled(sessions.map((session) => session.stop()));
|
|
123
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
124
|
+
if (errors.length > 0) {
|
|
125
|
+
throw new AggregateError(errors, "one or more harness sessions failed to stop");
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return this.#disposePromise;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function resumeStringField(resume, kind, field) {
|
|
132
|
+
if (resume === undefined || resume.kind !== kind || resume.data === null)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (typeof resume.data !== "object" || Array.isArray(resume.data))
|
|
135
|
+
return undefined;
|
|
136
|
+
const value = resume.data[field];
|
|
137
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
138
|
+
}
|
package/dist/process.d.ts
CHANGED
|
@@ -4,5 +4,9 @@
|
|
|
4
4
|
* runs with group-kill + SIGTERM->SIGKILL escalation, logged long-lived
|
|
5
5
|
* children, readiness helpers, and port allocation.
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
8
|
-
export
|
|
7
|
+
export { runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput } from "@velum-labs/routekit-runtime/process";
|
|
8
|
+
export { buildChildEnv } from "@velum-labs/routekit-runtime/environment";
|
|
9
|
+
export { freePort } from "@velum-labs/routekit-runtime/ports";
|
|
10
|
+
export { withDeadline, withTimeout } from "@velum-labs/routekit-runtime/timing";
|
|
11
|
+
export type { CliCaptureOptions, CliCaptureResult, LoggedChild, LoggedSpawnOptions } from "@velum-labs/routekit-runtime/process";
|
|
12
|
+
export type { BuildChildEnvInput } from "@velum-labs/routekit-runtime/environment";
|
package/dist/process.js
CHANGED
|
@@ -4,4 +4,7 @@
|
|
|
4
4
|
* runs with group-kill + SIGTERM->SIGKILL escalation, logged long-lived
|
|
5
5
|
* children, readiness helpers, and port allocation.
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
7
|
+
export { runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput } from "@velum-labs/routekit-runtime/process";
|
|
8
|
+
export { buildChildEnv } from "@velum-labs/routekit-runtime/environment";
|
|
9
|
+
export { freePort } from "@velum-labs/routekit-runtime/ports";
|
|
10
|
+
export { withDeadline, withTimeout } from "@velum-labs/routekit-runtime/timing";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { scopedTurn } from "../effect-api.js";
|
|
5
|
+
import { HarnessError } from "../errors.js";
|
|
6
|
+
import { SingleFlightTurnController } from "../lifecycle.js";
|
|
7
|
+
test("scoped turns release the one-live-turn lease on scope close", async () => {
|
|
8
|
+
const controller = new SingleFlightTurnController();
|
|
9
|
+
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
|
|
10
|
+
const lease = yield* scopedTurn(controller);
|
|
11
|
+
assert.equal(controller.active, true);
|
|
12
|
+
assert.equal(lease.signal.aborted, false);
|
|
13
|
+
})));
|
|
14
|
+
assert.equal(controller.active, false);
|
|
15
|
+
});
|
|
16
|
+
test("incomplete scoped turns abort the native operation", async () => {
|
|
17
|
+
const controller = new SingleFlightTurnController();
|
|
18
|
+
let aborted = false;
|
|
19
|
+
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
|
|
20
|
+
const lease = yield* scopedTurn(controller);
|
|
21
|
+
lease.signal.addEventListener("abort", () => {
|
|
22
|
+
aborted = true;
|
|
23
|
+
});
|
|
24
|
+
})));
|
|
25
|
+
assert.equal(aborted, true);
|
|
26
|
+
});
|
|
27
|
+
test("a second turn still fails while a scoped lease is live", async () => {
|
|
28
|
+
const controller = new SingleFlightTurnController();
|
|
29
|
+
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
|
|
30
|
+
yield* scopedTurn(controller);
|
|
31
|
+
assert.throws(() => controller.start(), (error) => error instanceof HarnessError);
|
|
32
|
+
})));
|
|
33
|
+
});
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import {
|
|
7
|
+
import { AsyncChannel, asHarnessError, createCachedHarnessDriver, createStreamJsonStepEmitter, createTrackedTmpDir, DriverRegistry, decideApproval, EventLog, HarnessError, PendingRequests, parseStreamJsonTrajectory, probeCliVersion, readCachedStatus, releaseTrackedTmpDir, resolveDriverEnv, SessionResourceRegistry, SingleFlightTurnController, statusSkipReason, streamJsonResultContentText, sweepTrackedTmpDirs, writeCachedStatus } from "../index.js";
|
|
8
8
|
import { createMockDriver, driverContractSuite } from "../testing/index.js";
|
|
9
9
|
// The mock driver is the reference implementation: it must pass the same
|
|
10
10
|
// contract suite every real driver is held against.
|
|
@@ -17,13 +17,64 @@ driverContractSuite({
|
|
|
17
17
|
startOptions: () => ({ cwd: process.cwd() }),
|
|
18
18
|
supportsResume: true
|
|
19
19
|
});
|
|
20
|
+
test("bounded AsyncChannel fails deterministically on overflow", async () => {
|
|
21
|
+
const channel = new AsyncChannel({ capacity: 2 });
|
|
22
|
+
assert.equal(channel.push(1), true);
|
|
23
|
+
assert.equal(channel.push(2), true);
|
|
24
|
+
assert.equal(channel.push(3), false);
|
|
25
|
+
const values = [];
|
|
26
|
+
await assert.rejects(async () => {
|
|
27
|
+
for await (const value of channel)
|
|
28
|
+
values.push(value);
|
|
29
|
+
}, /capacity 2 exceeded/);
|
|
30
|
+
assert.deepEqual(values, [1, 2]);
|
|
31
|
+
});
|
|
32
|
+
test("SingleFlightTurnController rejects overlap and cancels abandoned turns", () => {
|
|
33
|
+
const turns = new SingleFlightTurnController();
|
|
34
|
+
const first = turns.start();
|
|
35
|
+
assert.throws(() => turns.start(), (error) => error.code === "session_busy");
|
|
36
|
+
first.dispose();
|
|
37
|
+
assert.equal(first.signal.aborted, true);
|
|
38
|
+
const second = turns.start();
|
|
39
|
+
second.complete();
|
|
40
|
+
second.dispose();
|
|
41
|
+
assert.equal(second.signal.aborted, false);
|
|
42
|
+
});
|
|
43
|
+
test("session resource registry attempts every stop and unregisters completed sessions", async () => {
|
|
44
|
+
const stopped = [];
|
|
45
|
+
const registry = new SessionResourceRegistry();
|
|
46
|
+
const session = (id, fails = false) => ({
|
|
47
|
+
sessionId: id,
|
|
48
|
+
sendTurn: async function* () { },
|
|
49
|
+
respondToRequest: async () => { },
|
|
50
|
+
interrupt: async () => { },
|
|
51
|
+
resumeCursor: () => undefined,
|
|
52
|
+
stop: async () => {
|
|
53
|
+
stopped.push(id);
|
|
54
|
+
if (fails)
|
|
55
|
+
throw new Error(`failed ${id}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const completed = registry.manage(session("completed"));
|
|
59
|
+
registry.manage(session("bad", true));
|
|
60
|
+
registry.manage(session("later"));
|
|
61
|
+
await completed.stop();
|
|
62
|
+
assert.equal(registry.size, 2);
|
|
63
|
+
await assert.rejects(registry.dispose(), AggregateError);
|
|
64
|
+
assert.deepEqual(stopped.sort(), ["bad", "completed", "later"]);
|
|
65
|
+
assert.equal(registry.size, 0);
|
|
66
|
+
});
|
|
20
67
|
test("shared stream JSON primitives normalize incremental and buffered events", () => {
|
|
21
68
|
const stepsForEvent = (event) => event.type === "message" && typeof event.text === "string"
|
|
22
69
|
? [{ kind: "message", text: event.text }]
|
|
23
70
|
: [];
|
|
24
71
|
const resultStep = (result) => ({ kind: "result", text: result });
|
|
25
72
|
const emitted = [];
|
|
26
|
-
const emit = createStreamJsonStepEmitter({
|
|
73
|
+
const emit = createStreamJsonStepEmitter({
|
|
74
|
+
stepsForEvent,
|
|
75
|
+
resultStep,
|
|
76
|
+
onStep: (step) => emitted.push(step)
|
|
77
|
+
});
|
|
27
78
|
emit('{"type":"message","text":"hello"}');
|
|
28
79
|
emit('{"type":"result","result":"done"}');
|
|
29
80
|
assert.deepEqual(emitted, [
|
|
@@ -39,7 +90,10 @@ test("shared stream JSON primitives normalize incremental and buffered events",
|
|
|
39
90
|
assert.equal(parsed.finalOutput, "done");
|
|
40
91
|
assert.equal(parsed.sawResult, true);
|
|
41
92
|
assert.equal(parsed.isError, false);
|
|
42
|
-
assert.equal(streamJsonResultContentText([
|
|
93
|
+
assert.equal(streamJsonResultContentText([
|
|
94
|
+
{ type: "text", text: "a" },
|
|
95
|
+
{ type: "text", text: "b" }
|
|
96
|
+
]), "ab");
|
|
43
97
|
});
|
|
44
98
|
test("registry decodes config exactly once and classifies failures", async () => {
|
|
45
99
|
const registry = new DriverRegistry().register(createMockDriver());
|
|
@@ -180,7 +234,9 @@ test("cached driver construction reuses status written by probe", async () => {
|
|
|
180
234
|
});
|
|
181
235
|
try {
|
|
182
236
|
await driver.probe({ statusCacheDir: cacheDir });
|
|
183
|
-
const created = await driver.createInstance(driver.configSchema.parse({}), {
|
|
237
|
+
const created = await driver.createInstance(driver.configSchema.parse({}), {
|
|
238
|
+
statusCacheDir: cacheDir
|
|
239
|
+
});
|
|
184
240
|
assert.equal(created, instance);
|
|
185
241
|
assert.deepEqual(instanceStatus, status);
|
|
186
242
|
assert.equal(probeCount, 1);
|
|
@@ -78,6 +78,45 @@ export function driverContractSuite(input) {
|
|
|
78
78
|
await instance.dispose();
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
|
+
test(`${input.name}: concurrent turns are rejected explicitly`, { skip }, async () => {
|
|
82
|
+
const instance = await input.createInstance();
|
|
83
|
+
try {
|
|
84
|
+
const session = await instance.startSession(input.startOptions());
|
|
85
|
+
const first = session.sendTurn({ prompt })[Symbol.asyncIterator]();
|
|
86
|
+
await first.next();
|
|
87
|
+
const second = session.sendTurn({ prompt })[Symbol.asyncIterator]();
|
|
88
|
+
await assert.rejects(second.next(), (error) => error instanceof Error &&
|
|
89
|
+
"code" in error &&
|
|
90
|
+
error.code === "session_busy");
|
|
91
|
+
await first.return?.();
|
|
92
|
+
await session.stop();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await instance.dispose();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
test(`${input.name}: early iterator return cancels and releases the turn`, { skip }, async () => {
|
|
99
|
+
const instance = await input.createInstance();
|
|
100
|
+
try {
|
|
101
|
+
const session = await instance.startSession(input.startOptions());
|
|
102
|
+
const first = session.sendTurn({ prompt })[Symbol.asyncIterator]();
|
|
103
|
+
await first.next();
|
|
104
|
+
await first.return?.();
|
|
105
|
+
// A closed iterator must not leave the session permanently busy.
|
|
106
|
+
await collect(session.sendTurn({ prompt }), timeoutMs);
|
|
107
|
+
await session.stop();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await instance.dispose();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
test(`${input.name}: stop and instance disposal are idempotent`, { skip }, async () => {
|
|
114
|
+
const instance = await input.createInstance();
|
|
115
|
+
const session = await instance.startSession(input.startOptions());
|
|
116
|
+
await Promise.all([session.stop(), session.stop()]);
|
|
117
|
+
await instance.dispose();
|
|
118
|
+
await instance.dispose();
|
|
119
|
+
});
|
|
81
120
|
if (input.supportsResume === true) {
|
|
82
121
|
test(`${input.name}: resume cursor round-trips into a new session`, { skip }, async () => {
|
|
83
122
|
const instance = await input.createInstance();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { DEFAULT_AUTOMATION_APPROVAL_POLICY, decideApproval, PendingRequests } from "../approvals.js";
|
|
3
4
|
import { HarnessError } from "../errors.js";
|
|
4
|
-
import {
|
|
5
|
+
import { SessionResourceRegistry, SingleFlightTurnController } from "../lifecycle.js";
|
|
5
6
|
export const mockDriverConfigSchema = z.object({
|
|
6
7
|
/** Scripted assistant replies, consumed one per turn (last one repeats). */
|
|
7
8
|
replies: z.array(z.string()).default(["mock reply"]),
|
|
@@ -29,6 +30,7 @@ class MockSession {
|
|
|
29
30
|
#approvalPolicy;
|
|
30
31
|
#turnCount;
|
|
31
32
|
#stopped = false;
|
|
33
|
+
#turns = new SingleFlightTurnController();
|
|
32
34
|
constructor(config, options) {
|
|
33
35
|
this.#config = config;
|
|
34
36
|
this.#approvalPolicy = options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY;
|
|
@@ -46,44 +48,59 @@ class MockSession {
|
|
|
46
48
|
if (this.#stopped) {
|
|
47
49
|
throw new HarnessError("session_closed", "mock session is stopped");
|
|
48
50
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
51
|
+
const turn = this.#turns.start(input.signal);
|
|
52
|
+
let settled = false;
|
|
53
|
+
try {
|
|
54
|
+
const base = { kind: "generic", sessionId: this.sessionId, at: nowIso() };
|
|
55
|
+
const turnId = `turn_${this.#turnCount + 1}`;
|
|
56
|
+
yield { ...base, type: "turn.started", turnId };
|
|
57
|
+
if (turn.signal.aborted) {
|
|
58
|
+
yield { ...base, type: "turn.completed", turnId, endReason: "aborted" };
|
|
59
|
+
settled = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (this.#config.approvalDetail !== undefined) {
|
|
63
|
+
const auto = decideApproval(this.#approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY, "exec_command_approval");
|
|
64
|
+
if (auto === undefined) {
|
|
65
|
+
const request = this.#pending.open({
|
|
66
|
+
requestType: "exec_command_approval",
|
|
67
|
+
detail: this.#config.approvalDetail
|
|
68
|
+
});
|
|
69
|
+
yield {
|
|
70
|
+
...base,
|
|
71
|
+
type: "request.opened",
|
|
72
|
+
turnId,
|
|
73
|
+
requestId: request.requestId,
|
|
74
|
+
requestType: request.requestType,
|
|
75
|
+
...(request.detail !== undefined ? { detail: request.detail } : {})
|
|
76
|
+
};
|
|
77
|
+
const decision = await Promise.race([request.decision, abortAsDecision(turn.signal)]);
|
|
78
|
+
yield {
|
|
79
|
+
...base,
|
|
80
|
+
type: "request.resolved",
|
|
81
|
+
turnId,
|
|
82
|
+
requestId: request.requestId,
|
|
83
|
+
decision
|
|
84
|
+
};
|
|
85
|
+
if (decision === "decline" || decision === "cancel") {
|
|
86
|
+
yield { ...base, type: "turn.completed", turnId, endReason: "aborted" };
|
|
87
|
+
settled = true;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
79
90
|
}
|
|
80
91
|
}
|
|
92
|
+
const reply = this.#config.replies[Math.min(this.#turnCount, this.#config.replies.length - 1)] ??
|
|
93
|
+
"mock reply";
|
|
94
|
+
this.#turnCount += 1;
|
|
95
|
+
yield { ...base, type: "content.delta", turnId, stream: "assistant_text", text: reply };
|
|
96
|
+
yield { ...base, type: "turn.completed", turnId, endReason: "completed" };
|
|
97
|
+
settled = true;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (settled)
|
|
101
|
+
turn.complete();
|
|
102
|
+
turn.dispose();
|
|
81
103
|
}
|
|
82
|
-
const reply = this.#config.replies[Math.min(this.#turnCount, this.#config.replies.length - 1)] ??
|
|
83
|
-
"mock reply";
|
|
84
|
-
this.#turnCount += 1;
|
|
85
|
-
yield { ...base, type: "content.delta", turnId, stream: "assistant_text", text: reply };
|
|
86
|
-
yield { ...base, type: "turn.completed", turnId, endReason: "completed" };
|
|
87
104
|
}
|
|
88
105
|
async respondToRequest(requestId, decision) {
|
|
89
106
|
if (!this.#pending.resolve(requestId, decision)) {
|
|
@@ -91,6 +108,7 @@ class MockSession {
|
|
|
91
108
|
}
|
|
92
109
|
}
|
|
93
110
|
async interrupt() {
|
|
111
|
+
this.#turns.interrupt();
|
|
94
112
|
this.#pending.settleAll("cancel");
|
|
95
113
|
}
|
|
96
114
|
resumeCursor() {
|
|
@@ -101,14 +119,17 @@ class MockSession {
|
|
|
101
119
|
};
|
|
102
120
|
}
|
|
103
121
|
async stop() {
|
|
122
|
+
if (this.#stopped)
|
|
123
|
+
return;
|
|
104
124
|
this.#stopped = true;
|
|
125
|
+
this.#turns.interrupt(new Error("mock session stopped"));
|
|
105
126
|
this.#pending.settleAll("cancel");
|
|
106
127
|
}
|
|
107
128
|
}
|
|
108
129
|
class MockInstance {
|
|
109
130
|
kind = "generic";
|
|
110
131
|
#config;
|
|
111
|
-
#sessions = new
|
|
132
|
+
#sessions = new SessionResourceRegistry();
|
|
112
133
|
constructor(config) {
|
|
113
134
|
this.#config = config;
|
|
114
135
|
}
|
|
@@ -116,6 +137,7 @@ class MockInstance {
|
|
|
116
137
|
return statusFor(this.#config);
|
|
117
138
|
}
|
|
118
139
|
async startSession(options) {
|
|
140
|
+
this.#sessions.assertOpen();
|
|
119
141
|
if (!this.#config.installed) {
|
|
120
142
|
throw new HarnessError("not_installed", "mock CLI is not installed");
|
|
121
143
|
}
|
|
@@ -123,13 +145,10 @@ class MockInstance {
|
|
|
123
145
|
throw new HarnessError("not_authenticated", "mock CLI is not logged in");
|
|
124
146
|
}
|
|
125
147
|
const session = new MockSession(this.#config, options);
|
|
126
|
-
this.#sessions.
|
|
127
|
-
return session;
|
|
148
|
+
return this.#sessions.manage(session);
|
|
128
149
|
}
|
|
129
150
|
async dispose() {
|
|
130
|
-
|
|
131
|
-
await session.stop();
|
|
132
|
-
this.#sessions.clear();
|
|
151
|
+
await this.#sessions.dispose();
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
function abortAsDecision(signal) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-harness-core",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
"./testing": {
|
|
19
19
|
"types": "./dist/testing/index.d.ts",
|
|
20
20
|
"default": "./dist/testing/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./lifecycle": {
|
|
23
|
+
"types": "./dist/lifecycle-api.d.ts",
|
|
24
|
+
"default": "./dist/lifecycle-api.js"
|
|
25
|
+
},
|
|
26
|
+
"./effect": {
|
|
27
|
+
"types": "./dist/effect-api.d.ts",
|
|
28
|
+
"default": "./dist/effect-api.js"
|
|
21
29
|
}
|
|
22
30
|
},
|
|
23
31
|
"files": [
|
|
@@ -25,9 +33,10 @@
|
|
|
25
33
|
"LICENSE"
|
|
26
34
|
],
|
|
27
35
|
"dependencies": {
|
|
36
|
+
"effect": "4.0.0-rc.108",
|
|
28
37
|
"zod": "4.4.3",
|
|
29
|
-
"@velum-labs/routekit-runtime": "0.
|
|
30
|
-
"@velum-labs/routekit-contracts": "0.
|
|
38
|
+
"@velum-labs/routekit-runtime": "1.0.0",
|
|
39
|
+
"@velum-labs/routekit-contracts": "1.0.0"
|
|
31
40
|
},
|
|
32
41
|
"publishConfig": {
|
|
33
42
|
"registry": "https://registry.npmjs.org",
|