@playfast/muxmaxing-config 0.1.1
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/README.md +60 -0
- package/package.json +50 -0
- package/src/fixtures/sample.config.ts +35 -0
- package/src/index.ts +136 -0
- package/src/protocol.ts +103 -0
- package/src/worker-entry.test.ts +159 -0
- package/src/worker-entry.ts +237 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @playfast/muxmaxing-config
|
|
2
|
+
|
|
3
|
+
Author API for a repo-committed `muxmaxing.config.ts` — a hook bridge between the **host
|
|
4
|
+
device** (the machine running mux) and a **work unit** (the pod/VM muxmaxing creates for
|
|
5
|
+
your repo). Declare a typed `hostInput` payload, collect it on the host (env, CLIs,
|
|
6
|
+
logins), and act on unit lifecycle moments with it. Hooks are Effect-native and run
|
|
7
|
+
host-side in a Bun Worker, only for repos the user has explicitly approved.
|
|
8
|
+
|
|
9
|
+
## Example — preconfigure the Doppler CLI in every work unit
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// muxmaxing.config.ts (repo root, committed)
|
|
13
|
+
import { defineConfig } from '@playfast/muxmaxing-config'
|
|
14
|
+
import { Effect, Schema as S } from 'effect'
|
|
15
|
+
|
|
16
|
+
const TokenJson = S.parseJson(S.Struct({ key: S.String, slug: S.String }))
|
|
17
|
+
|
|
18
|
+
export default defineConfig({
|
|
19
|
+
hostInput: S.Struct({ dopplerToken: S.String, tokenSlug: S.String }),
|
|
20
|
+
|
|
21
|
+
// Runs ON THE HOST: mint an ephemeral read-only token with the user's own login.
|
|
22
|
+
collectHostDeviceInput: ({ host, unit }) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const minted = yield* host.bash(
|
|
25
|
+
`doppler configs tokens create mux-${unit.id} --project my-app --config dev --max-age 24h --json`,
|
|
26
|
+
)
|
|
27
|
+
const token = yield* S.decode(TokenJson)(minted.stdout)
|
|
28
|
+
return { dopplerToken: token.key, tokenSlug: token.slug }
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
// Runs when the unit is up: `ctx.unit.bash` execs INSIDE the unit.
|
|
32
|
+
onWorkUnitMachineCreated: (ctx) =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
yield* ctx.unit.bash(
|
|
35
|
+
`doppler configure set token ${ctx.hostInput.dopplerToken} --scope ${ctx.unit.workspaceFolder}`,
|
|
36
|
+
)
|
|
37
|
+
yield* ctx.log('doppler CLI preconfigured')
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
// Runs at teardown ON THE HOST: revoke exactly the token this unit was minted.
|
|
41
|
+
onWorkUnitRemoved: (ctx) =>
|
|
42
|
+
ctx.host.bash(
|
|
43
|
+
`doppler configs tokens revoke --slug ${ctx.hostInput.tokenSlug} --project my-app --config dev`,
|
|
44
|
+
),
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Contract
|
|
49
|
+
|
|
50
|
+
- `hostInput` — an Effect Schema; its encoded side must be plain JSON (it crosses the
|
|
51
|
+
worker boundary and is cached memory-only by the engine between hooks).
|
|
52
|
+
- `collectHostDeviceInput(ctx)` — runs once per unit on the host; produces the payload.
|
|
53
|
+
- `onWorkUnitMachineCreated(ctx)` / `onNewTerminal(ctx)` — `ctx.unit.bash` execs in the
|
|
54
|
+
unit, `ctx.host.bash` on the host, `ctx.log` writes to the unit's creation pane.
|
|
55
|
+
- `onWorkUnitRemoved(ctx)` — host-side cleanup; the unit may already be gone (no unit bash).
|
|
56
|
+
- A bash result is `{ stdout, stderr, exitCode }`; a non-zero exit is not an error — only
|
|
57
|
+
a transport failure fails the Effect.
|
|
58
|
+
|
|
59
|
+
Trust model: hooks run on the host in a Bun Worker — a thread boundary, **not** a security
|
|
60
|
+
sandbox. muxmaxing runs them only for repos the user approved (Settings → Hooks).
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@playfast/muxmaxing-config",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Author API for muxmaxing.config.ts — a repo-committed hook bridge between the host device and muxmaxing work units: typed host-input collection plus Effect-native lifecycle hooks (machine created, new terminal, removed) with bash bridged into the unit.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"muxmaxing",
|
|
8
|
+
"effect",
|
|
9
|
+
"hooks",
|
|
10
|
+
"devcontainer",
|
|
11
|
+
"provisioning"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/playfast/reform.git",
|
|
17
|
+
"directory": "packages/muxmaxing-config"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"exports": {
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./*": "./src/*.ts"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "rm -rf dist .tsbuildinfo",
|
|
34
|
+
"check": "tsc --noEmit",
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"lint": "oxlint src",
|
|
39
|
+
"lint:fix": "oxlint --fix src"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"effect": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/bun": "^1.3.14"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, Schema as S, String as Str } from 'effect'
|
|
2
|
+
import { defineConfig, type MuxmaxingConfig } from '@playfast/muxmaxing-config'
|
|
3
|
+
|
|
4
|
+
// The worker-entry test fixture — shaped like a real repo's `muxmaxing.config.ts`
|
|
5
|
+
// (imports the package by name, exercises host bash, unit bash, log, and hostInput).
|
|
6
|
+
// The explicit annotation is only for THIS package's `isolatedDeclarations` check —
|
|
7
|
+
// a real repo's config just default-exports `defineConfig({...})`.
|
|
8
|
+
|
|
9
|
+
interface SampleInput {
|
|
10
|
+
readonly token: string
|
|
11
|
+
readonly slug: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const config: MuxmaxingConfig<SampleInput, SampleInput> = defineConfig({
|
|
15
|
+
hostInput: S.Struct({ token: S.String, slug: S.String }),
|
|
16
|
+
|
|
17
|
+
collectHostDeviceInput: ({ host, unit }) =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const minted = yield* host.bash(`fake-mint ${unit.id}`)
|
|
20
|
+
return { token: Str.trim(minted.stdout), slug: `slug-${unit.id}` }
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
onWorkUnitMachineCreated: (ctx) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
yield* ctx.log('configuring')
|
|
26
|
+
const configured = yield* ctx.unit.bash(`configure ${ctx.hostInput.token}`)
|
|
27
|
+
if (configured.exitCode !== 0) {
|
|
28
|
+
return yield* Effect.fail(`configure exited ${configured.exitCode}`)
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
onWorkUnitRemoved: (ctx) => Effect.asVoid(ctx.host.bash(`revoke ${ctx.hostInput.slug}`)),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export default config
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type Cause, Data, type Effect, Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
// The author API for a repo-committed `muxmaxing.config.ts` — a HOOK BRIDGE between the
|
|
4
|
+
// host device (the machine running mux) and a work unit (the pod/VM muxmaxing created for
|
|
5
|
+
// the repo). The author declares a typed `hostInput` payload, collects it ON THE HOST
|
|
6
|
+
// (read host env, exec host CLIs — e.g. mint an ephemeral Doppler token with the user's
|
|
7
|
+
// own login), and acts on unit lifecycle moments with it (`ctx.bash` execs IN the unit).
|
|
8
|
+
//
|
|
9
|
+
// Hooks are Effect-native: each returns an `Effect`, never a Promise. They run host-side
|
|
10
|
+
// in a Bun Worker — a THREAD boundary, not a security sandbox — so muxmaxing only runs
|
|
11
|
+
// them for repos the user has explicitly approved.
|
|
12
|
+
|
|
13
|
+
/** The captured streams + exit code of a bridged bash execution (unit or host). A
|
|
14
|
+
* non-zero exit is NOT an error — it's a successful run whose `exitCode` the hook
|
|
15
|
+
* interprets; only a transport failure (the command could not RUN) fails the Effect. */
|
|
16
|
+
export interface BashResult {
|
|
17
|
+
readonly stdout: string
|
|
18
|
+
readonly stderr: string
|
|
19
|
+
readonly exitCode: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The constructor shape `Data.TaggedError(tag)<A>` produces, named so the generated
|
|
23
|
+
* `.d.ts` can describe the `extends` base under `isolatedDeclarations` (which forbids
|
|
24
|
+
* an inferred expression in an extends clause) — the reform-db errors pattern. */
|
|
25
|
+
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
|
|
26
|
+
args: A,
|
|
27
|
+
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
28
|
+
|
|
29
|
+
const HookBashErrorBase: TaggedErrorClass<'HookBashError', { readonly reason: string }> =
|
|
30
|
+
Data.TaggedError('HookBashError')<{ readonly reason: string }>
|
|
31
|
+
|
|
32
|
+
/** Transport failure of a bridged bash call — the command could not be executed at all
|
|
33
|
+
* (unit unreachable, host spawn failed, bridge torn down). */
|
|
34
|
+
export class HookBashError extends HookBashErrorBase {
|
|
35
|
+
// Surface `reason` wherever the error is rendered (`Cause.pretty` would otherwise
|
|
36
|
+
// print the generic "An error has occurred" — the reform-db errors gotcha).
|
|
37
|
+
override get message(): string {
|
|
38
|
+
return this.reason
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The host-device side of the bridge: exec on the machine running mux + its env. */
|
|
43
|
+
export interface HostBridge {
|
|
44
|
+
/** Run a bash command ON THE HOST DEVICE. */
|
|
45
|
+
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
46
|
+
/** The host process env (hooks share the host process — no sandbox). */
|
|
47
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The work unit a hook invocation is about. */
|
|
51
|
+
export interface UnitInfo {
|
|
52
|
+
readonly id: string
|
|
53
|
+
/** The unit-side folder the repo is checked out under (bash cwd for unit execs). */
|
|
54
|
+
readonly workspaceFolder: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The work-unit side of the bridge: the unit's identity + exec INSIDE it — the mirror
|
|
58
|
+
* of `HostBridge`, so hooks read as `ctx.host.bash(...)` / `ctx.unit.bash(...)`. */
|
|
59
|
+
export interface UnitBridge extends UnitInfo {
|
|
60
|
+
/** Run a bash command INSIDE the work unit. */
|
|
61
|
+
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ctx for `collectHostDeviceInput` — host-only (the payload doesn't exist yet). */
|
|
65
|
+
export interface CollectCtx {
|
|
66
|
+
readonly host: HostBridge
|
|
67
|
+
readonly unit: UnitInfo
|
|
68
|
+
/** Surface a line in the unit's creation pane. */
|
|
69
|
+
readonly log: (line: string) => Effect.Effect<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Ctx for unit-lifecycle hooks: the decoded `hostInput` + both sides of the bridge
|
|
73
|
+
* (`ctx.unit.bash` execs in the unit, `ctx.host.bash` on the host device). */
|
|
74
|
+
export interface HookCtx<A> {
|
|
75
|
+
readonly hostInput: A
|
|
76
|
+
readonly host: HostBridge
|
|
77
|
+
readonly unit: UnitBridge
|
|
78
|
+
readonly log: (line: string) => Effect.Effect<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ctx for `onWorkUnitRemoved` — the unit may already be gone, so there is no unit
|
|
82
|
+
* bash; cleanup acts on the host (e.g. revoke the token minted at collect time). */
|
|
83
|
+
export interface RemovedCtx<A> {
|
|
84
|
+
readonly hostInput: A
|
|
85
|
+
readonly host: HostBridge
|
|
86
|
+
readonly unit: UnitInfo
|
|
87
|
+
readonly log: (line: string) => Effect.Effect<void>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `muxmaxing.config.ts` contract. `A` is the decoded host-input payload; its
|
|
91
|
+
* ENCODED side `I` must be plain JSON — it crosses the worker boundary and is cached
|
|
92
|
+
* (memory-only) by the engine between hooks.
|
|
93
|
+
*
|
|
94
|
+
* `ExternalApi` postfix: this shape is authored by EXTERNAL repos (their
|
|
95
|
+
* `muxmaxing.config.ts`), where optional hook fields are the ergonomic contract. */
|
|
96
|
+
export interface MuxmaxingConfigExternalApi<A, I> {
|
|
97
|
+
/** The Schema of the payload that crosses host → hooks (encoded side: plain JSON). */
|
|
98
|
+
readonly hostInput: S.Schema<A, I, never>
|
|
99
|
+
/** Runs ON THE HOST when the unit machine comes up; produces the `hostInput` every
|
|
100
|
+
* later hook receives. Collected ONCE per unit and cached by the engine. */
|
|
101
|
+
readonly collectHostDeviceInput: (ctx: CollectCtx) => Effect.Effect<A, unknown>
|
|
102
|
+
/** Fired when the unit is up and reachable (after devcontainer lifecycle hooks). */
|
|
103
|
+
readonly onWorkUnitMachineCreated?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
104
|
+
/** Fired when a terminal opens in the unit. */
|
|
105
|
+
readonly onNewTerminal?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
106
|
+
/** Fired at unit teardown — host-side cleanup (revoke what collect minted). */
|
|
107
|
+
readonly onWorkUnitRemoved?: (ctx: RemovedCtx<A>) => Effect.Effect<void, unknown>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The public name for the config contract. */
|
|
111
|
+
export type MuxmaxingConfig<A, I> = MuxmaxingConfigExternalApi<A, I>
|
|
112
|
+
|
|
113
|
+
/** Identity with types — the `defineConfig` a repo's `muxmaxing.config.ts` default-exports. */
|
|
114
|
+
export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> =>
|
|
115
|
+
config
|
|
116
|
+
|
|
117
|
+
/** Runtime narrowing for the worker entry: the default export of a fetched config module,
|
|
118
|
+
* checked structurally (schema + functions). The `hostInput` Schema remains the real
|
|
119
|
+
* gate for the DATA; this predicate gates the SHAPE. */
|
|
120
|
+
export const isMuxmaxingConfig = (
|
|
121
|
+
candidate: unknown,
|
|
122
|
+
): candidate is MuxmaxingConfig<unknown, unknown> => {
|
|
123
|
+
if (typeof candidate !== 'object' || candidate === null) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
const record: Record<string, unknown> = { ...candidate }
|
|
127
|
+
const optionalHook = (name: string): boolean =>
|
|
128
|
+
record[name] === undefined || typeof record[name] === 'function'
|
|
129
|
+
return (
|
|
130
|
+
S.isSchema(record['hostInput']) &&
|
|
131
|
+
typeof record['collectHostDeviceInput'] === 'function' &&
|
|
132
|
+
optionalHook('onWorkUnitMachineCreated') &&
|
|
133
|
+
optionalHook('onNewTerminal') &&
|
|
134
|
+
optionalHook('onWorkUnitRemoved')
|
|
135
|
+
)
|
|
136
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
// The postMessage wire protocol between the engine (main thread) and the per-invocation
|
|
4
|
+
// hook worker. Every message is encoded before `postMessage` and decoded on receipt via
|
|
5
|
+
// these schemas — plain JSON only (structured clone fast path, no class prototypes on
|
|
6
|
+
// the wire). One worker serves exactly ONE `RunHook`; `Bash` requests stream back from
|
|
7
|
+
// the worker while its hook runs, each answered by a `BashReply`.
|
|
8
|
+
//
|
|
9
|
+
// Annotated `TaggedStruct` consts (not `S.TaggedClass`) so the declarations survive
|
|
10
|
+
// `isolatedDeclarations` — the same reason reform-db's errors annotate their base.
|
|
11
|
+
|
|
12
|
+
/** Which hook the worker should run. `collect` produces the hostInput; the rest consume it. */
|
|
13
|
+
export const HookName: S.Literal<['collect', 'machineCreated', 'newTerminal', 'removed']> =
|
|
14
|
+
S.Literal('collect', 'machineCreated', 'newTerminal', 'removed')
|
|
15
|
+
export type HookName = typeof HookName.Type
|
|
16
|
+
|
|
17
|
+
export const UnitRef: S.Struct<{ id: typeof S.String; workspaceFolder: typeof S.String }> =
|
|
18
|
+
S.Struct({ id: S.String, workspaceFolder: S.String })
|
|
19
|
+
export type UnitRef = typeof UnitRef.Type
|
|
20
|
+
|
|
21
|
+
// ── engine → worker ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** The single invocation a worker serves. `configPath` is the materialised config module
|
|
24
|
+
* to import; `hostInput` is the ENCODED payload (`null` for `collect`, which creates it). */
|
|
25
|
+
export const RunHookRequest: S.TaggedStruct<
|
|
26
|
+
'RunHook',
|
|
27
|
+
{
|
|
28
|
+
hook: typeof HookName
|
|
29
|
+
configPath: typeof S.String
|
|
30
|
+
hostInput: S.NullOr<typeof S.Unknown>
|
|
31
|
+
unit: typeof UnitRef
|
|
32
|
+
}
|
|
33
|
+
> = S.TaggedStruct('RunHook', {
|
|
34
|
+
hook: HookName,
|
|
35
|
+
configPath: S.String,
|
|
36
|
+
hostInput: S.NullOr(S.Unknown),
|
|
37
|
+
unit: UnitRef,
|
|
38
|
+
})
|
|
39
|
+
export type RunHookRequest = typeof RunHookRequest.Type
|
|
40
|
+
|
|
41
|
+
export const BashOk: S.TaggedStruct<
|
|
42
|
+
'BashOk',
|
|
43
|
+
{ stdout: typeof S.String; stderr: typeof S.String; exitCode: typeof S.Number }
|
|
44
|
+
> = S.TaggedStruct('BashOk', { stdout: S.String, stderr: S.String, exitCode: S.Number })
|
|
45
|
+
export type BashOk = typeof BashOk.Type
|
|
46
|
+
|
|
47
|
+
export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String }> =
|
|
48
|
+
S.TaggedStruct('BashFailed', { reason: S.String })
|
|
49
|
+
export type BashFailed = typeof BashFailed.Type
|
|
50
|
+
|
|
51
|
+
/** The engine's answer to one `Bash` request, correlated by `id`. */
|
|
52
|
+
export const BashReply: S.TaggedStruct<
|
|
53
|
+
'BashReply',
|
|
54
|
+
{ id: typeof S.Number; result: S.Union<[typeof BashOk, typeof BashFailed]> }
|
|
55
|
+
> = S.TaggedStruct('BashReply', { id: S.Number, result: S.Union(BashOk, BashFailed) })
|
|
56
|
+
export type BashReply = typeof BashReply.Type
|
|
57
|
+
|
|
58
|
+
export const EngineToWorker: S.Union<[typeof RunHookRequest, typeof BashReply]> = S.Union(
|
|
59
|
+
RunHookRequest,
|
|
60
|
+
BashReply,
|
|
61
|
+
)
|
|
62
|
+
export type EngineToWorker = typeof EngineToWorker.Type
|
|
63
|
+
|
|
64
|
+
// ── worker → engine ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** A bridged bash execution request: `unit` execs inside the work unit, `host` on the
|
|
67
|
+
* host device. The engine replies with a `BashReply` carrying the same `id`. */
|
|
68
|
+
export const BashRequest: S.TaggedStruct<
|
|
69
|
+
'Bash',
|
|
70
|
+
{
|
|
71
|
+
id: typeof S.Number
|
|
72
|
+
target: S.Literal<['unit', 'host']>
|
|
73
|
+
command: typeof S.String
|
|
74
|
+
}
|
|
75
|
+
> = S.TaggedStruct('Bash', {
|
|
76
|
+
id: S.Number,
|
|
77
|
+
target: S.Literal('unit', 'host'),
|
|
78
|
+
command: S.String,
|
|
79
|
+
})
|
|
80
|
+
export type BashRequest = typeof BashRequest.Type
|
|
81
|
+
|
|
82
|
+
/** A line for the unit's creation pane (the hook's `ctx.log`). */
|
|
83
|
+
export const LogMessage: S.TaggedStruct<'Log', { line: typeof S.String }> = S.TaggedStruct(
|
|
84
|
+
'Log',
|
|
85
|
+
{ line: S.String },
|
|
86
|
+
)
|
|
87
|
+
export type LogMessage = typeof LogMessage.Type
|
|
88
|
+
|
|
89
|
+
/** The hook finished. `hostInput` carries the freshly ENCODED payload for `collect`
|
|
90
|
+
* (the engine caches it, memory-only) and is `null` for every other hook. */
|
|
91
|
+
export const HookDone: S.TaggedStruct<'HookDone', { hostInput: S.NullOr<typeof S.Unknown> }> =
|
|
92
|
+
S.TaggedStruct('HookDone', { hostInput: S.NullOr(S.Unknown) })
|
|
93
|
+
export type HookDone = typeof HookDone.Type
|
|
94
|
+
|
|
95
|
+
/** The hook (or the config module itself) failed — best-effort reporting, never retried. */
|
|
96
|
+
export const HookFailed: S.TaggedStruct<'HookFailed', { reason: typeof S.String }> =
|
|
97
|
+
S.TaggedStruct('HookFailed', { reason: S.String })
|
|
98
|
+
export type HookFailed = typeof HookFailed.Type
|
|
99
|
+
|
|
100
|
+
export const WorkerToEngine: S.Union<
|
|
101
|
+
[typeof BashRequest, typeof LogMessage, typeof HookDone, typeof HookFailed]
|
|
102
|
+
> = S.Union(BashRequest, LogMessage, HookDone, HookFailed)
|
|
103
|
+
export type WorkerToEngine = typeof WorkerToEngine.Type
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { Schema as S } from 'effect'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import {
|
|
5
|
+
BashReply,
|
|
6
|
+
EngineToWorker,
|
|
7
|
+
type HookName,
|
|
8
|
+
RunHookRequest,
|
|
9
|
+
WorkerToEngine,
|
|
10
|
+
} from './protocol'
|
|
11
|
+
|
|
12
|
+
// Drives the REAL worker entry end-to-end: spawn a Bun Worker on `worker-entry.ts`,
|
|
13
|
+
// point it at the fixture config (which imports the package by name, like a real repo),
|
|
14
|
+
// answer its bridged Bash requests with canned results, and assert the final message.
|
|
15
|
+
// Needs the Bun runtime (`bun --bun run vitest`) for the `Worker` global.
|
|
16
|
+
|
|
17
|
+
const workerUrl = new URL('./worker-entry.ts', import.meta.url)
|
|
18
|
+
// fileURLToPath so the SPACE in this repo's path survives (URL.pathname would %20 it).
|
|
19
|
+
const fixtureConfigPath = fileURLToPath(new URL('./fixtures/sample.config.ts', import.meta.url))
|
|
20
|
+
|
|
21
|
+
const encodeToWorker = S.encodeSync(EngineToWorker)
|
|
22
|
+
const decodeFromWorker = S.decodeUnknownSync(WorkerToEngine)
|
|
23
|
+
|
|
24
|
+
interface DrivenHook {
|
|
25
|
+
/** Every worker→engine message, in arrival order (Bash/Log/HookDone/HookFailed). */
|
|
26
|
+
readonly messages: ReadonlyArray<WorkerToEngine>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Run ONE hook through a fresh worker, answering each `Bash` via `answerBash`. */
|
|
30
|
+
const driveHook = (input: {
|
|
31
|
+
readonly hook: HookName
|
|
32
|
+
readonly hostInput: unknown
|
|
33
|
+
readonly answerBash: (request: { target: string; command: string }) => BashReply['result']
|
|
34
|
+
}): Promise<DrivenHook> =>
|
|
35
|
+
new Promise((resolve, reject) => {
|
|
36
|
+
const worker = new Worker(workerUrl)
|
|
37
|
+
const seen: Array<WorkerToEngine> = []
|
|
38
|
+
const finish = (outcome: DrivenHook): void => {
|
|
39
|
+
worker.terminate()
|
|
40
|
+
resolve(outcome)
|
|
41
|
+
}
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
worker.terminate()
|
|
44
|
+
reject(new Error(`hook ${input.hook} timed out; saw ${JSON.stringify(seen)}`))
|
|
45
|
+
}, 15_000)
|
|
46
|
+
worker.onmessage = (event: MessageEvent): void => {
|
|
47
|
+
const message = decodeFromWorker(event.data)
|
|
48
|
+
seen.push(message)
|
|
49
|
+
if (message._tag === 'Bash') {
|
|
50
|
+
worker.postMessage(
|
|
51
|
+
encodeToWorker(
|
|
52
|
+
BashReply.make({
|
|
53
|
+
id: message.id,
|
|
54
|
+
result: input.answerBash({ target: message.target, command: message.command }),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (message._tag === 'HookDone' || message._tag === 'HookFailed') {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
finish({ messages: seen })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
worker.postMessage(
|
|
66
|
+
encodeToWorker(
|
|
67
|
+
RunHookRequest.make({
|
|
68
|
+
hook: input.hook,
|
|
69
|
+
configPath: fixtureConfigPath,
|
|
70
|
+
hostInput: input.hostInput,
|
|
71
|
+
unit: { id: 'unit-1', workspaceFolder: '/workspace/repo' },
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const okBash = (stdout: string): BashReply['result'] => ({
|
|
78
|
+
_tag: 'BashOk',
|
|
79
|
+
stdout,
|
|
80
|
+
stderr: '',
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('worker-entry', () => {
|
|
85
|
+
it('collect: runs collectHostDeviceInput on the host bridge and returns encoded hostInput', async () => {
|
|
86
|
+
const driven = await driveHook({
|
|
87
|
+
hook: 'collect',
|
|
88
|
+
hostInput: null,
|
|
89
|
+
answerBash: (request) => {
|
|
90
|
+
expect(request.target).toBe('host')
|
|
91
|
+
expect(request.command).toBe('fake-mint unit-1')
|
|
92
|
+
return okBash('tok-123\n')
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
const done = driven.messages.at(-1)
|
|
96
|
+
expect(done?._tag).toBe('HookDone')
|
|
97
|
+
if (done?._tag === 'HookDone') {
|
|
98
|
+
expect(done.hostInput).toEqual({ token: 'tok-123', slug: 'slug-unit-1' })
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('machineCreated: decodes hostInput, logs, and bashes into the unit', async () => {
|
|
103
|
+
const driven = await driveHook({
|
|
104
|
+
hook: 'machineCreated',
|
|
105
|
+
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
106
|
+
answerBash: (request) => {
|
|
107
|
+
expect(request.target).toBe('unit')
|
|
108
|
+
expect(request.command).toBe('configure tok-123')
|
|
109
|
+
return okBash('')
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
const tags = driven.messages.map((message) => message._tag)
|
|
113
|
+
expect(tags).toEqual(['Log', 'Bash', 'HookDone'])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('newTerminal: a hook the config leaves undefined is a silent no-op success', async () => {
|
|
117
|
+
const driven = await driveHook({
|
|
118
|
+
hook: 'newTerminal',
|
|
119
|
+
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
120
|
+
answerBash: () => okBash(''),
|
|
121
|
+
})
|
|
122
|
+
expect(driven.messages.map((message) => message._tag)).toEqual(['HookDone'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('removed: cleanup runs on the HOST bridge with the cached hostInput', async () => {
|
|
126
|
+
const driven = await driveHook({
|
|
127
|
+
hook: 'removed',
|
|
128
|
+
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
129
|
+
answerBash: (request) => {
|
|
130
|
+
expect(request.target).toBe('host')
|
|
131
|
+
expect(request.command).toBe('revoke slug-unit-1')
|
|
132
|
+
return okBash('')
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
expect(driven.messages.at(-1)?._tag).toBe('HookDone')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('malformed hostInput fails the hook (Schema is the gate), reported as HookFailed', async () => {
|
|
139
|
+
const driven = await driveHook({
|
|
140
|
+
hook: 'machineCreated',
|
|
141
|
+
hostInput: { token: 42 },
|
|
142
|
+
answerBash: () => okBash(''),
|
|
143
|
+
})
|
|
144
|
+
expect(driven.messages.at(-1)?._tag).toBe('HookFailed')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a failing unit bash surfaces the hook error as HookFailed', async () => {
|
|
148
|
+
const driven = await driveHook({
|
|
149
|
+
hook: 'machineCreated',
|
|
150
|
+
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
151
|
+
answerBash: () => ({ _tag: 'BashFailed', reason: 'pod unreachable' }),
|
|
152
|
+
})
|
|
153
|
+
const failed = driven.messages.at(-1)
|
|
154
|
+
expect(failed?._tag).toBe('HookFailed')
|
|
155
|
+
if (failed?._tag === 'HookFailed') {
|
|
156
|
+
expect(failed.reason).toContain('pod unreachable')
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Cause, Data, Effect, Either, Match, Option, Ref, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
type BashReply,
|
|
4
|
+
BashRequest,
|
|
5
|
+
EngineToWorker,
|
|
6
|
+
HookDone,
|
|
7
|
+
HookFailed,
|
|
8
|
+
LogMessage,
|
|
9
|
+
type RunHookRequest,
|
|
10
|
+
WorkerToEngine,
|
|
11
|
+
} from './protocol'
|
|
12
|
+
import {
|
|
13
|
+
type BashResult,
|
|
14
|
+
HookBashError,
|
|
15
|
+
type HostBridge,
|
|
16
|
+
isMuxmaxingConfig,
|
|
17
|
+
type MuxmaxingConfig,
|
|
18
|
+
type UnitInfo,
|
|
19
|
+
} from './index'
|
|
20
|
+
|
|
21
|
+
// The per-invocation hook worker: serves exactly ONE `RunHook`, bridging the hook's
|
|
22
|
+
// `ctx.bash`/`ctx.host.bash` back to the engine over postMessage (the engine owns the
|
|
23
|
+
// actual pod-exec / host-spawn). When the hook settles, the worker reports `HookDone`
|
|
24
|
+
// (or `HookFailed`), drops its message listener so the event loop drains, and exits;
|
|
25
|
+
// the engine's scope finalizer additionally calls `worker.terminate()`.
|
|
26
|
+
|
|
27
|
+
declare const self: Worker
|
|
28
|
+
|
|
29
|
+
class WorkerHookError extends Data.TaggedError('WorkerHookError')<{
|
|
30
|
+
readonly reason: string
|
|
31
|
+
}> {
|
|
32
|
+
// Without this getter `Cause.pretty` renders the generic "An error has occurred",
|
|
33
|
+
// hiding the actual failure from the HookFailed report (the reform-db errors gotcha).
|
|
34
|
+
override get message(): string {
|
|
35
|
+
return this.reason
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const send = (message: WorkerToEngine): void => {
|
|
40
|
+
self.postMessage(S.encodeSync(WorkerToEngine)(message))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const describe = (cause: unknown): string =>
|
|
44
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
45
|
+
|
|
46
|
+
// One in-flight resolver per bridged bash `id` — the engine answers each `Bash` with a
|
|
47
|
+
// correlated `BashReply`. Hooks run their bash calls through `Effect.async` below.
|
|
48
|
+
const pendingBash = new Map<number, (reply: BashReply) => void>()
|
|
49
|
+
|
|
50
|
+
const makeBridgedBash =
|
|
51
|
+
(idRef: Ref.Ref<number>, target: 'unit' | 'host') =>
|
|
52
|
+
(command: string): Effect.Effect<BashResult, HookBashError> =>
|
|
53
|
+
Ref.updateAndGet(idRef, (previous) => previous + 1).pipe(
|
|
54
|
+
Effect.flatMap((id) =>
|
|
55
|
+
Effect.async<BashResult, HookBashError>((resume) => {
|
|
56
|
+
pendingBash.set(id, (reply) => {
|
|
57
|
+
resume(
|
|
58
|
+
Match.value(reply.result).pipe(
|
|
59
|
+
Match.tags({
|
|
60
|
+
BashOk: (ok) =>
|
|
61
|
+
Effect.succeed<BashResult>({
|
|
62
|
+
stdout: ok.stdout,
|
|
63
|
+
stderr: ok.stderr,
|
|
64
|
+
exitCode: ok.exitCode,
|
|
65
|
+
}),
|
|
66
|
+
BashFailed: (failed) =>
|
|
67
|
+
Effect.fail(new HookBashError({ reason: failed.reason })),
|
|
68
|
+
}),
|
|
69
|
+
Match.exhaustive,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
send(BashRequest.make({ id, target, command }))
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const log = (line: string): Effect.Effect<void> =>
|
|
79
|
+
Effect.sync(() => send(LogMessage.make({ line })))
|
|
80
|
+
|
|
81
|
+
const loadConfig = (
|
|
82
|
+
configPath: string,
|
|
83
|
+
): Effect.Effect<MuxmaxingConfig<unknown, unknown>, WorkerHookError> =>
|
|
84
|
+
Effect.tryPromise({
|
|
85
|
+
try: (): Promise<unknown> => import(configPath),
|
|
86
|
+
catch: (cause) =>
|
|
87
|
+
new WorkerHookError({ reason: `config import failed: ${describe(cause)}` }),
|
|
88
|
+
}).pipe(
|
|
89
|
+
Effect.flatMap((moduleExports) => {
|
|
90
|
+
const record: Record<string, unknown> =
|
|
91
|
+
typeof moduleExports === 'object' && moduleExports !== null
|
|
92
|
+
? { ...moduleExports }
|
|
93
|
+
: {}
|
|
94
|
+
const defaulted = Option.fromNullable(record['default'])
|
|
95
|
+
return Option.match(defaulted, {
|
|
96
|
+
onNone: () =>
|
|
97
|
+
Effect.fail(new WorkerHookError({ reason: 'config has no default export' })),
|
|
98
|
+
onSome: (config) => {
|
|
99
|
+
if (isMuxmaxingConfig(config)) {
|
|
100
|
+
return Effect.succeed(config)
|
|
101
|
+
}
|
|
102
|
+
return Effect.fail(
|
|
103
|
+
new WorkerHookError({
|
|
104
|
+
reason:
|
|
105
|
+
'default export is not a muxmaxing config (expected defineConfig({ hostInput, collectHostDeviceInput, ... }))',
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const decodeHostInput = (
|
|
114
|
+
config: MuxmaxingConfig<unknown, unknown>,
|
|
115
|
+
encoded: unknown,
|
|
116
|
+
): Effect.Effect<unknown, WorkerHookError> =>
|
|
117
|
+
S.decodeUnknown(config.hostInput)(encoded).pipe(
|
|
118
|
+
Effect.mapError(
|
|
119
|
+
(issue) => new WorkerHookError({ reason: `hostInput decode failed: ${String(issue)}` }),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const hookFailure =
|
|
124
|
+
(hookName: string) =>
|
|
125
|
+
(cause: unknown): WorkerHookError =>
|
|
126
|
+
new WorkerHookError({ reason: `${hookName} failed: ${describe(cause)}` })
|
|
127
|
+
|
|
128
|
+
const runRequested = Effect.fn('runRequested')(
|
|
129
|
+
function* (request: RunHookRequest): Effect.fn.Return<HookDone, WorkerHookError> {
|
|
130
|
+
const config = yield* loadConfig(request.configPath)
|
|
131
|
+
const idRef = yield* Ref.make(0)
|
|
132
|
+
const unit: UnitInfo = {
|
|
133
|
+
id: request.unit.id,
|
|
134
|
+
workspaceFolder: request.unit.workspaceFolder,
|
|
135
|
+
}
|
|
136
|
+
const host: HostBridge = { bash: makeBridgedBash(idRef, 'host'), env: Bun.env }
|
|
137
|
+
const unitBridge = { ...unit, bash: makeBridgedBash(idRef, 'unit') }
|
|
138
|
+
|
|
139
|
+
// A consuming hook that the config leaves undefined is a silent no-op success.
|
|
140
|
+
const runOptional = <Ctx>(
|
|
141
|
+
hookName: string,
|
|
142
|
+
hook: ((ctx: Ctx) => Effect.Effect<void, unknown>) | undefined,
|
|
143
|
+
makeCtx: (hostInput: unknown) => Ctx,
|
|
144
|
+
): Effect.Effect<HookDone, WorkerHookError> =>
|
|
145
|
+
Option.match(Option.fromNullable(hook), {
|
|
146
|
+
onNone: () => Effect.succeed(HookDone.make({ hostInput: null })),
|
|
147
|
+
onSome: (run) =>
|
|
148
|
+
decodeHostInput(config, request.hostInput).pipe(
|
|
149
|
+
Effect.flatMap((hostInput) =>
|
|
150
|
+
run(makeCtx(hostInput)).pipe(Effect.mapError(hookFailure(hookName))),
|
|
151
|
+
),
|
|
152
|
+
Effect.map(() => HookDone.make({ hostInput: null })),
|
|
153
|
+
),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return yield* Match.value(request.hook).pipe(
|
|
157
|
+
Match.when('collect', () =>
|
|
158
|
+
config
|
|
159
|
+
.collectHostDeviceInput({ host, unit, log })
|
|
160
|
+
.pipe(
|
|
161
|
+
Effect.mapError(hookFailure('collectHostDeviceInput')),
|
|
162
|
+
Effect.flatMap((collected) =>
|
|
163
|
+
S.encodeUnknown(config.hostInput)(collected).pipe(
|
|
164
|
+
Effect.mapError(
|
|
165
|
+
(issue) =>
|
|
166
|
+
new WorkerHookError({
|
|
167
|
+
reason: `hostInput encode failed: ${String(issue)}`,
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
Effect.map((encoded) => HookDone.make({ hostInput: encoded ?? null })),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
Match.when('machineCreated', () =>
|
|
176
|
+
runOptional('onWorkUnitMachineCreated', config.onWorkUnitMachineCreated, (hostInput) => ({
|
|
177
|
+
hostInput,
|
|
178
|
+
host,
|
|
179
|
+
unit: unitBridge,
|
|
180
|
+
log,
|
|
181
|
+
})),
|
|
182
|
+
),
|
|
183
|
+
Match.when('newTerminal', () =>
|
|
184
|
+
runOptional('onNewTerminal', config.onNewTerminal, (hostInput) => ({
|
|
185
|
+
hostInput,
|
|
186
|
+
host,
|
|
187
|
+
unit: unitBridge,
|
|
188
|
+
log,
|
|
189
|
+
})),
|
|
190
|
+
),
|
|
191
|
+
Match.when('removed', () =>
|
|
192
|
+
runOptional('onWorkUnitRemoved', config.onWorkUnitRemoved, (hostInput) => ({
|
|
193
|
+
hostInput,
|
|
194
|
+
host,
|
|
195
|
+
unit,
|
|
196
|
+
log,
|
|
197
|
+
})),
|
|
198
|
+
),
|
|
199
|
+
Match.exhaustive,
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const handleRunHook = (request: RunHookRequest): void => {
|
|
204
|
+
void Effect.runPromise(
|
|
205
|
+
runRequested(request).pipe(
|
|
206
|
+
Effect.flatMap((done) => Effect.sync(() => send(done))),
|
|
207
|
+
Effect.catchAllCause((cause) =>
|
|
208
|
+
Effect.sync(() => send(HookFailed.make({ reason: Cause.pretty(cause) }))),
|
|
209
|
+
),
|
|
210
|
+
// Drop the listener so the event loop drains and the worker exits on its own;
|
|
211
|
+
// the engine's scope finalizer also terminates it (belt and suspenders).
|
|
212
|
+
Effect.ensuring(
|
|
213
|
+
Effect.sync(() => {
|
|
214
|
+
self.onmessage = null
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
self.onmessage = (event: MessageEvent): void => {
|
|
222
|
+
Either.match(S.decodeUnknownEither(EngineToWorker)(event.data), {
|
|
223
|
+
onLeft: () => send(HookFailed.make({ reason: 'undecodable engine message' })),
|
|
224
|
+
onRight: (message) =>
|
|
225
|
+
Match.value(message).pipe(
|
|
226
|
+
Match.tags({
|
|
227
|
+
RunHook: (request) => handleRunHook(request),
|
|
228
|
+
BashReply: (reply) => {
|
|
229
|
+
const resume = pendingBash.get(reply.id)
|
|
230
|
+
pendingBash.delete(reply.id)
|
|
231
|
+
resume?.(reply)
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
Match.exhaustive,
|
|
235
|
+
),
|
|
236
|
+
})
|
|
237
|
+
}
|