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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|