@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/** How long to wait before re-offering held frames to the link. */
|
|
2
|
+
const HELD_RETRY_MS = 250;
|
|
3
|
+
/** How many refused undroppable frames one session may hold before the next is reported lost. */
|
|
4
|
+
const HELD_FRAMES_MAX = 512;
|
|
5
|
+
import { systemClock, systemTicker } from '../core/time.js';
|
|
6
|
+
import { KeyedTurns } from '../core/keyed-turns.js';
|
|
7
|
+
import { refusal } from '../core/refusal.js';
|
|
8
|
+
import { ok, refuse } from '../core/result.js';
|
|
9
|
+
import { ControllerLink } from '../control/link.js';
|
|
10
|
+
import { WORKSPACE_PAGE_SIZE, hostConfigureResult, sessionListResult, workspaceListResult, transcriptFailed, transcriptListResult, transcriptTailResult, unsetHostConfiguration, workspaceReleaseBulkResult, workspaceReleaseEntryResult, workspaceReleaseResult, repositoryListResult, repositoryReadResult, stateTransitionUpdate, } from '../control/frames.js';
|
|
11
|
+
import { forwardSession } from '../control/stream.js';
|
|
12
|
+
import { localGate } from '../gate/local.js';
|
|
13
|
+
import { recordGateOutcome } from '../gate/outcome.js';
|
|
14
|
+
import { deadlineOrderRefusal, permissionHooks } from '../gate/gate.js';
|
|
15
|
+
import { SessionRegistry } from '../sessions/registry.js';
|
|
16
|
+
import { SessionStateMachine } from '../state/machine.js';
|
|
17
|
+
import { SessionObserver } from '../state/observer.js';
|
|
18
|
+
import { keyPreview, unusableKeyProblem } from '../workspace/git-worktree.js';
|
|
19
|
+
import { bulkOriginFor, postBulk } from './bulk-post.js';
|
|
20
|
+
import { readWhere } from './git-facts.js';
|
|
21
|
+
import { normalizePath } from '../core/paths.js';
|
|
22
|
+
import { mergeHooks, observationHooks } from './hooks.js';
|
|
23
|
+
import { createToolServer } from './mcp-server.js';
|
|
24
|
+
import { listTranscripts, tailTranscript } from './claude-transcripts.js';
|
|
25
|
+
import { listRepositoryDirectory, readRepositoryFile } from './repository-read.js';
|
|
26
|
+
import { nodePathResolver } from './paths.js';
|
|
27
|
+
import { isBypassMode, mergeMcpServers, readSessionConfigure, readSessionRequest } from './wire-request.js';
|
|
28
|
+
import { isDroppable } from '../control/frames.js';
|
|
29
|
+
/**
|
|
30
|
+
* Assemble one session: machine, observer, gate and forwarding, then start it.
|
|
31
|
+
*
|
|
32
|
+
* This is the wiring every consumer needs, written down where it can be used.
|
|
33
|
+
*/
|
|
34
|
+
export function composeSession(options) {
|
|
35
|
+
// The one combination this function will not assemble, and this is the only place that can see
|
|
36
|
+
// it: the gate knows whether it grants and the request knows which settings tiers load, and
|
|
37
|
+
// neither knows the other. Refused before any process exists, by name, rather than resolved by a
|
|
38
|
+
// precedence that would surprise one of them.
|
|
39
|
+
//
|
|
40
|
+
// The ground for the refusal is narrow. Per the documented permission rules, deny and ask are
|
|
41
|
+
// evaluated whatever a hook returns, so a grant skips only mode, allow rules and `canUseTool`; an
|
|
42
|
+
// effective allow does not short-circuit every later permission check. The refusal stands on the
|
|
43
|
+
// narrower ground that the two mechanisms answer the same question from different places, and a
|
|
44
|
+
// session that both grants and loads operator rules has two authorities with no stated
|
|
45
|
+
// precedence. See `gate.ts` for the full order and for why the claim is documented rather than
|
|
46
|
+
// measured.
|
|
47
|
+
//
|
|
48
|
+
// And `settingSources: []` does not mean "no operator rules live". Managed policy settings and
|
|
49
|
+
// `~/.claude.json` load regardless of this field, precisely on the managed corporate laptop this
|
|
50
|
+
// package's threat model is written for. So this refusal is incomplete on its own logic: it
|
|
51
|
+
// catches the tiers named here and cannot see the two that are always on.
|
|
52
|
+
const settingSources = options.request?.settingSources ?? [];
|
|
53
|
+
// The pair is refused, except under bypassPermissions. What the grant skips is "permission
|
|
54
|
+
// mode, allow rules and canUseTool"; under bypass the mode already allows all of that, so the
|
|
55
|
+
// grant changes nothing and there is no second authority to shadow. A hook deny survives every
|
|
56
|
+
// mode, so the gate's boundary set still holds. The precedence is therefore stated by the mode
|
|
57
|
+
// itself: the operator asked for bypass, and got exactly the CLI's bypass plus this gate's
|
|
58
|
+
// refusals. Every other mode keeps the refusal below, unchanged.
|
|
59
|
+
if ((options.gate?.grantOnAllow ?? false) &&
|
|
60
|
+
settingSources.length > 0 &&
|
|
61
|
+
!isBypassMode(options.request?.permissionMode)) {
|
|
62
|
+
return refuse('permission-grant-shadows-settings', `grantOnAllow makes this gate's allow effective, and this session also loads operator settings ` +
|
|
63
|
+
`(${settingSources.join(', ')}) — two authorities over the same call with no stated ` +
|
|
64
|
+
`precedence. Per the SDK's documented evaluation order a hook allow does NOT skip deny or ` +
|
|
65
|
+
`ask rules, so those survive the grant; what it skips is permission mode, allow rules and ` +
|
|
66
|
+
`canUseTool. Load no settings, do not grant, or run bypassPermissions — where the grant ` +
|
|
67
|
+
`changes nothing because the mode already allows what it would. Note that managed policy and ` +
|
|
68
|
+
`~/.claude.json load regardless of settingSources, so an empty list is not proof that no ` +
|
|
69
|
+
`operator rule is live.`);
|
|
70
|
+
}
|
|
71
|
+
// The second combination this function will not assemble. `permissionHooks` throws when the
|
|
72
|
+
// host's own deadline does not expire before the matcher's, and that throw is right: the pair is
|
|
73
|
+
// checkable, the failure it prevents is a block nobody can explain, and an embedder calling
|
|
74
|
+
// `permissionHooks` directly should meet it loudly. But gate timings ride `session_new`, so a
|
|
75
|
+
// controller can send the pair, and a throw from here would escape the payload dispatcher's
|
|
76
|
+
// `void this.#open(…)` as an unhandled rejection, which means the controller gets no answer at
|
|
77
|
+
// all. A command that vanishes reads as a host that hung, which is precisely what refusing every
|
|
78
|
+
// unknown payload by name exists to prevent. Caught here rather than by relaxing the throw: the
|
|
79
|
+
// check keeps its teeth, and the composer keeps its contract of returning a Result for
|
|
80
|
+
// everything it declines to build.
|
|
81
|
+
const inverted = deadlineOrderRefusal(options.gate?.decisionTimeoutMs, options.gate?.matcherTimeoutSeconds);
|
|
82
|
+
if (inverted !== null)
|
|
83
|
+
return refuse('gate-deadlines-inverted', inverted);
|
|
84
|
+
const clock = options.clock ?? systemClock;
|
|
85
|
+
const ticker = options.ticker ?? systemTicker;
|
|
86
|
+
const machine = new SessionStateMachine({
|
|
87
|
+
where: options.where ?? unknownWhere(options.cwd),
|
|
88
|
+
clock,
|
|
89
|
+
ticker,
|
|
90
|
+
correlationId: options.correlationId ?? null,
|
|
91
|
+
});
|
|
92
|
+
const observer = new SessionObserver(machine);
|
|
93
|
+
// The gate records every outcome on the machine. That emission is what makes a deny, an outage and
|
|
94
|
+
// an expiry visible off-box at all, so it is unconditional and the embedder's listener is extra.
|
|
95
|
+
const gate = permissionHooks({
|
|
96
|
+
decide: options.decide,
|
|
97
|
+
// The escalation body carries the controller's handle. This function is the one place that
|
|
98
|
+
// holds both the handle and the gate, so it is where the two transports are tied together.
|
|
99
|
+
sessionKey: options.sessionKey,
|
|
100
|
+
onOutcome: (outcome) => {
|
|
101
|
+
recordGateOutcome(machine, outcome);
|
|
102
|
+
options.onOutcome?.(outcome);
|
|
103
|
+
},
|
|
104
|
+
...(options.localGate === undefined ? {} : { localGate: options.localGate }),
|
|
105
|
+
...(options.gate?.decisionTimeoutMs === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { decisionTimeoutMs: options.gate.decisionTimeoutMs }),
|
|
108
|
+
...(options.gate?.holdAfterMs === undefined ? {} : { holdAfterMs: options.gate.holdAfterMs }),
|
|
109
|
+
...(options.gate?.matcherTimeoutSeconds === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: { matcherTimeoutSeconds: options.gate.matcherTimeoutSeconds }),
|
|
112
|
+
...(options.gate?.grantOnAllow === undefined ? {} : { grantOnAllow: options.gate.grantOnAllow }),
|
|
113
|
+
// The `gate-cannot-grant` residual reaches the same lane every other named degrade uses, so an
|
|
114
|
+
// embedder who already subscribes to refusals learns about it without opting in to anything new.
|
|
115
|
+
...(options.onRefusal === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: {
|
|
118
|
+
onDegrade: (degrade) => options.onRefusal?.(refusal(degrade.name, degrade.detail)),
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
// Observation first, the gate second. The order is a convention rather than a race guard (see
|
|
122
|
+
// permissionHooks' own note), but it is the one both files state, so it is written once here.
|
|
123
|
+
const hooks = mergeHooks(observationHooks({
|
|
124
|
+
observer,
|
|
125
|
+
...(options.onHookFailure === undefined ? {} : { onHandlerFailure: options.onHookFailure }),
|
|
126
|
+
}), gate);
|
|
127
|
+
const created = options.registry.create({ ...(options.request ?? {}), cwd: options.cwd, hooks });
|
|
128
|
+
if (!created.ok)
|
|
129
|
+
return refuse(created.refusal.reason, created.refusal.detail);
|
|
130
|
+
const session = created.value;
|
|
131
|
+
// Before the first record. See this file's header: the forwarder subscribes to the machine, so
|
|
132
|
+
// anything recorded ahead of this line is emitted to nobody.
|
|
133
|
+
const detach = forwardSession({
|
|
134
|
+
sessionKey: options.sessionKey,
|
|
135
|
+
session,
|
|
136
|
+
observer,
|
|
137
|
+
sink: options.sink,
|
|
138
|
+
...(options.onRefusal === undefined ? {} : { onRefusal: options.onRefusal }),
|
|
139
|
+
});
|
|
140
|
+
observer.created(`a session was requested in ${options.cwd}`);
|
|
141
|
+
return ok({ sessionKey: options.sessionKey, session, machine, observer, detach });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The `where` for a directory nobody has read git facts for.
|
|
145
|
+
*
|
|
146
|
+
* Stated rather than left null: a transition whose `where` is absent cannot be told apart from one
|
|
147
|
+
* whose repository could not be determined, and only the second is a fact worth reporting.
|
|
148
|
+
*/
|
|
149
|
+
function unknownWhere(cwd) {
|
|
150
|
+
return { cwd, worktree: null, branch: null, unknownReason: 'the repository was not read for this session' };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A controller's per-session timings, as a partial the composer can spread.
|
|
154
|
+
*
|
|
155
|
+
* Absent keys rather than nulls: `GateTimings` reads an absent key as "use the default", and a
|
|
156
|
+
* present `undefined` would spread over a value the embedder deliberately set. The difference is
|
|
157
|
+
* invisible until an embedder configures a timeout and a controller sends `gate: {…, holdAfterMs:
|
|
158
|
+
* null}` — at which point the embedder's value either survives or silently does not.
|
|
159
|
+
*
|
|
160
|
+
* Nothing validates the pair here, deliberately. `permissionHooks` throws at construction when the
|
|
161
|
+
* host's own deadline does not expire before the matcher's, and that check must stay the only one:
|
|
162
|
+
* a second copy of the rule is a second thing to keep in step. The throw surfaces as a refused
|
|
163
|
+
* composition before any process exists, which is where a controller wants to learn it.
|
|
164
|
+
*/
|
|
165
|
+
function readGateTimings(gate) {
|
|
166
|
+
if (gate === null)
|
|
167
|
+
return {};
|
|
168
|
+
return {
|
|
169
|
+
...(gate.decisionTimeoutMs === null ? {} : { decisionTimeoutMs: gate.decisionTimeoutMs }),
|
|
170
|
+
...(gate.holdAfterMs === null ? {} : { holdAfterMs: gate.holdAfterMs }),
|
|
171
|
+
...(gate.matcherTimeoutSeconds === null ? {} : { matcherTimeoutSeconds: gate.matcherTimeoutSeconds }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* How many turns may wait for one still-opening session before the rest are refused.
|
|
176
|
+
*
|
|
177
|
+
* The bound exists because the buffer is fed from the wire, not because anyone expects to reach it:
|
|
178
|
+
* a controller sends one seed per `session_new`, so the ordinary depth is 1. An unbounded per-handle
|
|
179
|
+
* buffer that a peer can grow is the shape this package refuses everywhere else, and the window it
|
|
180
|
+
* lives in is exactly as long as a workspace provider takes, which is seconds, not microseconds, and
|
|
181
|
+
* is the whole reason this queue exists.
|
|
182
|
+
*/
|
|
183
|
+
const MAX_HELD_TURNS = 8;
|
|
184
|
+
/**
|
|
185
|
+
* A host: one outbound link, one registry, and the dispatcher between them.
|
|
186
|
+
*
|
|
187
|
+
* It interprets exactly the payload kinds its `#dispatch` switch names and refuses the rest by
|
|
188
|
+
* name: the session commands (`session_new`, `session_prompt`, `session_cancel`,
|
|
189
|
+
* `session_configure`), `bulk_request`, and the host-scoped asks (the discovery, workspace,
|
|
190
|
+
* configure and repository asks). Derive the count from the switch below; never carry it from
|
|
191
|
+
* prose. Anything else arriving inbound is
|
|
192
|
+
* either a frame this host produces (an update, a delta, a receipt) or a kind a newer controller
|
|
193
|
+
* invented, and both are reported rather than ignored; a command that vanishes reads to the
|
|
194
|
+
* controller as a host that hung.
|
|
195
|
+
*/
|
|
196
|
+
export class PeriscopeHost {
|
|
197
|
+
#options;
|
|
198
|
+
#link;
|
|
199
|
+
#registry;
|
|
200
|
+
/** Keyed by the controller's handle, which is the only id present when `session_new` arrives. */
|
|
201
|
+
#sessions = new Map();
|
|
202
|
+
/**
|
|
203
|
+
* Handles whose open is still in flight. `#open` awaits the workspace provider before it can
|
|
204
|
+
* populate `#sessions`, so the map alone cannot make the duplicate guard hold across that await;
|
|
205
|
+
* the reservation is taken synchronously and released in the same call, whatever the outcome.
|
|
206
|
+
*/
|
|
207
|
+
#opening = new Set();
|
|
208
|
+
/**
|
|
209
|
+
* regression: an undroppable frame the link's queue refused at capacity — a turn-end transition, a
|
|
210
|
+
* result — was reported here and dropped, so the controller never learned the turn ended and the
|
|
211
|
+
* session read as still working until its next turn. The queue's refusal is backpressure; the
|
|
212
|
+
* caller has to hold the frame and offer it again. Held per session, in order; while a session holds
|
|
213
|
+
* frames its later undroppable frames queue behind them, so the controller sees the session's order.
|
|
214
|
+
*/
|
|
215
|
+
#heldFrames = new Map();
|
|
216
|
+
#heldFramesTimer = null;
|
|
217
|
+
/**
|
|
218
|
+
* Turns that arrived while their session was still opening, in order.
|
|
219
|
+
*
|
|
220
|
+
* A controller sends `session_new` then `session_prompt`, and that ordering is real on the wire,
|
|
221
|
+
* but a send returning Ok means written to the socket, not accepted, because the host
|
|
222
|
+
* acknowledges no controller frame (the reason `OutboundRetention` exists). Here the open is
|
|
223
|
+
* asynchronous: it awaits the workspace provider, and a real `GitWorktreeProvider` is a
|
|
224
|
+
* `git worktree add`, which takes seconds (measured at 13 s once). A seed landing in that window
|
|
225
|
+
* would be refused `session-unknown`, and the session would then open and sit idle forever.
|
|
226
|
+
* Nothing downstream could say why: a born session with no first turn looks exactly like a slow
|
|
227
|
+
* one.
|
|
228
|
+
*
|
|
229
|
+
* With `PlainDirProvider` (a `mkdir`) the window never opens, so only the git provider, which
|
|
230
|
+
* is what a session needs in order to commit or push at all, exposes this.
|
|
231
|
+
*
|
|
232
|
+
* The invariant, and it is the whole point: a held turn is answered, never dropped. Delivered
|
|
233
|
+
* when the session opens (`#compose`), refused if the open failed (`#open`'s `finally`, which runs
|
|
234
|
+
* on every path including a throw), or withdrawn because the controller cancelled it (`#withdraw`).
|
|
235
|
+
* Three exits, all of them reported. A queue that silently discarded on any one of them would be
|
|
236
|
+
* the same defect wearing a fix's clothes.
|
|
237
|
+
*
|
|
238
|
+
* It is deliberately not consulted for a handle this host has never heard of. `session-unknown`
|
|
239
|
+
* stays the honest, immediate answer for an id that does not exist and never will; retrying that
|
|
240
|
+
* is futile and `core/refusal.ts` says so. Only a handle already reserved in `#opening` can hold.
|
|
241
|
+
*/
|
|
242
|
+
#held = new Map();
|
|
243
|
+
/**
|
|
244
|
+
* Which workspace key each live-or-opening session provisioned at (protocol v5).
|
|
245
|
+
*
|
|
246
|
+
* Two consumers, and both exist because keys can alias. The release sites read it so a session
|
|
247
|
+
* provisioned at a shared key releases that key rather than its own session key; without it,
|
|
248
|
+
* `provision('shared-1')` paired with `release(sessionKey)` would leak the claim on every
|
|
249
|
+
* aliased session. And the reap's in-use guard scans its values: N sessions can share one key,
|
|
250
|
+
* each holding its own entry here, so "is this key in use" stays true until the last of them
|
|
251
|
+
* closes; a single-mapping guard would clear on the first close and let a reap delete a tree
|
|
252
|
+
* other sessions are still working in.
|
|
253
|
+
*
|
|
254
|
+
* Written synchronously before the provision await (so an opening session already guards its
|
|
255
|
+
* key); cleared in `#open`'s finally when the open produced no session, and in `#close`.
|
|
256
|
+
*/
|
|
257
|
+
#workspaceKeyFor = new Map();
|
|
258
|
+
/**
|
|
259
|
+
* Every provider call rides a per-key turn. `#dispatch` never awaits a handler, and the work
|
|
260
|
+
* behind these verbs is seconds long and mutually destructive on one key: a `git worktree add`
|
|
261
|
+
* measured at 13 to 18 s, a `git worktree remove --force` the same order. The reachable
|
|
262
|
+
* interleavings: a `session_new` provisioning into a directory the reap is mid-deleting (the
|
|
263
|
+
* in-use scan happened before the multi-second await), and a reap starting inside `#close`'s
|
|
264
|
+
* window (the map entry is deleted before its release settles). One serial queue per key closes
|
|
265
|
+
* every direction at the only chokepoint all three verbs share; keys that differ never wait on
|
|
266
|
+
* each other.
|
|
267
|
+
*/
|
|
268
|
+
#workspaceTurns = new KeyedTurns();
|
|
269
|
+
/**
|
|
270
|
+
* The pieces a `host_configure` may replace, held apart from the frozen options so a reconfigure
|
|
271
|
+
* swaps them in one place and every handler reads the current one. Initialised from the options.
|
|
272
|
+
*/
|
|
273
|
+
#workspaces;
|
|
274
|
+
#transcriptsRoot;
|
|
275
|
+
#bulk;
|
|
276
|
+
#linkCapabilities;
|
|
277
|
+
#configuration;
|
|
278
|
+
#overriddenByEnvironment;
|
|
279
|
+
#pendingRestart;
|
|
280
|
+
constructor(options) {
|
|
281
|
+
this.#options = options;
|
|
282
|
+
this.#workspaces = options.workspaces;
|
|
283
|
+
this.#transcriptsRoot = options.transcriptsRoot;
|
|
284
|
+
this.#bulk = options.bulk;
|
|
285
|
+
this.#linkCapabilities = options.linkCapabilities ?? [];
|
|
286
|
+
this.#configuration = options.configuration ?? unsetHostConfiguration();
|
|
287
|
+
this.#overriddenByEnvironment = options.overriddenByEnvironment ?? [];
|
|
288
|
+
this.#pendingRestart = options.pendingRestart ?? [];
|
|
289
|
+
if (options.registry !== undefined && (options.baseEnv !== undefined || options.homeDir !== undefined)) {
|
|
290
|
+
// A registry carries its own environment and home; a second pair beside it would be read by
|
|
291
|
+
// nothing, and an embedder who passed both would believe the pair was in effect.
|
|
292
|
+
throw new Error('PeriscopeHostOptions: baseEnv and homeDir are ignored when a registry is supplied; pass them to the registry');
|
|
293
|
+
}
|
|
294
|
+
this.#registry =
|
|
295
|
+
options.registry ??
|
|
296
|
+
new SessionRegistry({
|
|
297
|
+
baseEnv: options.baseEnv ?? {},
|
|
298
|
+
homeDir: options.homeDir ?? '',
|
|
299
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
300
|
+
});
|
|
301
|
+
const handlers = {
|
|
302
|
+
onTransition: (transition) => this.#report({ kind: 'link', transition }),
|
|
303
|
+
onSessionFrame: (frame) => this.#dispatch(frame),
|
|
304
|
+
onGap: (sessionKey, expected, received) => this.#report({ kind: 'gap', sessionKey, expected, received }),
|
|
305
|
+
onRefusal: (refused) => this.#report({ kind: 'refusal', refusal: refused, sessionKey: null }),
|
|
306
|
+
};
|
|
307
|
+
this.#link =
|
|
308
|
+
options.link?.(handlers) ??
|
|
309
|
+
new ControllerLink({
|
|
310
|
+
url: options.controllerUrl,
|
|
311
|
+
hostId: options.hostId,
|
|
312
|
+
handlers,
|
|
313
|
+
...(options.credential === undefined ? {} : { credential: options.credential }),
|
|
314
|
+
...(options.backoff === undefined ? {} : { backoff: options.backoff }),
|
|
315
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
316
|
+
...(options.ticker === undefined ? {} : { ticker: options.ticker }),
|
|
317
|
+
...(options.linkCapabilities === undefined ? {} : { capabilities: options.linkCapabilities }),
|
|
318
|
+
...(options.configuration === undefined ? {} : { configuration: options.configuration }),
|
|
319
|
+
...(options.pendingRestart === undefined ? {} : { pendingRestart: options.pendingRestart }),
|
|
320
|
+
...(options.linkTimings?.heartbeatIntervalMs === undefined
|
|
321
|
+
? {}
|
|
322
|
+
: { heartbeatIntervalMs: options.linkTimings.heartbeatIntervalMs }),
|
|
323
|
+
...(options.linkTimings?.heartbeatTimeoutMs === undefined
|
|
324
|
+
? {}
|
|
325
|
+
: { heartbeatTimeoutMs: options.linkTimings.heartbeatTimeoutMs }),
|
|
326
|
+
...(options.linkTimings?.connectTimeoutMs === undefined
|
|
327
|
+
? {}
|
|
328
|
+
: { connectTimeoutMs: options.linkTimings.connectTimeoutMs }),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
get link() {
|
|
332
|
+
return this.#link;
|
|
333
|
+
}
|
|
334
|
+
get registry() {
|
|
335
|
+
return this.#registry;
|
|
336
|
+
}
|
|
337
|
+
/** The composed session behind a controller handle, or a refusal naming the handle. */
|
|
338
|
+
session(sessionKey) {
|
|
339
|
+
const composed = this.#sessions.get(sessionKey);
|
|
340
|
+
if (composed === undefined) {
|
|
341
|
+
return refuse('session-unknown', `no session for controller handle ${sessionKey}`);
|
|
342
|
+
}
|
|
343
|
+
return ok(composed);
|
|
344
|
+
}
|
|
345
|
+
start() {
|
|
346
|
+
this.#link.start();
|
|
347
|
+
}
|
|
348
|
+
/** Ends every session first, then the link — so the end transitions still have somewhere to go. */
|
|
349
|
+
stop(detail = 'host shutting down') {
|
|
350
|
+
if (this.#heldFramesTimer !== null) {
|
|
351
|
+
clearTimeout(this.#heldFramesTimer);
|
|
352
|
+
this.#heldFramesTimer = null;
|
|
353
|
+
}
|
|
354
|
+
for (const composed of [...this.#sessions.values()])
|
|
355
|
+
composed.session.stop(detail);
|
|
356
|
+
this.#registry.stopAll(detail);
|
|
357
|
+
this.#link.stop(detail);
|
|
358
|
+
}
|
|
359
|
+
// -------------------------------------------------------------------------
|
|
360
|
+
#dispatch(frame) {
|
|
361
|
+
const payload = frame.payload;
|
|
362
|
+
switch (payload.kind) {
|
|
363
|
+
case 'session_new':
|
|
364
|
+
void this.#open(frame.sessionId, payload);
|
|
365
|
+
return;
|
|
366
|
+
case 'session_prompt': {
|
|
367
|
+
// A turn for a session whose open is still in flight waits for it rather than being refused.
|
|
368
|
+
// The reservation it tests is the one `#open` already takes; see `#held`.
|
|
369
|
+
if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
|
|
370
|
+
return this.#hold(frame.sessionId, payload.text);
|
|
371
|
+
}
|
|
372
|
+
const composed = this.session(frame.sessionId);
|
|
373
|
+
if (!composed.ok)
|
|
374
|
+
return this.#refuse(frame.sessionId, composed.refusal);
|
|
375
|
+
return this.#prompt(frame.sessionId, composed.value, payload.text);
|
|
376
|
+
}
|
|
377
|
+
case 'session_cancel': {
|
|
378
|
+
// The same race, the other frame. A cancel arriving while the session is still opening
|
|
379
|
+
// stops the turn that is waiting, the only turn that can exist yet. It does not stop the
|
|
380
|
+
// open: `session_cancel` ends a turn, never a session, and that contract is pinned.
|
|
381
|
+
if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
|
|
382
|
+
return this.#withdraw(frame.sessionId);
|
|
383
|
+
}
|
|
384
|
+
const composed = this.session(frame.sessionId);
|
|
385
|
+
if (!composed.ok)
|
|
386
|
+
return this.#refuse(frame.sessionId, composed.refusal);
|
|
387
|
+
composed.value.observer.interrupted('the controller cancelled the turn');
|
|
388
|
+
void composed.value.session.interrupt().catch((error) => {
|
|
389
|
+
this.#refuse(frame.sessionId, refusal('session-unknown', `the interrupt failed: ${describe(error)}`));
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case 'session_configure': {
|
|
394
|
+
// Protocol v6: the SDK's live setters. A session still opening has no query to configure yet; the
|
|
395
|
+
// controller is told so by name rather than left to assume the change landed.
|
|
396
|
+
const composed = this.session(frame.sessionId);
|
|
397
|
+
if (!composed.ok)
|
|
398
|
+
return this.#refuse(frame.sessionId, composed.refusal);
|
|
399
|
+
const change = readSessionConfigure(payload);
|
|
400
|
+
if (!change.ok)
|
|
401
|
+
return this.#refuse(frame.sessionId, change.refusal);
|
|
402
|
+
void composed.value.session.configure(change.value).catch((error) => {
|
|
403
|
+
this.#refuse(frame.sessionId, refusal('session-unknown', `the configure failed: ${describe(error)}`));
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
case 'bulk_request':
|
|
408
|
+
void this.#deliver(frame.sessionId, payload.deliveryId, payload.what, payload.postUrl, payload.fromOffset);
|
|
409
|
+
return;
|
|
410
|
+
// The discovery requests are host-scoped, not session-scoped. The routing key on these
|
|
411
|
+
// frames is a channel the controller chose; no session needs to exist behind it, so none of
|
|
412
|
+
// the session-existence machinery above applies. The answer rides back on the same key.
|
|
413
|
+
case 'session_list':
|
|
414
|
+
return this.#answerSessionList(frame.sessionId, payload);
|
|
415
|
+
case 'transcript_list':
|
|
416
|
+
void this.#answerTranscriptList(frame.sessionId, payload);
|
|
417
|
+
return;
|
|
418
|
+
case 'transcript_tail':
|
|
419
|
+
void this.#answerTranscriptTail(frame.sessionId, payload);
|
|
420
|
+
return;
|
|
421
|
+
case 'workspace_release':
|
|
422
|
+
void this.#answerWorkspaceRelease(frame.sessionId, payload);
|
|
423
|
+
return;
|
|
424
|
+
case 'workspace_release_bulk':
|
|
425
|
+
void this.#answerWorkspaceReleaseBulk(frame.sessionId, payload);
|
|
426
|
+
return;
|
|
427
|
+
case 'host_configure':
|
|
428
|
+
return this.#answerHostConfigure(frame.sessionId, payload);
|
|
429
|
+
case 'workspace_list':
|
|
430
|
+
void this.#answerWorkspaceList(frame.sessionId, payload);
|
|
431
|
+
return;
|
|
432
|
+
case 'repository_list':
|
|
433
|
+
void this.#answerRepositoryList(frame.sessionId, payload);
|
|
434
|
+
return;
|
|
435
|
+
case 'repository_read':
|
|
436
|
+
void this.#answerRepositoryRead(frame.sessionId, payload);
|
|
437
|
+
return;
|
|
438
|
+
default:
|
|
439
|
+
// A kind this host produces, or one a newer controller invented. Named either way.
|
|
440
|
+
this.#refuse(frame.sessionId, refusal('frame-malformed', `this host takes no inbound "${payload.kind}" — it is not a command`));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Send one turn at a live session and record that it happened.
|
|
446
|
+
*
|
|
447
|
+
* One path for both arrivals. A turn that waited and a turn that walked straight in must reach the
|
|
448
|
+
* agent identically and leave the same trace, or "it was queued" would become a second, quieter
|
|
449
|
+
* grade of delivery that nothing downstream could tell apart from the first.
|
|
450
|
+
*/
|
|
451
|
+
#prompt(sessionKey, composed, text) {
|
|
452
|
+
const sent = composed.session.prompt(text);
|
|
453
|
+
if (!sent.ok)
|
|
454
|
+
return this.#refuse(sessionKey, sent.refusal);
|
|
455
|
+
composed.observer.promptSubmitted('the controller sent a turn');
|
|
456
|
+
}
|
|
457
|
+
/** Hold a turn for a session still opening, or refuse it when the bound is already reached. */
|
|
458
|
+
#hold(sessionKey, text) {
|
|
459
|
+
const waiting = this.#held.get(sessionKey) ?? [];
|
|
460
|
+
if (waiting.length >= MAX_HELD_TURNS) {
|
|
461
|
+
// Named rather than dropped, and `session-unknown` rather than a new reason: from the sender's
|
|
462
|
+
// side this handle still holds no session, and the vocabulary in `core/refusal.ts` is closed on
|
|
463
|
+
// purpose; a reason minted for one call site is one nobody else can branch on.
|
|
464
|
+
return this.#refuse(sessionKey, refusal('session-unknown', `${MAX_HELD_TURNS} turns are already waiting for ${sessionKey} to open — this one was not taken`));
|
|
465
|
+
}
|
|
466
|
+
waiting.push(text);
|
|
467
|
+
this.#held.set(sessionKey, waiting);
|
|
468
|
+
this.#report({ kind: 'prompt-held', sessionKey, held: waiting.length });
|
|
469
|
+
}
|
|
470
|
+
/** Every held turn, in arrival order, the moment the session exists. */
|
|
471
|
+
#drain(sessionKey, composed) {
|
|
472
|
+
const waiting = this.#held.get(sessionKey);
|
|
473
|
+
if (waiting === undefined)
|
|
474
|
+
return;
|
|
475
|
+
this.#held.delete(sessionKey);
|
|
476
|
+
for (const text of waiting)
|
|
477
|
+
this.#prompt(sessionKey, composed, text);
|
|
478
|
+
this.#report({ kind: 'prompt-delivered', sessionKey, delivered: waiting.length });
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The controller withdrew the turns it was waiting on. The third answer, and the only one the
|
|
482
|
+
* sender asked for.
|
|
483
|
+
*
|
|
484
|
+
* It is reported rather than silent, which is what keeps it inside the invariant instead of
|
|
485
|
+
* being the exception that swallows it: a withdrawn turn and a dropped one are the same absence on
|
|
486
|
+
* the wire, and only one of them is something to investigate.
|
|
487
|
+
*
|
|
488
|
+
* The limit: no session exists yet, so there is no machine to record an `interrupted`
|
|
489
|
+
* transition on; this is reported by the host, not by the session's own state log. A cancel that
|
|
490
|
+
* lands after the open takes the ordinary path and is recorded there.
|
|
491
|
+
*/
|
|
492
|
+
#withdraw(sessionKey) {
|
|
493
|
+
const waiting = this.#held.get(sessionKey) ?? [];
|
|
494
|
+
this.#held.delete(sessionKey);
|
|
495
|
+
this.#report({ kind: 'prompt-withdrawn', sessionKey, withdrawn: waiting.length });
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The open ended without a session: every turn still waiting is refused, one refusal each.
|
|
499
|
+
*
|
|
500
|
+
* This is the half that keeps the queue honest. Holding a turn is a promise to answer it, and the
|
|
501
|
+
* only failure worse than refusing a turn that could have been served is accepting one and saying
|
|
502
|
+
* nothing, which is the defect this whole queue exists to end, relocated one layer in.
|
|
503
|
+
*/
|
|
504
|
+
#abandon(sessionKey) {
|
|
505
|
+
const waiting = this.#held.get(sessionKey);
|
|
506
|
+
if (waiting === undefined)
|
|
507
|
+
return;
|
|
508
|
+
this.#held.delete(sessionKey);
|
|
509
|
+
for (const _text of waiting) {
|
|
510
|
+
this.#refuse(sessionKey, refusal('session-unknown', `${sessionKey} never opened, so a turn held for it cannot be delivered`));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async #open(sessionKey, opening) {
|
|
514
|
+
if (this.#sessions.has(sessionKey) || this.#opening.has(sessionKey)) {
|
|
515
|
+
// Two `session_new` for one handle: the second must not silently replace the first, which
|
|
516
|
+
// would leave a live agent running with nothing routed to it. The `#opening` half of the
|
|
517
|
+
// check is what makes this hold for two frames in flight: with a workspace provider
|
|
518
|
+
// configured there is an await between here and the map write, and a guard that reads only
|
|
519
|
+
// the map lets both frames through it: double spawn, one leaked agent, one handle.
|
|
520
|
+
return this.#refuse(sessionKey, refusal('session-unknown', `${sessionKey} is already a live or opening session here`));
|
|
521
|
+
}
|
|
522
|
+
this.#opening.add(sessionKey);
|
|
523
|
+
try {
|
|
524
|
+
await this.#compose(sessionKey, opening);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
// Every exit from an open is a wire answer. The dispatcher calls this as
|
|
528
|
+
// `void this.#open(…)`, so without this catch a throw anywhere in `#compose` would become an
|
|
529
|
+
// unhandled rejection: the controller gets nothing at all, its record of the session reads
|
|
530
|
+
// `open` forever, and on a host with no unhandledRejection handler the process could die
|
|
531
|
+
// outright. A command that vanishes reads as a host that hung, the exact failure refusing
|
|
532
|
+
// every unknown payload by name exists to prevent, arriving through the one door that was not
|
|
533
|
+
// refusing.
|
|
534
|
+
//
|
|
535
|
+
// Caught here rather than at each throw site, deliberately. Guarding a named list of throw
|
|
536
|
+
// sources leaves the next one unguarded; this arm cannot be outrun by a source nobody has
|
|
537
|
+
// written yet.
|
|
538
|
+
//
|
|
539
|
+
// The `finally` below still runs for the queue: held turns are answered by `#abandon`, so a
|
|
540
|
+
// throw during an open does not silently strand the turns waiting on it.
|
|
541
|
+
this.#refuseOpen(sessionKey, null, refusal('session-spawn-failed', `opening ${sessionKey} threw rather than returning a refusal: ${describe(error)}`));
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
this.#opening.delete(sessionKey);
|
|
545
|
+
// A successful open drained and cleared the queue already, so this finds nothing. Anything still
|
|
546
|
+
// here means the open did not produce a session (a refused request, a failed provision, a
|
|
547
|
+
// `giveBack`, or a throw) and each waiting turn is answered rather than forgotten. It sits in
|
|
548
|
+
// `finally` so no failure path can skip it, including one nobody has written yet.
|
|
549
|
+
this.#abandon(sessionKey);
|
|
550
|
+
// An open that produced no session holds no workspace claim, so its key entry goes too: in
|
|
551
|
+
// `finally` for the same reason as the queue, and guarded so a successful open keeps the
|
|
552
|
+
// entry the release sites and the in-use guard read for the session's whole life.
|
|
553
|
+
if (!this.#sessions.has(sessionKey))
|
|
554
|
+
this.#workspaceKeyFor.delete(sessionKey);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async #compose(sessionKey, opening) {
|
|
558
|
+
// Narrowed before the workspace is claimed, and the order is the point. A request this host
|
|
559
|
+
// cannot use is refused while it still owns nothing: provisioning first would take a directory,
|
|
560
|
+
// find the request unusable, and have to hand it back, a claim/release round trip on every
|
|
561
|
+
// malformed frame, in the one path a stranger can drive.
|
|
562
|
+
const requested = readSessionRequest(opening.request);
|
|
563
|
+
if (!requested.ok)
|
|
564
|
+
return this.#refuseOpen(sessionKey, null, requested.refusal);
|
|
565
|
+
const provider = this.#workspaces;
|
|
566
|
+
// Where the workspace key resolves: each `??` is a default for an absence, never an
|
|
567
|
+
// override, so a controller that names a key never has the host's configured default consulted.
|
|
568
|
+
// Validated before the workspace is claimed, same order and same reason as the request
|
|
569
|
+
// narrowing above: a key this host cannot use must refuse while it still owns nothing.
|
|
570
|
+
const askedKey = opening.workspaceKey ?? this.#options.defaultWorkspaceKey ?? null;
|
|
571
|
+
if (askedKey !== null) {
|
|
572
|
+
const source = opening.workspaceKey !== null
|
|
573
|
+
? 'session_new.workspaceKey'
|
|
574
|
+
: "this host's configured default workspace key";
|
|
575
|
+
if (provider === undefined) {
|
|
576
|
+
// The `path-input-missing` posture's mirror image: with no provider there is nothing to
|
|
577
|
+
// provision at any key, and running in the controller's cwd instead would deliver the
|
|
578
|
+
// wrong topology silently, the exact failure an explicit key exists to end.
|
|
579
|
+
return this.#refuseOpen(sessionKey, null, refusal('workspace-provision-failed', `${source} names ${askedKey} and this host has no workspace provider — there is nothing to provision at that key, so the ask cannot be honoured`));
|
|
580
|
+
}
|
|
581
|
+
const problem = unusableKeyProblem(askedKey);
|
|
582
|
+
if (problem !== null) {
|
|
583
|
+
// An unusable key is refused by the host's own guard with the field named, never left to
|
|
584
|
+
// die inside git as an unnamed provision failure.
|
|
585
|
+
// `keyPreview`, never the raw key: an over-length key's refusal must itself stay sendable.
|
|
586
|
+
return this.#refuseOpen(sessionKey, null, refusal('workspace-provision-failed', `${source} ${keyPreview(askedKey)} ${problem}`));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const workspaceKey = askedKey ?? sessionKey;
|
|
590
|
+
let cwd = opening.cwd;
|
|
591
|
+
// The provider decides, except for the repository it clones from. A named cwd is advisory
|
|
592
|
+
// under a provider, with one exception: the provider's own repository root, the operator's
|
|
593
|
+
// checkout, trusted in their ~/.claude.json, where the CLI they know runs and every settings
|
|
594
|
+
// tier loads. There is no isolation to protect when the operator asks for the source itself,
|
|
595
|
+
// and no worktree could be more "the repo" than the repo. Any other named directory still gets
|
|
596
|
+
// a provisioned workspace. The workspace guard's root follows the cwd either way; the
|
|
597
|
+
// operator's checkout claims no workspace key, so the reap never touches it.
|
|
598
|
+
const operatorsCheckout = provider !== undefined &&
|
|
599
|
+
cwd !== null &&
|
|
600
|
+
provider.repositoryRoot !== undefined &&
|
|
601
|
+
normalizePath(cwd).toLowerCase() === normalizePath(provider.repositoryRoot).toLowerCase();
|
|
602
|
+
// A resume runs where its transcript lives, or not at all. The CLI keeps transcripts per cwd;
|
|
603
|
+
// a resume moved into a provisioned workspace finds nothing and becomes a fresh session that
|
|
604
|
+
// says nothing. A named cwd the provider would not honour is therefore refused by name, on the
|
|
605
|
+
// wire, instead of being quietly overruled.
|
|
606
|
+
if (provider !== undefined && !operatorsCheckout && cwd !== null && opening.request?.resume) {
|
|
607
|
+
return this.#refuseOpen(sessionKey, null, refusal('resume-cwd-not-honoured', `the resume names ${cwd}, but this host provisions workspaces and honours only its own repository root (${provider.repositoryRoot ?? 'none configured'}) — the transcript would not be found anywhere else, so the session is not started`));
|
|
608
|
+
}
|
|
609
|
+
let claimed = false;
|
|
610
|
+
if (provider !== undefined && !operatorsCheckout) {
|
|
611
|
+
// Before the await, so the reap's in-use guard already covers a session whose open is still
|
|
612
|
+
// in flight. `#open`'s finally clears it again when the open produced no session.
|
|
613
|
+
this.#workspaceKeyFor.set(sessionKey, workspaceKey);
|
|
614
|
+
// The turn, not a bare await: a provision arriving while this key's reap (or a closing
|
|
615
|
+
// session's release) is still running waits for it to settle instead of racing the removal.
|
|
616
|
+
const provisioned = await this.#workspaceTurns.run(workspaceKey, () => provider.provision(workspaceKey));
|
|
617
|
+
if (!provisioned.ok)
|
|
618
|
+
return this.#refuseOpen(sessionKey, null, provisioned.refusal);
|
|
619
|
+
cwd = provisioned.value.path;
|
|
620
|
+
claimed = true;
|
|
621
|
+
}
|
|
622
|
+
else if (cwd === null) {
|
|
623
|
+
// A null cwd is "the provider decides", and this host has no provider, so there is no
|
|
624
|
+
// decider and nowhere honest to fall back to. Not `process.cwd()`: sharing the host's own
|
|
625
|
+
// directory is the weakest isolation `bin/workspaces.ts` knows, and it is a posture an embedder
|
|
626
|
+
// chooses, never one a frame's absence smuggles in. Refused before anything is claimed, with
|
|
627
|
+
// the same name a path-taking tool uses for a pathless input.
|
|
628
|
+
return this.#refuseOpen(sessionKey, null, refusal('path-input-missing', 'session_new carries cwd: null and this host has no workspace provider — there is no decider to defer to, so the session has nowhere to run'));
|
|
629
|
+
}
|
|
630
|
+
// A workspace this host asked for and then could not use is handed back, on every failure path
|
|
631
|
+
// below. The provider's default is to leave the directory alone, so this is a release of the
|
|
632
|
+
// claim rather than a deletion, but a host that never releases makes `provision` and `release`
|
|
633
|
+
// unbalanced, and a provider counting sessions would leak one per refused start.
|
|
634
|
+
const giveBack = async (refused) => {
|
|
635
|
+
this.#refuseOpen(sessionKey, cwd, refused);
|
|
636
|
+
if (!claimed || provider === undefined)
|
|
637
|
+
return;
|
|
638
|
+
// The claim was taken at the workspace key, so it is handed back at the workspace key;
|
|
639
|
+
// releasing the session key here would release nothing the moment keys alias.
|
|
640
|
+
const released = await provider.release(workspaceKey);
|
|
641
|
+
if (!released.ok)
|
|
642
|
+
this.#refuse(sessionKey, released.refusal);
|
|
643
|
+
};
|
|
644
|
+
const tools = this.#toolServer(sessionKey);
|
|
645
|
+
if (!tools.ok)
|
|
646
|
+
return giveBack(tools.refusal);
|
|
647
|
+
// The one throw source in this function, named. `readWhere` walks up from `cwd` looking for
|
|
648
|
+
// a `.git`, so it touches the filesystem with a path the controller chose: a malformed `cwd`
|
|
649
|
+
// (an illegal segment, a path the OS refuses to stat) throws out of here rather than
|
|
650
|
+
// returning. The catch in `#open` would turn that into a refusal anyway; this exists so the
|
|
651
|
+
// refusal says where, instead of naming the whole open.
|
|
652
|
+
//
|
|
653
|
+
// Its two neighbours are not throw sources and are not guarded: `localGate({…})` builds a
|
|
654
|
+
// closure and touches nothing at construction, and `nodePathResolver` is only stored here; its
|
|
655
|
+
// one throw fires later, inside the gate's decide path, which is a different surface with a
|
|
656
|
+
// different answer.
|
|
657
|
+
let where;
|
|
658
|
+
try {
|
|
659
|
+
where = readWhere(cwd);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
return giveBack(refusal('session-spawn-failed', `reading git facts for ${cwd} threw, so this session has no honest 'where': ${describe(error)}`));
|
|
663
|
+
}
|
|
664
|
+
const servers = mergeMcpServers(requested.value.mcpServers, tools.value);
|
|
665
|
+
if (!servers.ok)
|
|
666
|
+
return giveBack(servers.refusal);
|
|
667
|
+
const composed = composeSession({
|
|
668
|
+
registry: this.#registry,
|
|
669
|
+
sessionKey,
|
|
670
|
+
cwd,
|
|
671
|
+
sink: this.#link,
|
|
672
|
+
decide: this.#options.decide,
|
|
673
|
+
// Jailed to its own workspace by default, and the default is the whole point: a host whose
|
|
674
|
+
// local gate is opt-in is a host most embedders run without one.
|
|
675
|
+
localGate: localGate({
|
|
676
|
+
workspaceRoot: cwd,
|
|
677
|
+
resolve: nodePathResolver,
|
|
678
|
+
protectedPaths: this.#options.protectedPaths,
|
|
679
|
+
...(this.#options.toolFamilies === undefined ? {} : { toolFamilies: this.#options.toolFamilies }),
|
|
680
|
+
}),
|
|
681
|
+
where,
|
|
682
|
+
// The controller's own handle when it sent one, the routing key otherwise. The key stays the
|
|
683
|
+
// fallback rather than null: a transition with no correlation at all is strictly less useful
|
|
684
|
+
// than one correlated to the handle the controller is already using. The two are still
|
|
685
|
+
// different contracts (one is interpreted by construction, the other never) and nothing here
|
|
686
|
+
// derives one from the other in the direction that would matter.
|
|
687
|
+
correlationId: opening.correlationId ?? sessionKey,
|
|
688
|
+
// This host grants what its gate approves, and that is a posture rather than an inherited
|
|
689
|
+
// default. It loads no settings files, so nothing sits behind the gate that a grant could
|
|
690
|
+
// override, and without it the gate would be a veto: able to refuse a call, unable to let one
|
|
691
|
+
// through. An embedder who wants the silent-allow posture sets `grantOnAllow: false` and gets
|
|
692
|
+
// a host whose agent cannot run a tool, which is a legitimate thing to want and a surprising
|
|
693
|
+
// thing to get by accident. Recorded in SECURITY.md, not only here.
|
|
694
|
+
// Three layers, and the order is a decision. The host's posture is the floor, the embedder's
|
|
695
|
+
// configuration overrides it, and the controller's per-session timings win last, because a
|
|
696
|
+
// timing is a statement about how long that controller is willing to wait, and it is the only
|
|
697
|
+
// party that knows. `grantOnAllow` is deliberately not reachable from the wire and so cannot be
|
|
698
|
+
// overridden by the last spread; see `SessionNewGate`.
|
|
699
|
+
gate: { grantOnAllow: true, ...(this.#options.gate ?? {}), ...readGateTimings(opening.gate) },
|
|
700
|
+
request: { ...requested.value, ...(servers.value === null ? {} : { mcpServers: servers.value }) },
|
|
701
|
+
...(this.#options.clock === undefined ? {} : { clock: this.#options.clock }),
|
|
702
|
+
...(this.#options.ticker === undefined ? {} : { ticker: this.#options.ticker }),
|
|
703
|
+
onRefusal: (refused) => this.#refuse(sessionKey, refused),
|
|
704
|
+
onHookFailure: (failure) => this.#refuse(sessionKey, refusal('transition-cause-unnamed', `a ${failure.event} handler threw: ${describe(failure.error)}`)),
|
|
705
|
+
});
|
|
706
|
+
if (!composed.ok)
|
|
707
|
+
return giveBack(composed.refusal);
|
|
708
|
+
this.#sessions.set(sessionKey, composed.value);
|
|
709
|
+
composed.value.machine.onTransition((transition) => this.#report({ kind: 'transition', sessionKey, transition }));
|
|
710
|
+
composed.value.session.onEnd(() => this.#close(sessionKey));
|
|
711
|
+
// `onDegrade` replays what was raised before this line ran, which is what makes the
|
|
712
|
+
// create-time degrades (an untrusted workspace is reported during `create()`) reachable at
|
|
713
|
+
// all; the collision degrade arrives live later, when the agent reports an id already held.
|
|
714
|
+
composed.value.session.onDegrade((degrade) => this.#report({ kind: 'degrade', sessionKey, degrade }));
|
|
715
|
+
this.#report({ kind: 'session-opened', sessionKey, cwd });
|
|
716
|
+
// After the open is reported, so the trace reads in causal order: held, opened, delivered. The
|
|
717
|
+
// gap between the first and the last is the provisioning window, stated rather than inferred.
|
|
718
|
+
this.#drain(sessionKey, composed.value);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Everything a finished session holds, released in the order that keeps its last frames.
|
|
722
|
+
*
|
|
723
|
+
* `forgetSession` deliberately does not discard unacked frames: a session's final transitions are
|
|
724
|
+
* its most important and are exactly the ones still in flight if the link is down when it ends.
|
|
725
|
+
*/
|
|
726
|
+
#close(sessionKey) {
|
|
727
|
+
const composed = this.#sessions.get(sessionKey);
|
|
728
|
+
if (composed === undefined)
|
|
729
|
+
return;
|
|
730
|
+
this.#sessions.delete(sessionKey);
|
|
731
|
+
composed.detach();
|
|
732
|
+
this.#link.forgetSession(sessionKey);
|
|
733
|
+
// Released at the key the workspace was provisioned at: for an aliased session the two
|
|
734
|
+
// differ, and releasing the session key would leak the claim. Deleted from the map first so
|
|
735
|
+
// the in-use guard stops counting this session the moment it is gone.
|
|
736
|
+
const workspaceKey = this.#workspaceKeyFor.get(sessionKey) ?? sessionKey;
|
|
737
|
+
this.#workspaceKeyFor.delete(sessionKey);
|
|
738
|
+
const provider = this.#workspaces;
|
|
739
|
+
// The release rides the key's turn: the map entry above is gone synchronously, so a reap
|
|
740
|
+
// arriving now already sees the key as free; serializing the release behind the same turn is
|
|
741
|
+
// what keeps that reap from removing the tree while this release still runs.
|
|
742
|
+
if (provider !== undefined) {
|
|
743
|
+
void this.#workspaceTurns
|
|
744
|
+
.run(workspaceKey, () => provider.release(workspaceKey))
|
|
745
|
+
.then((released) => {
|
|
746
|
+
if (!released.ok)
|
|
747
|
+
this.#refuse(sessionKey, released.refusal);
|
|
748
|
+
},
|
|
749
|
+
// A provider that throws instead of returning a refusal must land in the same place: the
|
|
750
|
+
// named-refusal contract for a quiet release leak exists precisely because the failure is
|
|
751
|
+
// invisible until a disk fills, and an unhandled rejection here was the one path around it.
|
|
752
|
+
(error) => this.#refuse(sessionKey, refusal('workspace-release-failed', describe(error))));
|
|
753
|
+
}
|
|
754
|
+
this.#report({ kind: 'session-closed', sessionKey });
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The in-process MCP server for a session, or null when this host offers no tools.
|
|
758
|
+
*
|
|
759
|
+
* The descriptors are the embedder's, not the controller's, and that is forced rather than
|
|
760
|
+
* chosen: no payload kind registers an in-process tool, so there is no way for a controller to
|
|
761
|
+
* declare one over the link. (A controller can name an HTTP or stdio MCP server through
|
|
762
|
+
* `session_new.request.mcpServers`; see `wire-request.ts`.)
|
|
763
|
+
*/
|
|
764
|
+
#toolServer(sessionKey) {
|
|
765
|
+
const tools = this.#options.tools;
|
|
766
|
+
if (tools === undefined)
|
|
767
|
+
return ok(null);
|
|
768
|
+
const built = createToolServer({
|
|
769
|
+
...tools,
|
|
770
|
+
// Read at call time, never captured: the agent has no id until its first turn is queued.
|
|
771
|
+
identity: () => ({ sessionId: this.#sessions.get(sessionKey)?.machine.sessionId ?? null }),
|
|
772
|
+
});
|
|
773
|
+
if (!built.ok) {
|
|
774
|
+
return refuse(built.refusal.reason, built.refusal.detail);
|
|
775
|
+
}
|
|
776
|
+
return ok({ [tools.name]: built.value });
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Answer `session_list` from what this host holds: every handle it routes, plus the registry's
|
|
780
|
+
* two counts. Synchronous: no filesystem, no process, nothing to await.
|
|
781
|
+
*/
|
|
782
|
+
#answerSessionList(channelKey, asked) {
|
|
783
|
+
const sessions = [...this.#sessions.entries()].map(([sessionKey, composed]) => ({
|
|
784
|
+
sessionKey,
|
|
785
|
+
sessionId: composed.session.id,
|
|
786
|
+
state: composed.session.state,
|
|
787
|
+
cwd: composed.machine.where.cwd,
|
|
788
|
+
startedAt: composed.session.facts?.startedAt ?? null,
|
|
789
|
+
}));
|
|
790
|
+
this.#send(channelKey, sessionListResult(asked.requestId, sessions, {
|
|
791
|
+
liveCount: this.#registry.liveCount,
|
|
792
|
+
provisioningCount: this.#registry.provisioningCount,
|
|
793
|
+
}));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Answer `transcript_list` from the discovery door, one page per request.
|
|
797
|
+
*
|
|
798
|
+
* Every exit sends a wire answer. A missing root, a failed walk and a thrown error all become a
|
|
799
|
+
* `transcript_failed` naming the reason; an answer that dies inside the host would read to the
|
|
800
|
+
* asker as a host that hung, which is the exact failure the closed dispatch set exists to end.
|
|
801
|
+
* (`#send` reports a send the link refused locally; the attempt is what every exit guarantees.)
|
|
802
|
+
*/
|
|
803
|
+
async #answerTranscriptList(channelKey, asked) {
|
|
804
|
+
const root = this.#transcriptsRoot;
|
|
805
|
+
if (root === undefined) {
|
|
806
|
+
return this.#failDiscovery(channelKey, asked.requestId, refusal('path-input-missing', 'this host has no transcripts root configured — the discovery door cannot look'));
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const page = await listTranscripts(root, { fromIndex: asked.fromIndex });
|
|
810
|
+
this.#send(channelKey, transcriptListResult(asked.requestId, page.entries, {
|
|
811
|
+
totalCount: page.totalCount,
|
|
812
|
+
...(page.nextIndex === null ? {} : { nextIndex: page.nextIndex }),
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/** Answer `transcript_tail`. Same exit discipline as the listing: every path answers. */
|
|
820
|
+
async #answerTranscriptTail(channelKey, asked) {
|
|
821
|
+
const root = this.#transcriptsRoot;
|
|
822
|
+
if (root === undefined) {
|
|
823
|
+
return this.#failDiscovery(channelKey, asked.requestId, refusal('path-input-missing', 'this host has no transcripts root configured — the discovery door cannot look'));
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
const answer = await tailTranscript(root, asked.projectSlug, asked.sessionId, {
|
|
827
|
+
fromOffset: asked.fromOffset,
|
|
828
|
+
needle: asked.needle,
|
|
829
|
+
});
|
|
830
|
+
if (!answer.ok)
|
|
831
|
+
return this.#failDiscovery(channelKey, asked.requestId, answer.refusal);
|
|
832
|
+
this.#send(channelKey, transcriptTailResult(asked.requestId, {
|
|
833
|
+
found: answer.value.found,
|
|
834
|
+
absent: answer.value.absent,
|
|
835
|
+
newOffset: answer.value.newOffset,
|
|
836
|
+
...(answer.value.sizeBytes === null ? {} : { sizeBytes: answer.value.sizeBytes }),
|
|
837
|
+
...(answer.value.mtimeMs === null ? {} : { mtimeMs: answer.value.mtimeMs }),
|
|
838
|
+
}));
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Answer `repository_list`: one directory of the repository this host provisions from,
|
|
846
|
+
* names only, jailed to the repository root by `listRepositoryDirectory`. Every exit is the one
|
|
847
|
+
* result kind; a host with no repository root has nothing to read under and says so by name.
|
|
848
|
+
*/
|
|
849
|
+
async #answerRepositoryList(channelKey, asked) {
|
|
850
|
+
const refused = (why) => this.#refuseRepository(channelKey, repositoryListResult(asked.requestId, [], false, wireRefusalOf(why)), why);
|
|
851
|
+
const root = this.#workspaces?.repositoryRoot;
|
|
852
|
+
if (root === undefined)
|
|
853
|
+
return refused(noRepositoryRoot());
|
|
854
|
+
try {
|
|
855
|
+
const listed = await listRepositoryDirectory(root, asked.path, undefined, this.#options.protectedPaths);
|
|
856
|
+
if (!listed.ok)
|
|
857
|
+
return refused(listed.refusal);
|
|
858
|
+
this.#send(channelKey, repositoryListResult(asked.requestId, listed.value.entries, listed.value.truncated));
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
refused(refusal('repository-read-failed', describe(error)));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/** Answer `repository_read`: the head of one text file under the repository root. */
|
|
865
|
+
async #answerRepositoryRead(channelKey, asked) {
|
|
866
|
+
const nothing = { text: null, sizeBytes: 0, truncated: false };
|
|
867
|
+
const refused = (why) => this.#refuseRepository(channelKey, repositoryReadResult(asked.requestId, nothing, wireRefusalOf(why)), why);
|
|
868
|
+
const root = this.#workspaces?.repositoryRoot;
|
|
869
|
+
if (root === undefined)
|
|
870
|
+
return refused(noRepositoryRoot());
|
|
871
|
+
try {
|
|
872
|
+
const read = await readRepositoryFile(root, asked.path, asked.maxBytes, this.#options.protectedPaths);
|
|
873
|
+
if (!read.ok)
|
|
874
|
+
return refused(read.refusal);
|
|
875
|
+
this.#send(channelKey, repositoryReadResult(asked.requestId, read.value));
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
refused(refusal('repository-read-failed', describe(error)));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/** A refused repository ask goes on the wire as its result kind, and is reported locally too. */
|
|
882
|
+
#refuseRepository(channelKey, answer, why) {
|
|
883
|
+
this.#send(channelKey, answer);
|
|
884
|
+
this.#refuse(channelKey, why);
|
|
885
|
+
}
|
|
886
|
+
/** A discovery request that could not be answered says so on the wire; the `#failDelivery` twin. */
|
|
887
|
+
#failDiscovery(channelKey, requestId, refused) {
|
|
888
|
+
this.#send(channelKey, transcriptFailed(requestId, { reason: refused.reason, detail: refused.detail }));
|
|
889
|
+
this.#refuse(channelKey, refused);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Answer `workspace_release` (protocol v5): remove a workspace's directory, on demand, by name.
|
|
893
|
+
*
|
|
894
|
+
* Every exit is the one result kind: `refusal: null` is the released answer, a named refusal
|
|
895
|
+
* is every other, so "every exit sends a wire answer" is a property of the shape here rather than
|
|
896
|
+
* a discipline across two builders. The answer is one small frame, nowhere near the frame cap,
|
|
897
|
+
* unconditionally, since the key screen is length-bounded and the detail echoes `keyPreview`,
|
|
898
|
+
* never the raw key.
|
|
899
|
+
*
|
|
900
|
+
* The in-use guard is many-to-one, because keys alias: N sessions can share one workspace key,
|
|
901
|
+
* each with its own entry in `#workspaceKeyFor`, so the guard scans values and holds until the
|
|
902
|
+
* last session on the key is gone. A guard that cleared on the first close would delete a tree
|
|
903
|
+
* the other sessions are still working in, the exact conditions the shared-worktree topology
|
|
904
|
+
* exists to create. Opening sessions count too: their entry is written before the provision
|
|
905
|
+
* await.
|
|
906
|
+
*
|
|
907
|
+
* `ReleaseOptions.remove` stays default-false everywhere else. This is the one production path
|
|
908
|
+
* that passes it, and it passes it because a caller asked; a session ending still leaves its
|
|
909
|
+
* directory for whoever wants to look at it.
|
|
910
|
+
*/
|
|
911
|
+
async #answerWorkspaceRelease(channelKey, asked) {
|
|
912
|
+
const outcome = await this.#releaseOne(asked, false);
|
|
913
|
+
this.#send(channelKey, workspaceReleaseResult(asked.requestId, outcome.result));
|
|
914
|
+
if (outcome.refused !== null)
|
|
915
|
+
this.#refuse(channelKey, outcome.refused);
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Every entry is judged and released on its own — different keys interleave, the same key
|
|
919
|
+
* serialises under its turn — and a refusal on one never aborts the rest. Duplicates are judged
|
|
920
|
+
* on the name as asked: the second entry naming a key or a path already in this ask does nothing
|
|
921
|
+
* and says so.
|
|
922
|
+
*/
|
|
923
|
+
async #answerWorkspaceReleaseBulk(channelKey, asked) {
|
|
924
|
+
const named = new Set();
|
|
925
|
+
const outcomes = await Promise.all(asked.releases.map((entry) => {
|
|
926
|
+
const name = entry.workspaceKey ?? entry.path;
|
|
927
|
+
const duplicate = name !== null && named.has(name);
|
|
928
|
+
if (name !== null)
|
|
929
|
+
named.add(name);
|
|
930
|
+
return this.#releaseOne(entry, duplicate);
|
|
931
|
+
}));
|
|
932
|
+
this.#send(channelKey, workspaceReleaseBulkResult(asked.requestId, outcomes.map((outcome) => outcome.result)));
|
|
933
|
+
for (const outcome of outcomes) {
|
|
934
|
+
if (outcome.refused !== null)
|
|
935
|
+
this.#refuse(channelKey, outcome.refused);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* One release: resolve the address to a key, screen it, then under the key's turn run the in-use
|
|
940
|
+
* guard and the provider's release with the ask's flags. Every exit is a result; `refused` is the
|
|
941
|
+
* same refusal for the log.
|
|
942
|
+
*/
|
|
943
|
+
async #releaseOne(ask, duplicate) {
|
|
944
|
+
const provider = this.#workspaces;
|
|
945
|
+
const refused = (reason, detail, partial = {}) => {
|
|
946
|
+
const problem = refusal(reason, detail);
|
|
947
|
+
return {
|
|
948
|
+
result: workspaceReleaseEntryResult({
|
|
949
|
+
...partial,
|
|
950
|
+
refusal: { reason: problem.reason, detail: problem.detail },
|
|
951
|
+
}),
|
|
952
|
+
refused: problem,
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
if (provider === undefined) {
|
|
956
|
+
return refused('workspace-release-failed', 'this host has no workspace provider — there is no workspace to release');
|
|
957
|
+
}
|
|
958
|
+
if (ask.workspaceKey !== null && ask.path !== null) {
|
|
959
|
+
return refused('workspace-release-failed', 'workspace_release names both a workspaceKey and a path — name exactly one');
|
|
960
|
+
}
|
|
961
|
+
let workspaceKey;
|
|
962
|
+
if (ask.path !== null) {
|
|
963
|
+
if (provider.keyForPath === undefined) {
|
|
964
|
+
return refused('workspace-release-failed', "this host's workspace provider cannot address a worktree by path — name its workspaceKey", { path: ask.path });
|
|
965
|
+
}
|
|
966
|
+
const resolved = provider.keyForPath(ask.path);
|
|
967
|
+
if (resolved === null) {
|
|
968
|
+
return refused('workspace-release-failed', `workspace_release.path ${keyPreview(ask.path)} is not a directory directly under this host's workspace root`, { path: ask.path });
|
|
969
|
+
}
|
|
970
|
+
workspaceKey = resolved;
|
|
971
|
+
}
|
|
972
|
+
else if (ask.workspaceKey !== null) {
|
|
973
|
+
workspaceKey = ask.workspaceKey;
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
return refused('workspace-release-failed', 'workspace_release names neither a workspaceKey nor a path — name exactly one');
|
|
977
|
+
}
|
|
978
|
+
const address = { workspaceKey, path: ask.path };
|
|
979
|
+
const problem = unusableKeyProblem(workspaceKey);
|
|
980
|
+
if (problem !== null) {
|
|
981
|
+
return refused('workspace-release-failed', `workspace_release.workspaceKey ${keyPreview(workspaceKey)} ${problem}`, address);
|
|
982
|
+
}
|
|
983
|
+
if (duplicate) {
|
|
984
|
+
return refused('workspace-release-failed', `workspace ${workspaceKey} is named twice in one ask — this entry does nothing`, address);
|
|
985
|
+
}
|
|
986
|
+
// The scan and the removal ride one turn. The in-use guard is check-then-act across a
|
|
987
|
+
// multi-second `git worktree remove`; scanned outside the turn, a `session_new` on this key
|
|
988
|
+
// could pass its own guard mid-removal and provision into a dying directory while this answer
|
|
989
|
+
// reads released. Inside the turn the scan runs after any queued provision or close-release
|
|
990
|
+
// settles, so it reads the map the other verbs have finished writing.
|
|
991
|
+
return this.#workspaceTurns.run(workspaceKey, async () => {
|
|
992
|
+
const inUse = [...this.#workspaceKeyFor.values()].includes(workspaceKey);
|
|
993
|
+
if (inUse) {
|
|
994
|
+
return refused('workspace-release-failed', `workspace ${workspaceKey} still backs a live or opening session on this host — close every session on it before asking for its removal`, address);
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const released = await provider.release(workspaceKey, {
|
|
998
|
+
remove: true,
|
|
999
|
+
deleteBranch: ask.deleteBranch,
|
|
1000
|
+
force: ask.force,
|
|
1001
|
+
});
|
|
1002
|
+
if (!released.ok)
|
|
1003
|
+
return refused(released.refusal.reason, released.refusal.detail, address);
|
|
1004
|
+
const receipt = released.value;
|
|
1005
|
+
// A provider that answers no receipt vouches for nothing beyond "released or absent".
|
|
1006
|
+
if (receipt === undefined)
|
|
1007
|
+
return { result: workspaceReleaseEntryResult(address), refused: null };
|
|
1008
|
+
const partial = {
|
|
1009
|
+
workspaceKey,
|
|
1010
|
+
path: receipt.path,
|
|
1011
|
+
directoryRemoved: receipt.directoryRemoved,
|
|
1012
|
+
branchDeleted: receipt.branchDeleted,
|
|
1013
|
+
};
|
|
1014
|
+
if (receipt.refusal !== null)
|
|
1015
|
+
return refused(receipt.refusal.reason, receipt.refusal.detail, partial);
|
|
1016
|
+
return { result: workspaceReleaseEntryResult(partial), refused: null };
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
// A provider that throws instead of refusing lands in the same named place, the contract
|
|
1020
|
+
// the ordinary release path already holds.
|
|
1021
|
+
return refused('workspace-release-failed', describe(error), address);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* List the worktrees under the workspace root, read from disk now. Paged like the transcript
|
|
1027
|
+
* listing; every exit is one `workspace_list_result`, refusal or page, so a host that cannot look
|
|
1028
|
+
* says so by name rather than going quiet.
|
|
1029
|
+
*/
|
|
1030
|
+
async #answerWorkspaceList(channelKey, asked) {
|
|
1031
|
+
const answer = (page, refused) => {
|
|
1032
|
+
this.#send(channelKey, workspaceListResult(asked.requestId, page.entries, page, refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail }));
|
|
1033
|
+
if (refused !== undefined)
|
|
1034
|
+
this.#refuse(channelKey, refused);
|
|
1035
|
+
};
|
|
1036
|
+
const empty = { entries: [], totalCount: 0, defaultBranch: null };
|
|
1037
|
+
const provider = this.#workspaces;
|
|
1038
|
+
if (provider === undefined) {
|
|
1039
|
+
return answer(empty, refusal('workspace-list-failed', 'this host has no workspace provider — there is nothing to list'));
|
|
1040
|
+
}
|
|
1041
|
+
if (provider.inventory === undefined) {
|
|
1042
|
+
return answer(empty, refusal('workspace-list-failed', "this host's workspace provider keeps no inventory"));
|
|
1043
|
+
}
|
|
1044
|
+
let inventory;
|
|
1045
|
+
try {
|
|
1046
|
+
inventory = await provider.inventory();
|
|
1047
|
+
}
|
|
1048
|
+
catch (error) {
|
|
1049
|
+
return answer(empty, refusal('workspace-list-failed', describe(error)));
|
|
1050
|
+
}
|
|
1051
|
+
if (!inventory.ok)
|
|
1052
|
+
return answer(empty, inventory.refusal);
|
|
1053
|
+
const all = inventory.value.entries;
|
|
1054
|
+
const from = Math.max(0, asked.fromIndex);
|
|
1055
|
+
const end = Math.min(all.length, from + WORKSPACE_PAGE_SIZE);
|
|
1056
|
+
return answer({
|
|
1057
|
+
entries: all.slice(from, end),
|
|
1058
|
+
totalCount: all.length,
|
|
1059
|
+
...(end < all.length ? { nextIndex: end } : {}),
|
|
1060
|
+
defaultBranch: inventory.value.defaultBranch,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Apply a configuration change. Every exit is one `host_configure_result` carrying the
|
|
1065
|
+
* effective configuration, so a controller always learns what this host runs with, refused or
|
|
1066
|
+
* not. The seam validates and writes; this method only decides whether the host is busy and swaps
|
|
1067
|
+
* the rebuilt pieces in, then tells the link what its next hello declares.
|
|
1068
|
+
*/
|
|
1069
|
+
#answerHostConfigure(channelKey, asked) {
|
|
1070
|
+
const answer = (refused) => {
|
|
1071
|
+
this.#send(channelKey, hostConfigureResult(asked.requestId, this.#configuration, this.#overriddenByEnvironment, refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail }, this.#pendingRestart));
|
|
1072
|
+
if (refused !== undefined)
|
|
1073
|
+
this.#refuse(channelKey, refused);
|
|
1074
|
+
};
|
|
1075
|
+
const reconfigure = this.#options.reconfigure;
|
|
1076
|
+
if (reconfigure === undefined) {
|
|
1077
|
+
return answer(refusal('config-write-failed', 'this host was composed without a configuration seam, so nothing can be written'));
|
|
1078
|
+
}
|
|
1079
|
+
// Busy means a workspace is claimed or being claimed: a session releases through the provider
|
|
1080
|
+
// that provisioned it, so the roots cannot move under one. The seam refuses only when the ask
|
|
1081
|
+
// actually changes a root; a scheme or transcripts-root change applies live regardless.
|
|
1082
|
+
const hostBusy = this.#sessions.size > 0 || this.#opening.size > 0;
|
|
1083
|
+
let outcome;
|
|
1084
|
+
try {
|
|
1085
|
+
outcome = reconfigure(asked.entries, hostBusy);
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
return answer(refusal('config-write-failed', describe(error)));
|
|
1089
|
+
}
|
|
1090
|
+
if (!outcome.ok)
|
|
1091
|
+
return answer(outcome.refusal);
|
|
1092
|
+
const rebuilt = outcome.value;
|
|
1093
|
+
this.#workspaces = rebuilt.workspaces;
|
|
1094
|
+
this.#transcriptsRoot = rebuilt.transcriptsRoot;
|
|
1095
|
+
this.#bulk = rebuilt.bulk;
|
|
1096
|
+
this.#linkCapabilities = rebuilt.linkCapabilities;
|
|
1097
|
+
this.#configuration = rebuilt.configuration;
|
|
1098
|
+
this.#overriddenByEnvironment = rebuilt.overriddenByEnvironment;
|
|
1099
|
+
// The control-plane addresses are never applied to the live link: the host keeps dialling what it
|
|
1100
|
+
// dialled, names the keys as pending, and the next start reads the file.
|
|
1101
|
+
this.#pendingRestart = rebuilt.pendingRestart;
|
|
1102
|
+
this.#link.announce?.(this.#linkCapabilities, this.#configuration, this.#pendingRestart);
|
|
1103
|
+
return answer();
|
|
1104
|
+
}
|
|
1105
|
+
async #deliver(sessionKey, deliveryId, what, postUrl, fromOffset) {
|
|
1106
|
+
const resolve = this.#bulk;
|
|
1107
|
+
if (resolve === undefined) {
|
|
1108
|
+
return this.#failDelivery(sessionKey, deliveryId, refusal('bulk-target-invalid', 'this host resolves no bulk content — nothing can be delivered'));
|
|
1109
|
+
}
|
|
1110
|
+
const filePath = resolve(what, sessionKey);
|
|
1111
|
+
if (!filePath.ok)
|
|
1112
|
+
return this.#failDelivery(sessionKey, deliveryId, filePath.refusal);
|
|
1113
|
+
// The one origin this host will POST to, derived from the link it already dials. Resolved per
|
|
1114
|
+
// delivery rather than cached so a reconfigured controller URL cannot leave a stale trusted
|
|
1115
|
+
// origin behind it.
|
|
1116
|
+
const allowedOrigin = bulkOriginFor(this.#options.controllerUrl);
|
|
1117
|
+
if (!allowedOrigin.ok)
|
|
1118
|
+
return this.#failDelivery(sessionKey, deliveryId, allowedOrigin.refusal);
|
|
1119
|
+
// The same credential the link and the decision POST present, resolved per delivery. A
|
|
1120
|
+
// credential that refuses posts headerless rather than not at all: this lane is a read-back the
|
|
1121
|
+
// controller itself asked for, and the receiver's refusal comes back loud on the wire as
|
|
1122
|
+
// `bulk-delivery-failed` naming the status, which beats a silent no-request.
|
|
1123
|
+
let headers;
|
|
1124
|
+
const credential = this.#options.credential;
|
|
1125
|
+
if (credential !== undefined) {
|
|
1126
|
+
const authorized = await credential.authorize();
|
|
1127
|
+
if (authorized.ok)
|
|
1128
|
+
headers = { [authorized.value.header]: authorized.value.value };
|
|
1129
|
+
}
|
|
1130
|
+
let receipt;
|
|
1131
|
+
try {
|
|
1132
|
+
receipt = await postBulk({
|
|
1133
|
+
deliveryId,
|
|
1134
|
+
postUrl,
|
|
1135
|
+
allowedOrigin: allowedOrigin.value,
|
|
1136
|
+
filePath: filePath.value,
|
|
1137
|
+
fromOffset,
|
|
1138
|
+
...(headers === undefined ? {} : { headers }),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
receipt = refuse('bulk-delivery-failed', describe(error));
|
|
1143
|
+
}
|
|
1144
|
+
if (!receipt.ok)
|
|
1145
|
+
return this.#failDelivery(sessionKey, deliveryId, receipt.refusal);
|
|
1146
|
+
this.#send(sessionKey, {
|
|
1147
|
+
kind: 'bulk_delivered',
|
|
1148
|
+
deliveryId,
|
|
1149
|
+
byteCount: receipt.value.byteCount,
|
|
1150
|
+
// The stat pair rides the receipt so a transcript puller can detect a rewrite. See
|
|
1151
|
+
// `BulkDelivered` in frames.ts.
|
|
1152
|
+
sizeBytes: receipt.value.sizeBytes,
|
|
1153
|
+
mtimeMs: receipt.value.mtimeMs,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
/** A delivery that did not happen says so on the wire; it is a receipt, not a silence. */
|
|
1157
|
+
#failDelivery(sessionKey, deliveryId, refused) {
|
|
1158
|
+
this.#send(sessionKey, {
|
|
1159
|
+
kind: 'bulk_failed',
|
|
1160
|
+
deliveryId,
|
|
1161
|
+
refusal: { reason: refused.reason, detail: refused.detail },
|
|
1162
|
+
});
|
|
1163
|
+
this.#refuse(sessionKey, refused);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Send one payload. A refusal other than queue overflow is reported locally, since there is no
|
|
1167
|
+
* wire to tell; an overflow refusal of an undroppable frame holds the frame for a retry instead.
|
|
1168
|
+
*/
|
|
1169
|
+
#send(sessionKey, payload) {
|
|
1170
|
+
const pending = this.#heldFrames.get(sessionKey);
|
|
1171
|
+
if (pending !== undefined && pending.length > 0) {
|
|
1172
|
+
if (isDroppable(payload.kind)) {
|
|
1173
|
+
this.#refuse(sessionKey, refusal('queue-dropped-droppable', `frames are held for this session; discarded an incoming ${payload.kind}`));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this.#holdFrame(sessionKey, payload);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const sent = this.#link.send(sessionKey, payload);
|
|
1180
|
+
if (sent.ok)
|
|
1181
|
+
return;
|
|
1182
|
+
if (sent.refusal.reason === 'queue-overflow-undroppable' && !isDroppable(payload.kind)) {
|
|
1183
|
+
this.#holdFrame(sessionKey, payload);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
this.#refuse(sessionKey, sent.refusal);
|
|
1187
|
+
}
|
|
1188
|
+
#holdFrame(sessionKey, payload) {
|
|
1189
|
+
const pending = this.#heldFrames.get(sessionKey) ?? [];
|
|
1190
|
+
if (pending.length >= HELD_FRAMES_MAX) {
|
|
1191
|
+
this.#refuse(sessionKey, refusal('queue-overflow-undroppable', `${HELD_FRAMES_MAX} frames are already held for this session; ${payload.kind} could not be held`));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
pending.push(payload);
|
|
1195
|
+
this.#heldFrames.set(sessionKey, pending);
|
|
1196
|
+
this.#scheduleHeldFrames();
|
|
1197
|
+
}
|
|
1198
|
+
#scheduleHeldFrames() {
|
|
1199
|
+
if (this.#heldFramesTimer !== null)
|
|
1200
|
+
return;
|
|
1201
|
+
// Ref'd on purpose: a held turn-end is work this process has promised to deliver.
|
|
1202
|
+
this.#heldFramesTimer = setTimeout(() => {
|
|
1203
|
+
this.#heldFramesTimer = null;
|
|
1204
|
+
this.#retryHeldFrames();
|
|
1205
|
+
}, HELD_RETRY_MS);
|
|
1206
|
+
}
|
|
1207
|
+
#retryHeldFrames() {
|
|
1208
|
+
let remaining = false;
|
|
1209
|
+
for (const [sessionKey, pending] of this.#heldFrames) {
|
|
1210
|
+
while (pending.length > 0) {
|
|
1211
|
+
const head = pending[0];
|
|
1212
|
+
if (head === undefined)
|
|
1213
|
+
break;
|
|
1214
|
+
const sent = this.#link.send(sessionKey, head);
|
|
1215
|
+
if (sent.ok) {
|
|
1216
|
+
pending.shift();
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (sent.refusal.reason !== 'queue-overflow-undroppable') {
|
|
1220
|
+
this.#refuse(sessionKey, sent.refusal);
|
|
1221
|
+
pending.shift();
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
if (pending.length === 0)
|
|
1227
|
+
this.#heldFrames.delete(sessionKey);
|
|
1228
|
+
else
|
|
1229
|
+
remaining = true;
|
|
1230
|
+
}
|
|
1231
|
+
if (remaining)
|
|
1232
|
+
this.#scheduleHeldFrames();
|
|
1233
|
+
}
|
|
1234
|
+
#refuse(sessionKey, refused) {
|
|
1235
|
+
this.#report({ kind: 'refusal', refusal: refused, sessionKey });
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* A refused open goes on the wire. Reported locally only, the controller would keep a session
|
|
1239
|
+
* record nobody could talk to and the operator would watch a session that "did nothing" while
|
|
1240
|
+
* the refusal sat in this host's log. The session never had a machine, so this is the one
|
|
1241
|
+
* transition composed by hand: spawning to ended, cause `refusal` with the reason as its event
|
|
1242
|
+
* and the whole detail; the controller stores it, ends its record, and can show why. Local
|
|
1243
|
+
* report first, then the wire.
|
|
1244
|
+
*/
|
|
1245
|
+
#refuseOpen(sessionKey, cwd, refused) {
|
|
1246
|
+
this.#refuse(sessionKey, refused);
|
|
1247
|
+
let where;
|
|
1248
|
+
try {
|
|
1249
|
+
where =
|
|
1250
|
+
cwd === null
|
|
1251
|
+
? { cwd: '', worktree: null, branch: null, unknownReason: 'the session never had a workspace' }
|
|
1252
|
+
: readWhere(cwd);
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
where = { cwd: cwd ?? '', worktree: null, branch: null, unknownReason: 'git facts could not be read' };
|
|
1256
|
+
}
|
|
1257
|
+
const transition = {
|
|
1258
|
+
sessionId: null,
|
|
1259
|
+
// The controller's own handle is the only correlation this session ever had.
|
|
1260
|
+
correlationId: sessionKey,
|
|
1261
|
+
seq: 1,
|
|
1262
|
+
at: new Date(this.#options.clock?.() ?? Date.now()).toISOString(),
|
|
1263
|
+
from: 'spawning',
|
|
1264
|
+
to: 'ended',
|
|
1265
|
+
activity: null,
|
|
1266
|
+
entryId: null,
|
|
1267
|
+
cause: { kind: 'refusal', event: refused.reason, detail: refused.detail },
|
|
1268
|
+
where,
|
|
1269
|
+
};
|
|
1270
|
+
const sent = this.#link.send(sessionKey, stateTransitionUpdate(transition));
|
|
1271
|
+
if (!sent.ok)
|
|
1272
|
+
this.#report({ kind: 'refusal', refusal: sent.refusal, sessionKey });
|
|
1273
|
+
}
|
|
1274
|
+
#report(event) {
|
|
1275
|
+
try {
|
|
1276
|
+
this.#options.report?.(event);
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
// The reporter is the failure channel; reporting a reporter would recurse.
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function describe(error) {
|
|
1284
|
+
return error instanceof Error ? error.message : String(error);
|
|
1285
|
+
}
|
|
1286
|
+
/** The repository asks on a host composed without a repository root: nothing to read under. */
|
|
1287
|
+
function noRepositoryRoot() {
|
|
1288
|
+
return refusal('repository-path-escape', 'this host has no repository root configured, so there is nothing to read under');
|
|
1289
|
+
}
|
|
1290
|
+
/** A local refusal as the wire carries it. */
|
|
1291
|
+
function wireRefusalOf(why) {
|
|
1292
|
+
return { reason: why.reason, detail: why.detail };
|
|
1293
|
+
}
|
|
1294
|
+
//# sourceMappingURL=host.js.map
|