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
package/dist/http/body.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ALLOWED_AGENTS_PROD = exports.ALLOWED_AGENTS_DEV = exports.BODY_INVALID_JSON = exports.BODY_TOO_LARGE = exports.WRITE_BODY_MAX = void 0;
|
|
3
|
+
exports.ALLOWED_AGENTS_PROD = exports.ALLOWED_AGENTS_DEV = exports.BODY_INVALID_JSON = exports.BODY_TOO_LARGE = exports.INGEST_BODY_MAX = exports.WRITE_BODY_MAX = void 0;
|
|
4
4
|
exports.readJsonBody = readJsonBody;
|
|
5
5
|
exports.stringField = stringField;
|
|
6
6
|
exports.allowedAgentsForCurrentMode = allowedAgentsForCurrentMode;
|
|
@@ -12,6 +12,9 @@ const responses_1 = require("./responses");
|
|
|
12
12
|
const validation_1 = require("../utils/validation");
|
|
13
13
|
/** Hard cap on incoming JSON body size (1 MiB). */
|
|
14
14
|
exports.WRITE_BODY_MAX = 1024 * 1024;
|
|
15
|
+
/** 3c Tier-2 ingest cap (32 KiB) — the DOS backstop for `/inner/ingest`; the
|
|
16
|
+
* source already ~2KB-truncates summaries, so real frames are far smaller. */
|
|
17
|
+
exports.INGEST_BODY_MAX = 32 * 1024;
|
|
15
18
|
exports.BODY_TOO_LARGE = Symbol('body-too-large');
|
|
16
19
|
exports.BODY_INVALID_JSON = Symbol('body-invalid-json');
|
|
17
20
|
/**
|
|
@@ -24,13 +27,13 @@ exports.BODY_INVALID_JSON = Symbol('body-invalid-json');
|
|
|
24
27
|
* handler ends. Explicit `req.destroy()` would race the response
|
|
25
28
|
* write — left alone.
|
|
26
29
|
*/
|
|
27
|
-
async function readJsonBody(req) {
|
|
30
|
+
async function readJsonBody(req, maxBytes = exports.WRITE_BODY_MAX) {
|
|
28
31
|
const chunks = [];
|
|
29
32
|
let total = 0;
|
|
30
33
|
for await (const chunk of req) {
|
|
31
34
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
32
35
|
total += buf.length;
|
|
33
|
-
if (total >
|
|
36
|
+
if (total > maxBytes)
|
|
34
37
|
return exports.BODY_TOO_LARGE;
|
|
35
38
|
chunks.push(buf);
|
|
36
39
|
}
|
package/dist/http/event-bus.js
CHANGED
|
@@ -64,6 +64,7 @@ const NON_ESSENTIAL_AFTER_THROTTLE = new Set([
|
|
|
64
64
|
function topicOf(kind) {
|
|
65
65
|
switch (kind) {
|
|
66
66
|
case 'player.phase_changed': return 'phase';
|
|
67
|
+
case 'player.activity': return 'phase';
|
|
67
68
|
case 'chat.appended':
|
|
68
69
|
case 'chat.compressed': return 'chat';
|
|
69
70
|
case 'flags.changed': return 'flags';
|
|
@@ -152,7 +152,7 @@ export interface PlayerSummaryV1 {
|
|
|
152
152
|
* stability rule at §6 — adding new adapters in future versions remains
|
|
153
153
|
* non-breaking, removing one requires `/v2/`. See #535.
|
|
154
154
|
*/
|
|
155
|
-
agentType: 'claude' | 'copilot' | 'mock' | 'claude-api' | 'opencode' | 'claude-code-headless';
|
|
155
|
+
agentType: 'claude' | 'copilot' | 'mock' | 'claude-api' | 'opencode' | 'claude-code-headless' | 'pi';
|
|
156
156
|
playerType?: string;
|
|
157
157
|
/** Authoritative attachment phase (post-v0.26 — see WIRE-PROTOCOL.md). */
|
|
158
158
|
phase?: AttachmentPhase;
|
|
@@ -194,6 +194,20 @@ export interface PlayerSummaryV1 {
|
|
|
194
194
|
/** Q5.6 — ISO timestamp of the most recent activity. Already on
|
|
195
195
|
* `MaestroPlayerInfo`; passed through verbatim by `toPlayerSummaryV1`. */
|
|
196
196
|
lastActivityAt?: string;
|
|
197
|
+
/**
|
|
198
|
+
* 3c Tier-1 coarse observability — the tool the player is currently
|
|
199
|
+
* executing, or `null` when idle/between tools. Sourced from session
|
|
200
|
+
* metadata via the heartbeat piggyback (~30s freshness); the live,
|
|
201
|
+
* fine-grained tail is the off-wire `/inner` side-channel (MD-F). Additive.
|
|
202
|
+
*/
|
|
203
|
+
currentTool?: string | null;
|
|
204
|
+
/**
|
|
205
|
+
* 3c Tier-1 coarse — estimated context tokens in use (pull-only, from Pi's
|
|
206
|
+
* `getContextUsage()`; `null`/absent right after compaction). Additive.
|
|
207
|
+
*/
|
|
208
|
+
contextTokens?: number;
|
|
209
|
+
/** 3c Tier-1 coarse — context usage as a percentage of the model window. Additive. */
|
|
210
|
+
contextPercent?: number;
|
|
197
211
|
}
|
|
198
212
|
/**
|
|
199
213
|
* The eventId token used in the `id:` line of the SSE frame and as the
|
|
@@ -269,7 +283,7 @@ export declare const PR1_SENTINEL_EVENT_ID: EventIdToken;
|
|
|
269
283
|
* **Append-only**. Do not remove. New event types ship as additive `/v1/`
|
|
270
284
|
* additions; removals require `/v2/`.
|
|
271
285
|
*/
|
|
272
|
-
export declare const SSE_EVENT_KINDS: readonly ["snapshot", "gap", "throttled", "heartbeat", "ensemble.created", "ensemble.destroyed", "player.added", "player.removed", "player.phase_changed", "chat.appended", "chat.compressed", "flags.changed", "schedules.changed", "host_profile.changed"];
|
|
286
|
+
export declare const SSE_EVENT_KINDS: readonly ["snapshot", "gap", "throttled", "heartbeat", "ensemble.created", "ensemble.destroyed", "player.added", "player.removed", "player.phase_changed", "chat.appended", "chat.compressed", "flags.changed", "schedules.changed", "host_profile.changed", "player.activity"];
|
|
273
287
|
export type SseEventKind = (typeof SSE_EVENT_KINDS)[number];
|
|
274
288
|
/** Common envelope for every event. */
|
|
275
289
|
export interface SseEventBase {
|
|
@@ -364,6 +378,24 @@ export type TempoEvent = (SseEventBase & {
|
|
|
364
378
|
}) | (SseEventBase & {
|
|
365
379
|
type: 'host_profile.changed';
|
|
366
380
|
payload: HostProfile;
|
|
381
|
+
}) | (SseEventBase & {
|
|
382
|
+
type: 'player.activity';
|
|
383
|
+
/**
|
|
384
|
+
* 3c Tier-1 coarse activity (MD-F). Emitted by the aggregate poll/diff
|
|
385
|
+
* when a player's `currentTool` or context usage changes between polls.
|
|
386
|
+
* `busy/idle` is DERIVED consumer-side from the player's phase
|
|
387
|
+
* (busy = phase==='processing'); `activityCount`/`lastActivityAt` already
|
|
388
|
+
* ride `PlayerSummaryV1`. This is the ON-wire coarse tier; the fine,
|
|
389
|
+
* live inner tail is the off-wire `/inner` side-channel.
|
|
390
|
+
*/
|
|
391
|
+
payload: {
|
|
392
|
+
playerId: string;
|
|
393
|
+
ensemble: string;
|
|
394
|
+
currentTool: string | null;
|
|
395
|
+
contextTokens?: number;
|
|
396
|
+
contextPercent?: number;
|
|
397
|
+
at: string;
|
|
398
|
+
};
|
|
367
399
|
});
|
|
368
400
|
/**
|
|
369
401
|
* Subset of event categories the server can pre-filter on `?topics=...`.
|
package/dist/http/event-types.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GateAuditSink } from './gate-registry';
|
|
2
|
+
/** Root of the per-player gate-audit tree. */
|
|
3
|
+
export declare function gateAuditRoot(): string;
|
|
4
|
+
/** Absolute JSONL path for a (ensemble, workflowId) pair under `root`. */
|
|
5
|
+
export declare function gateAuditPath(ensemble: string, workflowId: string, root?: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Build the daemon's audit sink. Returns a {@link GateAuditSink} that appends one
|
|
8
|
+
* JSON line per record. Append + mkdir are synchronous (durable-before-return);
|
|
9
|
+
* any I/O error is logged + swallowed so a disk problem never wedges a gate
|
|
10
|
+
* decision. `root` is injectable for tests (defaults to {@link gateAuditRoot}).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createGateAuditSink(root?: string): GateAuditSink;
|
|
@@ -0,0 +1,95 @@
|
|
|
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.gateAuditRoot = gateAuditRoot;
|
|
37
|
+
exports.gateAuditPath = gateAuditPath;
|
|
38
|
+
exports.createGateAuditSink = createGateAuditSink;
|
|
39
|
+
/**
|
|
40
|
+
* Operator-gate audit writer (3d / MD-G, R5 — security-locked) — the daemon's
|
|
41
|
+
* append-only JSONL sink for {@link GateRegistry} events. One file per player at
|
|
42
|
+
*
|
|
43
|
+
* <AGENT_TEMPO_HOME>/gate-audit/<ensemble>/<workflowId>.jsonl
|
|
44
|
+
*
|
|
45
|
+
* Each {@link GateAuditRecord} (arm | disarm | decision) is one JSON line,
|
|
46
|
+
* appended SYNCHRONOUSLY at the decision/posture-change point so the durable
|
|
47
|
+
* record lands before the daemon hands back control (no buffering window where a
|
|
48
|
+
* crash loses an allow/deny). The `ensemble` sidecar (not part of the locked
|
|
49
|
+
* record schema) only paths the file.
|
|
50
|
+
*
|
|
51
|
+
* Daemon-side ONLY. The writer is wired as the GateRegistry's audit sink in
|
|
52
|
+
* `daemon.ts`; failures are swallowed + logged (audit is best-effort durable —
|
|
53
|
+
* never let an append error break a live gate decision).
|
|
54
|
+
*/
|
|
55
|
+
const fs = __importStar(require("fs"));
|
|
56
|
+
const path = __importStar(require("path"));
|
|
57
|
+
const config_1 = require("../config");
|
|
58
|
+
const log = (...args) => console.error('[agent-tempo:gate-audit]', ...args);
|
|
59
|
+
/** Root of the per-player gate-audit tree. */
|
|
60
|
+
function gateAuditRoot() {
|
|
61
|
+
return path.join(config_1.AGENT_TEMPO_HOME, 'gate-audit');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sanitize a single path segment (ensemble / workflowId) so a crafted name can't
|
|
65
|
+
* traverse out of the audit root. Ensemble + workflowId are already validated
|
|
66
|
+
* upstream (ENSEMBLE_NAME_REGEX / the workflowId is daemon-built), but defend the
|
|
67
|
+
* filesystem boundary anyway: strip anything outside `[A-Za-z0-9._-]`, collapse
|
|
68
|
+
* to a non-empty token.
|
|
69
|
+
*/
|
|
70
|
+
function safeSegment(seg) {
|
|
71
|
+
const cleaned = seg.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
72
|
+
return cleaned.length > 0 ? cleaned : '_';
|
|
73
|
+
}
|
|
74
|
+
/** Absolute JSONL path for a (ensemble, workflowId) pair under `root`. */
|
|
75
|
+
function gateAuditPath(ensemble, workflowId, root = gateAuditRoot()) {
|
|
76
|
+
return path.join(root, safeSegment(ensemble || '_'), `${safeSegment(workflowId)}.jsonl`);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the daemon's audit sink. Returns a {@link GateAuditSink} that appends one
|
|
80
|
+
* JSON line per record. Append + mkdir are synchronous (durable-before-return);
|
|
81
|
+
* any I/O error is logged + swallowed so a disk problem never wedges a gate
|
|
82
|
+
* decision. `root` is injectable for tests (defaults to {@link gateAuditRoot}).
|
|
83
|
+
*/
|
|
84
|
+
function createGateAuditSink(root = gateAuditRoot()) {
|
|
85
|
+
return (record, ensemble) => {
|
|
86
|
+
try {
|
|
87
|
+
const file = gateAuditPath(ensemble, record.workflowId, root);
|
|
88
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
|
+
fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
log('append failed (non-fatal):', err instanceof Error ? err.message : err);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-gate registry (3d / MD-G) — the DAEMON-LOCAL state for the live
|
|
3
|
+
* tool-call approve/deny gate on a headless Pi player. Per-player (keyed by the
|
|
4
|
+
* fixed session `workflowId`), it holds two things:
|
|
5
|
+
*
|
|
6
|
+
* 1. ARMED state — whether an operator has armed the gate for this player.
|
|
7
|
+
* When disarmed, the player's `tool_call` handler never engages the gate
|
|
8
|
+
* (MD-C decides immediately); when armed, a non-`low-risk` tool routes here
|
|
9
|
+
* for an operator decision.
|
|
10
|
+
* 2. PENDING requests — one entry per in-flight gated tool call, keyed by a
|
|
11
|
+
* caller-supplied `requestId`. The operator resolves it (POST /gate/:id);
|
|
12
|
+
* the Pi subprocess POLLS for the resolution (GET /gate/:id/resolution) and
|
|
13
|
+
* resolves its awaited `tool_call` Promise on a non-pending answer.
|
|
14
|
+
*
|
|
15
|
+
* ── 45s auto-allow (R3, autonomous-first, maintainer-LOCKED) ──
|
|
16
|
+
* A pending request with no operator decision after {@link GATE_AUTO_ALLOW_MS}
|
|
17
|
+
* resolves to `auto-allow` so an unsupervised run never stalls (and Pi #2381 — a
|
|
18
|
+
* `tool_call` handler that never resolves hangs the loop — can't bite). Expiry is
|
|
19
|
+
* LAZY-ON-POLL (computed from `createdAt` vs the injected clock at
|
|
20
|
+
* {@link GateRegistry.getResolution} time) — no daemon timer, fully
|
|
21
|
+
* deterministic under test. The Pi-side poll loop additionally bounds itself +
|
|
22
|
+
* honors `ctx.signal`, so the loop is bounded on BOTH sides.
|
|
23
|
+
*
|
|
24
|
+
* ── Audit (R5, security-locked) ──
|
|
25
|
+
* Every posture change (arm / disarm) and every decision (operator allow|deny,
|
|
26
|
+
* timeout auto-allow) is handed to the injected {@link GateAuditSink} — the
|
|
27
|
+
* daemon wires the append-only JSONL writer
|
|
28
|
+
* (`~/.agent-tempo/gate-audit/<ensemble>/<workflowId>.jsonl`). The registry owns
|
|
29
|
+
* the auto-allow decision point, so it MUST audit that one; arm/disarm/operator
|
|
30
|
+
* decisions are audited here too for a single sink.
|
|
31
|
+
*
|
|
32
|
+
* ── gate_resolved on the /inner stream (architect-ruled DI) ──
|
|
33
|
+
* When a decision lands, the registry emits an `inner.gate_resolved` frame so the
|
|
34
|
+
* operator sees the outcome. To avoid a circular import (GateRegistry ↔ the
|
|
35
|
+
* inner-loop module), the publish path is an INJECTED {@link PublishToInner}
|
|
36
|
+
* callback — the daemon wires it to `innerLoop.publish`. The registry imports
|
|
37
|
+
* only the InnerFrame TYPE (erased at compile), never the inner-loop runtime.
|
|
38
|
+
*
|
|
39
|
+
* This module is daemon-side ONLY (loopback HTTP boundary, off Temporal, off the
|
|
40
|
+
* coordination bus). Nothing here is imported by `src/workflows/`.
|
|
41
|
+
*/
|
|
42
|
+
import type { InnerFrame } from '../pi/inner-loop-publisher';
|
|
43
|
+
/** Operator-gate timeout: a pending request auto-ALLOWS after this long (R3, locked). */
|
|
44
|
+
export declare const GATE_AUTO_ALLOW_MS = 45000;
|
|
45
|
+
/** Terminal decision on a gated tool call. `auto-allow` = the R3 timeout fired. */
|
|
46
|
+
export type GateDecision = 'allow' | 'deny' | 'auto-allow';
|
|
47
|
+
/** Who/what produced a decision. */
|
|
48
|
+
export type GateDecisionSource = 'operator' | 'timeout';
|
|
49
|
+
/** Metadata the source attaches when opening a gate request (for operator display + audit). */
|
|
50
|
+
export interface GateRequestMeta {
|
|
51
|
+
/** The tool being gated (already classified non-`low-risk`). */
|
|
52
|
+
tool: string;
|
|
53
|
+
/** A bounded summary of the tool args (source-truncated, ≤ a couple KB). */
|
|
54
|
+
argsSummary: string;
|
|
55
|
+
/** The player's Pi conversation id, if known (audit only). */
|
|
56
|
+
sessionId?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The player's ensemble — stashed per-player so the audit sink can path the
|
|
59
|
+
* JSONL under `<ensemble>/<workflowId>.jsonl` (workflowId is not cleanly
|
|
60
|
+
* splittable since both ensemble + playerId may contain hyphens).
|
|
61
|
+
*/
|
|
62
|
+
ensemble?: string;
|
|
63
|
+
}
|
|
64
|
+
/** The poll answer the Pi subprocess receives from GET /gate/:requestId/resolution. */
|
|
65
|
+
export type GateResolution = {
|
|
66
|
+
status: 'pending';
|
|
67
|
+
} | {
|
|
68
|
+
status: 'resolved';
|
|
69
|
+
decision: GateDecision;
|
|
70
|
+
source: GateDecisionSource;
|
|
71
|
+
};
|
|
72
|
+
/** One audited gate event (R5 schema, security-locked; `kind` discriminator per architect). */
|
|
73
|
+
export type GateAuditRecord = {
|
|
74
|
+
kind: 'decision';
|
|
75
|
+
ts: string;
|
|
76
|
+
workflowId: string;
|
|
77
|
+
sessionId?: string;
|
|
78
|
+
requestId: string;
|
|
79
|
+
tool: string;
|
|
80
|
+
argsSummary: string;
|
|
81
|
+
decision: GateDecision;
|
|
82
|
+
source: GateDecisionSource;
|
|
83
|
+
operatorTokenHint?: string;
|
|
84
|
+
} | {
|
|
85
|
+
kind: 'arm' | 'disarm';
|
|
86
|
+
ts: string;
|
|
87
|
+
workflowId: string;
|
|
88
|
+
source: 'operator';
|
|
89
|
+
operatorTokenHint?: string;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Append-only audit sink (daemon wires the JSONL writer; tests inject a spy).
|
|
93
|
+
* `ensemble` is a SIDECAR (not part of the locked record schema) the writer uses
|
|
94
|
+
* to path `<ensemble>/<workflowId>.jsonl`; `''` when unknown.
|
|
95
|
+
*/
|
|
96
|
+
export type GateAuditSink = (record: GateAuditRecord, ensemble: string) => void;
|
|
97
|
+
/**
|
|
98
|
+
* Injected publish path for the `inner.gate_resolved` outcome frame (architect's
|
|
99
|
+
* DI to avoid a GateRegistry ↔ inner-loop circular import). Daemon wires it to
|
|
100
|
+
* `innerLoop.publish`; tests inject a spy; default is a no-op.
|
|
101
|
+
*/
|
|
102
|
+
export type PublishToInner = (workflowId: string, frame: InnerFrame) => void;
|
|
103
|
+
/** Result of an operator decision attempt — drives the route's HTTP status. */
|
|
104
|
+
export type DecideResult = {
|
|
105
|
+
ok: true;
|
|
106
|
+
} | {
|
|
107
|
+
ok: false;
|
|
108
|
+
reason: 'not-found';
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
reason: 'already-decided';
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Per-daemon operator-gate registry, keyed by the player's fixed session
|
|
115
|
+
* `workflowId`. One instance is constructed in the daemon and shared between the
|
|
116
|
+
* Temporal worker (auto-disarm on detach/destroy) and the HTTP gate routes —
|
|
117
|
+
* the same singleton-sharing pattern as the 3c InnerLoop/IngestToken registries.
|
|
118
|
+
*/
|
|
119
|
+
export declare class GateRegistry {
|
|
120
|
+
private readonly audit;
|
|
121
|
+
private readonly now;
|
|
122
|
+
private readonly autoAllowMs;
|
|
123
|
+
private readonly publishToInner;
|
|
124
|
+
private readonly gates;
|
|
125
|
+
constructor(audit?: GateAuditSink, now?: () => number, autoAllowMs?: number, publishToInner?: PublishToInner);
|
|
126
|
+
/** Emit an `inner.gate_resolved` outcome frame on the player's /inner stream. */
|
|
127
|
+
private emitResolved;
|
|
128
|
+
private gate;
|
|
129
|
+
private nowIso;
|
|
130
|
+
/** Arm the gate for a player — subsequent non-`low-risk` tools route to the operator. */
|
|
131
|
+
arm(workflowId: string, ensemble: string, operatorTokenHint?: string): void;
|
|
132
|
+
/** Disarm the gate — new tools no longer engage it (in-flight pending still resolvable / auto-allow). */
|
|
133
|
+
disarm(workflowId: string, operatorTokenHint?: string): void;
|
|
134
|
+
/** Whether the gate is currently armed for a player (the engagement predicate reads this). */
|
|
135
|
+
isArmed(workflowId: string): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Open (or return the existing) pending request for a gated tool call.
|
|
138
|
+
* Idempotent on `requestId` — a retried open returns the existing entry so the
|
|
139
|
+
* source can safely re-register before polling. Does NOT audit (the request
|
|
140
|
+
* isn't a decision); the decision/auto-allow audit records carry tool+args.
|
|
141
|
+
*/
|
|
142
|
+
open(workflowId: string, requestId: string, meta: GateRequestMeta): void;
|
|
143
|
+
/**
|
|
144
|
+
* Operator decision (allow|deny) on a pending request. Returns a result that
|
|
145
|
+
* the route maps to a status: unknown → 404, already-decided → 409 (idempotency
|
|
146
|
+
* guard so a double-POST or a post-timeout race can't flip a recorded answer).
|
|
147
|
+
*/
|
|
148
|
+
decide(workflowId: string, requestId: string, decision: 'allow' | 'deny', operatorTokenHint?: string): DecideResult;
|
|
149
|
+
/**
|
|
150
|
+
* The Pi subprocess's poll answer. Returns `null` for an unknown request
|
|
151
|
+
* (route → 404). For a known request: an already-recorded decision, OR — if no
|
|
152
|
+
* decision and `now - createdAt >= autoAllowMs` — a freshly-recorded `auto-allow`
|
|
153
|
+
* (R3 timeout, source `timeout`, audited HERE since the registry owns the
|
|
154
|
+
* timeout clock), OR `pending`.
|
|
155
|
+
*/
|
|
156
|
+
getResolution(workflowId: string, requestId: string): GateResolution | null;
|
|
157
|
+
/**
|
|
158
|
+
* Auto-disarm + drop all pending for a player (detach / destroy). The
|
|
159
|
+
* subprocess is going away, so abandoning its pending requests is correct — a
|
|
160
|
+
* still-polling client would just stop. Idempotent.
|
|
161
|
+
*/
|
|
162
|
+
clearPlayer(workflowId: string): void;
|
|
163
|
+
/** Drop every player's gate state (daemon shutdown / clear-all). */
|
|
164
|
+
clear(): void;
|
|
165
|
+
/** Pending-request count for a player (diagnostics / tests). */
|
|
166
|
+
pendingCount(workflowId: string): number;
|
|
167
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GateRegistry = exports.GATE_AUTO_ALLOW_MS = void 0;
|
|
4
|
+
/** Operator-gate timeout: a pending request auto-ALLOWS after this long (R3, locked). */
|
|
5
|
+
exports.GATE_AUTO_ALLOW_MS = 45_000;
|
|
6
|
+
/**
|
|
7
|
+
* Per-daemon operator-gate registry, keyed by the player's fixed session
|
|
8
|
+
* `workflowId`. One instance is constructed in the daemon and shared between the
|
|
9
|
+
* Temporal worker (auto-disarm on detach/destroy) and the HTTP gate routes —
|
|
10
|
+
* the same singleton-sharing pattern as the 3c InnerLoop/IngestToken registries.
|
|
11
|
+
*/
|
|
12
|
+
class GateRegistry {
|
|
13
|
+
audit;
|
|
14
|
+
now;
|
|
15
|
+
autoAllowMs;
|
|
16
|
+
publishToInner;
|
|
17
|
+
gates = new Map();
|
|
18
|
+
constructor(audit = () => { }, now = Date.now, autoAllowMs = exports.GATE_AUTO_ALLOW_MS, publishToInner = () => { }) {
|
|
19
|
+
this.audit = audit;
|
|
20
|
+
this.now = now;
|
|
21
|
+
this.autoAllowMs = autoAllowMs;
|
|
22
|
+
this.publishToInner = publishToInner;
|
|
23
|
+
}
|
|
24
|
+
/** Emit an `inner.gate_resolved` outcome frame on the player's /inner stream. */
|
|
25
|
+
emitResolved(workflowId, requestId, decision, source) {
|
|
26
|
+
this.publishToInner(workflowId, { type: 'inner.gate_resolved', requestId, decision, source, ts: this.now() });
|
|
27
|
+
}
|
|
28
|
+
gate(workflowId) {
|
|
29
|
+
let g = this.gates.get(workflowId);
|
|
30
|
+
if (!g) {
|
|
31
|
+
g = { armed: false, ensemble: '', pending: new Map() };
|
|
32
|
+
this.gates.set(workflowId, g);
|
|
33
|
+
}
|
|
34
|
+
return g;
|
|
35
|
+
}
|
|
36
|
+
nowIso() {
|
|
37
|
+
// `now` is injectable; format the same epoch ms the registry reasons about so
|
|
38
|
+
// audit timestamps line up with the lazy-expiry clock under test.
|
|
39
|
+
return new Date(this.now()).toISOString();
|
|
40
|
+
}
|
|
41
|
+
// ── Arm / disarm (operator posture; audited) ───────────────────────────────
|
|
42
|
+
/** Arm the gate for a player — subsequent non-`low-risk` tools route to the operator. */
|
|
43
|
+
arm(workflowId, ensemble, operatorTokenHint) {
|
|
44
|
+
const g = this.gate(workflowId);
|
|
45
|
+
g.armed = true;
|
|
46
|
+
if (ensemble)
|
|
47
|
+
g.ensemble = ensemble;
|
|
48
|
+
this.audit({ kind: 'arm', ts: this.nowIso(), workflowId, source: 'operator', ...(operatorTokenHint ? { operatorTokenHint } : {}) }, g.ensemble);
|
|
49
|
+
}
|
|
50
|
+
/** Disarm the gate — new tools no longer engage it (in-flight pending still resolvable / auto-allow). */
|
|
51
|
+
disarm(workflowId, operatorTokenHint) {
|
|
52
|
+
const g = this.gate(workflowId);
|
|
53
|
+
g.armed = false;
|
|
54
|
+
this.audit({ kind: 'disarm', ts: this.nowIso(), workflowId, source: 'operator', ...(operatorTokenHint ? { operatorTokenHint } : {}) }, g.ensemble);
|
|
55
|
+
}
|
|
56
|
+
/** Whether the gate is currently armed for a player (the engagement predicate reads this). */
|
|
57
|
+
isArmed(workflowId) {
|
|
58
|
+
return this.gates.get(workflowId)?.armed ?? false;
|
|
59
|
+
}
|
|
60
|
+
// ── Pending requests (source opens; operator decides; source polls) ─────────
|
|
61
|
+
/**
|
|
62
|
+
* Open (or return the existing) pending request for a gated tool call.
|
|
63
|
+
* Idempotent on `requestId` — a retried open returns the existing entry so the
|
|
64
|
+
* source can safely re-register before polling. Does NOT audit (the request
|
|
65
|
+
* isn't a decision); the decision/auto-allow audit records carry tool+args.
|
|
66
|
+
*/
|
|
67
|
+
open(workflowId, requestId, meta) {
|
|
68
|
+
const g = this.gate(workflowId);
|
|
69
|
+
if (meta.ensemble)
|
|
70
|
+
g.ensemble = meta.ensemble;
|
|
71
|
+
if (g.pending.has(requestId))
|
|
72
|
+
return;
|
|
73
|
+
g.pending.set(requestId, {
|
|
74
|
+
tool: meta.tool,
|
|
75
|
+
argsSummary: meta.argsSummary,
|
|
76
|
+
sessionId: meta.sessionId,
|
|
77
|
+
createdAt: this.now(),
|
|
78
|
+
decision: null,
|
|
79
|
+
source: null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Operator decision (allow|deny) on a pending request. Returns a result that
|
|
84
|
+
* the route maps to a status: unknown → 404, already-decided → 409 (idempotency
|
|
85
|
+
* guard so a double-POST or a post-timeout race can't flip a recorded answer).
|
|
86
|
+
*/
|
|
87
|
+
decide(workflowId, requestId, decision, operatorTokenHint) {
|
|
88
|
+
const g = this.gates.get(workflowId);
|
|
89
|
+
const req = g?.pending.get(requestId);
|
|
90
|
+
if (!req)
|
|
91
|
+
return { ok: false, reason: 'not-found' };
|
|
92
|
+
if (req.decision !== null)
|
|
93
|
+
return { ok: false, reason: 'already-decided' };
|
|
94
|
+
req.decision = decision;
|
|
95
|
+
req.source = 'operator';
|
|
96
|
+
this.audit({
|
|
97
|
+
kind: 'decision',
|
|
98
|
+
ts: this.nowIso(),
|
|
99
|
+
workflowId,
|
|
100
|
+
...(req.sessionId ? { sessionId: req.sessionId } : {}),
|
|
101
|
+
requestId,
|
|
102
|
+
tool: req.tool,
|
|
103
|
+
argsSummary: req.argsSummary,
|
|
104
|
+
decision,
|
|
105
|
+
source: 'operator',
|
|
106
|
+
...(operatorTokenHint ? { operatorTokenHint } : {}),
|
|
107
|
+
}, g?.ensemble ?? '');
|
|
108
|
+
this.emitResolved(workflowId, requestId, decision, 'operator');
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The Pi subprocess's poll answer. Returns `null` for an unknown request
|
|
113
|
+
* (route → 404). For a known request: an already-recorded decision, OR — if no
|
|
114
|
+
* decision and `now - createdAt >= autoAllowMs` — a freshly-recorded `auto-allow`
|
|
115
|
+
* (R3 timeout, source `timeout`, audited HERE since the registry owns the
|
|
116
|
+
* timeout clock), OR `pending`.
|
|
117
|
+
*/
|
|
118
|
+
getResolution(workflowId, requestId) {
|
|
119
|
+
const g = this.gates.get(workflowId);
|
|
120
|
+
const req = g?.pending.get(requestId);
|
|
121
|
+
if (!req)
|
|
122
|
+
return null;
|
|
123
|
+
if (req.decision !== null) {
|
|
124
|
+
return { status: 'resolved', decision: req.decision, source: req.source ?? 'operator' };
|
|
125
|
+
}
|
|
126
|
+
if (this.now() - req.createdAt >= this.autoAllowMs) {
|
|
127
|
+
req.decision = 'auto-allow';
|
|
128
|
+
req.source = 'timeout';
|
|
129
|
+
this.audit({
|
|
130
|
+
kind: 'decision',
|
|
131
|
+
ts: this.nowIso(),
|
|
132
|
+
workflowId,
|
|
133
|
+
...(req.sessionId ? { sessionId: req.sessionId } : {}),
|
|
134
|
+
requestId,
|
|
135
|
+
tool: req.tool,
|
|
136
|
+
argsSummary: req.argsSummary,
|
|
137
|
+
decision: 'auto-allow',
|
|
138
|
+
source: 'timeout',
|
|
139
|
+
}, g?.ensemble ?? '');
|
|
140
|
+
this.emitResolved(workflowId, requestId, 'auto-allow', 'timeout');
|
|
141
|
+
return { status: 'resolved', decision: 'auto-allow', source: 'timeout' };
|
|
142
|
+
}
|
|
143
|
+
return { status: 'pending' };
|
|
144
|
+
}
|
|
145
|
+
// ── Lifecycle (auto-disarm on detach/destroy; daemon shutdown) ──────────────
|
|
146
|
+
/**
|
|
147
|
+
* Auto-disarm + drop all pending for a player (detach / destroy). The
|
|
148
|
+
* subprocess is going away, so abandoning its pending requests is correct — a
|
|
149
|
+
* still-polling client would just stop. Idempotent.
|
|
150
|
+
*/
|
|
151
|
+
clearPlayer(workflowId) {
|
|
152
|
+
this.gates.delete(workflowId);
|
|
153
|
+
}
|
|
154
|
+
/** Drop every player's gate state (daemon shutdown / clear-all). */
|
|
155
|
+
clear() {
|
|
156
|
+
this.gates.clear();
|
|
157
|
+
}
|
|
158
|
+
/** Pending-request count for a player (diagnostics / tests). */
|
|
159
|
+
pendingCount(workflowId) {
|
|
160
|
+
return this.gates.get(workflowId)?.pending.size ?? 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
exports.GateRegistry = GateRegistry;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP route handlers for the 3d operator gate (MD-G). server.ts dispatches to
|
|
3
|
+
* these; logic lives here so it stays testable.
|
|
4
|
+
*
|
|
5
|
+
* FOUR routes, TWO auth planes (mirrors the 3c inner-loop split):
|
|
6
|
+
*
|
|
7
|
+
* OPERATOR plane (operator/dashboard → daemon). Mounted AFTER the outer bearer
|
|
8
|
+
* gate with an explicit `requireTier(3)` (the highest RBAC tier, MD-E) — only
|
|
9
|
+
* an admin-token holder may arm/disarm or decide:
|
|
10
|
+
* - POST /v1/players/:e/:p/gate-arm → arm the gate
|
|
11
|
+
* - POST /v1/players/:e/:p/gate-disarm → disarm the gate
|
|
12
|
+
* - POST /v1/players/:e/:p/gate/:requestId → decide { decision:'allow'|'deny' }
|
|
13
|
+
*
|
|
14
|
+
* SOURCE plane (Pi subprocess → daemon; publisher-only). Same INGRESS gate as
|
|
15
|
+
* the inner-loop ingest — loopback `remoteAddress` + `X-Ingest-Token` validated
|
|
16
|
+
* against the URL-derived workflowId (cross-player-spoof guard), uniform 403:
|
|
17
|
+
* - GET /v1/players/:e/:p/gate/:requestId/resolution → poll { status, … }
|
|
18
|
+
*
|
|
19
|
+
* The Pi subprocess's awaited `tool_call` handler polls the resolution route
|
|
20
|
+
* until it gets a non-`pending` answer (operator decision OR the registry's lazy
|
|
21
|
+
* 45s auto-allow) or its own bounded/ctx.signal-cancelled deadline.
|
|
22
|
+
*
|
|
23
|
+
* Daemon-side ONLY (loopback boundary, off Temporal). Not imported by workflows.
|
|
24
|
+
*/
|
|
25
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
26
|
+
import type { GateRegistry } from './gate-registry';
|
|
27
|
+
import type { IngestTokenRegistry } from './ingest-registry';
|
|
28
|
+
export interface GateDeps {
|
|
29
|
+
gate: GateRegistry;
|
|
30
|
+
ingestTokens: IngestTokenRegistry;
|
|
31
|
+
}
|
|
32
|
+
/** POST /gate-arm — arm the operator gate for a player. 204. */
|
|
33
|
+
export declare function handleGateArm(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string): void;
|
|
34
|
+
/** POST /gate-disarm — disarm the operator gate for a player. 204. */
|
|
35
|
+
export declare function handleGateDisarm(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* POST /gate/:requestId — operator decision on a pending gated tool call.
|
|
38
|
+
* Body `{ decision: 'allow' | 'deny' }`. 204 on success; 404 unknown requestId;
|
|
39
|
+
* 409 already-decided (idempotency — a double-POST or post-timeout race can't
|
|
40
|
+
* flip a recorded answer); 400 malformed body / bad decision value.
|
|
41
|
+
*/
|
|
42
|
+
export declare function handleGateDecide(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string, requestId: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* GET /gate/:requestId/resolution — the Pi subprocess polls for the decision.
|
|
45
|
+
* 200 `{ status:'pending' }` | `{ status:'resolved', decision, source }`; 404 for
|
|
46
|
+
* an unknown requestId; uniform 403 on any INGRESS gate failure (no leak).
|
|
47
|
+
*/
|
|
48
|
+
export declare function handleGateResolution(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string, requestId: string): void;
|