@velum-labs/routekit-harness-core 0.9.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/LICENSE +201 -0
- package/README.md +36 -0
- package/dist/approvals.d.ts +45 -0
- package/dist/approvals.js +77 -0
- package/dist/channel.d.ts +12 -0
- package/dist/channel.js +62 -0
- package/dist/contract.d.ts +84 -0
- package/dist/contract.js +1 -0
- package/dist/driver-factory.d.ts +31 -0
- package/dist/driver-factory.js +73 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +88 -0
- package/dist/events.d.ts +4 -0
- package/dist/events.js +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +20 -0
- package/dist/kinds.d.ts +7 -0
- package/dist/kinds.js +14 -0
- package/dist/logging.d.ts +17 -0
- package/dist/logging.js +62 -0
- package/dist/process.d.ts +8 -0
- package/dist/process.js +7 -0
- package/dist/registry.d.ts +19 -0
- package/dist/registry.js +38 -0
- package/dist/status.d.ts +36 -0
- package/dist/status.js +49 -0
- package/dist/stream-json.d.ts +38 -0
- package/dist/stream-json.js +120 -0
- package/dist/test/harness-core.test.d.ts +1 -0
- package/dist/test/harness-core.test.js +259 -0
- package/dist/testing/contract-suite.d.ts +22 -0
- package/dist/testing/contract-suite.js +99 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/mock-driver.d.ts +15 -0
- package/dist/testing/mock-driver.js +154 -0
- package/dist/tmp-sweep.d.ts +17 -0
- package/dist/tmp-sweep.js +61 -0
- package/package.json +49 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one harness failure taxonomy. Every driver classifies failures into
|
|
3
|
+
* these codes at the boundary where they occur; retryability and failover
|
|
4
|
+
* category are derived from the code, never hand-picked per call site.
|
|
5
|
+
*/
|
|
6
|
+
export const HARNESS_ERROR_CODES = [
|
|
7
|
+
/** The CLI binary is not installed / not on PATH. */
|
|
8
|
+
"not_installed",
|
|
9
|
+
/** The CLI is installed but not logged in / no usable credential. */
|
|
10
|
+
"not_authenticated",
|
|
11
|
+
/** The CLI version fails the driver's floor or handshake. */
|
|
12
|
+
"version_unsupported",
|
|
13
|
+
/** The driver rejected its configuration at decode time. */
|
|
14
|
+
"invalid_config",
|
|
15
|
+
/** The session existed but its process/connection has gone away. */
|
|
16
|
+
"session_closed",
|
|
17
|
+
/** A wire payload failed schema/shape validation. */
|
|
18
|
+
"protocol_parse",
|
|
19
|
+
/** The run exceeded its deadline. */
|
|
20
|
+
"timeout",
|
|
21
|
+
/** The run was cancelled via its abort signal. */
|
|
22
|
+
"aborted",
|
|
23
|
+
/** The provider/CLI reported a failure the driver could not classify further. */
|
|
24
|
+
"provider_error"
|
|
25
|
+
];
|
|
26
|
+
export class HarnessError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
/** Optional finer-grained failover category when the driver knows better. */
|
|
29
|
+
category;
|
|
30
|
+
constructor(code, message, options = {}) {
|
|
31
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
32
|
+
this.name = "HarnessError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.category = options.category ?? defaultCategoryFor(code);
|
|
35
|
+
}
|
|
36
|
+
get retryable() {
|
|
37
|
+
return isRetryable(this);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function defaultCategoryFor(code) {
|
|
41
|
+
switch (code) {
|
|
42
|
+
case "timeout":
|
|
43
|
+
case "session_closed":
|
|
44
|
+
return "transient";
|
|
45
|
+
case "not_authenticated":
|
|
46
|
+
return "auth_permanent";
|
|
47
|
+
case "not_installed":
|
|
48
|
+
case "version_unsupported":
|
|
49
|
+
case "invalid_config":
|
|
50
|
+
case "protocol_parse":
|
|
51
|
+
case "aborted":
|
|
52
|
+
case "provider_error":
|
|
53
|
+
return "unknown";
|
|
54
|
+
default: {
|
|
55
|
+
const exhausted = code;
|
|
56
|
+
throw new Error(`unsupported harness error code: ${String(exhausted)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Retryability is derived from the taxonomy, never hardcoded per call site. */
|
|
61
|
+
export function isRetryable(error) {
|
|
62
|
+
if (error.code === "aborted")
|
|
63
|
+
return false;
|
|
64
|
+
switch (error.category) {
|
|
65
|
+
case "transient":
|
|
66
|
+
case "quota_exhausted":
|
|
67
|
+
return true;
|
|
68
|
+
case "auth_permanent":
|
|
69
|
+
case "context_overflow":
|
|
70
|
+
case "unknown":
|
|
71
|
+
return false;
|
|
72
|
+
default: {
|
|
73
|
+
const exhausted = error.category;
|
|
74
|
+
throw new Error(`unsupported harness error category: ${String(exhausted)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Wrap an arbitrary thrown value as a classified HarnessError. */
|
|
79
|
+
export function asHarnessError(value, fallbackCode = "provider_error") {
|
|
80
|
+
if (value instanceof HarnessError)
|
|
81
|
+
return value;
|
|
82
|
+
const errno = value;
|
|
83
|
+
if (errno?.code === "ENOENT") {
|
|
84
|
+
return new HarnessError("not_installed", errno.message, { cause: value });
|
|
85
|
+
}
|
|
86
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
87
|
+
return new HarnessError(fallbackCode, message, { cause: value });
|
|
88
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HarnessContentStream, HarnessEvent as RouteHarnessEvent, HarnessEventRaw, HarnessEventType, HarnessItemType, HarnessRequestType, HarnessTokenUsage, HarnessTurnEndReason } from "@velum-labs/routekit-contracts";
|
|
2
|
+
import type { HarnessKind } from "./kinds.js";
|
|
3
|
+
export type HarnessEvent = RouteHarnessEvent<HarnessKind>;
|
|
4
|
+
export type { HarnessContentStream, HarnessEventRaw, HarnessEventType, HarnessItemType, HarnessRequestType, HarnessTokenUsage, HarnessTurnEndReason };
|
package/dist/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @velum-labs/routekit-harness-core is the single coding-agent harness contract:
|
|
3
|
+
* driver -> instance -> session interfaces, the canonical harness event
|
|
4
|
+
* union (with raw provider envelopes), one tagged error taxonomy with
|
|
5
|
+
* derived retryability, deferred-based approvals with explicit policies,
|
|
6
|
+
* status probes with an identity-checked disk cache, and an explicit driver
|
|
7
|
+
* registry. Drivers (tool-codex, tool-claude, tool-cursor, tool-opencode)
|
|
8
|
+
* implement this contract; orchestrators and launchers consume it.
|
|
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
|
+
export type { ApprovalDecision, ApprovalPolicy, Deferred, PendingRequest } from "./approvals.js";
|
|
17
|
+
export { DEFAULT_STATUS_CACHE_DIR, readCachedStatus, statusSkipReason, writeCachedStatus } from "./status.js";
|
|
18
|
+
export type { HarnessAuthStatus, HarnessModelDescriptor, HarnessStatus } from "./status.js";
|
|
19
|
+
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
|
+
export type { CachedHarnessDriverInput, CliVersionProbeInput } from "./driver-factory.js";
|
|
23
|
+
export { AsyncChannel } from "./channel.js";
|
|
24
|
+
export { EventLog } from "./logging.js";
|
|
25
|
+
export type { EventLogOptions } from "./logging.js";
|
|
26
|
+
export { asArray, asObject, asString, createStreamJsonStepEmitter, parseStreamJsonLine, parseStreamJsonTrajectory, streamJsonResultContentText, stringifyStreamJsonValue, STREAM_JSON_MAX_TEXT, STREAM_JSON_MAX_TOOL_INPUT, truncateStreamJsonText } from "./stream-json.js";
|
|
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";
|
|
30
|
+
export type { BuildChildEnvInput, CliCaptureOptions, CliCaptureResult, LoggedChild, LoggedSpawnOptions } from "./process.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @velum-labs/routekit-harness-core is the single coding-agent harness contract:
|
|
3
|
+
* driver -> instance -> session interfaces, the canonical harness event
|
|
4
|
+
* union (with raw provider envelopes), one tagged error taxonomy with
|
|
5
|
+
* derived retryability, deferred-based approvals with explicit policies,
|
|
6
|
+
* status probes with an identity-checked disk cache, and an explicit driver
|
|
7
|
+
* registry. Drivers (tool-codex, tool-claude, tool-cursor, tool-opencode)
|
|
8
|
+
* implement this contract; orchestrators and launchers consume it.
|
|
9
|
+
*/
|
|
10
|
+
export { HARNESS_KINDS, isHarnessKind } from "./kinds.js";
|
|
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";
|
|
16
|
+
export { AsyncChannel } from "./channel.js";
|
|
17
|
+
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
|
+
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "./process.js";
|
package/dist/kinds.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single harness-kind vocabulary. Every layer (drivers, orchestrators,
|
|
3
|
+
* launchers, and status probes uses these exact identifiers.
|
|
4
|
+
*/
|
|
5
|
+
export declare const HARNESS_KINDS: readonly ["codex", "claude_code", "cursor", "opencode", "generic"];
|
|
6
|
+
export type HarnessKind = (typeof HARNESS_KINDS)[number];
|
|
7
|
+
export declare function isHarnessKind(value: string): value is HarnessKind;
|
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single harness-kind vocabulary. Every layer (drivers, orchestrators,
|
|
3
|
+
* launchers, and status probes uses these exact identifiers.
|
|
4
|
+
*/
|
|
5
|
+
export const HARNESS_KINDS = [
|
|
6
|
+
"codex",
|
|
7
|
+
"claude_code",
|
|
8
|
+
"cursor",
|
|
9
|
+
"opencode",
|
|
10
|
+
"generic"
|
|
11
|
+
];
|
|
12
|
+
export function isHarnessKind(value) {
|
|
13
|
+
return HARNESS_KINDS.includes(value);
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HarnessEvent } from "./events.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort NDJSON event log, one file per session. Diagnostics must never
|
|
4
|
+
* break the run: every write failure is swallowed. Large `raw` payloads are
|
|
5
|
+
* summarized to a shape (byte length, field count) rather than written whole,
|
|
6
|
+
* matching t3code's payload-privacy discipline.
|
|
7
|
+
*/
|
|
8
|
+
export type EventLogOptions = {
|
|
9
|
+
dir: string;
|
|
10
|
+
/** Rotate the file once it exceeds this many bytes (default 10 MiB). */
|
|
11
|
+
maxBytes?: number;
|
|
12
|
+
};
|
|
13
|
+
export declare class EventLog {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(options: EventLogOptions);
|
|
16
|
+
write(event: HarnessEvent): void;
|
|
17
|
+
}
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
function safeSegment(value) {
|
|
5
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128) || "_session";
|
|
6
|
+
}
|
|
7
|
+
function summarizeRaw(raw) {
|
|
8
|
+
if (raw === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const payload = raw.payload;
|
|
11
|
+
let payloadSummary;
|
|
12
|
+
if (payload !== undefined) {
|
|
13
|
+
const json = JSON.stringify(payload);
|
|
14
|
+
payloadSummary = {
|
|
15
|
+
bytes: json.length,
|
|
16
|
+
fields: typeof payload === "object" && payload !== null ? Object.keys(payload).length : 0
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
source: raw.source,
|
|
21
|
+
...(raw.method !== undefined ? { method: raw.method } : {}),
|
|
22
|
+
...(payloadSummary !== undefined ? { payload: payloadSummary } : {})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export class EventLog {
|
|
26
|
+
#dir;
|
|
27
|
+
#maxBytes;
|
|
28
|
+
#ready = false;
|
|
29
|
+
#broken = false;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.#dir = options.dir;
|
|
32
|
+
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
33
|
+
}
|
|
34
|
+
write(event) {
|
|
35
|
+
if (this.#broken)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
if (!this.#ready) {
|
|
39
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
40
|
+
this.#ready = true;
|
|
41
|
+
}
|
|
42
|
+
const file = join(this.#dir, `${safeSegment(event.sessionId)}.ndjson`);
|
|
43
|
+
this.#rotateIfNeeded(file);
|
|
44
|
+
const line = JSON.stringify({ ...event, raw: summarizeRaw(event.raw) });
|
|
45
|
+
appendFileSync(file, `${line}\n`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A bad path must not retry hot for every event.
|
|
49
|
+
this.#broken = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
#rotateIfNeeded(file) {
|
|
53
|
+
try {
|
|
54
|
+
const size = statSync(file).size;
|
|
55
|
+
if (size >= this.#maxBytes)
|
|
56
|
+
renameSync(file, `${file}.1`);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// No existing file (or stat failed): nothing to rotate.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process runtime drivers build on, re-exported so a driver package
|
|
3
|
+
* depends only on `@velum-labs/routekit-harness-core`: allowlisted child envs, capture
|
|
4
|
+
* runs with group-kill + SIGTERM->SIGKILL escalation, logged long-lived
|
|
5
|
+
* children, readiness helpers, and port allocation.
|
|
6
|
+
*/
|
|
7
|
+
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "@velum-labs/routekit-runtime";
|
|
8
|
+
export type { BuildChildEnvInput, CliCaptureOptions, CliCaptureResult, LoggedChild, LoggedSpawnOptions } from "@velum-labs/routekit-runtime";
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process runtime drivers build on, re-exported so a driver package
|
|
3
|
+
* depends only on `@velum-labs/routekit-harness-core`: allowlisted child envs, capture
|
|
4
|
+
* runs with group-kill + SIGTERM->SIGKILL escalation, logged long-lived
|
|
5
|
+
* children, readiness helpers, and port allocation.
|
|
6
|
+
*/
|
|
7
|
+
export { buildChildEnv, freePort, runCliCapture, spawnLogged, terminate, waitForHttp, waitForOutput, withDeadline, withTimeout } from "@velum-labs/routekit-runtime";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AnyHarnessDriver, DriverContext, HarnessInstance } from "./contract.js";
|
|
2
|
+
import type { HarnessKind } from "./kinds.js";
|
|
3
|
+
/**
|
|
4
|
+
* Explicit driver registry: drivers are registered by the composition root
|
|
5
|
+
* (CLI entrypoint, test setup), never via module-import side effects or
|
|
6
|
+
* mutable module-level globals.
|
|
7
|
+
*/
|
|
8
|
+
export declare class DriverRegistry {
|
|
9
|
+
#private;
|
|
10
|
+
register(driver: AnyHarnessDriver): this;
|
|
11
|
+
get(kind: HarnessKind): AnyHarnessDriver | undefined;
|
|
12
|
+
list(): readonly AnyHarnessDriver[];
|
|
13
|
+
/**
|
|
14
|
+
* Decode the raw config through the driver's own schema (exactly once,
|
|
15
|
+
* here) and create an instance. Unknown kinds and config decode failures
|
|
16
|
+
* are classified `HarnessError`s, not bare throws.
|
|
17
|
+
*/
|
|
18
|
+
createInstance(kind: HarnessKind, rawConfig: unknown, context?: DriverContext): Promise<HarnessInstance>;
|
|
19
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HarnessError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Explicit driver registry: drivers are registered by the composition root
|
|
4
|
+
* (CLI entrypoint, test setup), never via module-import side effects or
|
|
5
|
+
* mutable module-level globals.
|
|
6
|
+
*/
|
|
7
|
+
export class DriverRegistry {
|
|
8
|
+
#drivers = new Map();
|
|
9
|
+
register(driver) {
|
|
10
|
+
if (this.#drivers.has(driver.kind)) {
|
|
11
|
+
throw new Error(`harness driver already registered for kind "${driver.kind}"`);
|
|
12
|
+
}
|
|
13
|
+
this.#drivers.set(driver.kind, driver);
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
get(kind) {
|
|
17
|
+
return this.#drivers.get(kind);
|
|
18
|
+
}
|
|
19
|
+
list() {
|
|
20
|
+
return [...this.#drivers.values()];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Decode the raw config through the driver's own schema (exactly once,
|
|
24
|
+
* here) and create an instance. Unknown kinds and config decode failures
|
|
25
|
+
* are classified `HarnessError`s, not bare throws.
|
|
26
|
+
*/
|
|
27
|
+
async createInstance(kind, rawConfig, context) {
|
|
28
|
+
const driver = this.#drivers.get(kind);
|
|
29
|
+
if (driver === undefined) {
|
|
30
|
+
throw new HarnessError("invalid_config", `no harness driver registered for kind "${kind}"`);
|
|
31
|
+
}
|
|
32
|
+
const decoded = driver.configSchema.safeParse(rawConfig);
|
|
33
|
+
if (!decoded.success) {
|
|
34
|
+
throw new HarnessError("invalid_config", `invalid ${kind} driver config: ${decoded.error.message}`, { cause: decoded.error });
|
|
35
|
+
}
|
|
36
|
+
return driver.createInstance(decoded.data, context);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HarnessKind } from "./kinds.js";
|
|
2
|
+
export type HarnessAuthStatus = "authenticated" | "unauthenticated" | "unknown";
|
|
3
|
+
export type HarnessModelDescriptor = {
|
|
4
|
+
id: string;
|
|
5
|
+
displayName?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The probed state of one harness CLI: installed / version / auth / models.
|
|
9
|
+
* Consumed by diagnostics and readiness checks (skip with an actionable reason
|
|
10
|
+
* before any spend), and the front-door launchers.
|
|
11
|
+
*/
|
|
12
|
+
export type HarnessStatus = {
|
|
13
|
+
kind: HarnessKind;
|
|
14
|
+
installed: boolean;
|
|
15
|
+
/** The resolved command the probe ran (when installed). */
|
|
16
|
+
command?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
auth: {
|
|
19
|
+
status: HarnessAuthStatus;
|
|
20
|
+
detail?: string;
|
|
21
|
+
};
|
|
22
|
+
models?: HarnessModelDescriptor[];
|
|
23
|
+
checkedAt: string;
|
|
24
|
+
/** Why the probe itself failed, when it did (distinct from "not installed"). */
|
|
25
|
+
probeError?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare const DEFAULT_STATUS_CACHE_DIR: string;
|
|
28
|
+
/**
|
|
29
|
+
* Read a cached status snapshot. The payload's own `kind` must match the
|
|
30
|
+
* requested kind — the filename alone is never trusted as a routing key.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readCachedStatus(kind: HarnessKind, cacheDir?: string): HarnessStatus | undefined;
|
|
33
|
+
/** Persist a status snapshot atomically (write temp + rename). */
|
|
34
|
+
export declare function writeCachedStatus(status: HarnessStatus, cacheDir?: string): void;
|
|
35
|
+
/** An actionable skip reason when the status is not runnable, else undefined. */
|
|
36
|
+
export declare function statusSkipReason(status: HarnessStatus): string | undefined;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { isHarnessKind } from "./kinds.js";
|
|
6
|
+
export const DEFAULT_STATUS_CACHE_DIR = join(homedir(), ".routekit", "harness-status");
|
|
7
|
+
/**
|
|
8
|
+
* Read a cached status snapshot. The payload's own `kind` must match the
|
|
9
|
+
* requested kind — the filename alone is never trusted as a routing key.
|
|
10
|
+
*/
|
|
11
|
+
export function readCachedStatus(kind, cacheDir = DEFAULT_STATUS_CACHE_DIR) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(join(cacheDir, `${kind}.json`), "utf8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (!isHarnessKind(parsed.kind) || parsed.kind !== kind)
|
|
18
|
+
return undefined;
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Persist a status snapshot atomically (write temp + rename). */
|
|
26
|
+
export function writeCachedStatus(status, cacheDir = DEFAULT_STATUS_CACHE_DIR) {
|
|
27
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
28
|
+
const target = join(cacheDir, `${status.kind}.json`);
|
|
29
|
+
const temp = join(tmpdir(), `harness-status-${randomUUID()}.json`);
|
|
30
|
+
writeFileSync(temp, `${JSON.stringify(status, null, 2)}\n`);
|
|
31
|
+
try {
|
|
32
|
+
renameSync(temp, target);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Cross-device rename fallback: write directly (still a single syscall
|
|
36
|
+
// for the small payload).
|
|
37
|
+
writeFileSync(target, `${JSON.stringify(status, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** An actionable skip reason when the status is not runnable, else undefined. */
|
|
41
|
+
export function statusSkipReason(status) {
|
|
42
|
+
if (!status.installed) {
|
|
43
|
+
return status.probeError ?? `${status.kind} CLI is not installed or not on PATH.`;
|
|
44
|
+
}
|
|
45
|
+
if (status.auth.status === "unauthenticated") {
|
|
46
|
+
return status.auth.detail ?? `${status.kind} CLI is installed but not logged in.`;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const STREAM_JSON_MAX_TEXT = 4000;
|
|
2
|
+
export declare const STREAM_JSON_MAX_TOOL_INPUT = 600;
|
|
3
|
+
export type StreamJsonStepText = {
|
|
4
|
+
text?: string;
|
|
5
|
+
};
|
|
6
|
+
export type StreamJsonEmitterOptions<Step extends StreamJsonStepText> = {
|
|
7
|
+
stepsForEvent: (event: Record<string, unknown>) => Step[];
|
|
8
|
+
resultStep: (result: string) => Step;
|
|
9
|
+
onStep: (step: Step & {
|
|
10
|
+
index: number;
|
|
11
|
+
}) => void;
|
|
12
|
+
};
|
|
13
|
+
export type ParseStreamJsonOptions<Step extends StreamJsonStepText> = {
|
|
14
|
+
stdout: string;
|
|
15
|
+
stepsForEvent: (event: Record<string, unknown>) => Step[];
|
|
16
|
+
resultStep: (result: string) => Step;
|
|
17
|
+
fallbackText?: (step: Step & {
|
|
18
|
+
index: number;
|
|
19
|
+
}) => string | undefined;
|
|
20
|
+
};
|
|
21
|
+
export type ParsedStreamJson<Step extends StreamJsonStepText> = {
|
|
22
|
+
steps: Array<Step & {
|
|
23
|
+
index: number;
|
|
24
|
+
}>;
|
|
25
|
+
finalOutput: string;
|
|
26
|
+
sawResult: boolean;
|
|
27
|
+
isError: boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare function truncateStreamJsonText(text: string, limit: number): string;
|
|
30
|
+
export declare function asObject(value: unknown): Record<string, unknown> | undefined;
|
|
31
|
+
export declare function asArray(value: unknown): unknown[];
|
|
32
|
+
export declare function asString(value: unknown): string | undefined;
|
|
33
|
+
export declare function stringifyStreamJsonValue(value: unknown): string;
|
|
34
|
+
/** A tool_result `content` is either a string or an array of text/parts. */
|
|
35
|
+
export declare function streamJsonResultContentText(content: unknown): string;
|
|
36
|
+
export declare function parseStreamJsonLine(line: string): Record<string, unknown> | undefined;
|
|
37
|
+
export declare function createStreamJsonStepEmitter<Step extends StreamJsonStepText>(options: StreamJsonEmitterOptions<Step>): (line: string) => void;
|
|
38
|
+
export declare function parseStreamJsonTrajectory<Step extends StreamJsonStepText>(options: ParseStreamJsonOptions<Step>): ParsedStreamJson<Step>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const STREAM_JSON_MAX_TEXT = 4000;
|
|
2
|
+
export const STREAM_JSON_MAX_TOOL_INPUT = 600;
|
|
3
|
+
export function truncateStreamJsonText(text, limit) {
|
|
4
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}...[truncated]`;
|
|
5
|
+
}
|
|
6
|
+
export function asObject(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
export function asArray(value) {
|
|
12
|
+
return Array.isArray(value) ? value : [];
|
|
13
|
+
}
|
|
14
|
+
export function asString(value) {
|
|
15
|
+
return typeof value === "string" ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function stringifyStreamJsonValue(value) {
|
|
18
|
+
if (typeof value === "string")
|
|
19
|
+
return value;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return String(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** A tool_result `content` is either a string or an array of text/parts. */
|
|
28
|
+
export function streamJsonResultContentText(content) {
|
|
29
|
+
const direct = asString(content);
|
|
30
|
+
if (direct !== undefined)
|
|
31
|
+
return direct;
|
|
32
|
+
return asArray(content)
|
|
33
|
+
.map((part) => {
|
|
34
|
+
const obj = asObject(part);
|
|
35
|
+
if (obj === undefined)
|
|
36
|
+
return "";
|
|
37
|
+
if (obj.type === "text")
|
|
38
|
+
return asString(obj.text) ?? "";
|
|
39
|
+
return "";
|
|
40
|
+
})
|
|
41
|
+
.filter((text) => text.length > 0)
|
|
42
|
+
.join("");
|
|
43
|
+
}
|
|
44
|
+
export function parseStreamJsonLine(line) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (trimmed.length === 0 || trimmed[0] !== "{")
|
|
47
|
+
return undefined;
|
|
48
|
+
let event;
|
|
49
|
+
try {
|
|
50
|
+
event = JSON.parse(trimmed);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return asObject(event);
|
|
56
|
+
}
|
|
57
|
+
export function createStreamJsonStepEmitter(options) {
|
|
58
|
+
let index = 0;
|
|
59
|
+
let lastText = "";
|
|
60
|
+
const push = (step) => {
|
|
61
|
+
const indexed = { index, ...step };
|
|
62
|
+
index += 1;
|
|
63
|
+
if (indexed.text !== undefined)
|
|
64
|
+
lastText = indexed.text;
|
|
65
|
+
options.onStep(indexed);
|
|
66
|
+
};
|
|
67
|
+
return (line) => {
|
|
68
|
+
const obj = parseStreamJsonLine(line);
|
|
69
|
+
if (obj === undefined)
|
|
70
|
+
return;
|
|
71
|
+
if (asString(obj.type) === "result") {
|
|
72
|
+
const result = asString(obj.result);
|
|
73
|
+
if (result !== undefined && result.length > 0 && lastText !== result) {
|
|
74
|
+
push(options.resultStep(result));
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const step of options.stepsForEvent(obj))
|
|
79
|
+
push(step);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function parseStreamJsonTrajectory(options) {
|
|
83
|
+
const steps = [];
|
|
84
|
+
let finalOutput = "";
|
|
85
|
+
let sawResult = false;
|
|
86
|
+
let isError = false;
|
|
87
|
+
const push = (step) => {
|
|
88
|
+
steps.push({ index: steps.length, ...step });
|
|
89
|
+
};
|
|
90
|
+
for (const line of options.stdout.split("\n")) {
|
|
91
|
+
const obj = parseStreamJsonLine(line);
|
|
92
|
+
if (obj === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
if (asString(obj.type) === "result") {
|
|
95
|
+
sawResult = true;
|
|
96
|
+
if (obj.is_error === true)
|
|
97
|
+
isError = true;
|
|
98
|
+
const result = asString(obj.result);
|
|
99
|
+
if (result !== undefined && result.length > 0)
|
|
100
|
+
finalOutput = result;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const step of options.stepsForEvent(obj))
|
|
104
|
+
push(step);
|
|
105
|
+
}
|
|
106
|
+
if (finalOutput.length === 0) {
|
|
107
|
+
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
|
108
|
+
const step = steps[i];
|
|
109
|
+
const text = step !== undefined ? (options.fallbackText?.(step) ?? step.text) : undefined;
|
|
110
|
+
if (text !== undefined && text.length > 0) {
|
|
111
|
+
finalOutput = text;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (steps.at(-1)?.text !== finalOutput) {
|
|
117
|
+
push(options.resultStep(finalOutput));
|
|
118
|
+
}
|
|
119
|
+
return { steps, finalOutput, sawResult, isError };
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|