agent-tempo 1.2.0 → 1.4.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/CLAUDE.md +253 -219
- package/LICENSE +21 -21
- package/README.md +293 -289
- package/assets/icon-dark.svg +9 -9
- package/assets/icon.svg +9 -9
- package/assets/logo-dark.svg +11 -11
- package/assets/logo-light.svg +11 -11
- package/dashboard/README.md +91 -91
- package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
- package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
- package/dashboard/dist/index.html +20 -20
- package/dashboard/package.json +47 -47
- package/dist/activities/outbox.d.ts +30 -1
- package/dist/activities/outbox.js +96 -3
- package/dist/adapters/base.js +5 -0
- package/dist/adapters/copilot/adapter.js +12 -1
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/pi/adapter.d.ts +2 -0
- package/dist/adapters/pi/adapter.js +43 -0
- package/dist/adapters/pi/index.d.ts +16 -0
- package/dist/adapters/pi/index.js +10 -0
- package/dist/cli/global-wrapper.d.ts +19 -0
- package/dist/cli/global-wrapper.js +169 -0
- package/dist/cli/help-text.js +97 -97
- package/dist/cli/startup.js +11 -0
- package/dist/cli/upgrade-command.js +81 -81
- package/dist/cli.js +12 -0
- package/dist/client/core.js +9 -2
- package/dist/client/interface.d.ts +6 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +74 -0
- package/dist/daemon.js +37 -1
- package/dist/http/aggregate.d.ts +22 -1
- package/dist/http/aggregate.js +41 -0
- package/dist/http/auth.d.ts +94 -8
- package/dist/http/auth.js +93 -9
- package/dist/http/body.d.ts +4 -1
- package/dist/http/body.js +6 -3
- package/dist/http/event-bus.js +1 -0
- package/dist/http/event-types.d.ts +34 -2
- package/dist/http/event-types.js +1 -0
- package/dist/http/gate-audit.d.ts +12 -0
- package/dist/http/gate-audit.js +95 -0
- package/dist/http/gate-registry.d.ts +167 -0
- package/dist/http/gate-registry.js +163 -0
- package/dist/http/gate-routes.d.ts +48 -0
- package/dist/http/gate-routes.js +102 -0
- package/dist/http/ingest-registry.d.ts +30 -0
- package/dist/http/ingest-registry.js +108 -0
- package/dist/http/inner-loop-routes.d.ts +66 -0
- package/dist/http/inner-loop-routes.js +182 -0
- package/dist/http/inner-loop.d.ts +92 -0
- package/dist/http/inner-loop.js +155 -0
- package/dist/http/server.d.ts +38 -3
- package/dist/http/server.js +211 -6
- package/dist/http/snapshot.d.ts +6 -0
- package/dist/http/snapshot.js +6 -0
- package/dist/pi/cue-pump.d.ts +61 -0
- package/dist/pi/cue-pump.js +95 -0
- package/dist/pi/extension.d.ts +45 -0
- package/dist/pi/extension.js +407 -0
- package/dist/pi/gate-client.d.ts +54 -0
- package/dist/pi/gate-client.js +136 -0
- package/dist/pi/headless.d.ts +85 -0
- package/dist/pi/headless.js +224 -0
- package/dist/pi/index.d.ts +28 -0
- package/dist/pi/index.js +43 -0
- package/dist/pi/inner-loop-client.d.ts +67 -0
- package/dist/pi/inner-loop-client.js +164 -0
- package/dist/pi/inner-loop-publisher.d.ts +187 -0
- package/dist/pi/inner-loop-publisher.js +236 -0
- package/dist/pi/lazy-proxy.d.ts +37 -0
- package/dist/pi/lazy-proxy.js +55 -0
- package/dist/pi/mission-control/actions.d.ts +48 -0
- package/dist/pi/mission-control/actions.js +98 -0
- package/dist/pi/mission-control/board.d.ts +53 -0
- package/dist/pi/mission-control/board.js +104 -0
- package/dist/pi/mission-control/extension.d.ts +44 -0
- package/dist/pi/mission-control/extension.js +251 -0
- package/dist/pi/mission-control/index.d.ts +15 -0
- package/dist/pi/mission-control/index.js +32 -0
- package/dist/pi/mission-control/inner-tail.d.ts +48 -0
- package/dist/pi/mission-control/inner-tail.js +76 -0
- package/dist/pi/mission-control/pi-ui.d.ts +43 -0
- package/dist/pi/mission-control/pi-ui.js +10 -0
- package/dist/pi/mission-control/render.d.ts +6 -0
- package/dist/pi/mission-control/render.js +95 -0
- package/dist/pi/phase-driver.d.ts +74 -0
- package/dist/pi/phase-driver.js +122 -0
- package/dist/pi/pi-types.d.ts +208 -0
- package/dist/pi/pi-types.js +21 -0
- package/dist/pi/probe.d.ts +80 -0
- package/dist/pi/probe.js +154 -0
- package/dist/pi/render-tools.d.ts +17 -0
- package/dist/pi/render-tools.js +51 -0
- package/dist/pi/reset-pump.d.ts +47 -0
- package/dist/pi/reset-pump.js +85 -0
- package/dist/pi/tool-capability.d.ts +60 -0
- package/dist/pi/tool-capability.js +156 -0
- package/dist/pi/workflow-client.d.ts +158 -0
- package/dist/pi/workflow-client.js +289 -0
- package/dist/pi/zod-to-typebox.d.ts +74 -0
- package/dist/pi/zod-to-typebox.js +191 -0
- package/dist/scripts/verify-daemon-isolation-guard.js +24 -24
- package/dist/server-tools.d.ts +2 -0
- package/dist/server-tools.js +50 -46
- package/dist/server.js +4 -0
- package/dist/spawn.d.ts +55 -0
- package/dist/spawn.js +84 -12
- package/dist/tools/agent-types.d.ts +2 -2
- package/dist/tools/agent-types.js +22 -17
- package/dist/tools/attachment-info.d.ts +2 -2
- package/dist/tools/attachment-info.js +38 -33
- package/dist/tools/broadcast.d.ts +2 -2
- package/dist/tools/broadcast.js +69 -64
- package/dist/tools/cancel-stage.d.ts +2 -2
- package/dist/tools/cancel-stage.js +20 -15
- package/dist/tools/clear-state.d.ts +2 -2
- package/dist/tools/clear-state.js +25 -20
- package/dist/tools/coat-check-evict.d.ts +2 -2
- package/dist/tools/coat-check-evict.js +30 -25
- package/dist/tools/coat-check-get.d.ts +2 -2
- package/dist/tools/coat-check-get.js +39 -34
- package/dist/tools/coat-check-list.d.ts +2 -2
- package/dist/tools/coat-check-list.js +48 -43
- package/dist/tools/coat-check-put.d.ts +2 -2
- package/dist/tools/coat-check-put.js +41 -36
- package/dist/tools/cue.d.ts +2 -2
- package/dist/tools/cue.js +57 -52
- package/dist/tools/descriptor.d.ts +72 -0
- package/dist/tools/descriptor.js +39 -0
- package/dist/tools/destroy.d.ts +2 -2
- package/dist/tools/destroy.js +153 -148
- package/dist/tools/ensemble.d.ts +2 -2
- package/dist/tools/ensemble.js +71 -66
- package/dist/tools/evaluate-gate.d.ts +2 -2
- package/dist/tools/evaluate-gate.js +33 -27
- package/dist/tools/fetch-state.d.ts +2 -2
- package/dist/tools/fetch-state.js +43 -38
- package/dist/tools/gates.d.ts +2 -2
- package/dist/tools/gates.js +39 -34
- package/dist/tools/hosts.d.ts +2 -2
- package/dist/tools/hosts.js +25 -20
- package/dist/tools/listen.d.ts +2 -2
- package/dist/tools/listen.js +23 -18
- package/dist/tools/load-lineup.d.ts +2 -2
- package/dist/tools/load-lineup.js +324 -319
- package/dist/tools/migrate.d.ts +2 -2
- package/dist/tools/migrate.js +45 -40
- package/dist/tools/pause.d.ts +2 -2
- package/dist/tools/pause.js +34 -29
- package/dist/tools/play.d.ts +2 -2
- package/dist/tools/play.js +53 -48
- package/dist/tools/quality-gate.d.ts +2 -2
- package/dist/tools/quality-gate.js +26 -21
- package/dist/tools/recall.d.ts +2 -2
- package/dist/tools/recall.js +32 -27
- package/dist/tools/recruit.d.ts +2 -2
- package/dist/tools/recruit.js +325 -256
- package/dist/tools/release.d.ts +2 -2
- package/dist/tools/release.js +85 -80
- package/dist/tools/report.d.ts +2 -2
- package/dist/tools/report.js +28 -23
- package/dist/tools/reset.d.ts +3 -0
- package/dist/tools/reset.js +51 -0
- package/dist/tools/restart.d.ts +2 -2
- package/dist/tools/restart.js +51 -46
- package/dist/tools/restore.d.ts +2 -2
- package/dist/tools/restore.js +76 -71
- package/dist/tools/save-lineup.d.ts +2 -2
- package/dist/tools/save-lineup.js +32 -27
- package/dist/tools/save-state.d.ts +2 -2
- package/dist/tools/save-state.js +43 -38
- package/dist/tools/schedule.d.ts +2 -2
- package/dist/tools/schedule.js +133 -128
- package/dist/tools/schedules.d.ts +2 -2
- package/dist/tools/schedules.js +41 -36
- package/dist/tools/set-ensemble-description.d.ts +2 -2
- package/dist/tools/set-ensemble-description.js +26 -21
- package/dist/tools/set-name.d.ts +2 -2
- package/dist/tools/set-name.js +38 -33
- package/dist/tools/set-part.d.ts +2 -2
- package/dist/tools/set-part.js +20 -15
- package/dist/tools/shutdown.d.ts +2 -2
- package/dist/tools/shutdown.js +39 -34
- package/dist/tools/stage.d.ts +2 -2
- package/dist/tools/stage.js +28 -23
- package/dist/tools/stages.d.ts +2 -2
- package/dist/tools/stages.js +36 -31
- package/dist/tools/unschedule.d.ts +2 -2
- package/dist/tools/unschedule.js +30 -25
- package/dist/tools/who-am-i.d.ts +2 -2
- package/dist/tools/who-am-i.js +36 -31
- package/dist/tools/worktree.d.ts +2 -2
- package/dist/tools/worktree.js +134 -129
- package/dist/tui/index.js +6 -6
- package/dist/types.d.ts +47 -2
- package/dist/types.js +1 -1
- package/dist/utils/default-part.js +1 -0
- package/dist/utils/grpc-shutdown-guard.d.ts +52 -0
- package/dist/utils/grpc-shutdown-guard.js +88 -0
- package/dist/utils/sdk-probe.d.ts +23 -0
- package/dist/utils/sdk-probe.js +46 -7
- package/dist/worker.d.ts +3 -1
- package/dist/worker.js +6 -2
- package/dist/workflows/session.js +70 -2
- package/dist/workflows/signals.d.ts +32 -2
- package/dist/workflows/signals.js +25 -2
- package/examples/agents/tempo-composer.md +56 -56
- package/examples/agents/tempo-conductor.md +117 -117
- package/examples/agents/tempo-critic.md +73 -73
- package/examples/agents/tempo-improv.md +74 -74
- package/examples/agents/tempo-liner.md +75 -75
- package/examples/agents/tempo-roadie.md +61 -61
- package/examples/agents/tempo-soloist.md +71 -71
- package/examples/agents/tempo-tuner.md +94 -94
- package/examples/ensembles/tempo-big-band.yaml +146 -146
- package/examples/ensembles/tempo-dev-team.yaml +58 -58
- package/examples/ensembles/tempo-headless-jam.yaml +77 -77
- package/examples/ensembles/tempo-jam-session.yaml +41 -41
- package/examples/ensembles/tempo-mock-jam.yaml +79 -79
- package/examples/ensembles/tempo-review-squad.yaml +32 -32
- package/package.json +176 -173
- package/packaging/launchd/com.agent.tempo.plist +46 -46
- package/packaging/systemd/agent-tempo.service +32 -32
- package/packaging/windows/install-task.ps1 +71 -71
- package/scenarios/conductor-recruit-mock.yaml +33 -33
- package/scenarios/echo-roundtrip.yaml +15 -15
- package/scenarios/multi-player-handoff.yaml +38 -38
- package/scenarios/recruit-cascade.yaml +38 -38
- package/scenarios/two-player-conversation.yaml +33 -33
- package/workflow-bundle.js +97 -6
- package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
- package/dist/activities/claude-stop.d.ts +0 -21
- package/dist/activities/claude-stop.js +0 -94
- package/dist/channel.d.ts +0 -3
- package/dist/channel.js +0 -48
- package/dist/copilot-bridge.d.ts +0 -22
- package/dist/copilot-bridge.js +0 -565
- package/dist/scripts/258-spotcheck.js +0 -303
- package/dist/tools/detach.d.ts +0 -4
- package/dist/tools/detach.js +0 -45
- package/dist/tools/encore.d.ts +0 -4
- package/dist/tools/encore.js +0 -31
- package/dist/tools/helpers.d.ts +0 -21
- package/dist/tools/helpers.js +0 -25
- package/dist/tools/pause-ensemble.d.ts +0 -4
- package/dist/tools/pause-ensemble.js +0 -58
- package/dist/tools/resume-ensemble.d.ts +0 -4
- package/dist/tools/resume-ensemble.js +0 -79
- package/dist/tools/stop.d.ts +0 -4
- package/dist/tools/stop.js +0 -29
- package/dist/tui/client.d.ts +0 -6
- package/dist/tui/client.js +0 -9
- package/dist/tui/components/ActivityLog.d.ts +0 -16
- package/dist/tui/components/ActivityLog.js +0 -36
- package/dist/tui/components/CommandOverlay.d.ts +0 -15
- package/dist/tui/components/CommandOverlay.js +0 -34
- package/dist/tui/components/ConductorChat.d.ts +0 -16
- package/dist/tui/components/ConductorChat.js +0 -32
- package/dist/tui/components/EnsembleListView.d.ts +0 -14
- package/dist/tui/components/EnsembleListView.js +0 -32
- package/dist/tui/components/EnsemblePanel.d.ts +0 -12
- package/dist/tui/components/EnsemblePanel.js +0 -40
- package/dist/tui/components/InputBar.d.ts +0 -13
- package/dist/tui/components/InputBar.js +0 -58
- package/dist/tui/components/ScheduleOverlay.d.ts +0 -13
- package/dist/tui/components/ScheduleOverlay.js +0 -113
- package/dist/tui/components/TopBar.d.ts +0 -12
- package/dist/tui/components/TopBar.js +0 -15
- package/dist/tui/core-api.d.ts +0 -26
- package/dist/tui/core-api.js +0 -67
- package/dist/tui/hooks/useEnsembleDiscovery.d.ts +0 -3
- package/dist/tui/hooks/useEnsembleDiscovery.js +0 -30
- package/dist/tui/hooks/useMaestroPoller.d.ts +0 -3
- package/dist/tui/hooks/useMaestroPoller.js +0 -36
- package/dist/tui/hooks/useSendCommand.d.ts +0 -7
- package/dist/tui/hooks/useSendCommand.js +0 -29
- package/dist/utils/bg-preflight.d.ts +0 -25
- package/dist/utils/bg-preflight.js +0 -154
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleGateArm = handleGateArm;
|
|
4
|
+
exports.handleGateDisarm = handleGateDisarm;
|
|
5
|
+
exports.handleGateDecide = handleGateDecide;
|
|
6
|
+
exports.handleGateResolution = handleGateResolution;
|
|
7
|
+
const config_1 = require("../config");
|
|
8
|
+
const responses_1 = require("./responses");
|
|
9
|
+
const body_1 = require("./body");
|
|
10
|
+
const inner_loop_routes_1 = require("./inner-loop-routes");
|
|
11
|
+
/** Decision body cap — the payload is a tiny `{decision}`; this is the DOS backstop. */
|
|
12
|
+
const GATE_BODY_MAX = 4 * 1024;
|
|
13
|
+
function headerValue(v) {
|
|
14
|
+
if (v === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
return Array.isArray(v) ? v[0] : v;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* SOURCE-plane INGRESS gate (loopback + ingest-token vs URL workflowId), shared
|
|
20
|
+
* with the inner-loop ingest contract. Returns the workflowId on success, or
|
|
21
|
+
* `null` after writing a uniform 403 (no info leak — callers just `return`).
|
|
22
|
+
*/
|
|
23
|
+
function gateIngress(req, res, deps, ensemble, playerId) {
|
|
24
|
+
const deny = () => { (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' }); return null; };
|
|
25
|
+
if (!(0, inner_loop_routes_1.isLoopbackRemote)(req))
|
|
26
|
+
return deny();
|
|
27
|
+
const token = headerValue(req.headers[inner_loop_routes_1.INGEST_TOKEN_HEADER]);
|
|
28
|
+
if (!token)
|
|
29
|
+
return deny();
|
|
30
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
31
|
+
if (!deps.ingestTokens.validate(workflowId, token))
|
|
32
|
+
return deny();
|
|
33
|
+
return workflowId;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort short audit hint from the operator's bearer (last 6 chars) — never
|
|
37
|
+
* the full token. Absent on a loopback-trust request with no Authorization.
|
|
38
|
+
*/
|
|
39
|
+
function operatorTokenHint(req) {
|
|
40
|
+
const auth = headerValue(req.headers.authorization);
|
|
41
|
+
if (!auth)
|
|
42
|
+
return undefined;
|
|
43
|
+
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
|
|
44
|
+
const tok = m?.[1];
|
|
45
|
+
return tok && tok.length >= 6 ? `…${tok.slice(-6)}` : undefined;
|
|
46
|
+
}
|
|
47
|
+
// ── OPERATOR plane (server.ts applies requireTier(3) before dispatch) ──────────
|
|
48
|
+
/** POST /gate-arm — arm the operator gate for a player. 204. */
|
|
49
|
+
function handleGateArm(req, res, deps, ensemble, playerId) {
|
|
50
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
51
|
+
deps.gate.arm(workflowId, ensemble, operatorTokenHint(req));
|
|
52
|
+
res.writeHead(204);
|
|
53
|
+
res.end();
|
|
54
|
+
}
|
|
55
|
+
/** POST /gate-disarm — disarm the operator gate for a player. 204. */
|
|
56
|
+
function handleGateDisarm(req, res, deps, ensemble, playerId) {
|
|
57
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
58
|
+
deps.gate.disarm(workflowId, operatorTokenHint(req));
|
|
59
|
+
res.writeHead(204);
|
|
60
|
+
res.end();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* POST /gate/:requestId — operator decision on a pending gated tool call.
|
|
64
|
+
* Body `{ decision: 'allow' | 'deny' }`. 204 on success; 404 unknown requestId;
|
|
65
|
+
* 409 already-decided (idempotency — a double-POST or post-timeout race can't
|
|
66
|
+
* flip a recorded answer); 400 malformed body / bad decision value.
|
|
67
|
+
*/
|
|
68
|
+
async function handleGateDecide(req, res, deps, ensemble, playerId, requestId) {
|
|
69
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
70
|
+
const body = await (0, body_1.readJsonBody)(req, GATE_BODY_MAX);
|
|
71
|
+
if (body === body_1.BODY_TOO_LARGE || body === body_1.BODY_INVALID_JSON) {
|
|
72
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'bad-request' });
|
|
73
|
+
}
|
|
74
|
+
const decision = body.decision;
|
|
75
|
+
if (decision !== 'allow' && decision !== 'deny') {
|
|
76
|
+
return (0, responses_1.errorResponse)(res, 400, { error: 'bad-request', detail: "decision must be 'allow' or 'deny'" });
|
|
77
|
+
}
|
|
78
|
+
const result = deps.gate.decide(workflowId, requestId, decision, operatorTokenHint(req));
|
|
79
|
+
if (result.ok) {
|
|
80
|
+
res.writeHead(204);
|
|
81
|
+
res.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (result.reason === 'not-found')
|
|
85
|
+
return (0, responses_1.errorResponse)(res, 404, { error: 'not-found' });
|
|
86
|
+
return (0, responses_1.errorResponse)(res, 409, { error: 'already-decided' });
|
|
87
|
+
}
|
|
88
|
+
// ── SOURCE plane (ingest-token; Pi subprocess polls) ───────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* GET /gate/:requestId/resolution — the Pi subprocess polls for the decision.
|
|
91
|
+
* 200 `{ status:'pending' }` | `{ status:'resolved', decision, source }`; 404 for
|
|
92
|
+
* an unknown requestId; uniform 403 on any INGRESS gate failure (no leak).
|
|
93
|
+
*/
|
|
94
|
+
function handleGateResolution(req, res, deps, ensemble, playerId, requestId) {
|
|
95
|
+
const workflowId = gateIngress(req, res, deps, ensemble, playerId);
|
|
96
|
+
if (workflowId === null)
|
|
97
|
+
return;
|
|
98
|
+
const resolution = deps.gate.getResolution(workflowId, requestId);
|
|
99
|
+
if (resolution === null)
|
|
100
|
+
return (0, responses_1.errorResponse)(res, 404, { error: 'not-found' });
|
|
101
|
+
return (0, responses_1.jsonResponse)(res, 200, resolution);
|
|
102
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory map of `workflowId → ingest token`, owned by the daemon (one
|
|
3
|
+
* instance shared between the outbox minter and the HTTP ingest/presence
|
|
4
|
+
* validators). Tokens never persist — they live only for the player's lifetime.
|
|
5
|
+
*/
|
|
6
|
+
export declare class IngestTokenRegistry {
|
|
7
|
+
private readonly tokens;
|
|
8
|
+
/**
|
|
9
|
+
* Mint a fresh ingest token for a player, replacing any prior one (a restart
|
|
10
|
+
* re-mints). Returns the token to inject into the subprocess env.
|
|
11
|
+
*/
|
|
12
|
+
mint(workflowId: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Validate a presented token against the token minted for `workflowId`
|
|
15
|
+
* (timing-safe, via {@link tokensMatch}). Returns `false` for an unknown
|
|
16
|
+
* player or a mismatch — so a compromised player presenting its OWN token for
|
|
17
|
+
* another player's URL is rejected (cross-player-spoof guard). The workflowId
|
|
18
|
+
* is public (it's in the URL); the token is the secret, and only its
|
|
19
|
+
* comparison is constant-time.
|
|
20
|
+
*/
|
|
21
|
+
validate(workflowId: string, presented: string): boolean;
|
|
22
|
+
/** Revoke a player's ingest token (detach / destroy). Idempotent. */
|
|
23
|
+
revoke(workflowId: string): void;
|
|
24
|
+
/** Revoke every token (daemon shutdown / clear-all). */
|
|
25
|
+
revokeAll(): void;
|
|
26
|
+
/** Whether a player currently holds a minted token. */
|
|
27
|
+
has(workflowId: string): boolean;
|
|
28
|
+
/** Active token count (diagnostics / tests). */
|
|
29
|
+
size(): number;
|
|
30
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IngestTokenRegistry = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* Ingest-token registry (3c Tier-2 ingest-auth) — per security's design.
|
|
39
|
+
*
|
|
40
|
+
* The `/inner/ingest` + `/inner/presence` endpoints are the SOURCE-side
|
|
41
|
+
* (publisher → daemon) half of the off-wire fine-tail channel. Loopback-only is
|
|
42
|
+
* necessary but NOT sufficient: any local process could POST fake frames or
|
|
43
|
+
* inject into ANOTHER player's tail. So each headless Pi player is minted a
|
|
44
|
+
* per-player ingest token at spawn, scoped to its session `workflowId`; the
|
|
45
|
+
* ingest/presence handlers validate the presented token against the token minted
|
|
46
|
+
* for the URL-derived workflowId, binding every frame to its owning player.
|
|
47
|
+
*
|
|
48
|
+
* Lifecycle (driven from the daemon-only outbox.ts — NOT spawn.ts, which runs
|
|
49
|
+
* outside the daemon and must not import this):
|
|
50
|
+
* - MINT before `spawnPiHeadless` (token injected into the subprocess env).
|
|
51
|
+
* - REVOKE on detach + destroy.
|
|
52
|
+
* - REVOKE-ALL on daemon shutdown.
|
|
53
|
+
*
|
|
54
|
+
* Phase-4+ deferrals (carry-items, NOT 3c): token rotation, per-token
|
|
55
|
+
* rate-limiting, durable ingest audit.
|
|
56
|
+
*/
|
|
57
|
+
const crypto = __importStar(require("crypto"));
|
|
58
|
+
const auth_1 = require("./auth");
|
|
59
|
+
/** Bytes of entropy per ingest token (base64url → 43 chars, same as the HTTP bearer). */
|
|
60
|
+
const INGEST_TOKEN_BYTES = 32;
|
|
61
|
+
/**
|
|
62
|
+
* In-memory map of `workflowId → ingest token`, owned by the daemon (one
|
|
63
|
+
* instance shared between the outbox minter and the HTTP ingest/presence
|
|
64
|
+
* validators). Tokens never persist — they live only for the player's lifetime.
|
|
65
|
+
*/
|
|
66
|
+
class IngestTokenRegistry {
|
|
67
|
+
tokens = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* Mint a fresh ingest token for a player, replacing any prior one (a restart
|
|
70
|
+
* re-mints). Returns the token to inject into the subprocess env.
|
|
71
|
+
*/
|
|
72
|
+
mint(workflowId) {
|
|
73
|
+
const token = crypto.randomBytes(INGEST_TOKEN_BYTES).toString('base64url');
|
|
74
|
+
this.tokens.set(workflowId, token);
|
|
75
|
+
return token;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Validate a presented token against the token minted for `workflowId`
|
|
79
|
+
* (timing-safe, via {@link tokensMatch}). Returns `false` for an unknown
|
|
80
|
+
* player or a mismatch — so a compromised player presenting its OWN token for
|
|
81
|
+
* another player's URL is rejected (cross-player-spoof guard). The workflowId
|
|
82
|
+
* is public (it's in the URL); the token is the secret, and only its
|
|
83
|
+
* comparison is constant-time.
|
|
84
|
+
*/
|
|
85
|
+
validate(workflowId, presented) {
|
|
86
|
+
const expected = this.tokens.get(workflowId);
|
|
87
|
+
if (expected === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
return (0, auth_1.tokensMatch)(presented, expected);
|
|
90
|
+
}
|
|
91
|
+
/** Revoke a player's ingest token (detach / destroy). Idempotent. */
|
|
92
|
+
revoke(workflowId) {
|
|
93
|
+
this.tokens.delete(workflowId);
|
|
94
|
+
}
|
|
95
|
+
/** Revoke every token (daemon shutdown / clear-all). */
|
|
96
|
+
revokeAll() {
|
|
97
|
+
this.tokens.clear();
|
|
98
|
+
}
|
|
99
|
+
/** Whether a player currently holds a minted token. */
|
|
100
|
+
has(workflowId) {
|
|
101
|
+
return this.tokens.has(workflowId);
|
|
102
|
+
}
|
|
103
|
+
/** Active token count (diagnostics / tests). */
|
|
104
|
+
size() {
|
|
105
|
+
return this.tokens.size;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.IngestTokenRegistry = IngestTokenRegistry;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP route handlers for the 3c Tier-2 inner-loop side-channel (MD-F).
|
|
3
|
+
* server.ts dispatches to these; the logic lives here so it stays testable.
|
|
4
|
+
*
|
|
5
|
+
* THREE routes, TWO auth planes (security's ingest-auth design):
|
|
6
|
+
*
|
|
7
|
+
* INGRESS (source → daemon; publisher-only). Mounted in server.ts BEFORE the
|
|
8
|
+
* outer bearer gate so it works regardless of the daemon's bind address —
|
|
9
|
+
* authenticated by its OWN gates, not the operator bearer:
|
|
10
|
+
* - POST /v1/players/:ensemble/:playerId/inner/ingest → publish a frame
|
|
11
|
+
* - GET /v1/players/:ensemble/:playerId/inner/presence → { subscribers }
|
|
12
|
+
* Both gate on: (1) loopback `req.socket.remoteAddress`; (2) `X-Ingest-Token`
|
|
13
|
+
* validated against the URL-derived workflowId (cross-player-spoof guard).
|
|
14
|
+
* Every failure → 403 with no detail (no info leak).
|
|
15
|
+
*
|
|
16
|
+
* EGRESS (daemon → operator/widget). Mounted AFTER the outer bearer gate with
|
|
17
|
+
* an explicit `requireTier(3)`:
|
|
18
|
+
* - GET /v1/players/:ensemble/:playerId/inner → SSE fine-tail stream
|
|
19
|
+
*
|
|
20
|
+
* MD-F invariants preserved: off-Temporal, off the coordination bus,
|
|
21
|
+
* daemon-LOCAL (loopback ingest = same host), ephemeral (no ring/replay).
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
24
|
+
import type { InnerLoopRegistry } from './inner-loop';
|
|
25
|
+
import type { IngestTokenRegistry } from './ingest-registry';
|
|
26
|
+
import type { GateRegistry } from './gate-registry';
|
|
27
|
+
/** Header carrying the per-player ingest token (the source-plane credential). */
|
|
28
|
+
export declare const INGEST_TOKEN_HEADER = "x-ingest-token";
|
|
29
|
+
export interface InnerLoopDeps {
|
|
30
|
+
innerLoop: InnerLoopRegistry;
|
|
31
|
+
ingestTokens: IngestTokenRegistry;
|
|
32
|
+
/**
|
|
33
|
+
* 3d MD-G — the operator-gate registry, when wired. Two narrow couplings:
|
|
34
|
+
* - ingest: an `inner.gate_pending` frame ALSO registers the pending request
|
|
35
|
+
* in the gate (`open()`) — atomic register-and-surface from one POST (the
|
|
36
|
+
* "engagement IS registration" path; no separate open-route).
|
|
37
|
+
* - presence: the response carries `gateArmed` so the polling subprocess
|
|
38
|
+
* evaluates engagement (armed + present) from one fetch.
|
|
39
|
+
* The coupling is one-directional (inner-routes → GateRegistry); the gate's
|
|
40
|
+
* `gate_resolved` emission flows back via its injected publishToInner.
|
|
41
|
+
*/
|
|
42
|
+
gate?: GateRegistry;
|
|
43
|
+
}
|
|
44
|
+
/** True when the request originates from the same host (loopback). */
|
|
45
|
+
export declare function isLoopbackRemote(req: IncomingMessage): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* POST /v1/players/:e/:p/inner/ingest — the publisher forwards ONE InnerFrame.
|
|
48
|
+
* 204 on success; uniform 403 on any gate/shape/oversize failure (no-leak). The
|
|
49
|
+
* daemon TRUSTS the authenticated publisher's summaries (already ~2KB-truncated
|
|
50
|
+
* at source) — the 32KB cap is purely the DOS backstop; no re-truncation.
|
|
51
|
+
*/
|
|
52
|
+
export declare function handleInnerIngest(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* GET /v1/players/:e/:p/inner/presence — publisher-only presence probe.
|
|
55
|
+
* Same gates as ingest (presence is publisher-only; leaking "is someone
|
|
56
|
+
* watching X" would be a covert channel). 200 `{ subscribers }` or 403.
|
|
57
|
+
*/
|
|
58
|
+
export declare function handleInnerPresence(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* GET /v1/players/:e/:p/inner — operator/widget SSE fine-tail stream (EGRESS).
|
|
61
|
+
* server.ts has already applied the outer bearer + `requireTier(3)` gate. Plain
|
|
62
|
+
* `event:`/`data:` framing (fetch-consumable, no EventSource-specific framing);
|
|
63
|
+
* `:ka` keepalive; `:closed` when the player goes away. No ring/seq/replay —
|
|
64
|
+
* ephemeral best-effort tail (a disconnect loses in-flight deltas, by design).
|
|
65
|
+
*/
|
|
66
|
+
export declare function handleInnerSse(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): Promise<void>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INGEST_TOKEN_HEADER = void 0;
|
|
4
|
+
exports.isLoopbackRemote = isLoopbackRemote;
|
|
5
|
+
exports.handleInnerIngest = handleInnerIngest;
|
|
6
|
+
exports.handleInnerPresence = handleInnerPresence;
|
|
7
|
+
exports.handleInnerSse = handleInnerSse;
|
|
8
|
+
const config_1 = require("../config");
|
|
9
|
+
const responses_1 = require("./responses");
|
|
10
|
+
const body_1 = require("./body");
|
|
11
|
+
/** Loopback remote addresses Node may report for a same-host connection. */
|
|
12
|
+
const LOOPBACK_REMOTES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
13
|
+
/** Header carrying the per-player ingest token (the source-plane credential). */
|
|
14
|
+
exports.INGEST_TOKEN_HEADER = 'x-ingest-token';
|
|
15
|
+
/** Max bytes buffered to a slow `/inner` SSE socket before we drop the connection. */
|
|
16
|
+
const MAX_SSE_WRITE_BUFFER = 1024 * 1024;
|
|
17
|
+
/** Keepalive comment cadence on an idle `/inner` stream. */
|
|
18
|
+
const INNER_KEEPALIVE_MS = 15_000;
|
|
19
|
+
/** True when the request originates from the same host (loopback). */
|
|
20
|
+
function isLoopbackRemote(req) {
|
|
21
|
+
const addr = req.socket?.remoteAddress;
|
|
22
|
+
return addr != null && LOOPBACK_REMOTES.has(addr);
|
|
23
|
+
}
|
|
24
|
+
function headerValue(v) {
|
|
25
|
+
if (v === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
return Array.isArray(v) ? v[0] : v;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run the shared INGRESS gate (loopback + ingest-token vs URL workflowId).
|
|
31
|
+
* Returns the resolved workflowId on success, or `null` after having written a
|
|
32
|
+
* uniform `403` (no info leak — callers just `return` on null).
|
|
33
|
+
*/
|
|
34
|
+
function gateIngress(req, res, deps, ensemble, playerId) {
|
|
35
|
+
// Uniform 403 on EVERY failure — never reveal which gate tripped.
|
|
36
|
+
const deny = () => {
|
|
37
|
+
(0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
if (!isLoopbackRemote(req))
|
|
41
|
+
return deny();
|
|
42
|
+
const token = headerValue(req.headers[exports.INGEST_TOKEN_HEADER]);
|
|
43
|
+
if (!token)
|
|
44
|
+
return deny();
|
|
45
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
46
|
+
if (!deps.ingestTokens.validate(workflowId, token))
|
|
47
|
+
return deny();
|
|
48
|
+
return workflowId;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* POST /v1/players/:e/:p/inner/ingest — the publisher forwards ONE InnerFrame.
|
|
52
|
+
* 204 on success; uniform 403 on any gate/shape/oversize failure (no-leak). The
|
|
53
|
+
* daemon TRUSTS the authenticated publisher's summaries (already ~2KB-truncated
|
|
54
|
+
* at source) — the 32KB cap is purely the DOS backstop; no re-truncation.
|
|
55
|
+
*/
|
|
56
|
+
async function handleInnerIngest(req, res, deps, ensemble, playerId) {
|
|
57
|
+
const workflowId = gateIngress(req, res, deps, ensemble, playerId);
|
|
58
|
+
if (workflowId === null)
|
|
59
|
+
return;
|
|
60
|
+
const body = await (0, body_1.readJsonBody)(req, body_1.INGEST_BODY_MAX);
|
|
61
|
+
// Oversize / malformed / non-frame → uniform 403 (no-leak; the publisher just
|
|
62
|
+
// drops the frame on any non-204).
|
|
63
|
+
if (body === body_1.BODY_TOO_LARGE || body === body_1.BODY_INVALID_JSON) {
|
|
64
|
+
return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
|
|
65
|
+
}
|
|
66
|
+
const type = body.type;
|
|
67
|
+
if (typeof type !== 'string' || !type.startsWith('inner.')) {
|
|
68
|
+
return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
|
|
69
|
+
}
|
|
70
|
+
// S1 (security): `type` is interpolated RAW into the operator SSE `event:`
|
|
71
|
+
// line (handleInnerSse) — a CR/LF here would let an authenticated source
|
|
72
|
+
// inject/garble frames in the operator's stream. Reject at the INGRESS
|
|
73
|
+
// boundary so a malformed type never enters the registry. (Every other frame
|
|
74
|
+
// field is JSON.stringify'd into `data:`, which escapes control chars.)
|
|
75
|
+
if (/[\r\n]/.test(type)) {
|
|
76
|
+
return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
|
|
77
|
+
}
|
|
78
|
+
// Trust the authenticated publisher's frame shape (it owns the schema +
|
|
79
|
+
// truncation). Publish to local subscribers and ack with no body.
|
|
80
|
+
deps.innerLoop.publish(workflowId, body);
|
|
81
|
+
// 3d MD-G — a gate_pending frame ALSO registers the pending request in the
|
|
82
|
+
// gate (atomic register-and-surface from one POST; the "engagement IS
|
|
83
|
+
// registration" path, no separate open-route). Narrow, one-directional
|
|
84
|
+
// coupling: inner-routes → GateRegistry.open(). Guarded on the type so it
|
|
85
|
+
// never fires for ordinary inner frames. `open` is idempotent on requestId.
|
|
86
|
+
if (type === 'inner.gate_pending' && deps.gate) {
|
|
87
|
+
const f = body;
|
|
88
|
+
if (typeof f.requestId === 'string' && typeof f.tool === 'string') {
|
|
89
|
+
deps.gate.open(workflowId, f.requestId, {
|
|
90
|
+
tool: f.tool,
|
|
91
|
+
argsSummary: typeof f.argsSummary === 'string' ? f.argsSummary : '',
|
|
92
|
+
ensemble,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
res.writeHead(204);
|
|
97
|
+
res.end();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* GET /v1/players/:e/:p/inner/presence — publisher-only presence probe.
|
|
101
|
+
* Same gates as ingest (presence is publisher-only; leaking "is someone
|
|
102
|
+
* watching X" would be a covert channel). 200 `{ subscribers }` or 403.
|
|
103
|
+
*/
|
|
104
|
+
function handleInnerPresence(req, res, deps, ensemble, playerId) {
|
|
105
|
+
const workflowId = gateIngress(req, res, deps, ensemble, playerId);
|
|
106
|
+
if (workflowId === null)
|
|
107
|
+
return;
|
|
108
|
+
// 3d MD-G — fold `gateArmed` into the presence response so the polling
|
|
109
|
+
// subprocess reads BOTH engagement inputs (operator-present + armed) from one
|
|
110
|
+
// fetch (avoids a stale-armed / fresh-present mismatch). `false` when the gate
|
|
111
|
+
// is unwired or unarmed.
|
|
112
|
+
(0, responses_1.jsonResponse)(res, 200, {
|
|
113
|
+
subscribers: deps.innerLoop.subscriberCount(workflowId),
|
|
114
|
+
gateArmed: deps.gate?.isArmed(workflowId) ?? false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* GET /v1/players/:e/:p/inner — operator/widget SSE fine-tail stream (EGRESS).
|
|
119
|
+
* server.ts has already applied the outer bearer + `requireTier(3)` gate. Plain
|
|
120
|
+
* `event:`/`data:` framing (fetch-consumable, no EventSource-specific framing);
|
|
121
|
+
* `:ka` keepalive; `:closed` when the player goes away. No ring/seq/replay —
|
|
122
|
+
* ephemeral best-effort tail (a disconnect loses in-flight deltas, by design).
|
|
123
|
+
*/
|
|
124
|
+
async function handleInnerSse(req, res, deps, ensemble, playerId) {
|
|
125
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
|
|
126
|
+
res.writeHead(200, {
|
|
127
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
128
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
129
|
+
Connection: 'keep-alive',
|
|
130
|
+
'X-Accel-Buffering': 'no',
|
|
131
|
+
});
|
|
132
|
+
// Flush headers immediately so the operator's stream OPENS now, not on the
|
|
133
|
+
// first frame/keepalive — otherwise Node buffers the head until the first
|
|
134
|
+
// body write and a fetch/EventSource client blocks up to INNER_KEEPALIVE_MS.
|
|
135
|
+
// (Mirrors the main SSE handler in sse-handler.ts.)
|
|
136
|
+
res.flushHeaders?.();
|
|
137
|
+
const sub = deps.innerLoop.subscribe(workflowId);
|
|
138
|
+
let cleanedUp = false;
|
|
139
|
+
const keepalive = setInterval(() => {
|
|
140
|
+
try {
|
|
141
|
+
res.write(':ka\n\n');
|
|
142
|
+
}
|
|
143
|
+
catch { /* socket gone — close handler cleans up */ }
|
|
144
|
+
}, INNER_KEEPALIVE_MS);
|
|
145
|
+
if (typeof keepalive.unref === 'function')
|
|
146
|
+
keepalive.unref();
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
if (cleanedUp)
|
|
149
|
+
return;
|
|
150
|
+
cleanedUp = true;
|
|
151
|
+
clearInterval(keepalive);
|
|
152
|
+
deps.innerLoop.unsubscribe(workflowId, sub);
|
|
153
|
+
};
|
|
154
|
+
req.on('close', cleanup);
|
|
155
|
+
res.on('close', cleanup);
|
|
156
|
+
try {
|
|
157
|
+
for await (const frame of sub) {
|
|
158
|
+
res.write(`event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`);
|
|
159
|
+
// Bound the per-connection write buffer: a slow operator socket must not
|
|
160
|
+
// grow the daemon's memory unboundedly. Drop the connection if it backs up
|
|
161
|
+
// (ephemeral tail — reconnect re-tails live).
|
|
162
|
+
const buffered = res.socket?.writableLength ?? 0;
|
|
163
|
+
if (buffered > MAX_SSE_WRITE_BUFFER)
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
// Subscription ended (player gone / unsubscribe) — signal a clean close.
|
|
167
|
+
try {
|
|
168
|
+
res.write(':closed\n\n');
|
|
169
|
+
}
|
|
170
|
+
catch { /* already closed */ }
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* write-after-close or iterator error — fall through to cleanup */
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
cleanup();
|
|
177
|
+
try {
|
|
178
|
+
res.end();
|
|
179
|
+
}
|
|
180
|
+
catch { /* already ended */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inner-loop registry + subscription (3c Tier-2, MD-F) — the DAEMON-LOCAL,
|
|
3
|
+
* off-wire fine-tail sink. NOT on the coordination EnsembleEventBus, NOT on
|
|
4
|
+
* Temporal, NOT replayable (no ring / Last-Event-ID / seq). A per-player live
|
|
5
|
+
* tail of a headless Pi player's inner loop (thinking deltas, tool calls/results,
|
|
6
|
+
* token pressure, turn markers), served by the daemon RUNNING that player.
|
|
7
|
+
*
|
|
8
|
+
* Two clients meet here:
|
|
9
|
+
* - The OPERATOR side: `GET /v1/players/:e/:p/inner` opens an SSE stream →
|
|
10
|
+
* {@link InnerLoopRegistry.subscribe} → drains an {@link InnerSubscription}.
|
|
11
|
+
* - The SOURCE side: eng's `InnerLoopPublisher` (in the detached Pi subprocess)
|
|
12
|
+
* forwards frames via the thin loopback-HTTP client → `POST /inner/ingest` →
|
|
13
|
+
* {@link InnerLoopRegistry.publish}. The publisher presence-gates on
|
|
14
|
+
* {@link InnerLoopRegistry.subscriberCount} (read via `GET /inner/presence`)
|
|
15
|
+
* so zero watchers ⇒ zero forwarding.
|
|
16
|
+
*
|
|
17
|
+
* Backpressure (registry-owned, per the split with the source-side coalescing):
|
|
18
|
+
* each subscriber has a bounded queue with DROP-OLDEST. When frames are dropped,
|
|
19
|
+
* a single `compacted{dropped,sinceTs}` marker is delivered ahead of the next
|
|
20
|
+
* real frame so the operator knows the tail has gaps. (Source-side coalescing of
|
|
21
|
+
* thinking deltas + ~2KB summary truncation already bound the inbound rate; this
|
|
22
|
+
* is the last-resort guard against a stalled SSE socket.)
|
|
23
|
+
*
|
|
24
|
+
* This module implements eng's `InnerLoopRegistry` DI interface
|
|
25
|
+
* (`publish` + `subscriberCount`) as a SUPERSET that also owns subscriber
|
|
26
|
+
* lifecycle (`subscribe` / `unsubscribe` / `closePlayer`).
|
|
27
|
+
*/
|
|
28
|
+
import type { InnerFrame, InnerLoopRegistry as InnerLoopSink } from '../pi/inner-loop-publisher';
|
|
29
|
+
/** Per-subscriber bounded queue depth before drop-oldest engages. */
|
|
30
|
+
export declare const INNER_SUB_QUEUE_MAX = 256;
|
|
31
|
+
/**
|
|
32
|
+
* A frame as delivered on the `/inner` wire — eng's source {@link InnerFrame}
|
|
33
|
+
* plus the registry-injected `compacted` backpressure marker (never produced by
|
|
34
|
+
* the source; purely the sink's gap signal).
|
|
35
|
+
*/
|
|
36
|
+
export type InnerWireFrame = InnerFrame | {
|
|
37
|
+
type: 'compacted';
|
|
38
|
+
dropped: number;
|
|
39
|
+
sinceTs: number;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One connected `/inner` SSE subscriber. Async-iterable: the SSE handler does
|
|
43
|
+
* `for await (const frame of sub) { write(frame) }`. Bounded queue with
|
|
44
|
+
* drop-oldest; a `compacted{dropped,sinceTs}` marker is injected before the next
|
|
45
|
+
* real frame whenever drops have occurred since the last delivery.
|
|
46
|
+
*
|
|
47
|
+
* No ring / replay / seq — this is an ephemeral best-effort tail (MD-F): a
|
|
48
|
+
* disconnect loses in-flight deltas, by design.
|
|
49
|
+
*/
|
|
50
|
+
export declare class InnerSubscription implements AsyncIterableIterator<InnerWireFrame> {
|
|
51
|
+
private readonly now;
|
|
52
|
+
private readonly queue;
|
|
53
|
+
private dropped;
|
|
54
|
+
private droppedSinceTs;
|
|
55
|
+
private waiter;
|
|
56
|
+
private closed;
|
|
57
|
+
constructor(now?: () => number);
|
|
58
|
+
/** Enqueue a frame, dropping the oldest if the queue is full. Source → sub. */
|
|
59
|
+
push(frame: InnerFrame): void;
|
|
60
|
+
/** Pull the next deliverable, injecting a `compacted` marker first if drops are pending. */
|
|
61
|
+
private take;
|
|
62
|
+
next(): Promise<IteratorResult<InnerWireFrame>>;
|
|
63
|
+
/** Terminate the stream — wakes a parked consumer with `done: true`. Idempotent. */
|
|
64
|
+
close(): void;
|
|
65
|
+
return(): Promise<IteratorResult<InnerWireFrame>>;
|
|
66
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<InnerWireFrame>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Per-daemon registry of inner-loop subscribers, keyed by the player's fixed
|
|
70
|
+
* session `workflowId`. Implements eng's {@link InnerLoopSink} interface
|
|
71
|
+
* (`publish` + `subscriberCount`) so the publisher's thin client and this sink
|
|
72
|
+
* share one contract.
|
|
73
|
+
*/
|
|
74
|
+
export declare class InnerLoopRegistry implements InnerLoopSink {
|
|
75
|
+
private readonly now;
|
|
76
|
+
private readonly subs;
|
|
77
|
+
constructor(now?: () => number);
|
|
78
|
+
/** Open a new SSE subscription for a player. Caller drains it, then `unsubscribe`. */
|
|
79
|
+
subscribe(workflowId: string): InnerSubscription;
|
|
80
|
+
/** Remove + close one subscription (on SSE disconnect). Prunes the empty player set. */
|
|
81
|
+
unsubscribe(workflowId: string, sub: InnerSubscription): void;
|
|
82
|
+
/** Fan a frame out to every live subscriber for the player. Source → subs. */
|
|
83
|
+
publish(workflowId: string, frame: InnerFrame): void;
|
|
84
|
+
/** Live subscriber count — the publisher's presence gate reads this (via `/inner/presence`). */
|
|
85
|
+
subscriberCount(workflowId: string): number;
|
|
86
|
+
/** Close every subscriber for a player (player gone → streams end with `:closed`). */
|
|
87
|
+
closePlayer(workflowId: string): void;
|
|
88
|
+
/** Total live inner-tail subscribers across all players (diagnostics). */
|
|
89
|
+
totalSubscriberCount(): number;
|
|
90
|
+
/** Close everything (daemon shutdown). */
|
|
91
|
+
close(): void;
|
|
92
|
+
}
|