@vibest/plugin 0.0.0-stage → 0.0.1-beta.14
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 +21 -0
- package/package.json +29 -3
- package/src/index.ts +182 -0
- package/src/plugin.ts +12 -0
- package/src/runtime.ts +133 -0
- package/src/testing.ts +216 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iamdin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibest/plugin",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1-beta.14",
|
|
4
|
+
"description": "Authoring API for vibest plugins. vibestd provides this module (and effect) to plugins at runtime.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vibest-dev/vibest.git",
|
|
9
|
+
"directory": "packages/plugin"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./testing": "./src/testing.ts"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"effect": "4.0.2"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"effect": "^4.0.2"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsrs --noEmit --fast"
|
|
31
|
+
}
|
|
6
32
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @vibest/plugin: what an vibest plugin is written against. vibestd maps this module (and `effect`) to its own instances when it
|
|
2
|
+
// loads a plugin, so a plugin imports it normally and never bundles either. See architecture.md §8 and §9.
|
|
3
|
+
import { type Effect, Schema, type Scope } from "effect"
|
|
4
|
+
|
|
5
|
+
import type { RuntimeRegistry } from "./runtime.ts"
|
|
6
|
+
|
|
7
|
+
export * as Plugin from "./plugin.ts"
|
|
8
|
+
export * as Runtime from "./runtime.ts"
|
|
9
|
+
|
|
10
|
+
/** An `agent.start.before` callback fails with this to refuse the start; vibestd reports the reason and the plugin. */
|
|
11
|
+
export class AgentRefused extends Schema.TaggedError<AgentRefused>()("AgentRefused", { reason: Schema.String }) {}
|
|
12
|
+
|
|
13
|
+
/** One usage window a plugin, or a poll, reports. `percent` is 0–100. */
|
|
14
|
+
export interface SubscriptionLimit {
|
|
15
|
+
readonly window: string
|
|
16
|
+
readonly percent: number
|
|
17
|
+
readonly status: "available" | "limited"
|
|
18
|
+
readonly resetTime: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One harness login as vibestd last read it. `provider` is empty for Claude Code and Cursor, and set for Pi.
|
|
23
|
+
* `limits` empty means unknown, not unlimited.
|
|
24
|
+
*/
|
|
25
|
+
export interface SubscriptionSnapshot {
|
|
26
|
+
readonly harness: string
|
|
27
|
+
readonly provider: string
|
|
28
|
+
readonly machine: "local"
|
|
29
|
+
readonly account: {
|
|
30
|
+
readonly kind: "subscription" | "api-key" | "none"
|
|
31
|
+
readonly plan?: string | undefined
|
|
32
|
+
readonly provider?: string | undefined
|
|
33
|
+
}
|
|
34
|
+
readonly limits: ReadonlyArray<SubscriptionLimit>
|
|
35
|
+
readonly updatedAt: number
|
|
36
|
+
readonly error?: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Limits a plugin reports for one `(harness, provider)`. The next poll of a harness that reads usage replaces them. */
|
|
40
|
+
export interface SubscriptionReport {
|
|
41
|
+
readonly harness: string
|
|
42
|
+
readonly provider?: string
|
|
43
|
+
readonly limits: ReadonlyArray<SubscriptionLimit>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One runtime as vibestd last saw it on a machine (runtime-port.md §4, §7). */
|
|
47
|
+
export interface MachineRuntime {
|
|
48
|
+
readonly kind: string
|
|
49
|
+
readonly status: "online" | "offline" | "blocked"
|
|
50
|
+
readonly reason?: string | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One machine as `agent.start.before` reports it: the runtimes on it and the harnesses vibestd could start there. */
|
|
54
|
+
export interface MachineInfo {
|
|
55
|
+
readonly machine: string
|
|
56
|
+
readonly runtimes: ReadonlyArray<MachineRuntime>
|
|
57
|
+
readonly harnesses: ReadonlyArray<string>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A harness vibestd could start, as preflight saw it. */
|
|
61
|
+
export interface HarnessOption {
|
|
62
|
+
readonly harness: string
|
|
63
|
+
/** On PATH, logged in, its model list read. false: `problem` says why. */
|
|
64
|
+
readonly available: boolean
|
|
65
|
+
readonly problem?: string | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One earlier try for this session (a move, or a start that failed or was refused before). */
|
|
69
|
+
export interface Attempt {
|
|
70
|
+
readonly harness: string
|
|
71
|
+
readonly provider: string | undefined
|
|
72
|
+
readonly outcome: "limited" | "start_failed" | "not_confirmed" | "unavailable" | "refused"
|
|
73
|
+
readonly at: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read-only context every agent hook gets. */
|
|
77
|
+
export interface AgentContext {
|
|
78
|
+
/** The project directory. */
|
|
79
|
+
readonly project: string
|
|
80
|
+
/** `main` for the project's main agent, otherwise the definition (`general`, `reviewer`, …). */
|
|
81
|
+
readonly agentType: string
|
|
82
|
+
/** Who asked; undefined for the main agent and the user's own `vibest run`. */
|
|
83
|
+
readonly parentSessionId: string | undefined
|
|
84
|
+
readonly harnesses: ReadonlyArray<HarnessOption>
|
|
85
|
+
/** Logins and usage windows vibestd has right now, per (harness, provider). */
|
|
86
|
+
readonly subscriptions: ReadonlyArray<SubscriptionSnapshot>
|
|
87
|
+
/** Earlier tries for this session, oldest first; empty on a first start. */
|
|
88
|
+
readonly attempts: ReadonlyArray<Attempt>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Before any Agent starts, the main agent included. Plugins change the writable fields in place; vibestd runs callbacks
|
|
93
|
+
* one after another in plugin order, so a later plugin sees (and may overwrite) what an earlier one wrote.
|
|
94
|
+
* vibestd validates `harness` and `runtime` after the chain.
|
|
95
|
+
*/
|
|
96
|
+
export interface AgentStartBefore extends AgentContext {
|
|
97
|
+
/** The caller's text; a plugin may rewrite it. Empty for the main agent. */
|
|
98
|
+
prompt: string
|
|
99
|
+
/** The caller's preference on entry (undefined: none); the builtin default fills it. */
|
|
100
|
+
harness: string | undefined
|
|
101
|
+
provider: string | undefined
|
|
102
|
+
model: string | undefined
|
|
103
|
+
/** The machine to start on (writable; `local` is all there is today). */
|
|
104
|
+
machine: { id: string }
|
|
105
|
+
/** The terminal runtime to start in (writable): a `kind` from `machines`. The builtin choice plugin fills it. */
|
|
106
|
+
runtime: string | undefined
|
|
107
|
+
/** The runtime the parent session runs in (a subagent opens beside its parent, runtime-port.md §4); undefined for the user's own run. */
|
|
108
|
+
readonly parentRuntime: string | undefined
|
|
109
|
+
/** Every machine vibestd knows, with the runtimes on it and the harnesses it could start there (§7). */
|
|
110
|
+
readonly machines: ReadonlyArray<MachineInfo>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A started Agent's round failed on `reason`. Set `move` to restart the same session on another harness/provider.
|
|
115
|
+
*/
|
|
116
|
+
export interface AgentFail extends AgentContext {
|
|
117
|
+
readonly sessionId: string
|
|
118
|
+
/** Only reason produced today; the type is a union so more can be added. */
|
|
119
|
+
readonly reason: "limited"
|
|
120
|
+
/** The harness's own error text. */
|
|
121
|
+
readonly error: string
|
|
122
|
+
/** The failed round's text (what will be resent). */
|
|
123
|
+
readonly turn: string
|
|
124
|
+
readonly harness: string
|
|
125
|
+
readonly provider: string | undefined
|
|
126
|
+
move: { harness: string; provider?: string | undefined; model?: string | undefined } | undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A subagent's round is about to go idle. Set `prompt` to keep it working on that text instead.
|
|
131
|
+
* `prompt` is the only decision. `output` is a failure tail and does not stop the chain.
|
|
132
|
+
*/
|
|
133
|
+
export interface AgentIdle {
|
|
134
|
+
readonly sessionId: string
|
|
135
|
+
/** `main` for the project's main agent, otherwise the definition (`general`, …). */
|
|
136
|
+
readonly agentType: string
|
|
137
|
+
/** The directory the agent works in. */
|
|
138
|
+
readonly directory: string
|
|
139
|
+
/** The agent's last answer. */
|
|
140
|
+
readonly answer: string
|
|
141
|
+
/** Finished check attempts for this session, counted by the host. The plugin only compares it to its limit. */
|
|
142
|
+
readonly checkAttempts: number
|
|
143
|
+
/** Set once this result's command has finished. The plugin must not ask to run it again. */
|
|
144
|
+
readonly ran: { readonly ok: boolean; readonly tail: string } | undefined
|
|
145
|
+
/** Set when the host should run this command outside the lock. Not a decision. */
|
|
146
|
+
command: string | undefined
|
|
147
|
+
prompt: string | undefined
|
|
148
|
+
/** Tail of a failing check once the attempt budget is spent. Not a decision. */
|
|
149
|
+
output: string | undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every hook: the event its callbacks get and the error they may fail with. */
|
|
153
|
+
export interface Hooks {
|
|
154
|
+
readonly "agent.start.before": { readonly event: AgentStartBefore; readonly error: AgentRefused }
|
|
155
|
+
readonly "agent.fail": { readonly event: AgentFail; readonly error: never }
|
|
156
|
+
readonly "agent.idle": { readonly event: AgentIdle; readonly error: never }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The hook names of one domain without its prefix: `start.before` for `agent.start.before`. */
|
|
160
|
+
export type HookOf<D extends string> = { [K in keyof Hooks]: K extends `${D}.${infer S}` ? S : never }[keyof Hooks]
|
|
161
|
+
|
|
162
|
+
export interface Domain<D extends string> {
|
|
163
|
+
/** Registered until the plugin is unloaded (its scope closes). A name not in `Hooks` fails the plugin's setup. */
|
|
164
|
+
readonly hook: <N extends HookOf<D>>(
|
|
165
|
+
name: N,
|
|
166
|
+
callback: (
|
|
167
|
+
event: Hooks[Extract<`${D}.${N}`, keyof Hooks>]["event"],
|
|
168
|
+
) => Effect.Effect<void, Hooks[Extract<`${D}.${N}`, keyof Hooks>]["error"]>,
|
|
169
|
+
) => Effect.Effect<void, never, Scope.Scope>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface Context {
|
|
173
|
+
/** This plugin's `options` from vibest.json. */
|
|
174
|
+
readonly options: Readonly<Record<string, unknown>>
|
|
175
|
+
readonly agent: Domain<"agent">
|
|
176
|
+
/** Register a terminal runtime (`runtime-port.md`). Beside `agent.hook`; kept for the plugin's lifetime. */
|
|
177
|
+
readonly runtime: RuntimeRegistry
|
|
178
|
+
readonly subscription: {
|
|
179
|
+
/** Set the limits for `(harness, provider)` on this machine. Replaces that row's limits until the next usage poll. */
|
|
180
|
+
readonly report: (input: SubscriptionReport) => Effect.Effect<void>
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Effect, Scope } from "effect"
|
|
2
|
+
|
|
3
|
+
import type { Context } from "./index.ts"
|
|
4
|
+
|
|
5
|
+
export interface Plugin {
|
|
6
|
+
readonly id: string
|
|
7
|
+
/** Setup: register hooks. Runs once per load in the plugin's own scope; resources acquired here are released on unload. */
|
|
8
|
+
readonly effect: (ctx: Context) => Effect.Effect<void, never, Scope.Scope>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Returns the plugin unchanged; it only gives the object its type. */
|
|
12
|
+
export const define = (plugin: Plugin) => plugin
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Runtime plugin API (runtime-port.md, `runtime-api.ts`, accepted). Rules that keep it small and extensible:
|
|
2
|
+
// - No API version field: the API ships with vibest. Additions are optional members; a breaking change ships in a
|
|
3
|
+
// vibest release and plugins update with it.
|
|
4
|
+
// - Core checks whether an optional member exists, never which runtime it is.
|
|
5
|
+
// - One error type; `code` says what happened.
|
|
6
|
+
// - Connections are the plugin's business: it opens, reuses and re-opens them per machine inside its own scope.
|
|
7
|
+
import { type Effect, Schema, type Stream } from "effect"
|
|
8
|
+
|
|
9
|
+
/** `local`, or a saved machine; `target` is the runtime's to read (an ssh target, a host label, …). */
|
|
10
|
+
export interface Machine {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly target?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A terminal: the machine it is on and the runtime's own id there (opaque to core). Stored on the Agent row. */
|
|
16
|
+
export interface Terminal {
|
|
17
|
+
readonly machine: string
|
|
18
|
+
readonly id: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RuntimeError extends Schema.TaggedError<RuntimeError>()("RuntimeError", {
|
|
22
|
+
/** not_found | busy | unreachable (transient, retried) | incompatible (needs the user) | failed (anything else) */
|
|
23
|
+
code: Schema.Literals(["not_found", "busy", "unreachable", "incompatible", "failed"]),
|
|
24
|
+
message: Schema.String,
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
27
|
+
export type MachineStatus =
|
|
28
|
+
| { readonly status: "online" }
|
|
29
|
+
| { readonly status: "offline" | "blocked"; readonly reason: string }
|
|
30
|
+
|
|
31
|
+
export type AgentState = "idle" | "working" | "blocked" | "unknown"
|
|
32
|
+
|
|
33
|
+
/** One line of `vibest doctor` about a runtime: what is fine or not, and the command that fixes it. */
|
|
34
|
+
export interface RuntimeCheck {
|
|
35
|
+
readonly ok: boolean
|
|
36
|
+
/** What the line is about: a harness id, or none for the runtime itself. */
|
|
37
|
+
readonly subject?: string
|
|
38
|
+
readonly text: string
|
|
39
|
+
readonly fix?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What `vibest doctor` shows for a runtime on a machine. */
|
|
43
|
+
export interface RuntimeReport {
|
|
44
|
+
/** The runtime as the user knows it, with its version when known (`herdr 0.9.5`). */
|
|
45
|
+
readonly name: string
|
|
46
|
+
readonly checks: ReadonlyArray<RuntimeCheck>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RuntimeEvent =
|
|
50
|
+
| { readonly type: "exited"; readonly terminal: Terminal }
|
|
51
|
+
| { readonly type: "state"; readonly terminal: Terminal; readonly state: AgentState }
|
|
52
|
+
/** A machine's connection was re-made or its runtime restarted: core re-reads what it needs there. */
|
|
53
|
+
| { readonly type: "reset"; readonly machine: string }
|
|
54
|
+
|
|
55
|
+
/** What a runtime plugin registers. */
|
|
56
|
+
export interface Runtime {
|
|
57
|
+
/** Stored on each Agent row (`runtime.kind`); unique among loaded plugins. */
|
|
58
|
+
readonly kind: string
|
|
59
|
+
/** Usable on this machine now. `local` not installed → blocked with the reason. */
|
|
60
|
+
readonly status: (machine: Machine) => Effect.Effect<MachineStatus>
|
|
61
|
+
|
|
62
|
+
/** A terminal running `argv` exactly, kept with the project's other terminals (the runtime decides how). */
|
|
63
|
+
readonly open: (
|
|
64
|
+
machine: Machine,
|
|
65
|
+
input: {
|
|
66
|
+
readonly project: string
|
|
67
|
+
readonly cwd: string
|
|
68
|
+
readonly argv: ReadonlyArray<string>
|
|
69
|
+
readonly env: Readonly<Record<string, string>>
|
|
70
|
+
readonly label: string
|
|
71
|
+
/** Put it beside this terminal when possible. */
|
|
72
|
+
readonly near?: Terminal
|
|
73
|
+
/** As a split beside `near` instead of a tab of its own (`vibest run --peer`), when the runtime can. */
|
|
74
|
+
readonly split?: boolean
|
|
75
|
+
/** Focus the terminal when the runtime has such a notion (a terminal the user is waiting to see). */
|
|
76
|
+
readonly focus?: boolean
|
|
77
|
+
},
|
|
78
|
+
) => Effect.Effect<Terminal, RuntimeError>
|
|
79
|
+
/** Type text and/or press keys (enter, esc, ctrl+c, …). */
|
|
80
|
+
readonly send: (
|
|
81
|
+
terminal: Terminal,
|
|
82
|
+
input: { readonly text?: string; readonly keys?: ReadonlyArray<string> },
|
|
83
|
+
) => Effect.Effect<void, RuntimeError>
|
|
84
|
+
/** The last `lines` lines of output. */
|
|
85
|
+
readonly read: (terminal: Terminal, lines: number) => Effect.Effect<string, RuntimeError>
|
|
86
|
+
/** Is its program still running; its exit code once it is not. `not_found` once the terminal is gone. */
|
|
87
|
+
readonly process: (
|
|
88
|
+
terminal: Terminal,
|
|
89
|
+
) => Effect.Effect<{ readonly running: boolean; readonly exitCode?: number }, RuntimeError>
|
|
90
|
+
readonly close: (terminal: Terminal) => Effect.Effect<void, RuntimeError>
|
|
91
|
+
|
|
92
|
+
// Optional. Missing → core's fallback in brackets.
|
|
93
|
+
/** What the runtime sees of the agent; `not_found` when it names no agent in that terminal (core reads "absent"). [vibest's harness integration only] */
|
|
94
|
+
readonly state?: (terminal: Terminal) => Effect.Effect<AgentState, RuntimeError>
|
|
95
|
+
/** The harness conversation id the runtime's own agent record names, when it has one. [the harness's reports only] */
|
|
96
|
+
readonly session?: (terminal: Terminal) => Effect.Effect<string | null, RuntimeError>
|
|
97
|
+
/**
|
|
98
|
+
* Run `argv` in an existing terminal once it is back at an idle shell (its program ended): the counterpart of `open` for
|
|
99
|
+
* a terminal that already exists. Fails when the terminal is gone or its shell does not come back. [no restart in
|
|
100
|
+
* place: `vibest update` and a usage-limit move leave such a terminal's agent for the user to restart]
|
|
101
|
+
*/
|
|
102
|
+
readonly run?: (terminal: Terminal, argv: ReadonlyArray<string>) => Effect.Effect<void, RuntimeError>
|
|
103
|
+
/** Pushed changes, every machine. [poll `process`] */
|
|
104
|
+
readonly events?: Stream.Stream<RuntimeEvent>
|
|
105
|
+
/** One-off command on a machine. [run it in a hidden terminal: `open`, wait for exit, `read`] */
|
|
106
|
+
readonly exec?: (
|
|
107
|
+
machine: Machine,
|
|
108
|
+
argv: ReadonlyArray<string>,
|
|
109
|
+
cwd: string,
|
|
110
|
+
) => Effect.Effect<{ readonly code: number; readonly output: string }, RuntimeError>
|
|
111
|
+
/**
|
|
112
|
+
* Labels and tokens shown on a terminal. [the `label` given to `open`] The keys vibest sends: `agent`, `harness`,
|
|
113
|
+
* `title`, `definition` and `session` label the terminal; `project` and `live` label the project's workspace. Any
|
|
114
|
+
* other key a caller passes is the caller's own; a runtime shows only what it knows a key for.
|
|
115
|
+
*/
|
|
116
|
+
readonly present?: (terminal: Terminal, info: Readonly<Record<string, string>>) => Effect.Effect<void, RuntimeError>
|
|
117
|
+
/** Set a machine up on the runtime's side (`vibest machine add`). [the user does it] */
|
|
118
|
+
readonly prepare?: (machine: Machine) => Effect.Effect<void, RuntimeError>
|
|
119
|
+
/** What `vibest doctor` shows: anything the runtime needs for `harnesses` to work in it, with fixes. [its `status` only] */
|
|
120
|
+
readonly doctor?: (machine: Machine, harnesses: ReadonlyArray<string>) => Effect.Effect<RuntimeReport>
|
|
121
|
+
/** The command that shows this runtime on a machine to the user. [none] */
|
|
122
|
+
readonly attach?: (machine: Machine) => ReadonlyArray<string>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** In the plugin context, beside `agent.hook`. Registered for the plugin's lifetime; its connections close with it. */
|
|
126
|
+
export interface RuntimeRegistry {
|
|
127
|
+
readonly register: (runtime: Runtime) => Effect.Effect<void>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// export default Plugin.define({
|
|
131
|
+
// id: "runtime-tern",
|
|
132
|
+
// effect: (context) => context.runtime.register({ kind: "tern", status, open, send, read, process, close, attach: () => ["tern"] }),
|
|
133
|
+
// })
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// `runtimeContract` (runtime-port.md §3): every runtime plugin passes this against a real local instance before it
|
|
2
|
+
// ships — the required members first, then each optional member present. Framework-free: it fails with
|
|
3
|
+
// `ContractFailure` naming the member and why, so any runner (a test file, a CLI check) can drive it.
|
|
4
|
+
import { Cause, Duration, Effect, Exit, Option, Ref, Schema, type Scope, Stream } from "effect"
|
|
5
|
+
|
|
6
|
+
import type * as Runtime from "./runtime.ts"
|
|
7
|
+
|
|
8
|
+
export { type Runtime } from "./runtime.ts"
|
|
9
|
+
|
|
10
|
+
/** One contract violation: the member under test and what it did. */
|
|
11
|
+
export class ContractFailure extends Schema.TaggedError<ContractFailure>()("ContractFailure", {
|
|
12
|
+
member: Schema.String,
|
|
13
|
+
message: Schema.String,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
/** What the contract can fail with: its own violation, or the runtime's error from the call it made. */
|
|
17
|
+
export type ContractError = ContractFailure | Runtime.RuntimeError
|
|
18
|
+
|
|
19
|
+
const fail = (member: string, message: string) => new ContractFailure({ member, message: `runtime ${message}` })
|
|
20
|
+
const that = (member: string, ok: boolean, message: string) => (ok ? Effect.void : Effect.fail(fail(member, message)))
|
|
21
|
+
|
|
22
|
+
/** Poll `read` until `ready` is true, `what` names the wait in the failure. */
|
|
23
|
+
const until = <A, E, R>(
|
|
24
|
+
member: string,
|
|
25
|
+
read: Effect.Effect<A, E, R>,
|
|
26
|
+
ready: (value: A) => boolean,
|
|
27
|
+
what: string,
|
|
28
|
+
timeout: Duration.Duration,
|
|
29
|
+
) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const deadline = Date.now() + Duration.toMillis(timeout)
|
|
32
|
+
for (;;) {
|
|
33
|
+
const value = yield* read
|
|
34
|
+
if (ready(value)) return value
|
|
35
|
+
if (Date.now() >= deadline) return yield* Effect.fail(fail(member, `${what} within ${Duration.format(timeout)}`))
|
|
36
|
+
yield* Effect.sleep("100 millis")
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export interface ContractOptions {
|
|
41
|
+
/** Where `open` places its terminal (a scratch directory the caller owns). */
|
|
42
|
+
readonly cwd: string
|
|
43
|
+
/** How long one wait may take (default 15 s). */
|
|
44
|
+
readonly timeout?: Duration.Input
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the contract: `status`, `open` (argv exactly, env, label), `send` (text and keys), `read`, `process`, `close`,
|
|
49
|
+
* then `state`, `session`, `exec` and `present` when the runtime has them. `prepare` and `attach` are checked as
|
|
50
|
+
* members only: `prepare` changes a machine's setup and `attach` opens the runtime's own interface, so the contract
|
|
51
|
+
* never runs them. `events`, when present, is proven by the `exited` event of the terminal the contract itself closes;
|
|
52
|
+
* `process` on the closed terminal by `not_found` once it is gone, and on a program that ended on its own by
|
|
53
|
+
* `running: false` (its exit code when the runtime knows one). `run`, when present, runs a command in that terminal
|
|
54
|
+
* once its program ended.
|
|
55
|
+
*/
|
|
56
|
+
export const runtimeContract = (
|
|
57
|
+
runtime: Runtime.Runtime,
|
|
58
|
+
options: ContractOptions,
|
|
59
|
+
): Effect.Effect<void, ContractError, Scope.Scope> =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const timeout = Duration.fromInputUnsafe(options.timeout ?? "15 seconds")
|
|
62
|
+
const local: Runtime.Machine = { id: "local" }
|
|
63
|
+
const marker = `vbt-contract-${Math.random().toString(16).slice(2, 8)}`
|
|
64
|
+
// Typed into the shell `open` started, so the read proves the program ran rather than seeing its own typed command echoed.
|
|
65
|
+
const ran = (word: string) => `printf 'vbt-%s-${word}\n' "$VBT_CONTRACT"`
|
|
66
|
+
|
|
67
|
+
const shown = yield* runtime.status(local)
|
|
68
|
+
yield* that(
|
|
69
|
+
"status",
|
|
70
|
+
shown.status === "online" || shown.status === "offline" || shown.status === "blocked",
|
|
71
|
+
`reported a status that is not one of online, offline, blocked: ${JSON.stringify(shown)}`,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// The events follower runs from the start: the program that ends on its own must be seen without anyone closing it.
|
|
75
|
+
const seen = yield* Ref.make<ReadonlyArray<Runtime.RuntimeEvent>>([])
|
|
76
|
+
if (runtime.events !== undefined)
|
|
77
|
+
yield* runtime.events.pipe(
|
|
78
|
+
Stream.runForEach((event) => Ref.update(seen, (events) => [...events, event])),
|
|
79
|
+
Effect.forkScoped,
|
|
80
|
+
Effect.catchCause(() => Effect.void),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// An interactive shell, driven by `send`: the argv runs exactly, and the env it was given reaches what runs there.
|
|
84
|
+
const terminal = yield* runtime.open(local, {
|
|
85
|
+
project: options.cwd,
|
|
86
|
+
cwd: options.cwd,
|
|
87
|
+
argv: ["/bin/sh"],
|
|
88
|
+
env: { VBT_CONTRACT: marker },
|
|
89
|
+
label: `vibest contract ${marker}`,
|
|
90
|
+
})
|
|
91
|
+
yield* that("open", typeof terminal.id === "string" && terminal.id !== "", "returned a terminal without an id")
|
|
92
|
+
|
|
93
|
+
yield* runtime.send(terminal, { text: ran("sent"), keys: ["enter"] })
|
|
94
|
+
yield* until(
|
|
95
|
+
"send",
|
|
96
|
+
runtime.read(terminal, 40),
|
|
97
|
+
(out) => out.includes(`vbt-${marker}-sent`),
|
|
98
|
+
"did not run the text typed with send",
|
|
99
|
+
timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// Polled: a runtime may take a moment to see the program the terminal is running.
|
|
103
|
+
yield* runtime.send(terminal, { text: "sleep 60", keys: ["enter"] })
|
|
104
|
+
yield* until(
|
|
105
|
+
"process",
|
|
106
|
+
runtime.process(terminal),
|
|
107
|
+
(state) => state.running,
|
|
108
|
+
"did not report the program running while it still sleeps",
|
|
109
|
+
timeout,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (runtime.state !== undefined) {
|
|
113
|
+
// `not_found` is a valid answer: the runtime names no agent in a terminal it did not start one in (a plain
|
|
114
|
+
// shell), which core reads as "absent" (§3). Any other answer must be a state of the union.
|
|
115
|
+
const state = yield* runtime
|
|
116
|
+
.state(terminal)
|
|
117
|
+
.pipe(
|
|
118
|
+
Effect.catchTag("RuntimeError", (e) =>
|
|
119
|
+
e.code === "not_found" ? Effect.succeed("absent" as const) : Effect.fail(e),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
yield* that(
|
|
123
|
+
"state",
|
|
124
|
+
state === "absent" || state === "idle" || state === "working" || state === "blocked" || state === "unknown",
|
|
125
|
+
`reported a state that is not one of idle, working, blocked, unknown: ${JSON.stringify(state)}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (runtime.session !== undefined) {
|
|
130
|
+
const session = yield* runtime.session(terminal)
|
|
131
|
+
yield* that(
|
|
132
|
+
"session",
|
|
133
|
+
session === null || typeof session === "string",
|
|
134
|
+
`returned something other than a conversation id or null: ${JSON.stringify(session)}`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (runtime.present !== undefined) yield* runtime.present(terminal, { contract: marker })
|
|
139
|
+
|
|
140
|
+
if (runtime.exec !== undefined) {
|
|
141
|
+
const ran = yield* runtime.exec(local, ["printf", "contract-exec"], options.cwd)
|
|
142
|
+
yield* that(
|
|
143
|
+
"exec",
|
|
144
|
+
ran.code === 0 && ran.output.includes("contract-exec"),
|
|
145
|
+
`printf contract-exec gave code ${ran.code} and ${JSON.stringify(ran.output)}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (runtime.attach !== undefined) {
|
|
150
|
+
const argv = runtime.attach(local)
|
|
151
|
+
yield* that(
|
|
152
|
+
"attach",
|
|
153
|
+
argv.every((part) => typeof part === "string" && part !== ""),
|
|
154
|
+
"returned something other than a non-empty argv",
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A program that exits on its own: `process` stops reporting it running (its exit code when the runtime knows
|
|
159
|
+
// one; `not_found` once the terminal went with it) — no `close` ran.
|
|
160
|
+
const ended = yield* runtime.open(local, {
|
|
161
|
+
project: options.cwd,
|
|
162
|
+
cwd: options.cwd,
|
|
163
|
+
argv: ["/bin/sh", "-c", "exit 3"],
|
|
164
|
+
env: { VBT_CONTRACT: marker },
|
|
165
|
+
label: `vibest contract ${marker} exit`,
|
|
166
|
+
})
|
|
167
|
+
const gone = yield* until(
|
|
168
|
+
"process",
|
|
169
|
+
runtime.process(ended).pipe(Effect.exit),
|
|
170
|
+
(exit) => {
|
|
171
|
+
if (Exit.isFailure(exit)) {
|
|
172
|
+
const error = Cause.squash(exit.cause) as { readonly code?: string }
|
|
173
|
+
return error.code === "not_found"
|
|
174
|
+
}
|
|
175
|
+
return !exit.value.running
|
|
176
|
+
},
|
|
177
|
+
"did not report the exited program not running",
|
|
178
|
+
timeout,
|
|
179
|
+
)
|
|
180
|
+
if (Exit.isSuccess(gone) && gone.value.exitCode !== undefined)
|
|
181
|
+
yield* that(
|
|
182
|
+
"process",
|
|
183
|
+
gone.value.exitCode === 3,
|
|
184
|
+
`reported exit code ${gone.value.exitCode} for a program that ran exit 3`,
|
|
185
|
+
)
|
|
186
|
+
// The terminal is back at its shell: `run` starts the next program there.
|
|
187
|
+
if (runtime.run !== undefined && Exit.isSuccess(gone)) {
|
|
188
|
+
yield* runtime.run(ended, ["/bin/sh", "-c", ran("again")])
|
|
189
|
+
yield* until(
|
|
190
|
+
"run",
|
|
191
|
+
runtime.read(ended, 40),
|
|
192
|
+
(out) => out.includes(`vbt-${marker}-again`),
|
|
193
|
+
"did not run the command in the terminal whose program ended",
|
|
194
|
+
timeout,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (Exit.isSuccess(gone)) yield* runtime.close(ended)
|
|
198
|
+
|
|
199
|
+
yield* runtime.close(terminal)
|
|
200
|
+
if (runtime.events !== undefined)
|
|
201
|
+
yield* until(
|
|
202
|
+
"events",
|
|
203
|
+
Ref.get(seen),
|
|
204
|
+
(events) => events.some((event) => event.type === "exited" && event.terminal.id === terminal.id),
|
|
205
|
+
`emitted no exited event for the closed terminal ${terminal.id}`,
|
|
206
|
+
timeout,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
yield* until(
|
|
210
|
+
"process",
|
|
211
|
+
runtime.process(terminal).pipe(Effect.flip, Effect.option),
|
|
212
|
+
(found) => Option.isSome(found) && found.value.code === "not_found",
|
|
213
|
+
`kept answering process for the closed terminal ${terminal.id} instead of not_found`,
|
|
214
|
+
Duration.fromInputUnsafe("5 seconds"),
|
|
215
|
+
)
|
|
216
|
+
}).pipe(Effect.asVoid)
|
package/README.md
DELETED