agent-tempo 1.3.1 → 1.4.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/CLAUDE.md +39 -5
- package/README.md +6 -2
- 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 +1 -1
- package/dashboard/package.json +1 -1
- 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/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/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 +32 -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 +250 -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 +88 -0
- package/dist/pi/mission-control/board.js +141 -0
- package/dist/pi/mission-control/extension.d.ts +51 -0
- package/dist/pi/mission-control/extension.js +330 -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 +98 -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 +222 -0
- package/dist/pi/pi-types.js +21 -0
- package/dist/pi/probe.d.ts +99 -0
- package/dist/pi/probe.js +179 -0
- package/dist/pi/render-tools.d.ts +17 -0
- package/dist/pi/render-tools.js +56 -0
- package/dist/pi/reset-pump.d.ts +47 -0
- package/dist/pi/reset-pump.js +85 -0
- package/dist/pi/session-seed.d.ts +74 -0
- package/dist/pi/session-seed.js +103 -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/server-tools.d.ts +2 -0
- package/dist/server-tools.js +50 -46
- package/dist/spawn.d.ts +55 -0
- package/dist/spawn.js +72 -0
- 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 +29 -24
- package/dist/tools/coat-check-get.d.ts +2 -2
- package/dist/tools/coat-check-get.js +38 -33
- 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 +38 -33
- 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 +42 -37
- 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 +340 -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 +31 -26
- 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/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/package.json +4 -1
- package/workflow-bundle.js +97 -6
- package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
- package/dist/tools/helpers.d.ts +0 -21
- package/dist/tools/helpers.js +0 -25
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InnerLoopRegistry = exports.InnerSubscription = exports.INNER_SUB_QUEUE_MAX = void 0;
|
|
4
|
+
/** Per-subscriber bounded queue depth before drop-oldest engages. */
|
|
5
|
+
exports.INNER_SUB_QUEUE_MAX = 256;
|
|
6
|
+
/**
|
|
7
|
+
* One connected `/inner` SSE subscriber. Async-iterable: the SSE handler does
|
|
8
|
+
* `for await (const frame of sub) { write(frame) }`. Bounded queue with
|
|
9
|
+
* drop-oldest; a `compacted{dropped,sinceTs}` marker is injected before the next
|
|
10
|
+
* real frame whenever drops have occurred since the last delivery.
|
|
11
|
+
*
|
|
12
|
+
* No ring / replay / seq — this is an ephemeral best-effort tail (MD-F): a
|
|
13
|
+
* disconnect loses in-flight deltas, by design.
|
|
14
|
+
*/
|
|
15
|
+
class InnerSubscription {
|
|
16
|
+
now;
|
|
17
|
+
queue = [];
|
|
18
|
+
dropped = 0;
|
|
19
|
+
droppedSinceTs = 0;
|
|
20
|
+
waiter = null;
|
|
21
|
+
closed = false;
|
|
22
|
+
constructor(now = Date.now) {
|
|
23
|
+
this.now = now;
|
|
24
|
+
}
|
|
25
|
+
/** Enqueue a frame, dropping the oldest if the queue is full. Source → sub. */
|
|
26
|
+
push(frame) {
|
|
27
|
+
if (this.closed)
|
|
28
|
+
return;
|
|
29
|
+
if (this.queue.length >= exports.INNER_SUB_QUEUE_MAX) {
|
|
30
|
+
this.queue.shift(); // drop oldest
|
|
31
|
+
if (this.dropped === 0)
|
|
32
|
+
this.droppedSinceTs = this.now();
|
|
33
|
+
this.dropped++;
|
|
34
|
+
}
|
|
35
|
+
this.queue.push(frame);
|
|
36
|
+
// A full queue means no waiter is parked (a waiter only parks on an empty
|
|
37
|
+
// queue), so a drop never races a pending take — drain just wakes a waiter
|
|
38
|
+
// when one exists (queue was empty, now has one item).
|
|
39
|
+
if (this.waiter) {
|
|
40
|
+
const next = this.take();
|
|
41
|
+
if (next) {
|
|
42
|
+
const w = this.waiter;
|
|
43
|
+
this.waiter = null;
|
|
44
|
+
w({ value: next, done: false });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Pull the next deliverable, injecting a `compacted` marker first if drops are pending. */
|
|
49
|
+
take() {
|
|
50
|
+
if (this.dropped > 0) {
|
|
51
|
+
const marker = { type: 'compacted', dropped: this.dropped, sinceTs: this.droppedSinceTs };
|
|
52
|
+
this.dropped = 0;
|
|
53
|
+
return marker;
|
|
54
|
+
}
|
|
55
|
+
return this.queue.shift() ?? null;
|
|
56
|
+
}
|
|
57
|
+
next() {
|
|
58
|
+
if (this.closed)
|
|
59
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
60
|
+
const item = this.take();
|
|
61
|
+
if (item)
|
|
62
|
+
return Promise.resolve({ value: item, done: false });
|
|
63
|
+
return new Promise((resolve) => { this.waiter = resolve; });
|
|
64
|
+
}
|
|
65
|
+
/** Terminate the stream — wakes a parked consumer with `done: true`. Idempotent. */
|
|
66
|
+
close() {
|
|
67
|
+
if (this.closed)
|
|
68
|
+
return;
|
|
69
|
+
this.closed = true;
|
|
70
|
+
if (this.waiter) {
|
|
71
|
+
const w = this.waiter;
|
|
72
|
+
this.waiter = null;
|
|
73
|
+
w({ value: undefined, done: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return() {
|
|
77
|
+
this.close();
|
|
78
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
79
|
+
}
|
|
80
|
+
[Symbol.asyncIterator]() {
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.InnerSubscription = InnerSubscription;
|
|
85
|
+
/**
|
|
86
|
+
* Per-daemon registry of inner-loop subscribers, keyed by the player's fixed
|
|
87
|
+
* session `workflowId`. Implements eng's {@link InnerLoopSink} interface
|
|
88
|
+
* (`publish` + `subscriberCount`) so the publisher's thin client and this sink
|
|
89
|
+
* share one contract.
|
|
90
|
+
*/
|
|
91
|
+
class InnerLoopRegistry {
|
|
92
|
+
now;
|
|
93
|
+
subs = new Map();
|
|
94
|
+
constructor(now = Date.now) {
|
|
95
|
+
this.now = now;
|
|
96
|
+
}
|
|
97
|
+
/** Open a new SSE subscription for a player. Caller drains it, then `unsubscribe`. */
|
|
98
|
+
subscribe(workflowId) {
|
|
99
|
+
const sub = new InnerSubscription(this.now);
|
|
100
|
+
let set = this.subs.get(workflowId);
|
|
101
|
+
if (!set) {
|
|
102
|
+
set = new Set();
|
|
103
|
+
this.subs.set(workflowId, set);
|
|
104
|
+
}
|
|
105
|
+
set.add(sub);
|
|
106
|
+
return sub;
|
|
107
|
+
}
|
|
108
|
+
/** Remove + close one subscription (on SSE disconnect). Prunes the empty player set. */
|
|
109
|
+
unsubscribe(workflowId, sub) {
|
|
110
|
+
const set = this.subs.get(workflowId);
|
|
111
|
+
if (!set)
|
|
112
|
+
return;
|
|
113
|
+
set.delete(sub);
|
|
114
|
+
sub.close();
|
|
115
|
+
if (set.size === 0)
|
|
116
|
+
this.subs.delete(workflowId);
|
|
117
|
+
}
|
|
118
|
+
/** Fan a frame out to every live subscriber for the player. Source → subs. */
|
|
119
|
+
publish(workflowId, frame) {
|
|
120
|
+
const set = this.subs.get(workflowId);
|
|
121
|
+
if (!set)
|
|
122
|
+
return;
|
|
123
|
+
for (const sub of set)
|
|
124
|
+
sub.push(frame);
|
|
125
|
+
}
|
|
126
|
+
/** Live subscriber count — the publisher's presence gate reads this (via `/inner/presence`). */
|
|
127
|
+
subscriberCount(workflowId) {
|
|
128
|
+
return this.subs.get(workflowId)?.size ?? 0;
|
|
129
|
+
}
|
|
130
|
+
/** Close every subscriber for a player (player gone → streams end with `:closed`). */
|
|
131
|
+
closePlayer(workflowId) {
|
|
132
|
+
const set = this.subs.get(workflowId);
|
|
133
|
+
if (!set)
|
|
134
|
+
return;
|
|
135
|
+
for (const sub of set)
|
|
136
|
+
sub.close();
|
|
137
|
+
this.subs.delete(workflowId);
|
|
138
|
+
}
|
|
139
|
+
/** Total live inner-tail subscribers across all players (diagnostics). */
|
|
140
|
+
totalSubscriberCount() {
|
|
141
|
+
let n = 0;
|
|
142
|
+
for (const set of this.subs.values())
|
|
143
|
+
n += set.size;
|
|
144
|
+
return n;
|
|
145
|
+
}
|
|
146
|
+
/** Close everything (daemon shutdown). */
|
|
147
|
+
close() {
|
|
148
|
+
for (const set of this.subs.values()) {
|
|
149
|
+
for (const sub of set)
|
|
150
|
+
sub.close();
|
|
151
|
+
}
|
|
152
|
+
this.subs.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.InnerLoopRegistry = InnerLoopRegistry;
|
package/dist/http/server.d.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
import * as http from 'http';
|
|
19
19
|
import type { TempoClient } from '../client/interface';
|
|
20
20
|
import type { AggregateRunner } from './aggregate';
|
|
21
|
+
import type { InnerLoopRegistry } from './inner-loop';
|
|
22
|
+
import type { IngestTokenRegistry } from './ingest-registry';
|
|
23
|
+
import type { GateRegistry } from './gate-registry';
|
|
21
24
|
import { type CorsConfig } from './cors';
|
|
22
25
|
import { ConnectionCap } from './sse-handler';
|
|
23
26
|
/** Default bind addr per SSE-PROTOCOL.md §1. */
|
|
@@ -59,10 +62,14 @@ export interface HttpServerOptions {
|
|
|
59
62
|
*/
|
|
60
63
|
portFilePath?: string;
|
|
61
64
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
65
|
+
* @deprecated 3e — back-compat alias for {@link readToken}. A single injected
|
|
66
|
+
* bearer is treated as the READ token (T1). Prefer `readToken`/`adminToken`.
|
|
64
67
|
*/
|
|
65
68
|
httpToken?: string;
|
|
69
|
+
/** 3e — inject the read-tier (T1) token directly (tests). Overrides config/env. */
|
|
70
|
+
readToken?: string;
|
|
71
|
+
/** 3e — inject the admin (T1+T2+T3) token directly (tests). Overrides env. */
|
|
72
|
+
adminToken?: string;
|
|
66
73
|
/**
|
|
67
74
|
* Test seam — lets unit tests stub `process.uptime`-style readings.
|
|
68
75
|
*/
|
|
@@ -80,6 +87,25 @@ export interface HttpServerOptions {
|
|
|
80
87
|
* low value in tests to exercise the 503 path.
|
|
81
88
|
*/
|
|
82
89
|
maxSseConnections?: number;
|
|
90
|
+
/**
|
|
91
|
+
* 3c Tier-2 — the inner-loop fine-tail registry (off-wire SSE sink) and the
|
|
92
|
+
* ingest-token registry (source-plane auth). When both are provided the
|
|
93
|
+
* `/v1/players/:e/:p/inner` egress + `/inner/ingest` + `/inner/presence`
|
|
94
|
+
* ingress routes light up; absent → those routes 404/503. The daemon
|
|
95
|
+
* constructs + shares these (the outbox mints ingest tokens into the same
|
|
96
|
+
* `ingestTokens` instance the server validates against).
|
|
97
|
+
*/
|
|
98
|
+
innerLoop?: InnerLoopRegistry;
|
|
99
|
+
ingestTokens?: IngestTokenRegistry;
|
|
100
|
+
/**
|
|
101
|
+
* 3d MD-G — the operator-gate registry. When provided (with `ingestTokens`)
|
|
102
|
+
* the `/v1/players/:e/:p/gate-arm` + `/gate-disarm` + `/gate/:requestId`
|
|
103
|
+
* operator routes (requireTier(3)) and the `/gate/:requestId/resolution`
|
|
104
|
+
* subprocess-poll route (ingest-token) light up; absent → those routes 404.
|
|
105
|
+
* The daemon constructs + shares this with the worker (auto-disarm on
|
|
106
|
+
* detach/destroy) — same singleton pattern as the inner-loop registries.
|
|
107
|
+
*/
|
|
108
|
+
gate?: GateRegistry;
|
|
83
109
|
}
|
|
84
110
|
export interface HttpServerHandle {
|
|
85
111
|
/** The actual port the server is listening on (after `.listen()` resolves). */
|
|
@@ -106,13 +132,22 @@ interface HandleContext {
|
|
|
106
132
|
version: string;
|
|
107
133
|
bindAddr: string;
|
|
108
134
|
corsConfig: CorsConfig;
|
|
109
|
-
|
|
135
|
+
/** 3e RBAC read-tier token (T1), or null. */
|
|
136
|
+
readToken: string | null;
|
|
137
|
+
/** 3e RBAC admin token (T1+T2+T3) — env-var-only, or null when unset. */
|
|
138
|
+
adminToken: string | null;
|
|
110
139
|
startedAt: number;
|
|
111
140
|
subscriberCount: () => number;
|
|
112
141
|
/** Present when PR-2 streaming is wired; null on PR-1-only deployments. */
|
|
113
142
|
aggregate: AggregateRunner | null;
|
|
114
143
|
/** Process-wide SSE subscriber cap (§7.3). */
|
|
115
144
|
sseConnectionCap: ConnectionCap;
|
|
145
|
+
/** 3c Tier-2 inner-loop fine-tail sink (off-wire) — null when unwired. */
|
|
146
|
+
innerLoop: InnerLoopRegistry | null;
|
|
147
|
+
/** 3c Tier-2 ingest-token registry (source-plane auth) — null when unwired. */
|
|
148
|
+
ingestTokens: IngestTokenRegistry | null;
|
|
149
|
+
/** 3d MD-G operator-gate registry — null when unwired. */
|
|
150
|
+
gate: GateRegistry | null;
|
|
116
151
|
}
|
|
117
152
|
/**
|
|
118
153
|
* Top-level request dispatcher — exported for unit tests that want to
|