@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 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
+ }
@@ -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
+ }