@naswerks/periscope 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forwarding: one session's live output becomes frames on the outbound link.
|
|
3
|
+
*
|
|
4
|
+
* This module subscribes; it does not read the stream itself. `HostedSession` already reads the
|
|
5
|
+
* agent's stream once and fans it out, because the SDK hands back a single-consumer iterator and
|
|
6
|
+
* two readers would each silently see half of it. If a second read loop ever appears in this
|
|
7
|
+
* package, one of the two consumers is losing messages and nobody is being told.
|
|
8
|
+
*
|
|
9
|
+
* A throw here is deliberately not caught. The pump already contains a subscriber that throws,
|
|
10
|
+
* reports it as `subscriber_failed` (its own kind, so a forwarder's bug cannot impersonate the
|
|
11
|
+
* process dying) and keeps reading. Catching here would name the same failure twice and leave that
|
|
12
|
+
* mechanism with nothing to catch. Ordinary failure is not a throw anyway: `send` returns a
|
|
13
|
+
* refusal, which is reported through `onRefusal` and is the caller's pressure signal.
|
|
14
|
+
*
|
|
15
|
+
* Transitions are taken from the machine, not from the message handler's return value, and that
|
|
16
|
+
* is what puts the gate on the wire. Messages are only one of three things that move a session: the
|
|
17
|
+
* hook lane records tool entries, and the gate records denials, outages, expiries and holds
|
|
18
|
+
* directly. Forwarding only what `observeMessage` returns would carry the message lane and nothing
|
|
19
|
+
* else, so every refusal this package exists to produce (the offline boundary refusal, the
|
|
20
|
+
* credential-path denial, an unreachable controller) would be visible on-box and invisible to the
|
|
21
|
+
* controller that needs it. Subscribing where they are all recorded is the only place that sees
|
|
22
|
+
* all three. The naive version of this double-emits: adding the subscription while still forwarding
|
|
23
|
+
* `observeMessage`'s results sends every message-caused transition twice, which a consumer counting
|
|
24
|
+
* state changes reads as the session having done each thing twice. The results are still read here
|
|
25
|
+
* for their refusals, which the machine never sees, and never re-emitted.
|
|
26
|
+
*
|
|
27
|
+
* The message comes before the transition it caused, always. A transition is the settled
|
|
28
|
+
* conclusion of a message, so a consumer must never see a state change that refers to a message it
|
|
29
|
+
* has not been given; it would have to render a session that "started a tool" with no tool call in
|
|
30
|
+
* hand. Ordering survives the subscription because `record` fans out to its listeners
|
|
31
|
+
* synchronously: the message is emitted, then `observeMessage` records, then the listener emits.
|
|
32
|
+
* Every payload then goes through the link's one queue, so arrival order is delivery order.
|
|
33
|
+
* A hook- or gate-caused transition has no message, and that is not a violation of the rule
|
|
34
|
+
* above; it is a fact about where it came from. A consumer must not assume every transition is
|
|
35
|
+
* preceded by the message that explains it; `cause.kind` says which lane produced it.
|
|
36
|
+
*
|
|
37
|
+
* Which lane a message rides is not decided here. `stream-routing.ts` declares it per
|
|
38
|
+
* discriminator, `satisfies Record<MessageDiscriminator, …>` against the SDK's own union. This file
|
|
39
|
+
* reads the table; it holds no judgement of its own about what matters.
|
|
40
|
+
*/
|
|
41
|
+
import type { SDKMessage } from '../host/agent-process.js';
|
|
42
|
+
import type { HostedSession, SessionEnded, Unsubscribe } from '../sessions/session.js';
|
|
43
|
+
import type { SessionObserver } from '../state/observer.js';
|
|
44
|
+
import type { SessionTransition } from '../state/model.js';
|
|
45
|
+
import type { Refusal } from '../core/refusal.js';
|
|
46
|
+
import type { Result } from '../core/result.js';
|
|
47
|
+
import type { JsonObject, SessionPayload } from './frames.js';
|
|
48
|
+
import { agentMessageDelta, agentMessageUpdate, stateTransitionUpdate } from './frames.js';
|
|
49
|
+
import { laneFor } from './stream-routing.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where frames go. Structural rather than `ControllerLink` so the forwarder can be exercised with
|
|
53
|
+
* no socket — and so this module pulls no WebSocket dependency into a path that does not need one.
|
|
54
|
+
*/
|
|
55
|
+
export interface FrameSink {
|
|
56
|
+
send(sessionId: string, payload: SessionPayload): Result<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ForwardSessionOptions {
|
|
60
|
+
/**
|
|
61
|
+
* The frame's routing key: the controller's handle for this session, not the agent's own id.
|
|
62
|
+
*
|
|
63
|
+
* See `SessionFrame` in frames.ts for the three ids and why they are not interchangeable. The
|
|
64
|
+
* short version: a session's first transitions are recorded before the agent has named itself,
|
|
65
|
+
* and a session that dies in start-up never names itself at all, so keying frames by the agent's
|
|
66
|
+
* id would make exactly the frames that explain a failure unsendable.
|
|
67
|
+
*/
|
|
68
|
+
readonly sessionKey: string;
|
|
69
|
+
readonly session: HostedSession;
|
|
70
|
+
/** The session's own observer. Already attached to its machine; this does not create one. */
|
|
71
|
+
readonly observer: SessionObserver;
|
|
72
|
+
readonly sink: FrameSink;
|
|
73
|
+
/**
|
|
74
|
+
* Every refusal the sink returned, and every transition the machine would not record.
|
|
75
|
+
*
|
|
76
|
+
* Wired by callers that want to know; a refusal that nobody listens to is still a refusal and is
|
|
77
|
+
* never a silent success — `send` has already declined by the time this is called.
|
|
78
|
+
*/
|
|
79
|
+
readonly onRefusal?: (refusal: Refusal) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Start forwarding. Returns the unsubscribe, which is idempotent.
|
|
84
|
+
*
|
|
85
|
+
* The end transition is emitted from the session's own end listener rather than from the message
|
|
86
|
+
* stream, because a session can end without its stream ending (a start timeout, a stop request)
|
|
87
|
+
* and a trace whose last row is whatever happened to arrive last is not a trace of an ending.
|
|
88
|
+
*/
|
|
89
|
+
export function forwardSession(options: ForwardSessionOptions): Unsubscribe {
|
|
90
|
+
const { sessionKey, session, observer, sink } = options;
|
|
91
|
+
|
|
92
|
+
const refused = (refusal: Refusal): void => options.onRefusal?.(refusal);
|
|
93
|
+
|
|
94
|
+
const emit = (payload: SessionPayload): void => {
|
|
95
|
+
const sent = sink.send(sessionKey, payload);
|
|
96
|
+
if (!sent.ok) refused(sent.refusal);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Every transition this session records, whichever lane produced it. See this file's header.
|
|
100
|
+
const dropTransitions = observer.machine.onTransition((transition) =>
|
|
101
|
+
emit(stateTransitionUpdate(transition)),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A transition the machine refused is not on the wire and never will be, so this is the only
|
|
106
|
+
* place it can be named. The recorded ones are already gone out through the subscription above,
|
|
107
|
+
* which is why nothing here emits.
|
|
108
|
+
*/
|
|
109
|
+
const reportRejected = (recorded: readonly Result<SessionTransition>[]): void => {
|
|
110
|
+
for (const result of recorded) if (!result.ok) refused(result.refusal);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const dropMessages = session.onMessage((message: SDKMessage) => {
|
|
114
|
+
// The message first, then what it caused. See this file's header.
|
|
115
|
+
forwardMessage(message, emit);
|
|
116
|
+
reportRejected(observer.observeMessage(message));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const dropEnd = session.onEnd((ended: SessionEnded) => {
|
|
120
|
+
reportRejected([observer.ended({ kind: 'process', event: ended.cause, detail: ended.detail })]);
|
|
121
|
+
dropMessages();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
dropMessages();
|
|
126
|
+
dropEnd();
|
|
127
|
+
dropTransitions();
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One message onto its declared lane, or nowhere.
|
|
133
|
+
*
|
|
134
|
+
* A large message is refused, not truncated, and it will happen: a tool result carrying a big
|
|
135
|
+
* file exceeds the frame limit, `send` declines with `frame-too-large`, and the refusal names the
|
|
136
|
+
* bulk lane. That is the "commands only, never payloads" rule doing its job rather than a defect,
|
|
137
|
+
* but a consumer will see a turn whose largest tool result never arrived, so the refusal has to
|
|
138
|
+
* reach someone. It does, through `onRefusal`.
|
|
139
|
+
*/
|
|
140
|
+
function forwardMessage(message: SDKMessage, emit: (payload: SessionPayload) => void): void {
|
|
141
|
+
const lane = laneFor(message);
|
|
142
|
+
if (lane === 'declined') return;
|
|
143
|
+
|
|
144
|
+
// Cast rather than copy: the message arrived as JSON and the codec is where bytes and types meet,
|
|
145
|
+
// so re-validating it here would be a second wire edge. A message that genuinely cannot serialize
|
|
146
|
+
// is refused by `encode` under its own name and reaches `onRefusal` like any other refusal.
|
|
147
|
+
const body = message as unknown as JsonObject;
|
|
148
|
+
emit(lane === 'delta' ? agentMessageDelta(body) : agentMessageUpdate(body));
|
|
149
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single-consumer async queue: push values in, iterate them out, close when there are no more.
|
|
3
|
+
*
|
|
4
|
+
* It exists because a session's input has to be an `AsyncIterable` before the first turn is known —
|
|
5
|
+
* the agent process is started with a stream it will read from later, and the control surface
|
|
6
|
+
* (interrupt, model change, permission mode) is only available to a session driven that way. A
|
|
7
|
+
* plain string prompt would ship a session that cannot be interrupted.
|
|
8
|
+
*
|
|
9
|
+
* Pure and generic on purpose: nothing here knows what a message is, so it is testable without a
|
|
10
|
+
* process and reusable by anything else that needs the same shape.
|
|
11
|
+
*/
|
|
12
|
+
export class AsyncQueue<T> implements AsyncIterable<T> {
|
|
13
|
+
readonly #items: T[] = [];
|
|
14
|
+
readonly #waiting: ((result: IteratorResult<T>) => void)[] = [];
|
|
15
|
+
#ended = false;
|
|
16
|
+
|
|
17
|
+
/** Hand a value to the consumer, or hold it until one asks. Ignored after `end()`. */
|
|
18
|
+
push(item: T): void {
|
|
19
|
+
if (this.#ended) return;
|
|
20
|
+
const waiter = this.#waiting.shift();
|
|
21
|
+
if (waiter !== undefined) {
|
|
22
|
+
waiter({ value: item, done: false });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.#items.push(item);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* No more values. Idempotent, and it releases every waiting consumer rather than leaving them
|
|
30
|
+
* hanging — an iterator that never returns is indistinguishable from a session that never ends.
|
|
31
|
+
*/
|
|
32
|
+
end(): void {
|
|
33
|
+
if (this.#ended) return;
|
|
34
|
+
this.#ended = true;
|
|
35
|
+
while (this.#waiting.length > 0) {
|
|
36
|
+
this.#waiting.shift()?.({ value: undefined, done: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Values pushed but not yet taken. */
|
|
41
|
+
get depth(): number {
|
|
42
|
+
return this.#items.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get ended(): boolean {
|
|
46
|
+
return this.#ended;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
50
|
+
return {
|
|
51
|
+
next: (): Promise<IteratorResult<T>> => {
|
|
52
|
+
const item = this.#items.shift();
|
|
53
|
+
if (item !== undefined) return Promise.resolve({ value: item, done: false });
|
|
54
|
+
if (this.#ended) return Promise.resolve({ value: undefined, done: true });
|
|
55
|
+
return new Promise<IteratorResult<T>>((resolve) => this.#waiting.push(resolve));
|
|
56
|
+
},
|
|
57
|
+
return: (): Promise<IteratorResult<T>> => {
|
|
58
|
+
this.end();
|
|
59
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describing a thrown failure so the reader learns the cause, not the wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Node's `fetch` throws `TypeError: fetch failed` and puts the reason on `error.cause`; a WebSocket
|
|
5
|
+
* carries a `code` such as `DEPTH_ZERO_SELF_SIGNED_CERT`. A message that stops at the wrapper reads
|
|
6
|
+
* "could not be reached" for a controller that answered the TCP dial and refused nothing but its own
|
|
7
|
+
* certificate. So the nested causes are walked, the innermost code is named, and a certificate refusal
|
|
8
|
+
* carries the remedy, because it is the failure a local controller meets first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The `code` a Node error carries when TLS refused the peer's certificate. */
|
|
12
|
+
const CERTIFICATE_CODES = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|CERTIFICATE/;
|
|
13
|
+
|
|
14
|
+
/** The innermost error under nested `cause` members, and the codes met on the way down. */
|
|
15
|
+
function unwrap(error: unknown): { readonly leaf: unknown; readonly codes: string[] } {
|
|
16
|
+
const codes: string[] = [];
|
|
17
|
+
let current: unknown = error;
|
|
18
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
19
|
+
if (
|
|
20
|
+
typeof current === 'object' &&
|
|
21
|
+
current !== null &&
|
|
22
|
+
typeof (current as { code?: unknown }).code === 'string'
|
|
23
|
+
) {
|
|
24
|
+
codes.push((current as { code: string }).code);
|
|
25
|
+
}
|
|
26
|
+
const next =
|
|
27
|
+
typeof current === 'object' && current !== null ? (current as { cause?: unknown }).cause : undefined;
|
|
28
|
+
if (next === undefined || next === null) break;
|
|
29
|
+
current = next;
|
|
30
|
+
}
|
|
31
|
+
return { leaf: current, codes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Is this failure TLS refusing the peer's certificate? Read off every `code` met on the way down. */
|
|
35
|
+
export function isCertificateRefusal(error: unknown): boolean {
|
|
36
|
+
return unwrap(error).codes.some((code) => CERTIFICATE_CODES.test(code));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The failure in one line: the innermost message, prefixed by the innermost code when there is one.
|
|
41
|
+
* `fetch failed` becomes `DEPTH_ZERO_SELF_SIGNED_CERT: self-signed certificate`.
|
|
42
|
+
*/
|
|
43
|
+
export function describeFailure(error: unknown): string {
|
|
44
|
+
const { leaf, codes } = unwrap(error);
|
|
45
|
+
const message = leaf instanceof Error ? leaf.message : typeof leaf === 'string' ? leaf : String(leaf);
|
|
46
|
+
const code = codes.at(-1);
|
|
47
|
+
return code === undefined || message.includes(code) ? message : `${code}: ${message}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What to do about a certificate refusal, in the operator's words. Stated once, here, so the pair
|
|
52
|
+
* verb, the decision transport and the link say the same thing.
|
|
53
|
+
*/
|
|
54
|
+
export function certificateRemedy(where: string): string {
|
|
55
|
+
return (
|
|
56
|
+
`Node does not trust the certificate ${where} presents. For a controller on a development ` +
|
|
57
|
+
`certificate, export that certificate as PEM and point Node at it: set NODE_EXTRA_CA_CERTS to the ` +
|
|
58
|
+
`file's path, then run this command again in a new terminal. Disabling verification ` +
|
|
59
|
+
`(NODE_TLS_REJECT_UNAUTHORIZED=0) also works and trusts every certificate; prefer the file.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure core: no `node:` imports, no packages, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Pinned by src/pins/core-purity.test.ts. The point is that anything here runs anywhere — in the
|
|
5
|
+
* host, in a controller, in a browser — so the contract types can be shared without dragging a
|
|
6
|
+
* runtime along with them.
|
|
7
|
+
*/
|
|
8
|
+
export type { Refusal, RefusalReason } from './refusal.js';
|
|
9
|
+
export { REFUSAL_REASONS, isRefusalReason, refusal } from './refusal.js';
|
|
10
|
+
|
|
11
|
+
export type { Result } from './result.js';
|
|
12
|
+
export { isOk, ok, refuse, valueOr } from './result.js';
|
|
13
|
+
|
|
14
|
+
export type { HostNoun, SdkNoun } from './vocab.js';
|
|
15
|
+
export { HOST_NOUNS, SDK_NOUNS, isDeclaredNoun, nounOf } from './vocab.js';
|
|
16
|
+
|
|
17
|
+
export { isAbsolutePath, isContainedBy, normalizePath, requireAbsolute } from './paths.js';
|
|
18
|
+
|
|
19
|
+
export { MAX_WORKSPACE_ID_LENGTH } from './workspace-id.js';
|
|
20
|
+
|
|
21
|
+
export type { Clock, Ticker } from './time.js';
|
|
22
|
+
export { fixedClock, fixedTicker, systemClock, systemTicker } from './time.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-key serial executor: work submitted under one key runs one piece at a time, in arrival
|
|
3
|
+
* order; work under different keys interleaves freely.
|
|
4
|
+
*
|
|
5
|
+
* It exists because the host's workspace verbs are fire-and-forget concurrent (`#dispatch` never
|
|
6
|
+
* awaits a handler) while the work they do takes seconds and is mutually destructive on one key:
|
|
7
|
+
* a `git worktree add` and a `git worktree remove --force` each take many seconds, and the
|
|
8
|
+
* reachable interleavings are real — a provision landing inside a removal, a removal landing inside
|
|
9
|
+
* a close's release. Serializing per key closes every one of those windows without making
|
|
10
|
+
* unrelated workspaces wait on each other.
|
|
11
|
+
*
|
|
12
|
+
* Pure and generic on purpose: nothing here knows what a workspace is, so it is testable without
|
|
13
|
+
* a provider and reusable by anything else that needs the same shape.
|
|
14
|
+
*/
|
|
15
|
+
export class KeyedTurns {
|
|
16
|
+
/** The tail of each key's queue — the promise the next submission must wait behind. */
|
|
17
|
+
readonly #tails = new Map<string, Promise<unknown>>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `work` after everything previously submitted under `key` has settled.
|
|
21
|
+
*
|
|
22
|
+
* The returned promise carries `work`'s own result or rejection. A rejection does not poison the
|
|
23
|
+
* key: the next submission runs regardless, because a failed removal must not wedge every future
|
|
24
|
+
* provision at that key behind an error nobody can clear.
|
|
25
|
+
*/
|
|
26
|
+
async run<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
27
|
+
const previous = this.#tails.get(key) ?? Promise.resolve();
|
|
28
|
+
const turn = previous.then(work, work);
|
|
29
|
+
// The tail entry is removed when this turn settles with nothing queued behind it. The map
|
|
30
|
+
// identity check is what makes the cleanup safe under concurrent submission: a newer tail
|
|
31
|
+
// means a later submission queued behind this one, and the entry is now that one's to clean up.
|
|
32
|
+
const tail = turn.then(
|
|
33
|
+
() => undefined,
|
|
34
|
+
() => undefined,
|
|
35
|
+
);
|
|
36
|
+
this.#tails.set(key, tail);
|
|
37
|
+
void tail.then(() => {
|
|
38
|
+
if (this.#tails.get(key) === tail) this.#tails.delete(key);
|
|
39
|
+
});
|
|
40
|
+
return turn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Keys with unsettled work — an observability convenience, never a guard. */
|
|
44
|
+
get depth(): number {
|
|
45
|
+
return this.#tails.size;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path handling, written without `node:path` because this module is import-free by contract.
|
|
3
|
+
*
|
|
4
|
+
* Absolute always, on the wire and at every boundary — a relative path means "relative to a cwd the
|
|
5
|
+
* other end cannot see", which is a bug waiting for a second machine.
|
|
6
|
+
*/
|
|
7
|
+
import type { Result } from './result.js';
|
|
8
|
+
import { ok, refuse } from './result.js';
|
|
9
|
+
|
|
10
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
11
|
+
|
|
12
|
+
/** POSIX `/x`, Windows `C:\x`, or a UNC `\\server\share`. */
|
|
13
|
+
export function isAbsolutePath(candidate: string): boolean {
|
|
14
|
+
if (candidate.startsWith('/')) return true;
|
|
15
|
+
if (candidate.startsWith('\\\\')) return true;
|
|
16
|
+
return WINDOWS_DRIVE.test(candidate);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Separators to `/`, redundant separators collapsed, `.` dropped and `..` resolved textually.
|
|
21
|
+
*
|
|
22
|
+
* Textual resolution deliberately does NOT consult the filesystem, so it cannot follow a symlink.
|
|
23
|
+
* A caller enforcing a real jail must canonicalize through the filesystem first — see
|
|
24
|
+
* `host/`, which is the only module that can.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizePath(input: string): string {
|
|
27
|
+
const isUnc = input.startsWith('\\\\');
|
|
28
|
+
const unified = input.replace(/\\/g, '/');
|
|
29
|
+
const hasDrive = WINDOWS_DRIVE.test(input);
|
|
30
|
+
const rooted = unified.startsWith('/') || hasDrive || isUnc;
|
|
31
|
+
|
|
32
|
+
const segments: string[] = [];
|
|
33
|
+
let prefix = '';
|
|
34
|
+
|
|
35
|
+
let body = unified;
|
|
36
|
+
if (hasDrive) {
|
|
37
|
+
prefix = `${unified.slice(0, 2)}/`;
|
|
38
|
+
body = unified.slice(3);
|
|
39
|
+
} else if (isUnc) {
|
|
40
|
+
prefix = '//';
|
|
41
|
+
body = unified.slice(2);
|
|
42
|
+
} else if (rooted) {
|
|
43
|
+
prefix = '/';
|
|
44
|
+
body = unified.slice(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const segment of body.split('/')) {
|
|
48
|
+
if (segment === '' || segment === '.') continue;
|
|
49
|
+
if (segment === '..') {
|
|
50
|
+
if (segments.length > 0 && segments[segments.length - 1] !== '..') {
|
|
51
|
+
segments.pop();
|
|
52
|
+
} else if (!rooted) {
|
|
53
|
+
segments.push('..');
|
|
54
|
+
}
|
|
55
|
+
// Rooted paths cannot climb above their root; `/..` is `/`.
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
segments.push(segment);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const joined = segments.join('/');
|
|
62
|
+
if (prefix === '') return joined === '' ? '.' : joined;
|
|
63
|
+
return prefix + joined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Normalizes, and refuses anything not absolute. The shape every boundary should take. */
|
|
67
|
+
export function requireAbsolute(candidate: string): Result<string> {
|
|
68
|
+
if (!isAbsolutePath(candidate)) {
|
|
69
|
+
return refuse('path-not-absolute', `not an absolute path: ${candidate}`);
|
|
70
|
+
}
|
|
71
|
+
return ok(normalizePath(candidate));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is `candidate` at or beneath `root`? Both are normalized first; the comparison is
|
|
76
|
+
* segment-wise, so `/a/bc` is NOT inside `/a/b`.
|
|
77
|
+
*/
|
|
78
|
+
export function isContainedBy(candidate: string, root: string): boolean {
|
|
79
|
+
const normalizedRoot = normalizePath(root);
|
|
80
|
+
const normalizedCandidate = normalizePath(candidate);
|
|
81
|
+
if (normalizedCandidate === normalizedRoot) return true;
|
|
82
|
+
const boundary = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
|
|
83
|
+
return normalizedCandidate.startsWith(boundary);
|
|
84
|
+
}
|