@crewhaus/gateway-protocol 0.4.2 → 0.5.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/dist/control-codegen.d.ts +125 -0
- package/dist/control-codegen.js +135 -0
- package/dist/control.d.ts +329 -0
- package/dist/control.js +704 -0
- package/dist/index.d.ts +10 -10
- package/package.json +11 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen for `crewhaus.control.v1` — the ONE place the control-plane wiring
|
|
3
|
+
* that lands in an emitted daemon is written.
|
|
4
|
+
*
|
|
5
|
+
* Five targets emit a daemon (channel, managed, batch, crew, voice). If each
|
|
6
|
+
* of them spelled the control wiring itself, the five would drift the first
|
|
7
|
+
* time one of them was touched, and a supervisor would face five subtly
|
|
8
|
+
* different control planes. So the emitters describe their lanes and
|
|
9
|
+
* side-channels declaratively and this module renders the text; the runtime
|
|
10
|
+
* behind it lives in `./control`, which the emitted daemon imports.
|
|
11
|
+
*
|
|
12
|
+
* Every renderer takes an `indent` and applies it to every line it produces,
|
|
13
|
+
* so a caller can splice a block at any nesting depth without reflowing it.
|
|
14
|
+
*/
|
|
15
|
+
/** Package specifier the emitted daemon imports the control runtime from. */
|
|
16
|
+
export declare const CONTROL_RUNTIME_MODULE: "@crewhaus/gateway-protocol/control";
|
|
17
|
+
export type ControlPlaneEmit = {
|
|
18
|
+
/** Spec name — reported by `/control/v1/healthz` and `/status`. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/** Target discriminator (`channel` | `managed` | `batch` | `crew` | `voice`). */
|
|
21
|
+
readonly target: string;
|
|
22
|
+
/** Identifier the plane is bound to in the emitted daemon. Default `__control`. */
|
|
23
|
+
readonly varName?: string;
|
|
24
|
+
/** JS expression evaluating to `string[]` — the channels this daemon serves. */
|
|
25
|
+
readonly channelsExpr?: string;
|
|
26
|
+
/** JS expression evaluating to `number | Promise<number>` — parked approvals. */
|
|
27
|
+
readonly pendingApprovalsExpr?: string;
|
|
28
|
+
/**
|
|
29
|
+
* JS expression evaluating to the harness's `AuditLog` (or `undefined`).
|
|
30
|
+
* Every control call appends a `gateway_request` record through it.
|
|
31
|
+
*/
|
|
32
|
+
readonly auditLogExpr?: string;
|
|
33
|
+
readonly indent?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The import line for the control runtime. Emitted unconditionally by every
|
|
37
|
+
* daemon-shape target — control.v1 is the lowest common denominator all
|
|
38
|
+
* daemon shapes share, so a bundle either has it or is a pre-0.5.0 bundle.
|
|
39
|
+
*/
|
|
40
|
+
export declare function renderControlImports(opts?: {
|
|
41
|
+
readonly pendingApprovals?: boolean;
|
|
42
|
+
/** Pull in {@link runDrainSweep} — a drain step that sweeps housekeeping. */
|
|
43
|
+
readonly drainSweep?: boolean;
|
|
44
|
+
/** Pull in {@link sanitizeControlText} — a daemon that logs untrusted text. */
|
|
45
|
+
readonly logSafe?: boolean;
|
|
46
|
+
}): string;
|
|
47
|
+
/**
|
|
48
|
+
* `const __control = createControlPlane({ … });`
|
|
49
|
+
*
|
|
50
|
+
* Binding is deferred to {@link renderControlStart} so a daemon can register
|
|
51
|
+
* its lanes and drain steps (which close over the server/janitor it creates
|
|
52
|
+
* later) before the socket accepts a single request.
|
|
53
|
+
*/
|
|
54
|
+
export declare function renderControlPlaneBoot(opts: ControlPlaneEmit): string;
|
|
55
|
+
export type ControlLaneEmit = {
|
|
56
|
+
/** The lane an operator can poke. */
|
|
57
|
+
readonly lane: "heartbeat" | "schedule";
|
|
58
|
+
/** Identifier the lane handle binds to (e.g. `__heartbeatLane`). */
|
|
59
|
+
readonly varName: string;
|
|
60
|
+
/** Human cadence for `/status` — e.g. `every 60000ms`. */
|
|
61
|
+
readonly cadence: string;
|
|
62
|
+
/** Fixed interval, when the lane has one; projects `nextDueAt`. */
|
|
63
|
+
readonly everyMs?: number;
|
|
64
|
+
/** JS expression returning `string | undefined` — overrides the projection. */
|
|
65
|
+
readonly nextDueAtExpr?: string;
|
|
66
|
+
/**
|
|
67
|
+
* `false` for a lane whose body does NOT thread `__tick.sessionId` into a
|
|
68
|
+
* session of its own (the managed fan-out, the batch producer). Such a lane
|
|
69
|
+
* writes no wake marker and its 202 omits `sessionId` instead of handing the
|
|
70
|
+
* operator an id that names nothing — see `ControlLaneOptions.ownsSession`.
|
|
71
|
+
*/
|
|
72
|
+
readonly ownsSession?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The tick body. Rendered inside `async (__tick) => { … }`, so it can read
|
|
75
|
+
* `__tick.sessionId` (minted by the lane, identical for timer + operator
|
|
76
|
+
* fires) and `__tick.synthetic`.
|
|
77
|
+
*/
|
|
78
|
+
readonly body: string;
|
|
79
|
+
readonly planeVar?: string;
|
|
80
|
+
readonly indent?: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Register a pokeable lane. The returned handle's `tick()` is what the timer
|
|
84
|
+
* calls and `wake()` is what `POST /control/v1/wake` calls — ONE body, so the
|
|
85
|
+
* operator poke can never diverge from the organic fire, and the lane's
|
|
86
|
+
* never-overlap guard covers both (a wake during an in-flight tick 409s).
|
|
87
|
+
*/
|
|
88
|
+
export declare function renderControlLane(opts: ControlLaneEmit): string;
|
|
89
|
+
/**
|
|
90
|
+
* A read-only timer row for `/control/v1/status` (the janitor and dream lanes
|
|
91
|
+
* are observed, not pokeable). `expr` is a JS expression evaluating to a
|
|
92
|
+
* `ControlTimerReport`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function renderControlTimer(opts: {
|
|
95
|
+
readonly expr: string;
|
|
96
|
+
readonly planeVar?: string;
|
|
97
|
+
readonly indent?: string;
|
|
98
|
+
}): string;
|
|
99
|
+
/**
|
|
100
|
+
* Register a drain step. `POST /control/v1/drain` stops intake first (the
|
|
101
|
+
* public gate starts answering 503 + Retry-After the moment the request lands),
|
|
102
|
+
* waits out in-flight lane ticks, then runs these steps in order and exits 0.
|
|
103
|
+
*/
|
|
104
|
+
export declare function renderControlDrain(opts: {
|
|
105
|
+
readonly body: string;
|
|
106
|
+
readonly planeVar?: string;
|
|
107
|
+
readonly indent?: string;
|
|
108
|
+
}): string;
|
|
109
|
+
/** `await __control.start();` — binds the dedicated port when one is configured. */
|
|
110
|
+
export declare function renderControlStart(opts: {
|
|
111
|
+
readonly planeVar?: string;
|
|
112
|
+
readonly indent?: string;
|
|
113
|
+
}): string;
|
|
114
|
+
/**
|
|
115
|
+
* The PUBLIC-port wrapper: a bare unauthenticated `GET /healthz` (liveness
|
|
116
|
+
* only, no state — this is what deployment scaffolds' health checks have been
|
|
117
|
+
* declaring against nothing) plus the 503 + `Retry-After` intake shed once the
|
|
118
|
+
* daemon is draining. Everything else falls through to `innerExpr`.
|
|
119
|
+
*
|
|
120
|
+
* Renders an inline arrow suitable as a `Bun.serve({ fetch })` value.
|
|
121
|
+
*/
|
|
122
|
+
export declare function renderPublicGateFetch(opts: {
|
|
123
|
+
readonly innerExpr: string;
|
|
124
|
+
readonly planeVar?: string;
|
|
125
|
+
}): string;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen for `crewhaus.control.v1` — the ONE place the control-plane wiring
|
|
3
|
+
* that lands in an emitted daemon is written.
|
|
4
|
+
*
|
|
5
|
+
* Five targets emit a daemon (channel, managed, batch, crew, voice). If each
|
|
6
|
+
* of them spelled the control wiring itself, the five would drift the first
|
|
7
|
+
* time one of them was touched, and a supervisor would face five subtly
|
|
8
|
+
* different control planes. So the emitters describe their lanes and
|
|
9
|
+
* side-channels declaratively and this module renders the text; the runtime
|
|
10
|
+
* behind it lives in `./control`, which the emitted daemon imports.
|
|
11
|
+
*
|
|
12
|
+
* Every renderer takes an `indent` and applies it to every line it produces,
|
|
13
|
+
* so a caller can splice a block at any nesting depth without reflowing it.
|
|
14
|
+
*/
|
|
15
|
+
/** Package specifier the emitted daemon imports the control runtime from. */
|
|
16
|
+
export const CONTROL_RUNTIME_MODULE = "@crewhaus/gateway-protocol/control";
|
|
17
|
+
function indentBlock(text, indent) {
|
|
18
|
+
if (indent === "")
|
|
19
|
+
return text;
|
|
20
|
+
return text
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => (line === "" ? "" : `${indent}${line}`))
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
/** JSON-quote a string for embedding in emitted source. */
|
|
26
|
+
function q(value) {
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The import line for the control runtime. Emitted unconditionally by every
|
|
31
|
+
* daemon-shape target — control.v1 is the lowest common denominator all
|
|
32
|
+
* daemon shapes share, so a bundle either has it or is a pre-0.5.0 bundle.
|
|
33
|
+
*/
|
|
34
|
+
export function renderControlImports(opts = {}) {
|
|
35
|
+
const names = [
|
|
36
|
+
...(opts.pendingApprovals === true ? ["countPendingApprovals"] : []),
|
|
37
|
+
"createControlPlane",
|
|
38
|
+
...(opts.drainSweep === true ? ["runDrainSweep"] : []),
|
|
39
|
+
...(opts.logSafe === true ? ["sanitizeControlText"] : []),
|
|
40
|
+
].join(", ");
|
|
41
|
+
return `import { ${names} } from ${q(CONTROL_RUNTIME_MODULE)};\n`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `const __control = createControlPlane({ … });`
|
|
45
|
+
*
|
|
46
|
+
* Binding is deferred to {@link renderControlStart} so a daemon can register
|
|
47
|
+
* its lanes and drain steps (which close over the server/janitor it creates
|
|
48
|
+
* later) before the socket accepts a single request.
|
|
49
|
+
*/
|
|
50
|
+
export function renderControlPlaneBoot(opts) {
|
|
51
|
+
const v = opts.varName ?? "__control";
|
|
52
|
+
const fields = [
|
|
53
|
+
`name: ${q(opts.name)},`,
|
|
54
|
+
`target: ${q(opts.target)},`,
|
|
55
|
+
...(opts.channelsExpr !== undefined ? [`channels: () => ${opts.channelsExpr},`] : []),
|
|
56
|
+
...(opts.pendingApprovalsExpr !== undefined
|
|
57
|
+
? [`pendingApprovals: async () => ${opts.pendingApprovalsExpr},`]
|
|
58
|
+
: []),
|
|
59
|
+
...(opts.auditLogExpr !== undefined
|
|
60
|
+
? [
|
|
61
|
+
"audit: async (__rec) => {",
|
|
62
|
+
` const __log = ${opts.auditLogExpr};`,
|
|
63
|
+
" if (__log !== undefined) await __log.append(__rec);",
|
|
64
|
+
"},",
|
|
65
|
+
]
|
|
66
|
+
: []),
|
|
67
|
+
];
|
|
68
|
+
const body = [
|
|
69
|
+
"// crewhaus.control.v1 — the supervisor-facing control plane (§ dedicated",
|
|
70
|
+
"// port from CREWHAUS_CONTROL_PORT, bearer from CREWHAUS_CONTROL_TOKEN or a",
|
|
71
|
+
"// boot-minted .crewhaus/run/control-token). Unset port ⇒ no socket opens.",
|
|
72
|
+
`const ${v} = createControlPlane({`,
|
|
73
|
+
...fields.map((f) => ` ${f}`),
|
|
74
|
+
"});",
|
|
75
|
+
].join("\n");
|
|
76
|
+
return `${indentBlock(body, opts.indent ?? "")}\n`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Register a pokeable lane. The returned handle's `tick()` is what the timer
|
|
80
|
+
* calls and `wake()` is what `POST /control/v1/wake` calls — ONE body, so the
|
|
81
|
+
* operator poke can never diverge from the organic fire, and the lane's
|
|
82
|
+
* never-overlap guard covers both (a wake during an in-flight tick 409s).
|
|
83
|
+
*/
|
|
84
|
+
export function renderControlLane(opts) {
|
|
85
|
+
const plane = opts.planeVar ?? "__control";
|
|
86
|
+
const body = [
|
|
87
|
+
`const ${opts.varName} = ${plane}.lane({`,
|
|
88
|
+
` lane: ${q(opts.lane)},`,
|
|
89
|
+
` cadence: ${q(opts.cadence)},`,
|
|
90
|
+
...(opts.everyMs !== undefined ? [` everyMs: ${opts.everyMs},`] : []),
|
|
91
|
+
...(opts.nextDueAtExpr !== undefined ? [` nextDueAt: () => ${opts.nextDueAtExpr},`] : []),
|
|
92
|
+
...(opts.ownsSession === false ? [" ownsSession: false,"] : []),
|
|
93
|
+
" run: async (__tick) => {",
|
|
94
|
+
indentBlock(opts.body, " "),
|
|
95
|
+
" },",
|
|
96
|
+
"});",
|
|
97
|
+
].join("\n");
|
|
98
|
+
return `${indentBlock(body, opts.indent ?? "")}\n`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A read-only timer row for `/control/v1/status` (the janitor and dream lanes
|
|
102
|
+
* are observed, not pokeable). `expr` is a JS expression evaluating to a
|
|
103
|
+
* `ControlTimerReport`.
|
|
104
|
+
*/
|
|
105
|
+
export function renderControlTimer(opts) {
|
|
106
|
+
const plane = opts.planeVar ?? "__control";
|
|
107
|
+
return `${indentBlock(`${plane}.timer(() => (${opts.expr}));`, opts.indent ?? "")}\n`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Register a drain step. `POST /control/v1/drain` stops intake first (the
|
|
111
|
+
* public gate starts answering 503 + Retry-After the moment the request lands),
|
|
112
|
+
* waits out in-flight lane ticks, then runs these steps in order and exits 0.
|
|
113
|
+
*/
|
|
114
|
+
export function renderControlDrain(opts) {
|
|
115
|
+
const plane = opts.planeVar ?? "__control";
|
|
116
|
+
const body = [`${plane}.onDrain(async () => {`, indentBlock(opts.body, " "), "});"].join("\n");
|
|
117
|
+
return `${indentBlock(body, opts.indent ?? "")}\n`;
|
|
118
|
+
}
|
|
119
|
+
/** `await __control.start();` — binds the dedicated port when one is configured. */
|
|
120
|
+
export function renderControlStart(opts) {
|
|
121
|
+
const plane = opts.planeVar ?? "__control";
|
|
122
|
+
return `${indentBlock(`await ${plane}.start();`, opts.indent ?? "")}\n`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The PUBLIC-port wrapper: a bare unauthenticated `GET /healthz` (liveness
|
|
126
|
+
* only, no state — this is what deployment scaffolds' health checks have been
|
|
127
|
+
* declaring against nothing) plus the 503 + `Retry-After` intake shed once the
|
|
128
|
+
* daemon is draining. Everything else falls through to `innerExpr`.
|
|
129
|
+
*
|
|
130
|
+
* Renders an inline arrow suitable as a `Bun.serve({ fetch })` value.
|
|
131
|
+
*/
|
|
132
|
+
export function renderPublicGateFetch(opts) {
|
|
133
|
+
const plane = opts.planeVar ?? "__control";
|
|
134
|
+
return `(__req) => ${plane}.publicGate(__req) ?? (${opts.innerExpr})(__req)`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crewhaus.control.v1` — the daemon control plane every daemon-shape bundle
|
|
3
|
+
* serves.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. A compiled daemon's schedulers are in-process
|
|
6
|
+
* (`setInterval` for `heartbeat:`, `armSchedule` for `schedule:`), so from
|
|
7
|
+
* the outside there is no way to ask "when does the next heartbeat fire?" and
|
|
8
|
+
* no way to make one fire now: the phase of a heartbeat is knowable ONLY
|
|
9
|
+
* inside the process that armed it. A supervisor that wants to drive — not
|
|
10
|
+
* just watch — a fleet of daemons needs a uniform, signal-free surface. This
|
|
11
|
+
* module is that surface, written ONCE and consumed by every daemon-emitting
|
|
12
|
+
* target (channel, managed, batch, crew, voice) so the five shapes can never
|
|
13
|
+
* drift apart.
|
|
14
|
+
*
|
|
15
|
+
* SHAPE OF THE CONTRACT.
|
|
16
|
+
* - A DEDICATED control port, separate from any public webhook/gateway
|
|
17
|
+
* port. Default bind `127.0.0.1`; the port comes from
|
|
18
|
+
* `CREWHAUS_CONTROL_PORT` (`0` asks the kernel for an ephemeral port,
|
|
19
|
+
* which is then reported on stdout). Unset ⇒ no control socket at all,
|
|
20
|
+
* so upgrading a bundle never opens a listener nobody asked for.
|
|
21
|
+
* Exposing it on a PaaS is an explicit opt-in: set the bind + token as
|
|
22
|
+
* provider secrets at deploy time.
|
|
23
|
+
* - Bearer auth against `CREWHAUS_CONTROL_TOKEN`, or a token minted at boot
|
|
24
|
+
* into `<cwd>/.crewhaus/run/control-token` (0600) so a local manager can
|
|
25
|
+
* read it off disk. Compared in constant time; never logged, never
|
|
26
|
+
* echoed, never written to an audit payload.
|
|
27
|
+
* - `GET /control/v1/healthz` → `{ok, name, target}`
|
|
28
|
+
* - `GET /control/v1/status` → counters + per-lane timers + channels +
|
|
29
|
+
* pending approvals
|
|
30
|
+
* - `POST /control/v1/wake` → one synthetic tick down the IDENTICAL code
|
|
31
|
+
* path as the timer fire
|
|
32
|
+
* - `POST /control/v1/drain` → stop intake, finish in-flight work, exit 0
|
|
33
|
+
* - Every call appends a `gateway_request` record to the harness's
|
|
34
|
+
* hash-chained audit log when one is wired.
|
|
35
|
+
*
|
|
36
|
+
* Separately — and INDEPENDENT of whether the control port is bound — a bare,
|
|
37
|
+
* unauthenticated `GET /healthz` is served on the daemon's PUBLIC port when it
|
|
38
|
+
* has one ({@link ControlPlane.publicGate}). Deployment scaffolds declare that
|
|
39
|
+
* health check but no daemon served it; the liveness answer carries no state,
|
|
40
|
+
* so closing that gap never exposes control.
|
|
41
|
+
*
|
|
42
|
+
* TESTABILITY. `fetch` is a plain `Request → Response` function, so the whole
|
|
43
|
+
* router (auth, wake, 409-while-in-flight, drain) is exercisable with no
|
|
44
|
+
* socket, no timers and no daemon. `start()` is the only part that touches
|
|
45
|
+
* `Bun.serve`.
|
|
46
|
+
*/
|
|
47
|
+
/** Wire identifier for this control protocol. Bumping it is a breaking change. */
|
|
48
|
+
export declare const CONTROL_PROTOCOL: "crewhaus.control.v1";
|
|
49
|
+
/** Every control route lives under this prefix. */
|
|
50
|
+
export declare const CONTROL_PATH_PREFIX: "/control/v1";
|
|
51
|
+
/** Harness-local ops directory. All daemon-written control state lives here. */
|
|
52
|
+
export declare const CONTROL_RUN_DIR: ".crewhaus/run";
|
|
53
|
+
/** File the boot-minted bearer token lands in, mode 0600. */
|
|
54
|
+
export declare const CONTROL_TOKEN_FILENAME: "control-token";
|
|
55
|
+
export declare const CONTROL_BIND_ENV: "CREWHAUS_CONTROL_BIND";
|
|
56
|
+
export declare const CONTROL_PORT_ENV: "CREWHAUS_CONTROL_PORT";
|
|
57
|
+
export declare const CONTROL_TOKEN_ENV: "CREWHAUS_CONTROL_TOKEN";
|
|
58
|
+
/** Loopback-only unless the operator explicitly widens it. */
|
|
59
|
+
export declare const DEFAULT_CONTROL_BIND: "127.0.0.1";
|
|
60
|
+
/**
|
|
61
|
+
* Longest free-text a control-plane field (`reason`, `by`) may carry into a
|
|
62
|
+
* daemon's prose log, and the default cap {@link sanitizeControlText} applies.
|
|
63
|
+
*/
|
|
64
|
+
export declare const CONTROL_TEXT_MAX = 200;
|
|
65
|
+
/**
|
|
66
|
+
* Budget (ms) for a best-effort housekeeping step run during a drain, and the
|
|
67
|
+
* env var that widens it. `0` skips the step entirely.
|
|
68
|
+
*/
|
|
69
|
+
export declare const DRAIN_SWEEP_BUDGET_ENV: "CREWHAUS_DRAIN_SWEEP_MS";
|
|
70
|
+
export declare const DEFAULT_DRAIN_SWEEP_MS = 5000;
|
|
71
|
+
/** `Retry-After` (seconds) on the 503 a draining daemon answers intake with. */
|
|
72
|
+
export declare const DRAIN_RETRY_AFTER_SECONDS = 15;
|
|
73
|
+
/** The two lanes an operator can poke. Both are in-process timers. */
|
|
74
|
+
export type ControlLane = "heartbeat" | "schedule";
|
|
75
|
+
export type ControlOutcome = "ok" | "error";
|
|
76
|
+
/** Counters `/control/v1/status` reports. Mutated in place by the daemon. */
|
|
77
|
+
export type ControlCounters = {
|
|
78
|
+
turns: number;
|
|
79
|
+
heartbeatTicks: number;
|
|
80
|
+
scheduleWakes: number;
|
|
81
|
+
janitorRuns: number;
|
|
82
|
+
};
|
|
83
|
+
export type ControlTimerReport = {
|
|
84
|
+
readonly lane: string;
|
|
85
|
+
/** Human cadence, e.g. `every 60000ms` or `cron "0 * * * *" UTC`. */
|
|
86
|
+
readonly cadence: string;
|
|
87
|
+
readonly lastFiredAt?: string;
|
|
88
|
+
readonly lastOutcome?: ControlOutcome;
|
|
89
|
+
readonly nextDueAt?: string;
|
|
90
|
+
};
|
|
91
|
+
/** What a lane's tick body receives. `synthetic` is set only for operator pokes. */
|
|
92
|
+
export type ControlTickContext = {
|
|
93
|
+
readonly sessionId: string;
|
|
94
|
+
readonly synthetic?: {
|
|
95
|
+
readonly reason: string;
|
|
96
|
+
readonly by: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
export type ControlLaneOptions = {
|
|
100
|
+
readonly lane: ControlLane;
|
|
101
|
+
readonly cadence: string;
|
|
102
|
+
/**
|
|
103
|
+
* Interval in ms, when the lane has a fixed one. Used to project
|
|
104
|
+
* `nextDueAt` from the last fire — the number no offline reader can
|
|
105
|
+
* compute for a heartbeat, which is the entire reason `/status` exists.
|
|
106
|
+
*/
|
|
107
|
+
readonly everyMs?: number;
|
|
108
|
+
/** Overrides the `everyMs` projection (cron lanes supply their own). */
|
|
109
|
+
readonly nextDueAt?: () => string | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Does this lane's body actually THREAD `ctx.sessionId` into a session?
|
|
112
|
+
*
|
|
113
|
+
* On the channel shape it does — the tick calls `agent.runTurn({sessionId})`,
|
|
114
|
+
* so the id the 202 hands back names a transcript the operator can open, and
|
|
115
|
+
* writing the poke marker into that session's event log is what attributes
|
|
116
|
+
* the turn to the operator who asked for it.
|
|
117
|
+
*
|
|
118
|
+
* On the multi-tenant (managed) and producer (batch) shapes it does NOT: one
|
|
119
|
+
* tick fans out to a turn per tenant, each in its own session, or enqueues a
|
|
120
|
+
* job whose handler mints its own. Advertising the lane's id there promised a
|
|
121
|
+
* transcript that never existed, and the marker it left behind was a `.jsonl`
|
|
122
|
+
* with no `.json` beside it — a file `sweepExpired`, `crewhaus retention` and
|
|
123
|
+
* the janitor's TTL eviction all skip BY DESIGN, so it outlived every
|
|
124
|
+
* retention policy the harness has. Such a lane declares `ownsSession: false`:
|
|
125
|
+
* no marker is written, and the 202 omits `sessionId` rather than lying. The
|
|
126
|
+
* poke stays evidenced through the control plane's `gateway_request` audit
|
|
127
|
+
* record, which carries the same `reason`/`by` and IS covered by retention.
|
|
128
|
+
*
|
|
129
|
+
* Default `true` (the channel shape's behaviour).
|
|
130
|
+
*/
|
|
131
|
+
readonly ownsSession?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* The tick body. The SAME function backs the timer fire and the operator
|
|
134
|
+
* wake — there is deliberately no second code path to drift.
|
|
135
|
+
*/
|
|
136
|
+
readonly run: (ctx: ControlTickContext) => Promise<unknown>;
|
|
137
|
+
};
|
|
138
|
+
export interface ControlLaneHandle {
|
|
139
|
+
readonly lane: ControlLane;
|
|
140
|
+
/** True while a tick for this lane is executing. Ticks never overlap. */
|
|
141
|
+
busy(): boolean;
|
|
142
|
+
/**
|
|
143
|
+
* ORGANIC fire (the timer's own call). Awaits the tick to completion so
|
|
144
|
+
* `armSchedule`'s re-arm-after-resolve rule keeps holding. A fire while the
|
|
145
|
+
* previous tick is still running is dropped, not queued.
|
|
146
|
+
*/
|
|
147
|
+
tick(): Promise<void>;
|
|
148
|
+
/**
|
|
149
|
+
* SYNTHETIC fire (an operator poke). Records the marker, starts the tick and
|
|
150
|
+
* returns immediately — the caller answers 202 without waiting for a model
|
|
151
|
+
* round-trip. `sessionId` is present only when the lane owns the session it
|
|
152
|
+
* was minted for (see {@link ControlLaneOptions.ownsSession}); a lane that
|
|
153
|
+
* does not returns none rather than a dangling id.
|
|
154
|
+
*/
|
|
155
|
+
wake(args: {
|
|
156
|
+
readonly reason: string;
|
|
157
|
+
readonly by: string;
|
|
158
|
+
}): Promise<{
|
|
159
|
+
readonly accepted: boolean;
|
|
160
|
+
readonly sessionId?: string;
|
|
161
|
+
}>;
|
|
162
|
+
/** Resolves once no tick is in flight (used by drain). */
|
|
163
|
+
settled(): Promise<void>;
|
|
164
|
+
report(): ControlTimerReport;
|
|
165
|
+
}
|
|
166
|
+
export type ControlAuditRecord = {
|
|
167
|
+
readonly kind: "gateway_request";
|
|
168
|
+
readonly payload: Record<string, unknown>;
|
|
169
|
+
};
|
|
170
|
+
export type ControlPlaneOptions = {
|
|
171
|
+
readonly name: string;
|
|
172
|
+
readonly target: string;
|
|
173
|
+
/** Harness root. Defaults to `process.cwd()` — never the bundle dir. */
|
|
174
|
+
readonly cwd?: string;
|
|
175
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
176
|
+
/** Channel ids this daemon serves (channel shape); empty elsewhere. */
|
|
177
|
+
readonly channels?: () => readonly string[];
|
|
178
|
+
/** Count of parked approvals. Read-only — must never evict or mutate. */
|
|
179
|
+
readonly pendingApprovals?: () => number | Promise<number>;
|
|
180
|
+
/**
|
|
181
|
+
* The harness's existing hash-chained audit log. Every control call appends
|
|
182
|
+
* a `gateway_request` record through it. Absent ⇒ control still works, just
|
|
183
|
+
* un-evidenced (a daemon booted with `CREWHAUS_SECURITY_AUDIT=0`).
|
|
184
|
+
*/
|
|
185
|
+
readonly audit?: (record: ControlAuditRecord) => Promise<unknown> | unknown;
|
|
186
|
+
/** Session root for the synthetic-wake marker. Defaults to event-log's. */
|
|
187
|
+
readonly sessionRootDir?: string;
|
|
188
|
+
readonly now?: () => number;
|
|
189
|
+
readonly pid?: number;
|
|
190
|
+
readonly stdout?: (line: string) => void;
|
|
191
|
+
readonly stderr?: (line: string) => void;
|
|
192
|
+
/** Injected for tests; defaults to `process.exit`. */
|
|
193
|
+
readonly exit?: (code: number) => void;
|
|
194
|
+
/**
|
|
195
|
+
* Delay between answering a drain request and running the drain sequence,
|
|
196
|
+
* so the 202 is on the wire before the process starts tearing down.
|
|
197
|
+
*/
|
|
198
|
+
readonly drainSettleMs?: number;
|
|
199
|
+
};
|
|
200
|
+
export interface ControlPlane {
|
|
201
|
+
readonly counters: ControlCounters;
|
|
202
|
+
/** Register a pokeable lane. Returns the handle the timer body fires. */
|
|
203
|
+
lane(opts: ControlLaneOptions): ControlLaneHandle;
|
|
204
|
+
/** Register a read-only timer row (janitor, dream) for `/status`. */
|
|
205
|
+
timer(report: () => ControlTimerReport): void;
|
|
206
|
+
/** Register a drain step. Steps run in registration order. */
|
|
207
|
+
onDrain(step: () => Promise<void> | void): void;
|
|
208
|
+
draining(): boolean;
|
|
209
|
+
/** The whole router, socket-free. */
|
|
210
|
+
fetch(req: Request): Promise<Response>;
|
|
211
|
+
/**
|
|
212
|
+
* Bind the dedicated control port when `CREWHAUS_CONTROL_PORT` is set.
|
|
213
|
+
* Returns undefined when control is not configured (the default).
|
|
214
|
+
*/
|
|
215
|
+
start(): Promise<{
|
|
216
|
+
readonly port: number;
|
|
217
|
+
readonly url: string;
|
|
218
|
+
} | undefined>;
|
|
219
|
+
stop(): Promise<void>;
|
|
220
|
+
/**
|
|
221
|
+
* The PUBLIC-port gate: answers the bare `GET /healthz` liveness check and,
|
|
222
|
+
* once draining, sheds every other request with `503` + `Retry-After`.
|
|
223
|
+
* Returns undefined when the request should fall through to the daemon's
|
|
224
|
+
* real handler.
|
|
225
|
+
*/
|
|
226
|
+
publicGate(req: Request): Response | undefined;
|
|
227
|
+
/** Where the bearer came from. Never returns the token itself. */
|
|
228
|
+
tokenSource(): {
|
|
229
|
+
readonly source: "env" | "file";
|
|
230
|
+
readonly path?: string;
|
|
231
|
+
} | undefined;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Flatten untrusted text into ONE printable log-safe line.
|
|
235
|
+
*
|
|
236
|
+
* WHY THIS EXISTS. A daemon's stdout/stderr is not just for humans: the
|
|
237
|
+
* manager PARSES it — the `[control] crewhaus.control.v1 listening on
|
|
238
|
+
* http://host:port` announcement is the only way it learns a kernel-assigned
|
|
239
|
+
* control port. Every prose line a daemon prints that interpolates text the
|
|
240
|
+
* daemon did not author is therefore a log-injection surface: an operator's
|
|
241
|
+
* `reason`, and — strictly worse, because it needs no operator at all — the
|
|
242
|
+
* AGENT's own turn output, which a channel message can steer. A single
|
|
243
|
+
* newline in either would let that text START a line, and a forged
|
|
244
|
+
* announcement line is enough to repoint the manager's control calls (bearer
|
|
245
|
+
* included) at an attacker-chosen port.
|
|
246
|
+
*
|
|
247
|
+
* So: every control character (C0, DEL + C1) and every Unicode line separator
|
|
248
|
+
* is replaced with a space, whitespace runs collapse, and the result is capped
|
|
249
|
+
* — one line, bounded, no matter what went in. Anchoring the manager's own
|
|
250
|
+
* announcement regex to a line start is the other half of the same fix; this
|
|
251
|
+
* half is what makes the anchor hold, because it guarantees untrusted text can
|
|
252
|
+
* never begin a line.
|
|
253
|
+
*/
|
|
254
|
+
export declare function sanitizeControlText(value: string, maxLen?: number): string;
|
|
255
|
+
export type DrainSweepOutcome = "done" | "timeout" | "failed" | "skipped";
|
|
256
|
+
/**
|
|
257
|
+
* Run a BEST-EFFORT housekeeping step during a drain, under its own budget.
|
|
258
|
+
*
|
|
259
|
+
* WHY THIS EXISTS. A drain's contract is "stop intake, finish in-flight work,
|
|
260
|
+
* exit 0", and a supervisor holds it to a deadline. A janitor sweep is not
|
|
261
|
+
* in-flight work — it is housekeeping the next boot repeats — so letting it
|
|
262
|
+
* sit inside that deadline spends the operator's whole drain budget on a step
|
|
263
|
+
* nothing depends on, and the turn the drain existed to finish gets SIGTERM'd
|
|
264
|
+
* anyway. Emitted drain steps therefore close their listeners FIRST and run
|
|
265
|
+
* the sweep through here, last and time-boxed: a slow or wedged sweep can
|
|
266
|
+
* cost at most `budgetMs`, and its failure is reported, never thrown.
|
|
267
|
+
*/
|
|
268
|
+
export declare function runDrainSweep(step: () => Promise<unknown> | unknown, opts?: {
|
|
269
|
+
readonly budgetMs?: number;
|
|
270
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
271
|
+
readonly onOutcome?: (outcome: DrainSweepOutcome, detail?: string) => void;
|
|
272
|
+
}): Promise<DrainSweepOutcome>;
|
|
273
|
+
/**
|
|
274
|
+
* Constant-time string compare. Both sides are hashed first so the comparison
|
|
275
|
+
* is over fixed-length buffers — `timingSafeEqual` throws on length mismatch,
|
|
276
|
+
* and branching on length would itself leak the token's length.
|
|
277
|
+
*/
|
|
278
|
+
export declare function constantTimeEquals(a: string, b: string): boolean;
|
|
279
|
+
export type ResolvedControlToken = {
|
|
280
|
+
readonly token: string;
|
|
281
|
+
readonly source: "env" | "file";
|
|
282
|
+
readonly path?: string;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the control bearer. `CREWHAUS_CONTROL_TOKEN` wins; otherwise a fresh
|
|
286
|
+
* 32-byte token is minted into `<cwd>/.crewhaus/run/control-token` at 0600.
|
|
287
|
+
*
|
|
288
|
+
* Minting FRESH each boot is deliberate: a token left behind by a dead daemon
|
|
289
|
+
* must not authenticate against its replacement, and the manager reads the
|
|
290
|
+
* file after it spawns the process, so there is nothing to preserve.
|
|
291
|
+
*/
|
|
292
|
+
export declare function resolveControlToken(opts: {
|
|
293
|
+
readonly cwd: string;
|
|
294
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
295
|
+
}): ResolvedControlToken;
|
|
296
|
+
/**
|
|
297
|
+
* Append the operator-poke marker to the tick's session log.
|
|
298
|
+
*
|
|
299
|
+
* It is written as a `user_message` carrying `synthetic: true` — the
|
|
300
|
+
* established convention for runtime-injected turns. Every turn-deriving
|
|
301
|
+
* reader in the stack (feedback distill, the eval-judge transcript digest, the
|
|
302
|
+
* session summarizer, the advise rules) already skips `synthetic: true` user
|
|
303
|
+
* messages, so an operator poke can never inflate a turn count or land in a
|
|
304
|
+
* training set as if a human had typed it. The `control` sub-object is what
|
|
305
|
+
* lets evals and watch-me positively IDENTIFY the poke and tell it apart from
|
|
306
|
+
* an organic wake.
|
|
307
|
+
*/
|
|
308
|
+
export declare function recordSyntheticWake(args: {
|
|
309
|
+
readonly sessionId: string;
|
|
310
|
+
readonly lane: ControlLane;
|
|
311
|
+
readonly reason: string;
|
|
312
|
+
readonly by: string;
|
|
313
|
+
readonly sessionRootDir?: string;
|
|
314
|
+
}): Promise<void>;
|
|
315
|
+
/** Default filename `@crewhaus/session-store`'s approval store writes. */
|
|
316
|
+
export declare const APPROVALS_FILENAME: "approvals.jsonl";
|
|
317
|
+
/**
|
|
318
|
+
* Count parked approvals WITHOUT calling `PendingApprovalStore.list()`.
|
|
319
|
+
*
|
|
320
|
+
* `list()` compacts the backing file as a side-effect (it drops expired and
|
|
321
|
+
* superseded lines), exactly like `SessionStore.list()`'s TTL eviction. A
|
|
322
|
+
* status endpoint is a read: polling it must never rewrite an operator's
|
|
323
|
+
* approvals ledger. So this folds the JSONL itself — last-wins by `id`, the
|
|
324
|
+
* same upsert rule `persist` documents — and counts the records still awaiting
|
|
325
|
+
* a human. A missing file, a torn tail line, or an unreadable record counts as
|
|
326
|
+
* nothing rather than failing the whole status call.
|
|
327
|
+
*/
|
|
328
|
+
export declare function countPendingApprovals(filePath: string): number;
|
|
329
|
+
export declare function createControlPlane(opts: ControlPlaneOptions): ControlPlane;
|