@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,58 @@
|
|
|
1
|
+
/** The declared state model: what a session is, what caused it, and what is still open. */
|
|
2
|
+
export type {
|
|
3
|
+
ActivityKind,
|
|
4
|
+
AgedEntry,
|
|
5
|
+
CauseEvent,
|
|
6
|
+
CauseKind,
|
|
7
|
+
ControlEventName,
|
|
8
|
+
EntryLane,
|
|
9
|
+
HookEventName,
|
|
10
|
+
MessageEventName,
|
|
11
|
+
OpenEntry,
|
|
12
|
+
ProcessEventName,
|
|
13
|
+
SessionActivity,
|
|
14
|
+
SessionSnapshot,
|
|
15
|
+
SessionState,
|
|
16
|
+
SessionTransition,
|
|
17
|
+
TimeoutEventName,
|
|
18
|
+
TransitionCause,
|
|
19
|
+
TransitionWhere,
|
|
20
|
+
} from './model.js';
|
|
21
|
+
export {
|
|
22
|
+
ACTIVITY_KINDS,
|
|
23
|
+
CAUSE_KINDS,
|
|
24
|
+
CONTROL_EVENTS,
|
|
25
|
+
HOOK_EVENTS,
|
|
26
|
+
HOST_ACTIVITY_KINDS,
|
|
27
|
+
MESSAGE_EVENTS,
|
|
28
|
+
PROCESS_EVENTS,
|
|
29
|
+
SDK_ACTIVITY_KINDS,
|
|
30
|
+
SESSION_STATES,
|
|
31
|
+
TIMEOUT_EVENTS,
|
|
32
|
+
formatActivity,
|
|
33
|
+
isCauseEvent,
|
|
34
|
+
isCauseKind,
|
|
35
|
+
sameActivity,
|
|
36
|
+
} from './model.js';
|
|
37
|
+
|
|
38
|
+
export type {
|
|
39
|
+
EntryOp,
|
|
40
|
+
RejectedTransition,
|
|
41
|
+
RejectionListener,
|
|
42
|
+
StateMachineOptions,
|
|
43
|
+
TransitionListener,
|
|
44
|
+
TransitionRequest,
|
|
45
|
+
} from './machine.js';
|
|
46
|
+
export { SessionStateMachine } from './machine.js';
|
|
47
|
+
|
|
48
|
+
export type { TransitionStoreOptions } from './store.js';
|
|
49
|
+
export { TransitionStore } from './store.js';
|
|
50
|
+
|
|
51
|
+
export { SessionObserver } from './observer.js';
|
|
52
|
+
|
|
53
|
+
export { SessionStateReporter } from './reporter.js';
|
|
54
|
+
|
|
55
|
+
// The coverage table. Exported because it is the contract: a later layer checks its own event
|
|
56
|
+
// wiring against these rows rather than rediscovering which events exist.
|
|
57
|
+
export type { CoverageHandling, CoverageRow } from './coverage.js';
|
|
58
|
+
export { HOOK_COVERAGE, MESSAGE_COVERAGE, coverageTally } from './coverage.js';
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state machine for one session. It records; it never decides what a session means.
|
|
3
|
+
*
|
|
4
|
+
* The state is the record, and that is the whole design.
|
|
5
|
+
* `state`, `activity` and `sessionId` are all derived from the last recorded transition and the
|
|
6
|
+
* open-entry set. There is no separate field to assign, so a code path that skips the record also
|
|
7
|
+
* fails to change the state — suppression is self-defeating instead of silent.
|
|
8
|
+
*
|
|
9
|
+
* That inversion is the point. With a separate state field, one early return can skip a publish
|
|
10
|
+
* and a state stamp together: the control concern wins, and the observability loss is invisible —
|
|
11
|
+
* no error, no null, just a column that stays `running` forever while every reader infers. Here
|
|
12
|
+
* the same edit would leave the session stuck in its previous state, visibly, in the next thing
|
|
13
|
+
* anyone read.
|
|
14
|
+
*
|
|
15
|
+
* What is deliberately not in scope here, so the suppressing branch cannot be written.
|
|
16
|
+
* This module imports the pure core and its own model, and nothing else. It cannot see an observer
|
|
17
|
+
* count, a controller link, a registry, or how a session was created — so there is no session-class
|
|
18
|
+
* fact available to condition an emission on, whatever a later editor intends. Pinned by
|
|
19
|
+
* pins/state-record.test.ts.
|
|
20
|
+
*
|
|
21
|
+
* It never throws. A transition that cannot name its cause is refused, counted, and reported on
|
|
22
|
+
* `onRejected` — but not thrown, because these calls run inside hook handlers that are wrapped in
|
|
23
|
+
* `try/catch` by contract, and a thrown rejection would be swallowed there. Silent loss is the one
|
|
24
|
+
* failure this module exists to make impossible, so "loud" here means observable and counted.
|
|
25
|
+
*/
|
|
26
|
+
import type { Clock, Ticker } from '../core/time.js';
|
|
27
|
+
import type { Refusal } from '../core/refusal.js';
|
|
28
|
+
import type { Result } from '../core/result.js';
|
|
29
|
+
import { ok, refuse } from '../core/result.js';
|
|
30
|
+
import type {
|
|
31
|
+
AgedEntry,
|
|
32
|
+
OpenEntry,
|
|
33
|
+
SessionActivity,
|
|
34
|
+
SessionSnapshot,
|
|
35
|
+
SessionState,
|
|
36
|
+
SessionTransition,
|
|
37
|
+
TransitionCause,
|
|
38
|
+
TransitionWhere,
|
|
39
|
+
} from './model.js';
|
|
40
|
+
import { isCauseEvent, isCauseKind } from './model.js';
|
|
41
|
+
|
|
42
|
+
/** Opens, closes or backgrounds one entry. `null` on a request means the entry set is untouched. */
|
|
43
|
+
export type EntryOp =
|
|
44
|
+
| {
|
|
45
|
+
readonly op: 'open';
|
|
46
|
+
readonly entryId: string;
|
|
47
|
+
readonly activity: SessionActivity;
|
|
48
|
+
/** Set when the entry belongs to a subagent rather than the main thread. */
|
|
49
|
+
readonly agentId?: string | null;
|
|
50
|
+
}
|
|
51
|
+
| { readonly op: 'close'; readonly entryId: string }
|
|
52
|
+
| { readonly op: 'background'; readonly entryId: string }
|
|
53
|
+
/** Mark every still-open entry abandoned. Marks — never erases. */
|
|
54
|
+
| { readonly op: 'abandon-open'; readonly reason: string };
|
|
55
|
+
|
|
56
|
+
export interface TransitionRequest {
|
|
57
|
+
readonly to: SessionState;
|
|
58
|
+
readonly cause: TransitionCause;
|
|
59
|
+
readonly entry?: EntryOp | null;
|
|
60
|
+
/** Supplied once, when the agent reports itself. Carried forward automatically after that. */
|
|
61
|
+
readonly sessionId?: string | null;
|
|
62
|
+
/**
|
|
63
|
+
* A new `where`, when this transition is what changed it.
|
|
64
|
+
*
|
|
65
|
+
* The agent can change its own working directory mid-session, and every later transition has to
|
|
66
|
+
* carry the new one — a trace whose `where` silently describes a directory the session left is
|
|
67
|
+
* worse than one with no `where` at all. Carried forward automatically when absent, so it is
|
|
68
|
+
* derived from the record like everything else rather than living in a field of its own.
|
|
69
|
+
*/
|
|
70
|
+
readonly where?: TransitionWhere | null;
|
|
71
|
+
/** Overrides the clock. For a caller that already stamped the originating event. */
|
|
72
|
+
readonly at?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RejectedTransition {
|
|
76
|
+
readonly refusal: Refusal;
|
|
77
|
+
readonly at: string;
|
|
78
|
+
readonly attempted: TransitionRequest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TransitionListener = (transition: SessionTransition) => void;
|
|
82
|
+
export type RejectionListener = (rejected: RejectedTransition) => void;
|
|
83
|
+
export type Unsubscribe = () => void;
|
|
84
|
+
|
|
85
|
+
export interface StateMachineOptions {
|
|
86
|
+
readonly where: TransitionWhere;
|
|
87
|
+
readonly clock: Clock;
|
|
88
|
+
readonly ticker: Ticker;
|
|
89
|
+
/** Opaque and never interpreted. See SessionTransition.correlationId. */
|
|
90
|
+
readonly correlationId?: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The state a machine holds before anything has been recorded.
|
|
95
|
+
*
|
|
96
|
+
* A machine is constructed at the moment a process is asked for, and the first record is the
|
|
97
|
+
* `spawning` one. Until then `from` has to be something, and `spawning` is the honest answer: the
|
|
98
|
+
* caller has already decided to start a session.
|
|
99
|
+
*/
|
|
100
|
+
const INITIAL_STATE: SessionState = 'spawning';
|
|
101
|
+
|
|
102
|
+
export class SessionStateMachine {
|
|
103
|
+
/** Only until the first record. After that `where` is derived like everything else. */
|
|
104
|
+
readonly #initialWhere: TransitionWhere;
|
|
105
|
+
readonly #clock: Clock;
|
|
106
|
+
readonly #ticker: Ticker;
|
|
107
|
+
readonly #correlationId: string | null;
|
|
108
|
+
|
|
109
|
+
readonly #entries = new Map<string, OpenEntry>();
|
|
110
|
+
readonly #listeners = new Set<TransitionListener>();
|
|
111
|
+
readonly #rejectionListeners = new Set<RejectionListener>();
|
|
112
|
+
|
|
113
|
+
/** The last recorded transition. The only mutable state, assigned in exactly one place. */
|
|
114
|
+
#current: SessionTransition | null = null;
|
|
115
|
+
#seq = 0;
|
|
116
|
+
#rejectedCount = 0;
|
|
117
|
+
|
|
118
|
+
constructor(options: StateMachineOptions) {
|
|
119
|
+
this.#initialWhere = options.where;
|
|
120
|
+
this.#clock = options.clock;
|
|
121
|
+
this.#ticker = options.ticker;
|
|
122
|
+
this.#correlationId = options.correlationId ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Derived from the last transition. There is no field to set. */
|
|
126
|
+
get state(): SessionState {
|
|
127
|
+
return this.#current?.to ?? INITIAL_STATE;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Derived from the open-entry set. There is no field to set. */
|
|
131
|
+
get activity(): SessionActivity | null {
|
|
132
|
+
return foregroundActivity(this.#entries);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Null until the agent reports itself; carried forward after. There is no field to set. */
|
|
136
|
+
get sessionId(): string | null {
|
|
137
|
+
return this.#current?.sessionId ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get correlationId(): string | null {
|
|
141
|
+
return this.#correlationId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Derived from the last transition. There is no field to set. */
|
|
145
|
+
get where(): TransitionWhere {
|
|
146
|
+
return this.#current?.where ?? this.#initialWhere;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get transitionCount(): number {
|
|
150
|
+
return this.#seq;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** How many transitions were refused for an unnameable cause. Never silently zero. */
|
|
154
|
+
get rejectedCount(): number {
|
|
155
|
+
return this.#rejectedCount;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every entry that has not exited, with the number a human wants: how long it has been open. */
|
|
159
|
+
openEntries(): AgedEntry[] {
|
|
160
|
+
const now = this.#ticker();
|
|
161
|
+
return [...this.#entries.values()].map((entry) => ({
|
|
162
|
+
...entry,
|
|
163
|
+
ageMs: Math.max(0, now - Date.parse(entry.openedAt)),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
onTransition(listener: TransitionListener): Unsubscribe {
|
|
168
|
+
this.#listeners.add(listener);
|
|
169
|
+
return () => this.#listeners.delete(listener);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onRejected(listener: RejectionListener): Unsubscribe {
|
|
173
|
+
this.#rejectionListeners.add(listener);
|
|
174
|
+
return () => this.#rejectionListeners.delete(listener);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Record a transition. The only way a session's state changes.
|
|
179
|
+
*
|
|
180
|
+
* The only thing this branches on is whether the cause can be named. Nothing about which
|
|
181
|
+
* session this is, how it was created, or whether anyone is listening is reachable from here —
|
|
182
|
+
* see this file's header. `#commit` below is straight-line by construction.
|
|
183
|
+
*/
|
|
184
|
+
record(request: TransitionRequest): Result<SessionTransition> {
|
|
185
|
+
const named = nameable(request.cause);
|
|
186
|
+
if (named !== null) return this.#reject(named, request);
|
|
187
|
+
return ok(this.#commit(request));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What this host can say about this session. Raw material for a controller — not a roster. */
|
|
191
|
+
snapshot(): SessionSnapshot {
|
|
192
|
+
return {
|
|
193
|
+
sessionId: this.sessionId,
|
|
194
|
+
correlationId: this.#correlationId,
|
|
195
|
+
state: this.state,
|
|
196
|
+
activity: this.activity,
|
|
197
|
+
where: this.where,
|
|
198
|
+
openEntries: this.openEntries(),
|
|
199
|
+
lastTransitionAt: this.#current?.at ?? null,
|
|
200
|
+
transitionCount: this.#seq,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The single mutation point. Straight-line: every call that reaches here records.
|
|
208
|
+
*
|
|
209
|
+
* Do not add a guard to this method. A condition here is a session whose state changed without
|
|
210
|
+
* saying so, which is the defect this whole module exists to prevent.
|
|
211
|
+
*/
|
|
212
|
+
#commit(request: TransitionRequest): SessionTransition {
|
|
213
|
+
const carried = carryForward(request, this.#current, this.#initialWhere, this.#clock);
|
|
214
|
+
const entryId = applyEntryOp(this.#entries, carried.entry, carried.at, request.cause);
|
|
215
|
+
|
|
216
|
+
this.#seq += 1;
|
|
217
|
+
const transition: SessionTransition = {
|
|
218
|
+
sessionId: carried.sessionId,
|
|
219
|
+
seq: this.#seq,
|
|
220
|
+
at: carried.at,
|
|
221
|
+
from: carried.from,
|
|
222
|
+
to: request.to,
|
|
223
|
+
activity: foregroundActivity(this.#entries),
|
|
224
|
+
entryId,
|
|
225
|
+
cause: request.cause,
|
|
226
|
+
where: carried.where,
|
|
227
|
+
correlationId: this.#correlationId,
|
|
228
|
+
};
|
|
229
|
+
this.#current = transition;
|
|
230
|
+
|
|
231
|
+
for (const listener of this.#listeners) listener(transition);
|
|
232
|
+
return transition;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#reject(detail: string, attempted: TransitionRequest): Result<SessionTransition> {
|
|
236
|
+
this.#rejectedCount += 1;
|
|
237
|
+
const refusal: Refusal = { reason: 'transition-cause-unnamed', detail };
|
|
238
|
+
const rejected: RejectedTransition = { refusal, at: this.#clock(), attempted };
|
|
239
|
+
for (const listener of this.#rejectionListeners) listener(rejected);
|
|
240
|
+
return refuse<SessionTransition>('transition-cause-unnamed', detail);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The four values a transition inherits from the one before it, resolved in one place.
|
|
246
|
+
*
|
|
247
|
+
* They are lifted out of `#commit` deliberately. Each is a defaulting expression — "use what was
|
|
248
|
+
* given, else what the last transition carried" — and a defaulting expression is still a branch in
|
|
249
|
+
* the shape of one. Keeping them here leaves the commit path with no conditional of any kind, so
|
|
250
|
+
* "the record cannot be skipped" is a claim a reader can check by looking rather than by trusting.
|
|
251
|
+
*/
|
|
252
|
+
function carryForward(
|
|
253
|
+
request: TransitionRequest,
|
|
254
|
+
current: SessionTransition | null,
|
|
255
|
+
initialWhere: TransitionWhere,
|
|
256
|
+
clock: Clock,
|
|
257
|
+
): {
|
|
258
|
+
at: string;
|
|
259
|
+
from: SessionState;
|
|
260
|
+
sessionId: string | null;
|
|
261
|
+
where: TransitionWhere;
|
|
262
|
+
entry: EntryOp | null;
|
|
263
|
+
} {
|
|
264
|
+
return {
|
|
265
|
+
at: request.at ?? clock(),
|
|
266
|
+
from: current?.to ?? INITIAL_STATE,
|
|
267
|
+
sessionId: request.sessionId ?? current?.sessionId ?? null,
|
|
268
|
+
where: request.where ?? current?.where ?? initialWhere,
|
|
269
|
+
entry: request.entry ?? null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Null when the cause is nameable, otherwise why it is not.
|
|
275
|
+
*
|
|
276
|
+
* The compiler already closes `CauseEvent`, so this only fires for a value that never met the
|
|
277
|
+
* compiler — a cause rebuilt from a decoded frame, or a caller in plain JavaScript.
|
|
278
|
+
*/
|
|
279
|
+
function nameable(cause: TransitionCause | undefined | null): string | null {
|
|
280
|
+
if (cause === undefined || cause === null) return 'a transition carried no cause at all';
|
|
281
|
+
if (!isCauseKind(cause.kind)) return `cause.kind "${String(cause.kind)}" is not a declared cause kind`;
|
|
282
|
+
if (typeof cause.event !== 'string' || cause.event.length === 0) return 'cause.event is empty';
|
|
283
|
+
if (!isCauseEvent(cause.event)) return `cause.event "${String(cause.event)}" is not a declared cause event`;
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* What holds the session right now: the most recently opened foreground entry.
|
|
289
|
+
*
|
|
290
|
+
* Background entries are excluded by construction — that is the whole point of the lane. Abandoned
|
|
291
|
+
* entries are excluded too: they are still recorded and still ageing, but a session is not blocked
|
|
292
|
+
* on work that has been marked as never coming back.
|
|
293
|
+
*
|
|
294
|
+
* Most-recent rather than oldest, because `activity` answers "what is it doing now". "What has it
|
|
295
|
+
* been stuck on longest" is a different question, and `openEntries()` with its ages answers it
|
|
296
|
+
* without this field having to mean two things.
|
|
297
|
+
*/
|
|
298
|
+
function foregroundActivity(entries: ReadonlyMap<string, OpenEntry>): SessionActivity | null {
|
|
299
|
+
let held: OpenEntry | null = null;
|
|
300
|
+
for (const entry of entries.values()) {
|
|
301
|
+
if (entry.lane !== 'foreground') continue;
|
|
302
|
+
if (entry.abandonedAt !== null) continue;
|
|
303
|
+
held = entry;
|
|
304
|
+
}
|
|
305
|
+
return held?.activity ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Applies one entry operation and returns the entry it touched, or null. Pure over the map. */
|
|
309
|
+
function applyEntryOp(
|
|
310
|
+
entries: Map<string, OpenEntry>,
|
|
311
|
+
op: EntryOp | null,
|
|
312
|
+
at: string,
|
|
313
|
+
cause: TransitionCause,
|
|
314
|
+
): string | null {
|
|
315
|
+
if (op === null) return null;
|
|
316
|
+
|
|
317
|
+
if (op.op === 'open') {
|
|
318
|
+
// Re-opening the same entry keeps the original openedAt. Two sources can report the same
|
|
319
|
+
// condition (the compaction hook and the status message both say "compacting"), and taking the
|
|
320
|
+
// later timestamp would silently reset the age — which is the one number an unpaired entry
|
|
321
|
+
// exists to carry. The transition still records either way; only the clock is protected.
|
|
322
|
+
const existing = entries.get(op.entryId);
|
|
323
|
+
const sameThing = existing !== undefined && existing.activity.kind === op.activity.kind;
|
|
324
|
+
entries.set(op.entryId, {
|
|
325
|
+
entryId: op.entryId,
|
|
326
|
+
activity: op.activity,
|
|
327
|
+
lane: 'foreground',
|
|
328
|
+
openedAt: sameThing ? existing.openedAt : at,
|
|
329
|
+
backgroundedAt: null,
|
|
330
|
+
abandonedAt: null,
|
|
331
|
+
abandonReason: null,
|
|
332
|
+
cause,
|
|
333
|
+
agentId: op.agentId ?? null,
|
|
334
|
+
});
|
|
335
|
+
return op.entryId;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (op.op === 'close') {
|
|
339
|
+
entries.delete(op.entryId);
|
|
340
|
+
return op.entryId;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (op.op === 'background') {
|
|
344
|
+
const open = entries.get(op.entryId);
|
|
345
|
+
// A background report for an entry this host never saw open is not an error and not a state
|
|
346
|
+
// change: the transition still records, naming the id, and the entry set is simply unchanged.
|
|
347
|
+
if (open === undefined) return op.entryId;
|
|
348
|
+
entries.set(op.entryId, { ...open, lane: 'background', backgroundedAt: at });
|
|
349
|
+
return op.entryId;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// abandon-open: mark, never erase. An entry that never exited is the most useful thing this
|
|
353
|
+
// model can report, and a cleanup that deleted it would destroy exactly that signal.
|
|
354
|
+
for (const [entryId, entry] of entries) {
|
|
355
|
+
if (entry.abandonedAt !== null) continue;
|
|
356
|
+
entries.set(entryId, { ...entry, abandonedAt: at, abandonReason: op.reason });
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|