opencode-session-port 0.0.0-stage → 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/package.json +56 -4
  4. package/src/core/contracts/crypto.ts +66 -0
  5. package/src/core/contracts/index.ts +13 -0
  6. package/src/core/contracts/json.ts +142 -0
  7. package/src/core/contracts/session.ts +12 -0
  8. package/src/core/crypto/container.ts +686 -0
  9. package/src/core/crypto/envelope.ts +232 -0
  10. package/src/core/crypto/errors.ts +52 -0
  11. package/src/core/crypto/gzip-stream.ts +388 -0
  12. package/src/core/crypto/index.ts +13 -0
  13. package/src/core/crypto/kdf.ts +101 -0
  14. package/src/core/crypto/policy.ts +202 -0
  15. package/src/core/crypto/zero.ts +33 -0
  16. package/src/core/restore/identity.ts +202 -0
  17. package/src/core/restore/index.ts +24 -0
  18. package/src/core/restore/planner.ts +1122 -0
  19. package/src/core/sessions/collector.ts +323 -0
  20. package/src/core/sessions/errors.ts +37 -0
  21. package/src/core/sessions/guards.ts +11 -0
  22. package/src/core/sessions/index.ts +29 -0
  23. package/src/core/sessions/policy.ts +80 -0
  24. package/src/core/sessions/port.ts +88 -0
  25. package/src/core/sessions/serialize.ts +15 -0
  26. package/src/core/sessions/unsettled.ts +35 -0
  27. package/src/integrations/omo/index.ts +28 -0
  28. package/src/tui/adapters/host-client.ts +254 -0
  29. package/src/tui/adapters/index.ts +72 -0
  30. package/src/tui/adapters/location-resolver.ts +281 -0
  31. package/src/tui/adapters/project-destinations.ts +271 -0
  32. package/src/tui/adapters/session-id-inventory.ts +378 -0
  33. package/src/tui/adapters/session-import.ts +189 -0
  34. package/src/tui/adapters/session-read.ts +115 -0
  35. package/src/tui/artifacts/files.ts +285 -0
  36. package/src/tui/orchestration/checkpoint.ts +160 -0
  37. package/src/tui/orchestration/index.ts +55 -0
  38. package/src/tui/orchestration/restore-auth.ts +94 -0
  39. package/src/tui/orchestration/restore-execution.ts +381 -0
  40. package/src/tui/orchestration/restore-preflight.ts +223 -0
  41. package/src/tui/orchestration/shared.ts +75 -0
  42. package/src/tui/ui/password-dialog.tsx +251 -0
  43. package/src/tui/ui/password-flow.ts +288 -0
  44. package/src/tui/ui/restore-project-selector.ts +62 -0
  45. package/src/tui/ui/restore-report-dialog.tsx +251 -0
  46. package/src/tui/ui/restore-report-flow.ts +94 -0
  47. package/src/tui/ui/sessionport-commands.tsx +65 -0
  48. package/src/tui/wiring/composition.ts +354 -0
  49. package/src/tui/wiring/experimental-profile.ts +86 -0
  50. package/src/tui/wiring/index.ts +51 -0
  51. package/src/tui/wiring/install.ts +108 -0
  52. package/src/tui/wiring/reports.ts +340 -0
  53. package/src/tui.ts +34 -0
  54. package/tsconfig.json +18 -0
  55. package/tui.ts +12 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Host project-destinations adapter: lists candidate restore destinations from
3
+ * the public TUI client `context.client.project.list` method.
4
+ *
5
+ * Candidates are declaration/source-derived, not runtime-verified (OQ-16 stays
6
+ * OPEN). Each candidate pairs the declared `Project.id` with the declared
7
+ * `Project.canonical` path proposed as the import `targetDirectory`. The pinned
8
+ * v2.0.22 declaration/source research (`lib-31`) describes `canonical` as an
9
+ * absolute stored/main-checkout path that may be another clone and is NOT
10
+ * guaranteed to be current, existing, unique, or to map back to the listed
11
+ * project ID when used as a `session.import.location.directory` target. This
12
+ * adapter therefore makes no canonical→ID mapping claim and never asserts the
13
+ * pair is valid: callers MUST runtime-validate the selected
14
+ * `{ projectID, targetDirectory }` pair before restore use and fail closed
15
+ * whenever the pair cannot be resolved unambiguously.
16
+ *
17
+ * Scope and data rules:
18
+ * - Returns destination candidates only; no restore or import behavior.
19
+ * - Reads only `Project.id`, `Project.canonical`, and `Project.name`.
20
+ * `Project.sandboxes`, artifact fields, and any source project/location
21
+ * field are never read or returned.
22
+ * - Paths are never normalized, resolved, joined, or trimmed: `canonical` is
23
+ * carried verbatim into `targetDirectory`, and blank values fail closed.
24
+ * - `label` is `name` when it is a non-blank string, otherwise the verbatim
25
+ * `canonical` path, so the label is always non-empty.
26
+ *
27
+ * Fail-closed behavior (invalid entries are never silently skipped):
28
+ * - missing or non-callable `project.list` → `HostAdapterError`
29
+ * (`capability-unavailable`) before any host call, matching the other host
30
+ * adapters;
31
+ * - non-array responses, non-object records, missing/blank/non-string `id` or
32
+ * `canonical`, and non-string `name` values → `ProjectDestinationError`
33
+ * (`invalid-response`);
34
+ * - conflicting `projectID` ↔ `targetDirectory` pairs (one ID mapping to
35
+ * different paths, one path mapping to different IDs, or a repeated pair
36
+ * with different labels) → `ProjectDestinationError` (`invalid-response`);
37
+ * only entries that are unambiguously identical (same ID, same canonical,
38
+ * same label) are deduplicated.
39
+ * - Error messages carry only record positions/counts and value types, never
40
+ * project IDs, paths, or names.
41
+ *
42
+ * Method invocation follows the `host-client.ts` resolver convention:
43
+ * `resolveUnaryMethod` resolves `client.project.list` and invokes it with the
44
+ * owning `client.project` object preserved as the receiver via `Reflect.apply`.
45
+ * `project.list` takes no required argument; the pinned declaration types its
46
+ * only parameter as optional (`requestOptions?`), so the resolver's single
47
+ * `undefined` argument is declaration-equivalent to `client.project.list()`.
48
+ * Host/API rejections propagate unchanged; this adapter never wraps, retries,
49
+ * or swallows them.
50
+ *
51
+ * Evidence boundary: pinned v2.0.22 declaration/source research (`lib-31`) and
52
+ * synthetic fake-client tests only. This module makes no live host call and no
53
+ * cross-version, mapping, compatibility, or support claim follows from it.
54
+ */
55
+
56
+ import type { Plugin } from "@opencode/plugin/tui";
57
+ import { HostAdapterError, isObjectRecord, resolveUnaryMethod } from "./host-client.ts";
58
+
59
+ /* ------------------------------------------------------------------------- */
60
+ /* Errors */
61
+ /* ------------------------------------------------------------------------- */
62
+
63
+ /**
64
+ * Stable machine-readable failure code for project-list response validation.
65
+ * Capability absence reuses `HostAdapterError("capability-unavailable")`;
66
+ * this code covers a response that violated the declared `Project` record
67
+ * shape or contained an ambiguous `projectID` ↔ `canonical` pair. The shared
68
+ * `HostAdapterErrorCode` also includes `invalid-response` for other boundaries,
69
+ * but this module retains its specific `ProjectDestinationError` type and
70
+ * taxonomy for project-list failures.
71
+ */
72
+ export type ProjectDestinationErrorCode = "invalid-response";
73
+
74
+ /** Fail-closed project-list response error; host API errors are never wrapped in it. */
75
+ export class ProjectDestinationError extends Error {
76
+ readonly code: ProjectDestinationErrorCode;
77
+
78
+ constructor(message: string, options?: ErrorOptions) {
79
+ super(message, options);
80
+ this.name = "ProjectDestinationError";
81
+ this.code = "invalid-response";
82
+ }
83
+ }
84
+
85
+ /* ------------------------------------------------------------------------- */
86
+ /* Narrow client surface (documentation + compile-time assertions) */
87
+ /* ------------------------------------------------------------------------- */
88
+
89
+ /**
90
+ * Narrow structural view of the public project surface used for destination
91
+ * enumeration. `project` and `list` are optional because capability absence is
92
+ * a real runtime state; the factory fails closed with a clear
93
+ * `HostAdapterError` when the method is missing or not callable.
94
+ */
95
+ export interface ProjectListClientLike {
96
+ readonly project?: {
97
+ list?(): Promise<unknown>;
98
+ };
99
+ }
100
+
101
+ type HostTuiClient = Plugin.Context["client"];
102
+ type AssertTrue<T extends true> = T;
103
+
104
+ /**
105
+ * The installed v2.0.22 TUI client (`Plugin.Context["client"]`) must satisfy
106
+ * the narrow project-list surface; if a future pinned declaration drifts,
107
+ * `tsc` fails here instead of failing at runtime.
108
+ */
109
+ type _AssertHostProjectListClientCompatible = AssertTrue<HostTuiClient extends ProjectListClientLike ? true : false>;
110
+
111
+ /**
112
+ * The declared `project.list` output must keep exposing the candidate source
113
+ * fields (`id`, `canonical`, optional `name`) this adapter maps from. Drift in
114
+ * those fields is a compile error, not a silent runtime fallback.
115
+ */
116
+ type HostProjectListOutput = Awaited<ReturnType<HostTuiClient["project"]["list"]>>;
117
+ type _AssertHostProjectListOutputHasCandidateFields = AssertTrue<
118
+ HostProjectListOutput extends readonly { readonly id: string; readonly canonical: string; readonly name?: string }[]
119
+ ? true
120
+ : false
121
+ >;
122
+
123
+ /* ------------------------------------------------------------------------- */
124
+ /* Public types */
125
+ /* ------------------------------------------------------------------------- */
126
+
127
+ /**
128
+ * One destination candidate. `projectID` and `targetDirectory` are the
129
+ * declaration-derived proposed pair (`Project.id` / `Project.canonical`);
130
+ * `targetDirectory` is verbatim, never normalized. The pair requires runtime
131
+ * validation before restore use — see the module header (OQ-16 OPEN).
132
+ */
133
+ export interface ProjectDestinationCandidate {
134
+ readonly projectID: string;
135
+ /** `Project.canonical`, verbatim, proposed as the explicit import target. */
136
+ readonly targetDirectory: string;
137
+ /** Non-empty display label: `Project.name` when non-blank, else `canonical`. */
138
+ readonly label: string;
139
+ }
140
+
141
+ /** Destination-candidate listing surface. One call per selection flow; no caching. */
142
+ export interface ProjectDestinationsAdapter {
143
+ /**
144
+ * Calls the public `project.list` once and maps the response to candidates.
145
+ * Rejects with `HostAdapterError` (`capability-unavailable`) only when the
146
+ * method is unavailable (checked at construction), with
147
+ * `ProjectDestinationError` (`invalid-response`) for malformed/ambiguous
148
+ * responses, and with the unchanged host error for host rejections.
149
+ */
150
+ listDestinations(): Promise<readonly ProjectDestinationCandidate[]>;
151
+ }
152
+
153
+ /* ------------------------------------------------------------------------- */
154
+ /* Factory */
155
+ /* ------------------------------------------------------------------------- */
156
+
157
+ /**
158
+ * Creates the destination-candidates adapter. Fails closed with
159
+ * `HostAdapterError` (`capability-unavailable`) when `project.list` is absent
160
+ * or not callable, before any host call.
161
+ */
162
+ export function createProjectDestinationsAdapter(client: unknown): ProjectDestinationsAdapter {
163
+ const projectList = resolveUnaryMethod<undefined>(client, "project", "list");
164
+ if (projectList === undefined) {
165
+ throw new HostAdapterError(
166
+ "capability-unavailable",
167
+ "The installed TUI client does not expose a callable project.list; cannot enumerate destination candidates",
168
+ );
169
+ }
170
+ return {
171
+ async listDestinations(): Promise<readonly ProjectDestinationCandidate[]> {
172
+ // The resolver always applies exactly one argument; the pinned
173
+ // declaration's only parameter is optional, so `undefined` is the
174
+ // declaration-equivalent of calling `client.project.list()`.
175
+ const response = await projectList(undefined);
176
+ return mapProjectListResponse(response);
177
+ },
178
+ };
179
+ }
180
+
181
+ /* ------------------------------------------------------------------------- */
182
+ /* Response mapping (fail closed, no silent skips) */
183
+ /* ------------------------------------------------------------------------- */
184
+
185
+ /**
186
+ * Maps a raw `project.list` response to destination candidates.
187
+ *
188
+ * Strict and fail-closed: any non-array response or any malformed record
189
+ * rejects the entire list (no filtering, no partial results). Duplicate
190
+ * entries are collapsed only when unambiguously identical; every other
191
+ * repeated `projectID` or `canonical` value rejects as an ambiguous pair.
192
+ * Error messages never embed project data (only positions, counts, types).
193
+ */
194
+ export function mapProjectListResponse(response: unknown): readonly ProjectDestinationCandidate[] {
195
+ if (!Array.isArray(response)) {
196
+ throw new ProjectDestinationError(
197
+ `project.list response must be an array of project records; received ${describeValue(response)}`,
198
+ );
199
+ }
200
+ const total = response.length;
201
+ const candidates: ProjectDestinationCandidate[] = [];
202
+ const byProjectID = new Map<string, ProjectDestinationCandidate>();
203
+ const byTargetDirectory = new Map<string, ProjectDestinationCandidate>();
204
+
205
+ for (let index = 0; index < total; index += 1) {
206
+ const candidate = parseProjectRecord(response[index], index, total);
207
+ const knownByProjectID = byProjectID.get(candidate.projectID);
208
+ const knownByTargetDirectory = byTargetDirectory.get(candidate.targetDirectory);
209
+ if (knownByProjectID !== undefined || knownByTargetDirectory !== undefined) {
210
+ // Deduplicate only an unambiguously identical entry: both lookups
211
+ // must resolve to the same previously accepted candidate and every
212
+ // candidate field must match. Anything else is a conflicting pair.
213
+ const isExactDuplicate =
214
+ knownByProjectID !== undefined &&
215
+ knownByProjectID === knownByTargetDirectory &&
216
+ candidate.projectID === knownByProjectID.projectID &&
217
+ candidate.targetDirectory === knownByProjectID.targetDirectory &&
218
+ candidate.label === knownByProjectID.label;
219
+ if (isExactDuplicate) continue;
220
+ throw new ProjectDestinationError(
221
+ `project.list record ${index + 1} of ${total} conflicts with an earlier project ID/canonical directory pair; refusing to guess a destination`,
222
+ );
223
+ }
224
+ byProjectID.set(candidate.projectID, candidate);
225
+ byTargetDirectory.set(candidate.targetDirectory, candidate);
226
+ candidates.push(candidate);
227
+ }
228
+ return candidates;
229
+ }
230
+
231
+ /**
232
+ * Validates one `Project` record against the fields this adapter reads and
233
+ * builds a candidate. `id` and `canonical` must be non-blank strings and are
234
+ * carried verbatim (blankness is checked with `trim`, the value is never
235
+ * trimmed or normalized). `name` must be absent or a string; a non-blank
236
+ * string is the label, otherwise the label falls back to `canonical`.
237
+ */
238
+ function parseProjectRecord(raw: unknown, index: number, total: number): ProjectDestinationCandidate {
239
+ const position = `project.list record ${index + 1} of ${total}`;
240
+ if (!isObjectRecord(raw)) {
241
+ throw new ProjectDestinationError(`${position} must be an object; received ${describeValue(raw)}`);
242
+ }
243
+ const projectID = requireResponseString(raw["id"], "id", position);
244
+ const targetDirectory = requireResponseString(raw["canonical"], "canonical", position);
245
+ const rawName = raw["name"];
246
+ if (rawName === undefined) {
247
+ return { projectID, targetDirectory, label: targetDirectory };
248
+ }
249
+ if (typeof rawName !== "string") {
250
+ throw new ProjectDestinationError(`${position} has a non-string name; received ${describeValue(rawName)}`);
251
+ }
252
+ const label = rawName.trim().length > 0 ? rawName : targetDirectory;
253
+ return { projectID, targetDirectory, label };
254
+ }
255
+
256
+ /** Requires a non-blank string field or fails closed; the value is returned verbatim. */
257
+ function requireResponseString(value: unknown, field: string, position: string): string {
258
+ if (typeof value !== "string" || value.trim().length === 0) {
259
+ throw new ProjectDestinationError(
260
+ `${position} must carry a non-blank string ${field}; received ${describeValue(value)}`,
261
+ );
262
+ }
263
+ return value;
264
+ }
265
+
266
+ /** Type-only description for error messages; never includes the value itself. */
267
+ function describeValue(value: unknown): string {
268
+ if (value === null) return "null";
269
+ if (Array.isArray(value)) return "array";
270
+ return typeof value;
271
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Bounded, metadata-only cross-project session-ID inventory (2026-10-14
3
+ * owner-approved collision amendment).
4
+ *
5
+ * `createSessionIdInventory` builds one bounded read-only scan over the
6
+ * already-validated project list and an injected core `SessionReadPort`
7
+ * (`session.list` through the public client surface). It returns the session
8
+ * IDs the API reports as visible, without reading any message, without
9
+ * retaining any record content, and without any host call beyond
10
+ * `session.list`.
11
+ *
12
+ * Inputs are explicit and injected; no client surface is resolved here:
13
+ * - `projects` — the validated `project.list` candidates from the restore
14
+ * selection flow (`ProjectDestinationCandidate` satisfies the structural
15
+ * `SessionIdInventoryProject` view). Their `projectID`s scope the scan in
16
+ * candidate order.
17
+ * - `selectedProjectID` — the resolved destination project. It is always
18
+ * included in the scan; when the candidates omit it, it is appended rather
19
+ * than silently ignored. Duplicate project IDs in the inventory fail closed.
20
+ * - `policy` — the existing `SessionCollectionPolicy`. Only `sessionPageSize`
21
+ * (per-request page size) and `maxSessions` (aggregate ceiling across every
22
+ * project and page) are applied; no new numeric default or cap is invented,
23
+ * and the message limits are unused because no message is ever read.
24
+ *
25
+ * Scan semantics mirror the core collector's known page handling without
26
+ * modifying it (the collector stays untouched):
27
+ * - per project, `listSessions({ project, limit })` then
28
+ * `listSessions({ project, limit, cursor })`, following `cursor.next` until
29
+ * it is absent/null;
30
+ * - each item must snapshot to a JSON-stable object carrying a non-empty
31
+ * string `id` and a `projectID` exactly equal to the requested project;
32
+ * - any malformed page/item/id/scope, duplicate session ID anywhere in the
33
+ * scan, repeated cursor, empty page carrying a next cursor, aggregate
34
+ * overflow, or API failure rejects the whole inventory with the collector's
35
+ * existing `SessionCollectionError` codes (`invalid-request`,
36
+ * `invalid-policy`, `session-list-failed`, `malformed-session-page`,
37
+ * `malformed-session-record`, `session-project-mismatch`,
38
+ * `duplicate-session-id`, `repeated-session-cursor`,
39
+ * `session-limit-exceeded`). No partial ID list is ever returned.
40
+ *
41
+ * Data rules: only `info.id` and `info.projectID` are read from each record;
42
+ * the record is snapshotted solely to fail closed on non-JSON values and is
43
+ * never retained. Messages are never requested (`listMessages` is never
44
+ * called), no `message.list`/`location.get`/`project.list`/import call is made
45
+ * — the inventory takes an injected read port, not a client — and no project
46
+ * location side effect is possible.
47
+ *
48
+ * Snapshot/concurrency limitations (O-21/OQ-23 stay OPEN): the upstream list
49
+ * APIs document no snapshot consistency. This scan is a best-effort sequence
50
+ * of live page reads, not an atomic cross-project snapshot; sessions created,
51
+ * deleted, or renamed while it runs may be missed or stale, and the result is
52
+ * only the inventory the API returned during this scan. There is no
53
+ * completeness guarantee beyond that, no retry/re-read policy, and no claim
54
+ * that the ID set is point-in-time consistent across projects.
55
+ *
56
+ * Evidence boundary: synthetic fake-port tests only; no live host call,
57
+ * filesystem, crypto, or import is performed, and no runtime or compatibility
58
+ * claim follows.
59
+ */
60
+
61
+ import { snapshotJsonValue, type JsonObject, type JsonValue } from "../../core/contracts/index.ts";
62
+ import {
63
+ SessionCollectionError,
64
+ validateSessionCollectionPolicy,
65
+ type SessionCollectionPolicy,
66
+ type SessionListPageRequest,
67
+ type SessionReadPort,
68
+ } from "../../core/sessions/index.ts";
69
+ import { isObjectRecord } from "./host-client.ts";
70
+
71
+ /* ------------------------------------------------------------------------- */
72
+ /* Public types */
73
+ /* ------------------------------------------------------------------------- */
74
+
75
+ /**
76
+ * Structural view of one already-validated project-list candidate. Only the
77
+ * project ID is used; `ProjectDestinationCandidate` (which also carries the
78
+ * proposed `targetDirectory` and `label`) satisfies this view without being
79
+ * re-derived or re-validated here.
80
+ */
81
+ export interface SessionIdInventoryProject {
82
+ /** Non-blank project ID from the validated `project.list` candidate set. */
83
+ readonly projectID: string;
84
+ }
85
+
86
+ /** Explicit, fully injected inventory dependencies. Every field is required; no defaults exist. */
87
+ export interface SessionIdInventoryInput {
88
+ /** Injected core read port; only `listSessions` is ever called. */
89
+ readonly readPort: SessionReadPort;
90
+ /**
91
+ * Validated project-list candidates whose project IDs scope the scan, in
92
+ * candidate order. Malformed or duplicate entries fail closed.
93
+ */
94
+ readonly projects: readonly SessionIdInventoryProject[];
95
+ /**
96
+ * Resolved selected destination project; always included in the scan even
97
+ * when the candidates omit it (it is appended, never silently ignored).
98
+ */
99
+ readonly selectedProjectID: string;
100
+ /** Explicit collection policy; only `sessionPageSize` and `maxSessions` are applied. */
101
+ readonly policy: SessionCollectionPolicy;
102
+ }
103
+
104
+ /** One bounded metadata-only cross-project session-ID inventory surface. */
105
+ export interface SessionIdInventory {
106
+ /**
107
+ * Performs one scan and resolves with every session ID the API returned,
108
+ * each exactly once, in deterministic project/page order. Rejects with a
109
+ * `SessionCollectionError` (collector codes) without returning any partial
110
+ * list; see the module header for the exact fail-closed rules and the
111
+ * documented snapshot/concurrency limitations.
112
+ */
113
+ readGloballyVisibleSessionIDs(): Promise<readonly string[]>;
114
+ }
115
+
116
+ /* ------------------------------------------------------------------------- */
117
+ /* Factory */
118
+ /* ------------------------------------------------------------------------- */
119
+
120
+ /**
121
+ * Creates the metadata-only inventory. All inputs are validated fail-closed
122
+ * here, before any host call: a malformed/duplicate project inventory throws
123
+ * `SessionCollectionError("invalid-request")`, an invalid policy throws
124
+ * `SessionCollectionError("invalid-policy")` through the shared core
125
+ * validator, and a port without a callable `listSessions` throws
126
+ * `invalid-request`.
127
+ */
128
+ export function createSessionIdInventory(input: SessionIdInventoryInput): SessionIdInventory {
129
+ if (!isObjectRecord(input)) {
130
+ throw new SessionCollectionError(
131
+ "invalid-request",
132
+ "createSessionIdInventory requires an input object with readPort, projects, selectedProjectID, and policy",
133
+ );
134
+ }
135
+ const readPort = input.readPort;
136
+ if (!isObjectRecord(readPort) || typeof readPort.listSessions !== "function") {
137
+ throw new SessionCollectionError(
138
+ "invalid-request",
139
+ "SessionIdInventory requires an injected SessionReadPort with a callable listSessions",
140
+ );
141
+ }
142
+ validateSessionCollectionPolicy(input.policy);
143
+ const projectIDs = resolveInventoryProjects(input.projects, input.selectedProjectID);
144
+ const policy = input.policy;
145
+
146
+ return {
147
+ async readGloballyVisibleSessionIDs(): Promise<readonly string[]> {
148
+ const sessionIDs: string[] = [];
149
+ const seenSessionIDs = new Set<string>();
150
+ for (const projectID of projectIDs) {
151
+ await collectProjectSessionIDs(readPort, projectID, policy, sessionIDs, seenSessionIDs);
152
+ }
153
+ return sessionIDs;
154
+ },
155
+ };
156
+ }
157
+
158
+ /* ------------------------------------------------------------------------- */
159
+ /* Project-inventory validation (fail closed, no silent skips) */
160
+ /* ------------------------------------------------------------------------- */
161
+
162
+ /**
163
+ * Validates the project inventory and returns the deterministic scan order:
164
+ * candidate project IDs verbatim in candidate order, then the selected
165
+ * project ID appended when it is not already present. Malformed entries,
166
+ * blank IDs, and any duplicate project ID reject the whole inventory before
167
+ * any host call.
168
+ */
169
+ function resolveInventoryProjects(projects: unknown, selectedProjectID: unknown): readonly string[] {
170
+ if (!Array.isArray(projects)) {
171
+ throw new SessionCollectionError(
172
+ "invalid-request",
173
+ "SessionIdInventory projects must be an array of validated project candidates",
174
+ );
175
+ }
176
+ const projectIDs: string[] = [];
177
+ const seenProjectIDs = new Set<string>();
178
+ for (let index = 0; index < projects.length; index += 1) {
179
+ const entry: unknown = projects[index];
180
+ if (!isObjectRecord(entry)) {
181
+ throw new SessionCollectionError(
182
+ "invalid-request",
183
+ `SessionIdInventory projects[${index}] must be an object with a projectID`,
184
+ );
185
+ }
186
+ const projectID = entry["projectID"];
187
+ if (typeof projectID !== "string" || projectID.trim().length === 0) {
188
+ throw new SessionCollectionError(
189
+ "invalid-request",
190
+ `SessionIdInventory projects[${index}].projectID must be a non-blank string`,
191
+ );
192
+ }
193
+ if (seenProjectIDs.has(projectID)) {
194
+ throw new SessionCollectionError(
195
+ "invalid-request",
196
+ `SessionIdInventory project inventory repeats project id ${JSON.stringify(projectID)}; refusing an ambiguous scope`,
197
+ );
198
+ }
199
+ seenProjectIDs.add(projectID);
200
+ projectIDs.push(projectID);
201
+ }
202
+ if (typeof selectedProjectID !== "string" || selectedProjectID.trim().length === 0) {
203
+ throw new SessionCollectionError(
204
+ "invalid-request",
205
+ "SessionIdInventory requires an explicit non-blank selectedProjectID",
206
+ );
207
+ }
208
+ if (!seenProjectIDs.has(selectedProjectID)) {
209
+ projectIDs.push(selectedProjectID);
210
+ }
211
+ return projectIDs;
212
+ }
213
+
214
+ /* ------------------------------------------------------------------------- */
215
+ /* Per-project pagination (collector page semantics, metadata only) */
216
+ /* ------------------------------------------------------------------------- */
217
+
218
+ /**
219
+ * Pages one project's session list to exhaustion and appends each newly seen
220
+ * session ID to the shared aggregate list. The aggregate `seenSessionIDs`
221
+ * set spans every project, so a duplicate ID anywhere in the scan fails
222
+ * closed; `sessionIDs.length` is the aggregate ceiling counter checked
223
+ * against `policy.maxSessions`.
224
+ */
225
+ async function collectProjectSessionIDs(
226
+ readPort: SessionReadPort,
227
+ projectID: string,
228
+ policy: SessionCollectionPolicy,
229
+ sessionIDs: string[],
230
+ seenSessionIDs: Set<string>,
231
+ ): Promise<void> {
232
+ const usedCursors = new Set<string>();
233
+ let cursor: string | undefined;
234
+
235
+ for (;;) {
236
+ const pageRequest: SessionListPageRequest =
237
+ cursor === undefined
238
+ ? { project: projectID, limit: policy.sessionPageSize }
239
+ : { project: projectID, limit: policy.sessionPageSize, cursor };
240
+ const label = `Session metadata inventory for project ${JSON.stringify(projectID)}`;
241
+ const rawPage = await callPort(
242
+ () => readPort.listSessions(pageRequest),
243
+ "session-list-failed",
244
+ `${label} request failed`,
245
+ );
246
+ const page = parsePageEnvelope(rawPage, "malformed-session-page", `${label} response`);
247
+
248
+ for (const rawSession of page.items) {
249
+ const info = snapshotJsonRecord(rawSession, "malformed-session-record", `${label} item ${sessionIDs.length}`);
250
+ const id = info["id"];
251
+ if (typeof id !== "string" || id.length === 0) {
252
+ throw new SessionCollectionError(
253
+ "malformed-session-record",
254
+ `${label} returned an item without a non-empty string id`,
255
+ );
256
+ }
257
+ const boundProjectID = info["projectID"];
258
+ if (typeof boundProjectID !== "string" || boundProjectID !== projectID) {
259
+ throw new SessionCollectionError(
260
+ "session-project-mismatch",
261
+ `${label} returned session ${JSON.stringify(id)} with ${
262
+ boundProjectID === undefined ? "no projectID" : `projectID ${JSON.stringify(boundProjectID)}`
263
+ }; refusing to include a session outside the requested project scope`,
264
+ );
265
+ }
266
+ if (seenSessionIDs.has(id)) {
267
+ throw new SessionCollectionError(
268
+ "duplicate-session-id",
269
+ `${label} returned session id ${JSON.stringify(id)} more than once across the inventory; refusing to continue`,
270
+ );
271
+ }
272
+ if (sessionIDs.length >= policy.maxSessions) {
273
+ throw new SessionCollectionError(
274
+ "session-limit-exceeded",
275
+ `Session metadata inventory exceeded SessionCollectionPolicy.maxSessions=${policy.maxSessions}; refusing to return a partial inventory`,
276
+ );
277
+ }
278
+ seenSessionIDs.add(id);
279
+ sessionIDs.push(id);
280
+ }
281
+
282
+ if (page.next === undefined) break;
283
+ if (usedCursors.has(page.next)) {
284
+ throw new SessionCollectionError(
285
+ "repeated-session-cursor",
286
+ `${label} returned a cursor that was already used: ${JSON.stringify(page.next)}; refusing to loop`,
287
+ );
288
+ }
289
+ usedCursors.add(page.next);
290
+ cursor = page.next;
291
+ }
292
+ }
293
+
294
+ /* ------------------------------------------------------------------------- */
295
+ /* Shared page/record semantics (mirrors the core collector) */
296
+ /* ------------------------------------------------------------------------- */
297
+
298
+ interface ParsedPage {
299
+ readonly items: readonly unknown[];
300
+ /** `cursor.next` when present and non-null; `undefined` means the page sequence is complete. */
301
+ readonly next: string | undefined;
302
+ }
303
+
304
+ /**
305
+ * Validates the untrusted `{ data, cursor }` envelope with the same semantics
306
+ * the core collector uses: a non-object response, a missing `data` array, a
307
+ * non-object `cursor`, a non-string/blank `cursor.next`, or an empty page
308
+ * carrying a next cursor all fail closed.
309
+ */
310
+ function parsePageEnvelope(value: unknown, code: "malformed-session-page", label: string): ParsedPage {
311
+ if (!isObjectRecord(value)) {
312
+ throw new SessionCollectionError(code, `${label} was not an object`);
313
+ }
314
+ const data = value["data"];
315
+ if (!Array.isArray(data)) {
316
+ throw new SessionCollectionError(code, `${label} did not contain a data array`);
317
+ }
318
+
319
+ let next: string | undefined;
320
+ const cursor = value["cursor"];
321
+ if (cursor !== undefined && cursor !== null) {
322
+ if (!isObjectRecord(cursor)) {
323
+ throw new SessionCollectionError(code, `${label} cursor was not an object`);
324
+ }
325
+ const rawNext = cursor["next"];
326
+ if (rawNext !== undefined && rawNext !== null) {
327
+ if (typeof rawNext !== "string" || rawNext.length === 0) {
328
+ throw new SessionCollectionError(code, `${label} cursor.next was not a non-empty string or null`);
329
+ }
330
+ next = rawNext;
331
+ }
332
+ }
333
+
334
+ if (next !== undefined && data.length === 0) {
335
+ throw new SessionCollectionError(
336
+ code,
337
+ `${label} returned no items with a next cursor; refusing to guess at an unbounded page sequence`,
338
+ );
339
+ }
340
+
341
+ return { items: data, next };
342
+ }
343
+
344
+ /** Copies one API record into a detached JSON-only object, rejecting anything JSON would omit or coerce. */
345
+ function snapshotJsonRecord(value: unknown, code: "malformed-session-record", label: string): JsonObject {
346
+ if (!isObjectRecord(value)) {
347
+ throw new SessionCollectionError(code, `${label} was not an object`);
348
+ }
349
+ let snapshot: JsonValue;
350
+ try {
351
+ snapshot = snapshotJsonValue(value, label);
352
+ } catch (cause) {
353
+ throw new SessionCollectionError(code, `${label} is not JSON-stable: ${errorText(cause)}`, { cause });
354
+ }
355
+ if (!isObjectRecord(snapshot)) {
356
+ throw new SessionCollectionError(code, `${label} did not snapshot to an object`);
357
+ }
358
+ return snapshot as JsonObject;
359
+ }
360
+
361
+ /** Awaits one port call and converts non-collection failures into a coded error while preserving the cause. */
362
+ async function callPort(
363
+ call: () => Promise<unknown>,
364
+ code: "session-list-failed",
365
+ label: string,
366
+ ): Promise<unknown> {
367
+ try {
368
+ return await call();
369
+ } catch (cause) {
370
+ if (cause instanceof SessionCollectionError) throw cause;
371
+ throw new SessionCollectionError(code, `${label}: ${errorText(cause)}`, { cause });
372
+ }
373
+ }
374
+
375
+ /** Best-effort message text for wrapping a caught `unknown` value. */
376
+ function errorText(value: unknown): string {
377
+ return value instanceof Error ? value.message : String(value);
378
+ }