@playfast/muxmaxing-config 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +16 -18
- package/src/index.ts +4 -53
- package/src/protocol.ts +12 -31
- package/src/worker-entry.ts +110 -97
- package/src/fixtures/sample.config.ts +0 -35
- package/src/worker-entry.test.ts +0 -159
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ host-side in a Bun Worker, only for repos the user has explicitly approved.
|
|
|
13
13
|
import { defineConfig } from '@playfast/muxmaxing-config'
|
|
14
14
|
import { Effect, Schema as S } from 'effect'
|
|
15
15
|
|
|
16
|
-
const TokenJson = S.parseJson(S.Struct({
|
|
16
|
+
const TokenJson = S.parseJson(S.Struct({ token: S.String, slug: S.String }))
|
|
17
17
|
|
|
18
18
|
export default defineConfig({
|
|
19
19
|
hostInput: S.Struct({ dopplerToken: S.String, tokenSlug: S.String }),
|
|
@@ -25,7 +25,7 @@ export default defineConfig({
|
|
|
25
25
|
`doppler configs tokens create mux-${unit.id} --project my-app --config dev --max-age 24h --json`,
|
|
26
26
|
)
|
|
27
27
|
const token = yield* S.decode(TokenJson)(minted.stdout)
|
|
28
|
-
return { dopplerToken: token.
|
|
28
|
+
return { dopplerToken: token.token, tokenSlug: token.slug }
|
|
29
29
|
}),
|
|
30
30
|
|
|
31
31
|
// Runs when the unit is up: `ctx.unit.bash` execs INSIDE the unit.
|
package/package.json
CHANGED
|
@@ -1,50 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/muxmaxing-config",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.2.0",
|
|
5
4
|
"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
5
|
"keywords": [
|
|
7
|
-
"
|
|
6
|
+
"devcontainer",
|
|
8
7
|
"effect",
|
|
9
8
|
"hooks",
|
|
10
|
-
"
|
|
9
|
+
"muxmaxing",
|
|
11
10
|
"provisioning"
|
|
12
11
|
],
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
14
|
+
},
|
|
13
15
|
"license": "MIT",
|
|
14
16
|
"repository": {
|
|
15
17
|
"type": "git",
|
|
16
18
|
"url": "https://github.com/playfast/reform.git",
|
|
17
19
|
"directory": "packages/muxmaxing-config"
|
|
18
20
|
},
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
22
26
|
"sideEffects": false,
|
|
23
27
|
"exports": {
|
|
24
28
|
"./package.json": "./package.json",
|
|
25
29
|
".": "./src/index.ts",
|
|
26
30
|
"./*": "./src/*.ts"
|
|
27
31
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
32
35
|
"scripts": {
|
|
33
36
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
34
37
|
"check": "tsc --noEmit",
|
|
35
38
|
"build": "tsc -p tsconfig.build.json",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"test:watch": "vitest",
|
|
38
39
|
"lint": "oxlint src",
|
|
39
40
|
"lint:fix": "oxlint --fix src"
|
|
40
41
|
},
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"effect": "*"
|
|
43
|
-
},
|
|
44
42
|
"devDependencies": {
|
|
45
43
|
"@types/bun": "^1.3.14"
|
|
46
44
|
},
|
|
47
|
-
"
|
|
48
|
-
"
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"effect": "*"
|
|
49
47
|
}
|
|
50
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import { type Cause, Data, type Effect, Schema as S } from 'effect'
|
|
2
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
3
|
export interface BashResult {
|
|
17
4
|
readonly stdout: string
|
|
18
5
|
readonly stderr: string
|
|
19
6
|
readonly exitCode: number
|
|
20
7
|
}
|
|
21
8
|
|
|
22
|
-
|
|
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. */
|
|
9
|
+
// isolatedDeclarations forbids an inferred expression in an extends clause.
|
|
25
10
|
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
|
|
26
11
|
args: A,
|
|
27
12
|
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
@@ -29,48 +14,33 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
|
|
|
29
14
|
const HookBashErrorBase: TaggedErrorClass<'HookBashError', { readonly reason: string }> =
|
|
30
15
|
Data.TaggedError('HookBashError')<{ readonly reason: string }>
|
|
31
16
|
|
|
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
17
|
export class HookBashError extends HookBashErrorBase {
|
|
35
|
-
//
|
|
36
|
-
// print the generic "An error has occurred" — the reform-db errors gotcha).
|
|
18
|
+
// Cause.pretty would otherwise print the generic "An error has occurred".
|
|
37
19
|
override get message(): string {
|
|
38
20
|
return this.reason
|
|
39
21
|
}
|
|
40
22
|
}
|
|
41
23
|
|
|
42
|
-
/** The host-device side of the bridge: exec on the machine running mux + its env. */
|
|
43
24
|
export interface HostBridge {
|
|
44
|
-
/** Run a bash command ON THE HOST DEVICE. */
|
|
45
25
|
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
46
|
-
/** The host process env (hooks share the host process — no sandbox). */
|
|
47
26
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
48
27
|
}
|
|
49
28
|
|
|
50
|
-
/** The work unit a hook invocation is about. */
|
|
51
29
|
export interface UnitInfo {
|
|
52
30
|
readonly id: string
|
|
53
|
-
/** The unit-side folder the repo is checked out under (bash cwd for unit execs). */
|
|
54
31
|
readonly workspaceFolder: string
|
|
55
32
|
}
|
|
56
33
|
|
|
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
34
|
export interface UnitBridge extends UnitInfo {
|
|
60
|
-
/** Run a bash command INSIDE the work unit. */
|
|
61
35
|
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
62
36
|
}
|
|
63
37
|
|
|
64
|
-
/** Ctx for `collectHostDeviceInput` — host-only (the payload doesn't exist yet). */
|
|
65
38
|
export interface CollectCtx {
|
|
66
39
|
readonly host: HostBridge
|
|
67
40
|
readonly unit: UnitInfo
|
|
68
|
-
/** Surface a line in the unit's creation pane. */
|
|
69
41
|
readonly log: (line: string) => Effect.Effect<void>
|
|
70
42
|
}
|
|
71
43
|
|
|
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
44
|
export interface HookCtx<A> {
|
|
75
45
|
readonly hostInput: A
|
|
76
46
|
readonly host: HostBridge
|
|
@@ -78,8 +48,6 @@ export interface HookCtx<A> {
|
|
|
78
48
|
readonly log: (line: string) => Effect.Effect<void>
|
|
79
49
|
}
|
|
80
50
|
|
|
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
51
|
export interface RemovedCtx<A> {
|
|
84
52
|
readonly hostInput: A
|
|
85
53
|
readonly host: HostBridge
|
|
@@ -87,36 +55,19 @@ export interface RemovedCtx<A> {
|
|
|
87
55
|
readonly log: (line: string) => Effect.Effect<void>
|
|
88
56
|
}
|
|
89
57
|
|
|
90
|
-
|
|
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. */
|
|
58
|
+
// ExternalApi: optional hooks are the ergonomic contract for external repos.
|
|
96
59
|
export interface MuxmaxingConfigExternalApi<A, I> {
|
|
97
|
-
/** The Schema of the payload that crosses host → hooks (encoded side: plain JSON). */
|
|
98
60
|
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
61
|
readonly collectHostDeviceInput: (ctx: CollectCtx) => Effect.Effect<A, unknown>
|
|
102
|
-
/** Fired when the unit is up and reachable (after devcontainer lifecycle hooks). */
|
|
103
62
|
readonly onWorkUnitMachineCreated?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
104
|
-
/** Fired when a terminal opens in the unit. */
|
|
105
63
|
readonly onNewTerminal?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
106
|
-
/** Fired at unit teardown — host-side cleanup (revoke what collect minted). */
|
|
107
64
|
readonly onWorkUnitRemoved?: (ctx: RemovedCtx<A>) => Effect.Effect<void, unknown>
|
|
108
65
|
}
|
|
109
66
|
|
|
110
|
-
/** The public name for the config contract. */
|
|
111
67
|
export type MuxmaxingConfig<A, I> = MuxmaxingConfigExternalApi<A, I>
|
|
112
68
|
|
|
113
|
-
|
|
114
|
-
export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> =>
|
|
115
|
-
config
|
|
69
|
+
export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> => config
|
|
116
70
|
|
|
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
71
|
export const isMuxmaxingConfig = (
|
|
121
72
|
candidate: unknown,
|
|
122
73
|
): candidate is MuxmaxingConfig<unknown, unknown> => {
|
package/src/protocol.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { Schema as S } from 'effect'
|
|
2
2
|
|
|
3
|
-
//
|
|
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. */
|
|
3
|
+
// TaggedStruct consts (not TaggedClass) so declarations survive isolatedDeclarations.
|
|
13
4
|
export const HookName: S.Literal<['collect', 'machineCreated', 'newTerminal', 'removed']> =
|
|
14
5
|
S.Literal('collect', 'machineCreated', 'newTerminal', 'removed')
|
|
15
6
|
export type HookName = typeof HookName.Type
|
|
@@ -18,10 +9,6 @@ export const UnitRef: S.Struct<{ id: typeof S.String; workspaceFolder: typeof S.
|
|
|
18
9
|
S.Struct({ id: S.String, workspaceFolder: S.String })
|
|
19
10
|
export type UnitRef = typeof UnitRef.Type
|
|
20
11
|
|
|
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
12
|
export const RunHookRequest: S.TaggedStruct<
|
|
26
13
|
'RunHook',
|
|
27
14
|
{
|
|
@@ -44,11 +31,12 @@ export const BashOk: S.TaggedStruct<
|
|
|
44
31
|
> = S.TaggedStruct('BashOk', { stdout: S.String, stderr: S.String, exitCode: S.Number })
|
|
45
32
|
export type BashOk = typeof BashOk.Type
|
|
46
33
|
|
|
47
|
-
export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String }> =
|
|
48
|
-
|
|
34
|
+
export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String }> = S.TaggedStruct(
|
|
35
|
+
'BashFailed',
|
|
36
|
+
{ reason: S.String },
|
|
37
|
+
)
|
|
49
38
|
export type BashFailed = typeof BashFailed.Type
|
|
50
39
|
|
|
51
|
-
/** The engine's answer to one `Bash` request, correlated by `id`. */
|
|
52
40
|
export const BashReply: S.TaggedStruct<
|
|
53
41
|
'BashReply',
|
|
54
42
|
{ id: typeof S.Number; result: S.Union<[typeof BashOk, typeof BashFailed]> }
|
|
@@ -61,10 +49,6 @@ export const EngineToWorker: S.Union<[typeof RunHookRequest, typeof BashReply]>
|
|
|
61
49
|
)
|
|
62
50
|
export type EngineToWorker = typeof EngineToWorker.Type
|
|
63
51
|
|
|
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
52
|
export const BashRequest: S.TaggedStruct<
|
|
69
53
|
'Bash',
|
|
70
54
|
{
|
|
@@ -79,22 +63,19 @@ export const BashRequest: S.TaggedStruct<
|
|
|
79
63
|
})
|
|
80
64
|
export type BashRequest = typeof BashRequest.Type
|
|
81
65
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ line: S.String },
|
|
86
|
-
)
|
|
66
|
+
export const LogMessage: S.TaggedStruct<'Log', { line: typeof S.String }> = S.TaggedStruct('Log', {
|
|
67
|
+
line: S.String,
|
|
68
|
+
})
|
|
87
69
|
export type LogMessage = typeof LogMessage.Type
|
|
88
70
|
|
|
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
71
|
export const HookDone: S.TaggedStruct<'HookDone', { hostInput: S.NullOr<typeof S.Unknown> }> =
|
|
92
72
|
S.TaggedStruct('HookDone', { hostInput: S.NullOr(S.Unknown) })
|
|
93
73
|
export type HookDone = typeof HookDone.Type
|
|
94
74
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
75
|
+
export const HookFailed: S.TaggedStruct<'HookFailed', { reason: typeof S.String }> = S.TaggedStruct(
|
|
76
|
+
'HookFailed',
|
|
77
|
+
{ reason: S.String },
|
|
78
|
+
)
|
|
98
79
|
export type HookFailed = typeof HookFailed.Type
|
|
99
80
|
|
|
100
81
|
export const WorkerToEngine: S.Union<
|
package/src/worker-entry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Cause, Data, Effect, Either, Match, Option, Ref, Schema as S } from 'effect'
|
|
2
|
+
import { parentPort } from 'node:worker_threads'
|
|
2
3
|
import {
|
|
3
4
|
type BashReply,
|
|
4
5
|
BashRequest,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
LogMessage,
|
|
9
10
|
type RunHookRequest,
|
|
10
11
|
WorkerToEngine,
|
|
11
|
-
} from '
|
|
12
|
+
} from '@playfast/muxmaxing-config/protocol'
|
|
12
13
|
import {
|
|
13
14
|
type BashResult,
|
|
14
15
|
HookBashError,
|
|
@@ -16,35 +17,37 @@ import {
|
|
|
16
17
|
isMuxmaxingConfig,
|
|
17
18
|
type MuxmaxingConfig,
|
|
18
19
|
type UnitInfo,
|
|
19
|
-
} from '
|
|
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()`.
|
|
20
|
+
} from '@playfast/muxmaxing-config'
|
|
26
21
|
|
|
27
22
|
declare const self: Worker
|
|
28
23
|
|
|
24
|
+
const hasWebWorkerScope = typeof self !== 'undefined'
|
|
25
|
+
|
|
29
26
|
class WorkerHookError extends Data.TaggedError('WorkerHookError')<{
|
|
30
27
|
readonly reason: string
|
|
31
28
|
}> {
|
|
32
|
-
//
|
|
33
|
-
// hiding the actual failure from the HookFailed report (the reform-db errors gotcha).
|
|
29
|
+
// Cause.pretty would otherwise print the generic "An error has occurred".
|
|
34
30
|
override get message(): string {
|
|
35
31
|
return this.reason
|
|
36
32
|
}
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
const send = (message: WorkerToEngine): void => {
|
|
40
|
-
|
|
36
|
+
const encoded = S.encodeSync(WorkerToEngine)(message)
|
|
37
|
+
if (hasWebWorkerScope) {
|
|
38
|
+
self.postMessage(encoded)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if (parentPort !== null) {
|
|
42
|
+
parentPort.postMessage(encoded)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
Effect.runSync(Effect.dieMessage('worker entry has no parent message port'))
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
const describe = (cause: unknown): string =>
|
|
44
49
|
cause instanceof Error ? cause.message : String(cause)
|
|
45
50
|
|
|
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
51
|
const pendingBash = new Map<number, (reply: BashReply) => void>()
|
|
49
52
|
|
|
50
53
|
const makeBridgedBash =
|
|
@@ -63,8 +66,7 @@ const makeBridgedBash =
|
|
|
63
66
|
stderr: ok.stderr,
|
|
64
67
|
exitCode: ok.exitCode,
|
|
65
68
|
}),
|
|
66
|
-
BashFailed: (failed) =>
|
|
67
|
-
Effect.fail(new HookBashError({ reason: failed.reason })),
|
|
69
|
+
BashFailed: (failed) => Effect.fail(new HookBashError({ reason: failed.reason })),
|
|
68
70
|
}),
|
|
69
71
|
Match.exhaustive,
|
|
70
72
|
),
|
|
@@ -83,18 +85,14 @@ const loadConfig = (
|
|
|
83
85
|
): Effect.Effect<MuxmaxingConfig<unknown, unknown>, WorkerHookError> =>
|
|
84
86
|
Effect.tryPromise({
|
|
85
87
|
try: (): Promise<unknown> => import(configPath),
|
|
86
|
-
catch: (cause) =>
|
|
87
|
-
new WorkerHookError({ reason: `config import failed: ${describe(cause)}` }),
|
|
88
|
+
catch: (cause) => new WorkerHookError({ reason: `config import failed: ${describe(cause)}` }),
|
|
88
89
|
}).pipe(
|
|
89
90
|
Effect.flatMap((moduleExports) => {
|
|
90
91
|
const record: Record<string, unknown> =
|
|
91
|
-
typeof moduleExports === 'object' && moduleExports !== null
|
|
92
|
-
? { ...moduleExports }
|
|
93
|
-
: {}
|
|
92
|
+
typeof moduleExports === 'object' && moduleExports !== null ? { ...moduleExports } : {}
|
|
94
93
|
const defaulted = Option.fromNullable(record['default'])
|
|
95
94
|
return Option.match(defaulted, {
|
|
96
|
-
onNone: () =>
|
|
97
|
-
Effect.fail(new WorkerHookError({ reason: 'config has no default export' })),
|
|
95
|
+
onNone: () => Effect.fail(new WorkerHookError({ reason: 'config has no default export' })),
|
|
98
96
|
onSome: (config) => {
|
|
99
97
|
if (isMuxmaxingConfig(config)) {
|
|
100
98
|
return Effect.succeed(config)
|
|
@@ -125,80 +123,80 @@ const hookFailure =
|
|
|
125
123
|
(cause: unknown): WorkerHookError =>
|
|
126
124
|
new WorkerHookError({ reason: `${hookName} failed: ${describe(cause)}` })
|
|
127
125
|
|
|
128
|
-
const runRequested = Effect.fn('runRequested')(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
126
|
+
const runRequested = Effect.fn('runRequested')(function* (
|
|
127
|
+
request: RunHookRequest,
|
|
128
|
+
): Effect.fn.Return<HookDone, WorkerHookError> {
|
|
129
|
+
const config = yield* loadConfig(request.configPath)
|
|
130
|
+
const idRef = yield* Ref.make(0)
|
|
131
|
+
const unit: UnitInfo = {
|
|
132
|
+
id: request.unit.id,
|
|
133
|
+
workspaceFolder: request.unit.workspaceFolder,
|
|
134
|
+
}
|
|
135
|
+
const workerEnv = typeof Bun === 'undefined' ? process.env : Bun.env
|
|
136
|
+
const host: HostBridge = { bash: makeBridgedBash(idRef, 'host'), env: workerEnv }
|
|
137
|
+
const unitBridge = { ...unit, bash: makeBridgedBash(idRef, 'unit') }
|
|
138
|
+
|
|
139
|
+
// Undefined consuming hooks are intentional silent no-ops.
|
|
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))),
|
|
153
151
|
),
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
),
|
|
170
|
-
),
|
|
152
|
+
Effect.map(() => HookDone.make({ hostInput: null })),
|
|
153
|
+
),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return yield* Match.value(request.hook).pipe(
|
|
157
|
+
Match.when('collect', () =>
|
|
158
|
+
config.collectHostDeviceInput({ host, unit, log }).pipe(
|
|
159
|
+
Effect.mapError(hookFailure('collectHostDeviceInput')),
|
|
160
|
+
Effect.flatMap((collected) =>
|
|
161
|
+
S.encodeUnknown(config.hostInput)(collected).pipe(
|
|
162
|
+
Effect.mapError(
|
|
163
|
+
(issue) =>
|
|
164
|
+
new WorkerHookError({
|
|
165
|
+
reason: `hostInput encode failed: ${String(issue)}`,
|
|
166
|
+
}),
|
|
171
167
|
),
|
|
172
|
-
Effect.map((encoded) => HookDone.make({ hostInput: encoded ?? null })),
|
|
173
168
|
),
|
|
169
|
+
),
|
|
170
|
+
Effect.map((encoded) => HookDone.make({ hostInput: encoded ?? null })),
|
|
174
171
|
),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
),
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
),
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
),
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
172
|
+
),
|
|
173
|
+
Match.when('machineCreated', () =>
|
|
174
|
+
runOptional('onWorkUnitMachineCreated', config.onWorkUnitMachineCreated, (hostInput) => ({
|
|
175
|
+
hostInput,
|
|
176
|
+
host,
|
|
177
|
+
unit: unitBridge,
|
|
178
|
+
log,
|
|
179
|
+
})),
|
|
180
|
+
),
|
|
181
|
+
Match.when('newTerminal', () =>
|
|
182
|
+
runOptional('onNewTerminal', config.onNewTerminal, (hostInput) => ({
|
|
183
|
+
hostInput,
|
|
184
|
+
host,
|
|
185
|
+
unit: unitBridge,
|
|
186
|
+
log,
|
|
187
|
+
})),
|
|
188
|
+
),
|
|
189
|
+
Match.when('removed', () =>
|
|
190
|
+
runOptional('onWorkUnitRemoved', config.onWorkUnitRemoved, (hostInput) => ({
|
|
191
|
+
hostInput,
|
|
192
|
+
host,
|
|
193
|
+
unit,
|
|
194
|
+
log,
|
|
195
|
+
})),
|
|
196
|
+
),
|
|
197
|
+
Match.exhaustive,
|
|
198
|
+
)
|
|
199
|
+
})
|
|
202
200
|
|
|
203
201
|
const handleRunHook = (request: RunHookRequest): void => {
|
|
204
202
|
void Effect.runPromise(
|
|
@@ -207,19 +205,24 @@ const handleRunHook = (request: RunHookRequest): void => {
|
|
|
207
205
|
Effect.catchAllCause((cause) =>
|
|
208
206
|
Effect.sync(() => send(HookFailed.make({ reason: Cause.pretty(cause) }))),
|
|
209
207
|
),
|
|
210
|
-
// Drop the listener so the event loop drains and the worker exits
|
|
211
|
-
// the engine's scope finalizer also terminates it (belt and suspenders).
|
|
208
|
+
// Drop the listener so the event loop drains and the worker exits.
|
|
212
209
|
Effect.ensuring(
|
|
213
210
|
Effect.sync(() => {
|
|
214
|
-
|
|
211
|
+
if (hasWebWorkerScope) {
|
|
212
|
+
self.onmessage = null
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (parentPort !== null) {
|
|
216
|
+
parentPort.close()
|
|
217
|
+
}
|
|
215
218
|
}),
|
|
216
219
|
),
|
|
217
220
|
),
|
|
218
221
|
)
|
|
219
222
|
}
|
|
220
223
|
|
|
221
|
-
|
|
222
|
-
Either.match(S.decodeUnknownEither(EngineToWorker)(
|
|
224
|
+
const handleMessage = (payload: unknown): void => {
|
|
225
|
+
Either.match(S.decodeUnknownEither(EngineToWorker)(payload), {
|
|
223
226
|
onLeft: () => send(HookFailed.make({ reason: 'undecodable engine message' })),
|
|
224
227
|
onRight: (message) =>
|
|
225
228
|
Match.value(message).pipe(
|
|
@@ -235,3 +238,13 @@ self.onmessage = (event: MessageEvent): void => {
|
|
|
235
238
|
),
|
|
236
239
|
})
|
|
237
240
|
}
|
|
241
|
+
|
|
242
|
+
if (hasWebWorkerScope) {
|
|
243
|
+
self.onmessage = (event: MessageEvent): void => handleMessage(event.data)
|
|
244
|
+
}
|
|
245
|
+
if (!hasWebWorkerScope && parentPort !== null) {
|
|
246
|
+
parentPort.on('message', handleMessage)
|
|
247
|
+
}
|
|
248
|
+
if (!hasWebWorkerScope && parentPort === null) {
|
|
249
|
+
Effect.runSync(Effect.dieMessage('worker entry has no parent message port'))
|
|
250
|
+
}
|
|
@@ -1,35 +0,0 @@
|
|
|
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/worker-entry.test.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
})
|