@ccmsg/cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type AgentInfo,
|
|
6
|
+
type Capability,
|
|
7
|
+
type HelloArgs,
|
|
8
|
+
type HelloResult,
|
|
9
|
+
type Endpoint,
|
|
10
|
+
type InstanceId,
|
|
11
|
+
type InstanceInfo,
|
|
12
|
+
type LastLiveSession,
|
|
13
|
+
type PeerInfo,
|
|
14
|
+
PROTOCOL_VERSION,
|
|
15
|
+
type SessionState,
|
|
16
|
+
type SessionStoppingResult,
|
|
17
|
+
type Sid,
|
|
18
|
+
type Timestamp,
|
|
19
|
+
} from "@ccmsg/protocol";
|
|
20
|
+
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
21
|
+
import { within } from "../files/index.ts";
|
|
22
|
+
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
23
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
24
|
+
import { classify, type SessionInputs } from "./classify.ts";
|
|
25
|
+
import { HarnessSessions, isWaiting } from "./harness.ts";
|
|
26
|
+
import { LastLiveStore, type StoredEntry } from "./last-live.ts";
|
|
27
|
+
import { stoppedOn } from "./status.ts";
|
|
28
|
+
import { TerminalCache, type TerminalReader } from "./terminals.ts";
|
|
29
|
+
|
|
30
|
+
/** What the sessions domain needs from the instance around it. */
|
|
31
|
+
export interface SessionsDeps {
|
|
32
|
+
readonly self: InstanceId;
|
|
33
|
+
/** Where this instance says it is reached, which `hello` states beside the
|
|
34
|
+
* id: the caller got here by some URL of its own — a proxy's, an alias — and
|
|
35
|
+
* what a peer is to dial is neither that nor derivable from the id. Absent on
|
|
36
|
+
* an instance reached by the unix socket alone, which has no URL to state. */
|
|
37
|
+
readonly endpoint?: Endpoint;
|
|
38
|
+
/** The one config home this instance answers for (§8.2). Its `sessions/` is
|
|
39
|
+
* the only directory read, and no other config home is ever looked for (M6). */
|
|
40
|
+
readonly configHome: string;
|
|
41
|
+
/** Where `last_live` is written. Derived from the config home by the caller,
|
|
42
|
+
* which is where every per-instance path is decided (§8.1). */
|
|
43
|
+
readonly stateDir: string;
|
|
44
|
+
readonly capabilities: readonly Capability[];
|
|
45
|
+
/** The daemon build, reported by `hello` for display. */
|
|
46
|
+
readonly version: string;
|
|
47
|
+
readonly startedAt: Timestamp;
|
|
48
|
+
/** The one way a value reaches subscribers (§6.1). */
|
|
49
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
50
|
+
/** What the transcript fold says about a session (§5.1). Absent while
|
|
51
|
+
* nothing folds transcripts, in which case the two values it settles are
|
|
52
|
+
* simply unknown and every rule that reads them behaves as it does for a
|
|
53
|
+
* session whose transcript has said nothing. */
|
|
54
|
+
readonly transcript?: TranscriptSource;
|
|
55
|
+
/** What the gateway has seen of a session (§5.1). Absent on an instance with
|
|
56
|
+
* no gateway configured, which costs the classification one of its five
|
|
57
|
+
* inputs and none of its states. */
|
|
58
|
+
readonly gateway?: GatewaySource;
|
|
59
|
+
/** The sessions this instance speaks about, or what the fold says about one,
|
|
60
|
+
* has changed. What rests on either — the topics whose value is derived from
|
|
61
|
+
* the same fold, and the tails they keep running (§6.3) — is told to catch
|
|
62
|
+
* up. Absent when nothing does. */
|
|
63
|
+
readonly onChanged?: () => void;
|
|
64
|
+
/** How often the confirmation poll runs, for a test that cannot wait. */
|
|
65
|
+
readonly pollMs?: number;
|
|
66
|
+
/** How the terminal a session runs in is read from its process. Absent on a
|
|
67
|
+
* host where no process's environment can be read, where every row's
|
|
68
|
+
* terminal stays unknown — which is a state the classification has. */
|
|
69
|
+
readonly terminals?: TerminalReader;
|
|
70
|
+
/** The mesh, on an instance that has one. It answers the one greeting this
|
|
71
|
+
* domain cannot judge: a peer's, whose claim is settled by an exchange of its
|
|
72
|
+
* own rather than by anything a session says (§7.2). */
|
|
73
|
+
readonly mesh?: MeshSource;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What `hello` needs of the mesh: verify the greeting of a peer, and say which
|
|
77
|
+
* instances there are and which of them can be reached (§7.5). */
|
|
78
|
+
export interface MeshSource {
|
|
79
|
+
greet(conn: Requester, claim: MeshClaim): Promise<void>;
|
|
80
|
+
instances(): InstanceInfo[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The mesh claim a peer greets with, as the contract states it. */
|
|
84
|
+
type MeshClaim = NonNullable<HelloArgs["mesh"]>;
|
|
85
|
+
|
|
86
|
+
/** The fold, as the sessions domain reads it: two values about one session,
|
|
87
|
+
* asked for when a payload is built rather than copied here when they change
|
|
88
|
+
* (§3.3 — the current value lives with whoever owns it). */
|
|
89
|
+
export interface TranscriptSource {
|
|
90
|
+
facts(sid: Sid): TranscriptFacts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The gateway, as the sessions domain reads it: when it last saw inference
|
|
94
|
+
* for one session, asked for when a payload is built (§3.3). */
|
|
95
|
+
export interface GatewaySource {
|
|
96
|
+
activeAt(sid: Sid): Timestamp | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What a session said about itself when it greeted.
|
|
100
|
+
*
|
|
101
|
+
* The contract states these fields once and every place that describes a
|
|
102
|
+
* session refers to them, so what a greeting carries and what `peers` repeats
|
|
103
|
+
* are the same fields under the same names — nothing is renamed on the way
|
|
104
|
+
* through, and nothing is invented for a field the session left unsaid. */
|
|
105
|
+
type SessionMeta = Pick<
|
|
106
|
+
HelloArgs,
|
|
107
|
+
"repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title" | "model" | "effort"
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
const META_FIELDS = [
|
|
111
|
+
"repo",
|
|
112
|
+
"ws",
|
|
113
|
+
"cwd",
|
|
114
|
+
"transcript_path",
|
|
115
|
+
"repo_root",
|
|
116
|
+
"branch",
|
|
117
|
+
"title",
|
|
118
|
+
"model",
|
|
119
|
+
"effort",
|
|
120
|
+
] as const;
|
|
121
|
+
|
|
122
|
+
/** One session holding a connection to us. */
|
|
123
|
+
interface Connected {
|
|
124
|
+
readonly sid: Sid;
|
|
125
|
+
readonly connected_at: Timestamp;
|
|
126
|
+
readonly protocol_version: number;
|
|
127
|
+
readonly client_version?: string;
|
|
128
|
+
readonly meta: SessionMeta;
|
|
129
|
+
/** The most recent request on any of its connections. Distinct from when a
|
|
130
|
+
* person last spoke to it, which is folded out of the transcript and is the
|
|
131
|
+
* one an attention-ordered list wants (§5.3). */
|
|
132
|
+
last_activity_at: Timestamp;
|
|
133
|
+
/** More than one client process of a session may hold a connection. */
|
|
134
|
+
conns: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The sessions this instance can speak about, and the two topics that carry
|
|
138
|
+
* them.
|
|
139
|
+
*
|
|
140
|
+
* The current value lives here rather than in the topic mechanism (§3.3): what
|
|
141
|
+
* is connected is held in memory and dies with the process, what the harness
|
|
142
|
+
* reports is re-read from `sessions/`, and only `last_live` survives a restart.
|
|
143
|
+
* The classification of §5.2 is derived from those three whenever a payload is
|
|
144
|
+
* built, and never stored (M4). */
|
|
145
|
+
export class Sessions implements UpstreamResource {
|
|
146
|
+
readonly #connected = new Map<Sid, Connected>();
|
|
147
|
+
readonly #harness: HarnessSessions;
|
|
148
|
+
readonly #terminals: TerminalCache | undefined;
|
|
149
|
+
readonly #lastLive: LastLiveStore;
|
|
150
|
+
/** Sessions seen live since the last recompute, kept so the moment one stops
|
|
151
|
+
* being live is what writes its `last_live` entry. */
|
|
152
|
+
#live = new Map<Sid, StoredEntry>();
|
|
153
|
+
/** The topic names currently subscribed. Both topics rest on the same
|
|
154
|
+
* directory watch, so it runs while either has a listener (§6.3). */
|
|
155
|
+
readonly #wanted = new Set<string>();
|
|
156
|
+
/** What a session said about itself when it last greeted, kept for as long
|
|
157
|
+
* as the harness still names the session.
|
|
158
|
+
*
|
|
159
|
+
* A greeting is one instant and a connection is shorter than a session: a
|
|
160
|
+
* session-start hook says where it works and leaves, and every client process
|
|
161
|
+
* of the session comes and goes. What it said does not stop being true when
|
|
162
|
+
* the process that said it exits, so holding it only while a connection is
|
|
163
|
+
* open would mean the instance forgetting a session's repository the moment
|
|
164
|
+
* it stopped being told it — and then writing it down as gone with nothing
|
|
165
|
+
* but a sid on the entry.
|
|
166
|
+
*
|
|
167
|
+
* What bounds it is the harness: the words are kept while `sessions/` still
|
|
168
|
+
* names the sid, and dropped in the same breath as the `last_live` entry that
|
|
169
|
+
* spends them. Nothing here is written to disk (M4) — a restart forgets it,
|
|
170
|
+
* and the next greeting says it again. */
|
|
171
|
+
readonly #stated = new Map<Sid, SessionMeta>();
|
|
172
|
+
/** Sessions that have said they are about to go, and when they said it.
|
|
173
|
+
*
|
|
174
|
+
* Held here rather than written to `last_live`, because the declaration
|
|
175
|
+
* arrives while the session is still connected and `last_live` holds what is
|
|
176
|
+
* gone: the entry is written when the connection closes, and this is what
|
|
177
|
+
* stamps it then (contract, `session_stopping`). A session that declares and
|
|
178
|
+
* then carries on stays connected and keeps its declaration, which is spent
|
|
179
|
+
* whenever it does leave. */
|
|
180
|
+
readonly #stopping = new Map<Sid, Timestamp>();
|
|
181
|
+
|
|
182
|
+
constructor(private readonly deps: SessionsDeps) {
|
|
183
|
+
this.#harness = new HarnessSessions(
|
|
184
|
+
join(deps.configHome, "sessions"),
|
|
185
|
+
deps.self,
|
|
186
|
+
() => this.changed(),
|
|
187
|
+
deps.pollMs,
|
|
188
|
+
);
|
|
189
|
+
this.#lastLive = new LastLiveStore(join(deps.stateDir, "last-live.json"));
|
|
190
|
+
this.#lastLive.load();
|
|
191
|
+
this.#terminals =
|
|
192
|
+
deps.terminals === undefined
|
|
193
|
+
? undefined
|
|
194
|
+
: new TerminalCache(deps.terminals, () => this.changed());
|
|
195
|
+
this.#live = this.#liveNow(Date.now(), this.#rows());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `hello`, which is where a session becomes something this instance can
|
|
199
|
+
* speak about, and where everything this instance knows about where that
|
|
200
|
+
* session lives comes from.
|
|
201
|
+
*
|
|
202
|
+
* What registers a session is the greeting naming a sid, not the role it
|
|
203
|
+
* claims: the sid is the session it speaks for, and reading the role here
|
|
204
|
+
* would put the contract's "a session names its sid" rule in a second place
|
|
205
|
+
* (M1). */
|
|
206
|
+
hello = (input: HandlerInput): HelloResult | Promise<HelloResult> => {
|
|
207
|
+
const args = input.args as unknown as HelloArgs;
|
|
208
|
+
// A role is set once and fixed for the connection's life (contract, `Role`),
|
|
209
|
+
// so a second greeting is not a re-identification: it is a request to be
|
|
210
|
+
// somebody else on a connection that already is somebody.
|
|
211
|
+
if (input.conn.identity.state === "settled") {
|
|
212
|
+
throw new OpError("bad_request", "a connection greets once, and this one already has");
|
|
213
|
+
}
|
|
214
|
+
if (args.protocol_version !== PROTOCOL_VERSION) {
|
|
215
|
+
throw new OpError("bad_request", `this instance speaks protocol ${PROTOCOL_VERSION}`);
|
|
216
|
+
}
|
|
217
|
+
if (args.role === "instance") {
|
|
218
|
+
// A peer's greeting is answered only once the connection has been proven
|
|
219
|
+
// to be the endpoint it names. The verification rejects when it is not,
|
|
220
|
+
// and the connection stays anonymous because nothing settles an identity
|
|
221
|
+
// but a reply (mesh-peer-auth §5, daemon-v2 §3.2 step 7). This is the one
|
|
222
|
+
// greeting that has to wait for something, which is why it is the one
|
|
223
|
+
// that answers with a promise.
|
|
224
|
+
if (args.mesh === undefined) {
|
|
225
|
+
throw new OpError("invalid_args", "an instance greets with its mesh claim");
|
|
226
|
+
}
|
|
227
|
+
const mesh = this.deps.mesh;
|
|
228
|
+
if (mesh === undefined) {
|
|
229
|
+
throw new OpError(
|
|
230
|
+
"capability_unavailable",
|
|
231
|
+
"this instance has no mesh, so no peer connection can be proven",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return mesh.greet(input.conn, args.mesh).then(() => this.#greeted(args, input));
|
|
235
|
+
}
|
|
236
|
+
return this.#greeted(args, input);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** What every greeting answers, once whatever had to be settled has been. */
|
|
240
|
+
#greeted(args: HelloArgs, input: HandlerInput): HelloResult {
|
|
241
|
+
const sid = requiredSid(args);
|
|
242
|
+
if (sid !== undefined) {
|
|
243
|
+
this.register(sid, args, this.deps.configHome);
|
|
244
|
+
input.conn.onClose(() => this.release(sid));
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
protocol_version: PROTOCOL_VERSION,
|
|
248
|
+
instance: this.deps.self,
|
|
249
|
+
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
250
|
+
// Without a mesh the cluster is this instance alone. It appears in the
|
|
251
|
+
// list only where it has a URL to be named by: an instance serving the
|
|
252
|
+
// unix socket alone is reached by nothing that could dial an endpoint,
|
|
253
|
+
// and `instance` above has already said who is answering.
|
|
254
|
+
instances:
|
|
255
|
+
this.deps.mesh?.instances() ??
|
|
256
|
+
(this.deps.endpoint === undefined
|
|
257
|
+
? []
|
|
258
|
+
: [
|
|
259
|
+
{
|
|
260
|
+
id: this.deps.self,
|
|
261
|
+
endpoint: this.deps.endpoint,
|
|
262
|
+
host: hostname(),
|
|
263
|
+
reachable: true,
|
|
264
|
+
},
|
|
265
|
+
]),
|
|
266
|
+
capabilities: [...this.deps.capabilities],
|
|
267
|
+
version: this.deps.version,
|
|
268
|
+
started_at: this.deps.startedAt,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Where a session stands (§5.2). Undefined for a sid this instance has
|
|
273
|
+
* never seen live and does not hold in `last_live`. */
|
|
274
|
+
classify(
|
|
275
|
+
sid: Sid,
|
|
276
|
+
now: Timestamp = Date.now(),
|
|
277
|
+
rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
|
|
278
|
+
): SessionState | undefined {
|
|
279
|
+
return classify(this.inputs(sid, rows), now);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The harness's sessions as they are at this instant. One read serves one
|
|
283
|
+
* question, and a caller answering several about the same instant passes the
|
|
284
|
+
* result on rather than reading again. */
|
|
285
|
+
#rows(): ReadonlyMap<Sid, AgentInfo> {
|
|
286
|
+
const rows = this.#harness.scan();
|
|
287
|
+
const terminals = this.#terminals;
|
|
288
|
+
if (terminals === undefined) return rows;
|
|
289
|
+
// What the scan found is what exists: a pid that has left it is one whose
|
|
290
|
+
// terminal is no longer anybody's, and one that has arrived is read once.
|
|
291
|
+
terminals.observe([...rows.values()].map((row) => row.pid));
|
|
292
|
+
const named = new Map<Sid, AgentInfo>();
|
|
293
|
+
for (const [sid, row] of rows) {
|
|
294
|
+
const terminal = terminals.get(row.pid);
|
|
295
|
+
named.set(
|
|
296
|
+
sid,
|
|
297
|
+
terminal === undefined
|
|
298
|
+
? row
|
|
299
|
+
: {
|
|
300
|
+
...row,
|
|
301
|
+
terminal_id: terminal.id,
|
|
302
|
+
...(terminal.namespace === undefined
|
|
303
|
+
? {}
|
|
304
|
+
: { terminal_namespace: terminal.namespace }),
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return named;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Everything the classification of one session reads, exposed so the rule
|
|
312
|
+
* and its inputs can be tested apart from each other.
|
|
313
|
+
*
|
|
314
|
+
* The harness's rows are read here rather than taken from the watch. Which
|
|
315
|
+
* sessions the harness has is a fact about this config home, true whether or
|
|
316
|
+
* not anybody subscribed to hear about it (§5.1) — the watch of §6.3 exists
|
|
317
|
+
* to push a change to subscribers, and reading its cache instead would make
|
|
318
|
+
* "a session exists" mean "somebody is listening", which is how a live
|
|
319
|
+
* session becomes `session_not_found` to a sender and how a session that is
|
|
320
|
+
* still running is written into `last_live` as gone. */
|
|
321
|
+
inputs(sid: Sid, rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): SessionInputs {
|
|
322
|
+
const row = rows.get(sid);
|
|
323
|
+
const stored = this.#lastLive.get(sid);
|
|
324
|
+
const facts = this.deps.transcript?.facts(sid);
|
|
325
|
+
const gatewayActiveAt = this.#gatewayActiveAt(sid, row !== undefined);
|
|
326
|
+
return {
|
|
327
|
+
connected: this.#connected.has(sid),
|
|
328
|
+
...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
|
|
329
|
+
...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
|
|
330
|
+
...(row === undefined
|
|
331
|
+
? {}
|
|
332
|
+
: {
|
|
333
|
+
harness: {
|
|
334
|
+
waiting: isWaiting(row),
|
|
335
|
+
...(row.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The sessions holding a connection to us. Whoever follows their
|
|
343
|
+
* transcripts needs the set, and a greeting is what puts a session in it. */
|
|
344
|
+
connectedSids(): Sid[] {
|
|
345
|
+
return [...this.#connected.keys()];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Note that a session asked for something. `last_activity_at` is the most
|
|
349
|
+
* recent request on any of its connections, so every request restamps the one
|
|
350
|
+
* row all of them share.
|
|
351
|
+
*
|
|
352
|
+
* Nothing is published here. The value travels on the next `peers` payload
|
|
353
|
+
* whatever caused it, and publishing per request would put a frame on the
|
|
354
|
+
* wire for every call a session makes — a row that changed only in its clock
|
|
355
|
+
* is not news a subscriber asked for. */
|
|
356
|
+
touch(sid: Sid, at: Timestamp = Date.now()): void {
|
|
357
|
+
const held = this.#connected.get(sid);
|
|
358
|
+
if (held !== undefined) held.last_activity_at = at;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Where a session's transcript is, as it announced it (§5.1). Whoever
|
|
362
|
+
* follows one needs the path, and the greeting is the only thing that
|
|
363
|
+
* states it. */
|
|
364
|
+
transcriptPath(sid: Sid): string | undefined {
|
|
365
|
+
return this.#stated.get(sid)?.transcript_path;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Where a session works, as it greeted: the container its files are reached
|
|
369
|
+
* through, and the directory it runs in. Both are stated only by a greeting,
|
|
370
|
+
* so a session that named neither is one no path is admitted for. */
|
|
371
|
+
where(sid: Sid): { root?: string; cwd?: string } {
|
|
372
|
+
const meta = this.#connected.get(sid)?.meta;
|
|
373
|
+
const cwd = meta?.cwd ?? this.#rows().get(sid)?.cwd;
|
|
374
|
+
// The container when the session named one, the working directory
|
|
375
|
+
// otherwise — the same order `repo_root` is meant in (§4.2).
|
|
376
|
+
const root = meta?.repo_root ?? cwd;
|
|
377
|
+
return {
|
|
378
|
+
...(root === undefined || root === "" ? {} : { root }),
|
|
379
|
+
...(cwd === undefined || cwd === "" ? {} : { cwd }),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The harness's sessions as they are right now, read rather than taken
|
|
384
|
+
* from the watch's cache. What acts on a session's process resolves its pid
|
|
385
|
+
* through this: the watch runs only while somebody is subscribed (§6.3), and
|
|
386
|
+
* a pid from a poll that has not run is a number belonging to nobody. */
|
|
387
|
+
rowsNow(): ReadonlyMap<Sid, AgentInfo> {
|
|
388
|
+
return this.#rows();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Drop one entry from `last_live`, which is what
|
|
392
|
+
* `session_last_live_remove` asks for. The removal touches that list alone:
|
|
393
|
+
* the session stays resumable by every other route. */
|
|
394
|
+
forget(sid: Sid): boolean {
|
|
395
|
+
const removed = this.#lastLive.remove(sid);
|
|
396
|
+
if (removed) this.changed();
|
|
397
|
+
return removed;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Recompute and state both topics. What the fold settles is an input to the
|
|
401
|
+
* classification and to `peers`, so a fold that changed says so here. */
|
|
402
|
+
refresh(): void {
|
|
403
|
+
this.changed();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** `session_stopping`: a session saying it is about to go, which is what
|
|
407
|
+
* makes it Paused rather than Disappeared once it is gone (§5.2).
|
|
408
|
+
*
|
|
409
|
+
* Nothing is recorded now and nothing is published: the session is still
|
|
410
|
+
* here, and the list this changes is the one it is not on yet. What the
|
|
411
|
+
* declaration does is wait for the disconnection that follows it. */
|
|
412
|
+
stopping = (input: HandlerInput): SessionStoppingResult => {
|
|
413
|
+
const sid = input.identity?.sid;
|
|
414
|
+
if (sid === undefined) {
|
|
415
|
+
throw new OpError(
|
|
416
|
+
"bad_request",
|
|
417
|
+
"a session says it is stopping, and this greeting named none",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const at = Date.now();
|
|
421
|
+
this.#stopping.set(sid, at);
|
|
422
|
+
return { stopped_at: at };
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// --- UpstreamResource (§6.3): the directory is read while, and only while,
|
|
426
|
+
// somebody is subscribed to a topic that rests on it.
|
|
427
|
+
|
|
428
|
+
start(topic: string): void {
|
|
429
|
+
this.#wanted.add(topic);
|
|
430
|
+
this.#harness.start();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
stop(topic: string): void {
|
|
434
|
+
this.#wanted.delete(topic);
|
|
435
|
+
if (this.#wanted.size === 0) this.#harness.stop();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
snapshot(topic: string): readonly TopicValue[] {
|
|
439
|
+
const rows = this.#rows();
|
|
440
|
+
const data = topic === "agents" ? this.agents(rows) : this.peers(Date.now(), rows);
|
|
441
|
+
return [{ instance: this.deps.self, data }];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Whether the directory watch is running, which is what "the subscription
|
|
445
|
+
* drives the resource" means in practice. */
|
|
446
|
+
get watching(): boolean {
|
|
447
|
+
return this.#harness.running;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The `peers` payload: what is connected now, and what was connected when
|
|
451
|
+
* this instance last saw it. Both travel together because registering is
|
|
452
|
+
* exactly what moves a session from the second list to the first.
|
|
453
|
+
*
|
|
454
|
+
* Every row states its `state` and its `pinned`. The contract lets an
|
|
455
|
+
* instance leave them out, and a client then shows a session it cannot group
|
|
456
|
+
* — this instance is one that classifies, so it says so on every row rather
|
|
457
|
+
* than on the rows it happens to have an answer for.
|
|
458
|
+
*
|
|
459
|
+
* `instances` is the same view `hello` answers with, restated here so that a
|
|
460
|
+
* link going down reaches a subscriber on the topic it is already on rather
|
|
461
|
+
* than only on its next greeting (§7.5). It is this instance's view: what a
|
|
462
|
+
* peer relayed here carries the peer's own, and neither is folded into the
|
|
463
|
+
* other. An instance with no mesh states none, which is a different thing
|
|
464
|
+
* from stating that nothing is reachable. */
|
|
465
|
+
peers(
|
|
466
|
+
now: Timestamp = Date.now(),
|
|
467
|
+
rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
|
|
468
|
+
): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
|
|
469
|
+
const instances = this.deps.mesh?.instances();
|
|
470
|
+
return {
|
|
471
|
+
peers: [...this.#connected.values()].map((session) => this.#peer(session, now, rows)),
|
|
472
|
+
last_live: this.#lastLive.entries(now).map((entry) => ({
|
|
473
|
+
...entry,
|
|
474
|
+
state: this.classify(entry.sid, now, rows) ?? "disappeared",
|
|
475
|
+
pinned: this.#pinned(entry.sid),
|
|
476
|
+
})),
|
|
477
|
+
...(instances === undefined ? {} : { instances }),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The `agents` payload: the harness's own view, as it stated it.
|
|
482
|
+
*
|
|
483
|
+
* `polled_at` is left out. Stating when the read behind the list ran would
|
|
484
|
+
* make every confirmation poll a value the list did not have before, so the
|
|
485
|
+
* one suppression every topic shares (M5) would let a five-second heartbeat
|
|
486
|
+
* through for a directory that had not changed. */
|
|
487
|
+
agents(rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): { agents: AgentInfo[] } {
|
|
488
|
+
return { agents: [...rows.values()] };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Bind a session to this instance, and take what it says about itself. Its
|
|
492
|
+
* entry in `last_live` goes the moment it registers, which is the whole of
|
|
493
|
+
* "an entry leaves the list when its session comes back".
|
|
494
|
+
*
|
|
495
|
+
* What a greeting names is taken field by field, and not naming a field
|
|
496
|
+
* means it is unchanged rather than withdrawn. One session reaches this
|
|
497
|
+
* instance as a run of short-lived processes — a session-start hook, a
|
|
498
|
+
* `post`, a session-end hook — and none of them knows every field: only the
|
|
499
|
+
* hooks are told where the transcript is, and only a command running in the
|
|
500
|
+
* session's own directory can work out the repository. A greeting that took
|
|
501
|
+
* silence for a retraction would let each of them erase what the last one
|
|
502
|
+
* knew, and the session would be described by whichever process spoke most
|
|
503
|
+
* recently rather than by everything it has said. */
|
|
504
|
+
private register(sid: Sid, args: HelloArgs, configHome: string): void {
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
const held = this.#connected.get(sid);
|
|
507
|
+
const meta = { ...this.#stated.get(sid), ...metaOf(args, configHome) };
|
|
508
|
+
this.#connected.set(sid, {
|
|
509
|
+
sid,
|
|
510
|
+
connected_at: held?.connected_at ?? now,
|
|
511
|
+
protocol_version: args.protocol_version,
|
|
512
|
+
...(args.client_version === undefined ? {} : { client_version: args.client_version }),
|
|
513
|
+
meta,
|
|
514
|
+
last_activity_at: now,
|
|
515
|
+
conns: (held?.conns ?? 0) + 1,
|
|
516
|
+
});
|
|
517
|
+
this.#stated.set(sid, meta);
|
|
518
|
+
this.#lastLive.remove(sid);
|
|
519
|
+
this.changed(now);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** One of a session's connections closed. The session is only gone when its
|
|
523
|
+
* last one is. */
|
|
524
|
+
private release(sid: Sid): void {
|
|
525
|
+
const held = this.#connected.get(sid);
|
|
526
|
+
if (held === undefined) return;
|
|
527
|
+
if (held.conns > 1) {
|
|
528
|
+
this.#connected.set(sid, { ...held, conns: held.conns - 1 });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.#connected.delete(sid);
|
|
532
|
+
this.changed();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Recompute, record what stopped being live, and state both topics.
|
|
536
|
+
*
|
|
537
|
+
* Publishing is unconditional here because suppression belongs to the topic
|
|
538
|
+
* mechanism and is written once for every topic (M5) — a payload equal to
|
|
539
|
+
* the last one goes no further than that. */
|
|
540
|
+
private changed(now: Timestamp = Date.now()): void {
|
|
541
|
+
const rows = this.#rows();
|
|
542
|
+
const live = this.#liveNow(now, rows);
|
|
543
|
+
for (const [sid, entry] of this.#live) {
|
|
544
|
+
if (live.has(sid)) continue;
|
|
545
|
+
// The declaration came first and the departure has now arrived, which is
|
|
546
|
+
// the order the two are one event in (contract, `session_stopping`).
|
|
547
|
+
const stoppedAt = this.#stopping.get(sid);
|
|
548
|
+
this.#stopping.delete(sid);
|
|
549
|
+
this.#lastLive.record({
|
|
550
|
+
...entry,
|
|
551
|
+
last_seen_at: now,
|
|
552
|
+
...(stoppedAt === undefined ? {} : { stopped_at: stoppedAt }),
|
|
553
|
+
});
|
|
554
|
+
// The words are spent: the entry just written carries them, and the
|
|
555
|
+
// session they were about is one the harness no longer names. A sid that
|
|
556
|
+
// comes back says them again.
|
|
557
|
+
this.#stated.delete(sid);
|
|
558
|
+
}
|
|
559
|
+
this.#live = live;
|
|
560
|
+
this.deps.publish("peers", this.peers(now, rows));
|
|
561
|
+
this.deps.publish("agents", this.agents(rows));
|
|
562
|
+
this.deps.onChanged?.();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Every session live right now, in the form its `last_live` entry takes if
|
|
566
|
+
* it stops being live. */
|
|
567
|
+
#liveNow(now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): Map<Sid, StoredEntry> {
|
|
568
|
+
const live = new Map<Sid, StoredEntry>();
|
|
569
|
+
for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, rows));
|
|
570
|
+
for (const sid of rows.keys()) live.set(sid, this.#entry(sid, now, rows));
|
|
571
|
+
return live;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
#entry(sid: Sid, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): StoredEntry {
|
|
575
|
+
const held = this.#connected.get(sid);
|
|
576
|
+
const row = rows.get(sid);
|
|
577
|
+
// What answered last, not what the session named when it greeted: the
|
|
578
|
+
// greeting is one instant and `/model` moves afterwards, so the fold is
|
|
579
|
+
// asked first and the greeting only fills in for a transcript that has
|
|
580
|
+
// said nothing yet.
|
|
581
|
+
const answered = this.deps.transcript?.facts(sid);
|
|
582
|
+
const stated = this.#stated.get(sid);
|
|
583
|
+
const model = answered?.model ?? stated?.model;
|
|
584
|
+
const effort = answered?.model === undefined ? stated?.effort : answered.effort;
|
|
585
|
+
return {
|
|
586
|
+
sid,
|
|
587
|
+
instance: this.deps.self,
|
|
588
|
+
// The harness knows a title for a session that stated none itself, so
|
|
589
|
+
// it goes first and what the session named overrides it.
|
|
590
|
+
...(row?.name === undefined ? {} : { title: row.name }),
|
|
591
|
+
...this.#where(sid, rows),
|
|
592
|
+
...(model === undefined ? {} : { model }),
|
|
593
|
+
...(effort === undefined ? {} : { effort }),
|
|
594
|
+
...(held === undefined ? {} : { connected_at: held.connected_at }),
|
|
595
|
+
last_seen_at: now,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
#peer(session: Connected, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): PeerInfo {
|
|
600
|
+
// The two "last activity" values are different questions (§5.3): the one
|
|
601
|
+
// above moves on every request the session makes, this one only when a
|
|
602
|
+
// person speaks, and the fold is the only place that knows the second.
|
|
603
|
+
const userInput = this.deps.transcript?.facts(session.sid).last_user_input_at;
|
|
604
|
+
// What the gateway last saw run for this session: an attribute of the row
|
|
605
|
+
// beside the classification, not folded into it (§5.1). Absent from an
|
|
606
|
+
// instance with no gateway, where nothing observes inference at all.
|
|
607
|
+
const gatewayActiveAt = this.#gatewayActiveAt(session.sid, rows.has(session.sid));
|
|
608
|
+
return {
|
|
609
|
+
sid: session.sid,
|
|
610
|
+
instance: this.deps.self,
|
|
611
|
+
...this.#where(session.sid, rows),
|
|
612
|
+
state: this.classify(session.sid, now, rows) ?? "live",
|
|
613
|
+
pinned: this.#pinned(session.sid),
|
|
614
|
+
connected_at: session.connected_at,
|
|
615
|
+
last_activity_at: session.last_activity_at,
|
|
616
|
+
...(userInput === undefined ? {} : { last_user_input_at: userInput }),
|
|
617
|
+
...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
|
|
618
|
+
...(session.client_version === undefined ? {} : { client_version: session.client_version }),
|
|
619
|
+
protocol_version: session.protocol_version,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** When the gateway last saw inference for a session, for a session this
|
|
624
|
+
* instance knows (§5.1).
|
|
625
|
+
*
|
|
626
|
+
* The gateway sits above every config home and its events name only a session
|
|
627
|
+
* id, so what it reports is not by itself evidence about *this* instance's
|
|
628
|
+
* sessions: a session id belonging to another config home would otherwise
|
|
629
|
+
* classify as live here, put a row on this instance's `peers`, and make
|
|
630
|
+
* `message_send` accept a message for a session that has no inbox here and
|
|
631
|
+
* never will. So the reading is narrowed to the sids this instance knows —
|
|
632
|
+
* one that has greeted us, still connected or remembered in `last_live`, or
|
|
633
|
+
* one the harness's own `sessions/` names. The events themselves are not
|
|
634
|
+
* dropped: `llm_requests` carries what the gateway saw whoever it was for,
|
|
635
|
+
* because that topic is a view of the gateway rather than of this instance's
|
|
636
|
+
* sessions. */
|
|
637
|
+
#gatewayActiveAt(sid: Sid, inHarness: boolean): Timestamp | undefined {
|
|
638
|
+
const known = inHarness || this.#connected.has(sid) || this.#lastLive.get(sid) !== undefined;
|
|
639
|
+
return known ? this.deps.gateway?.activeAt(sid) : undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Whether a person has pinned this session. Nothing can set a pin yet, so
|
|
643
|
+
* this is false for every session — stated rather than left out, because an
|
|
644
|
+
* absent `pinned` and a false one mean the same thing to a client and this
|
|
645
|
+
* instance states what it knows on every row. */
|
|
646
|
+
#pinned(_sid: Sid): boolean {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Where a session is, as the contract's shared fields.
|
|
651
|
+
*
|
|
652
|
+
* The session's own greeting is the source. The harness's row supplies the
|
|
653
|
+
* working directory for a session that greeted without one, and for one that
|
|
654
|
+
* never greeted at all — it is the only field the harness also knows.
|
|
655
|
+
*
|
|
656
|
+
* `repo` and `ws` have no fallback: they are display names for a layout this
|
|
657
|
+
* instance has no stated way to read out of a path, so a session that does
|
|
658
|
+
* not name them is shown without them rather than with a guess. The same
|
|
659
|
+
* goes for `repo_root`, which §4.2 says to derive from `cwd` when it is not
|
|
660
|
+
* given — no primary source states that derivation, so it is left unstated
|
|
661
|
+
* until one does. */
|
|
662
|
+
#where(
|
|
663
|
+
sid: Sid,
|
|
664
|
+
rows: ReadonlyMap<Sid, AgentInfo>,
|
|
665
|
+
): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
|
|
666
|
+
const meta = this.#stated.get(sid) ?? {};
|
|
667
|
+
const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
|
|
668
|
+
return {
|
|
669
|
+
repo: meta.repo ?? "",
|
|
670
|
+
ws: meta.ws ?? "",
|
|
671
|
+
cwd,
|
|
672
|
+
...(meta.transcript_path === undefined ? {} : { transcript_path: meta.transcript_path }),
|
|
673
|
+
...(meta.repo_root === undefined ? {} : { repo_root: meta.repo_root }),
|
|
674
|
+
...(meta.branch === undefined ? {} : { branch: meta.branch }),
|
|
675
|
+
...(meta.title === undefined ? {} : { title: meta.title }),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** What a greeting said about the session, and nothing more: the fields the
|
|
681
|
+
* contract shares between `hello` and `peers`, copied across under their own
|
|
682
|
+
* names.
|
|
683
|
+
*
|
|
684
|
+
* `transcript_path` is the exception, because it is the one field that is not
|
|
685
|
+
* only displayed: it names a file this instance then reads and follows. What is
|
|
686
|
+
* taken is a path under this config home's `projects/`, resolved, and nothing
|
|
687
|
+
* else — a session naming a file elsewhere is a session that named nothing,
|
|
688
|
+
* which is what a session that stayed silent already is (M6). It is not an
|
|
689
|
+
* error: how a session describes itself is its own business, and the instance
|
|
690
|
+
* simply does not act on a description it cannot stand behind. */
|
|
691
|
+
function metaOf(args: HelloArgs, configHome: string): SessionMeta {
|
|
692
|
+
const meta: Record<string, string> = {};
|
|
693
|
+
for (const field of META_FIELDS) {
|
|
694
|
+
const value = args[field];
|
|
695
|
+
if (value === undefined) continue;
|
|
696
|
+
if (field === "transcript_path") {
|
|
697
|
+
const path = ownTranscript(value, configHome);
|
|
698
|
+
if (path !== undefined) meta[field] = path;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
meta[field] = value;
|
|
702
|
+
}
|
|
703
|
+
return meta as SessionMeta;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A transcript path this instance will read, or nothing.
|
|
707
|
+
|
|
708
|
+
* The test is where the file would be, not whether it is there. M6 is a
|
|
709
|
+
* boundary on what this instance reads, and a path inside `projects/` stays
|
|
710
|
+
* inside it whether or not anything has been written there yet — a session
|
|
711
|
+
* greeting at its very start names a transcript the harness has created
|
|
712
|
+
* neither the file nor the directory for, and refusing it would mean the one
|
|
713
|
+
* greeting that says where a session's transcript is is the one greeting whose
|
|
714
|
+
* answer is thrown away. Nothing is read early by accepting it: the tail
|
|
715
|
+
* starts when somebody follows the session, and a file that is not there yet
|
|
716
|
+
* is one it waits for.
|
|
717
|
+
*
|
|
718
|
+
* As much of the path as exists is resolved, so a path spelled through a
|
|
719
|
+
* symlink and one spelled directly are the same path, and a link anywhere
|
|
720
|
+
* along it that leads out of the tree lands outside and is refused. What is
|
|
721
|
+
* already there must be a file: a directory by that name is not a transcript.
|
|
722
|
+
*/
|
|
723
|
+
function ownTranscript(named: string, configHome: string): string | undefined {
|
|
724
|
+
if (!isAbsolute(named)) return undefined;
|
|
725
|
+
let projects: string;
|
|
726
|
+
try {
|
|
727
|
+
projects = realpathSync(join(configHome, "projects"));
|
|
728
|
+
} catch {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
const settled = resolveAsFarAsItGoes(named);
|
|
732
|
+
if (settled === undefined || !within(settled, projects)) return undefined;
|
|
733
|
+
const stat = statSync(settled, { throwIfNoEntry: false });
|
|
734
|
+
return stat === undefined || stat.isFile() ? settled : undefined;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The path with every segment of it that exists resolved.
|
|
738
|
+
*
|
|
739
|
+
* A segment that is there may be a link and is followed; a segment that is not
|
|
740
|
+
* there cannot be a link to anywhere, because there is nothing at it, so it is
|
|
741
|
+
* kept as it was spelled. The result is compared against the tree as a whole,
|
|
742
|
+
* which is what makes a `..` among the unwritten segments land wherever it
|
|
743
|
+
* actually points rather than pass for being spelled inside. */
|
|
744
|
+
function resolveAsFarAsItGoes(path: string): string | undefined {
|
|
745
|
+
const unwritten: string[] = [];
|
|
746
|
+
let at = path;
|
|
747
|
+
for (;;) {
|
|
748
|
+
try {
|
|
749
|
+
return join(realpathSync(at), ...unwritten);
|
|
750
|
+
} catch {
|
|
751
|
+
const parent = dirname(at);
|
|
752
|
+
// The root itself always resolves, so this is a path that named
|
|
753
|
+
// something no filesystem root holds.
|
|
754
|
+
if (parent === at) return undefined;
|
|
755
|
+
unwritten.unshift(basename(at));
|
|
756
|
+
at = parent;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** What each role must and must not say when it greets.
|
|
762
|
+
*
|
|
763
|
+
* The sid is what registers a session, so which role is entitled to name one is
|
|
764
|
+
* decided here rather than left to whoever reads the field: a `user` naming a
|
|
765
|
+
* sid would be a person registering as the session, and a `session` without one
|
|
766
|
+
* is a session this instance cannot speak about. */
|
|
767
|
+
function requiredSid(args: HelloArgs): Sid | undefined {
|
|
768
|
+
switch (args.role) {
|
|
769
|
+
case "session":
|
|
770
|
+
if (args.sid === undefined) throw new OpError("invalid_args", "a session names its sid");
|
|
771
|
+
return args.sid;
|
|
772
|
+
case "user":
|
|
773
|
+
if (args.sid !== undefined) {
|
|
774
|
+
throw new OpError("invalid_args", "a sid is the greeting of a session, not of a person");
|
|
775
|
+
}
|
|
776
|
+
return undefined;
|
|
777
|
+
case "instance":
|
|
778
|
+
// A peer speaks for no session: what it is has already been settled by
|
|
779
|
+
// the handshake, and a sid here would be it registering as one.
|
|
780
|
+
if (args.sid !== undefined) {
|
|
781
|
+
throw new OpError("invalid_args", "a sid is the greeting of a session, not of an instance");
|
|
782
|
+
}
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
}
|