opencode-session-port 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +84 -2
- package/dist/tui.js +10276 -0
- package/package.json +48 -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 +17 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import {
|
|
2
|
+
snapshotJsonValue,
|
|
3
|
+
type CheckpointPayload,
|
|
4
|
+
type JsonObject,
|
|
5
|
+
type JsonValue,
|
|
6
|
+
type SessionRecord,
|
|
7
|
+
} from "../contracts/index.ts";
|
|
8
|
+
import { SessionCollectionError, type SessionCollectionErrorCode } from "./errors.ts";
|
|
9
|
+
import { errorText, isJsonObject } from "./guards.ts";
|
|
10
|
+
import { validateSessionCollectionPolicy, type SessionCollectionPolicy } from "./policy.ts";
|
|
11
|
+
import type { MessageListPageRequest, SessionListPageRequest, SessionReadPort } from "./port.ts";
|
|
12
|
+
import { findUnsettledMessageReason } from "./unsettled.ts";
|
|
13
|
+
|
|
14
|
+
/** Input for one checkpoint payload collection. The project and every bound are explicit. */
|
|
15
|
+
export interface CollectCheckpointPayloadRequest {
|
|
16
|
+
/** Explicit project scope; current-project resolution is out of scope (OQ-10). */
|
|
17
|
+
readonly projectID: string;
|
|
18
|
+
/** Explicit local limits; no defaults are applied. */
|
|
19
|
+
readonly policy: SessionCollectionPolicy;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collects a complete `CheckpointPayload` for one explicit project by paging
|
|
24
|
+
* the session list and each session's message list to exhaustion.
|
|
25
|
+
*
|
|
26
|
+
* Behavior (v2.0.22 declarations/source research, E13–E15):
|
|
27
|
+
* - Follows `cursor.next` until it is absent or `null`; page size always comes
|
|
28
|
+
* from the explicit policy.
|
|
29
|
+
* - Includes every session the API returns. Archived sessions are not
|
|
30
|
+
* filtered and child links are not inferred; child-association semantics and
|
|
31
|
+
* legacy fork semantics remain OPEN (OQ-10).
|
|
32
|
+
* - First message page requests `order: "asc"`; subsequent pages pass only the
|
|
33
|
+
* opaque cursor (the API cannot combine `order` with `cursor`). No `type`
|
|
34
|
+
* filter is ever sent, and `session.export` is never used because it drops
|
|
35
|
+
* unsettled entries silently (E15).
|
|
36
|
+
* - Every item is validated into a detached JSON-only snapshot via the shared
|
|
37
|
+
* `snapshotJsonValue` contract; opaque `info`/message fields and all
|
|
38
|
+
* IDs/references are preserved unchanged (no canonicalization or remapping
|
|
39
|
+
* here — O-04/O-05 are OPEN).
|
|
40
|
+
* - Returned sessions are bound to the requested scope fail-closed: every
|
|
41
|
+
* session from the list must carry a present `projectID` exactly equal to
|
|
42
|
+
* the requested project; missing or mismatched values reject the whole
|
|
43
|
+
* collection and out-of-scope sessions are neither filtered out nor
|
|
44
|
+
* included.
|
|
45
|
+
* - Message association is endpoint-scoped instead of per-record: the
|
|
46
|
+
* collector addresses each session's messages through
|
|
47
|
+
* `listMessages(sessionID, ...)`, and the v2.0.22 route
|
|
48
|
+
* `GET /api/session/:sessionID/message` scopes returned records to that path
|
|
49
|
+
* session. v2.0.22 `Session.Message.Info` carries no per-message
|
|
50
|
+
* `sessionID` (its common Base is `id`/metadata/`time`), so each message
|
|
51
|
+
* record cannot independently assert its owning session. This collector
|
|
52
|
+
* therefore does not require, validate, normalize, or invent a per-message
|
|
53
|
+
* `sessionID`; message records are retained verbatim and unknown fields
|
|
54
|
+
* (including any future `sessionID`-shaped field) stay opaque.
|
|
55
|
+
* - The `maxTotalMessages` bound spans every session and every message page:
|
|
56
|
+
* before a page is accumulated, the number already collected plus that whole
|
|
57
|
+
* page must not exceed the bound, or the entire collection fails with
|
|
58
|
+
* `total-message-limit-exceeded`. A page that would push the collection over
|
|
59
|
+
* the bound is rejected, never truncated.
|
|
60
|
+
* - Any unsettled message (assistant without `time.completed`; running
|
|
61
|
+
* shell/compaction), duplicate session/message id, repeated cursor, API
|
|
62
|
+
* failure, malformed page/record, session scope-binding violation (missing
|
|
63
|
+
* or mismatched session `projectID`), or exceeded bound fails the whole
|
|
64
|
+
* collection. It never returns a partial payload or truncated history.
|
|
65
|
+
*
|
|
66
|
+
* Caveat (O-21 / OQ-23): the upstream list APIs document no snapshot
|
|
67
|
+
* consistency, so this is a best-effort page sequence, not an atomic snapshot,
|
|
68
|
+
* and it defines no retry policy. This function is one step of a checkpoint,
|
|
69
|
+
* not a complete checkpoint transaction; compression, encryption, and artifact
|
|
70
|
+
* writing are separate concerns.
|
|
71
|
+
*/
|
|
72
|
+
export async function collectCheckpointPayload(
|
|
73
|
+
port: SessionReadPort,
|
|
74
|
+
input: CollectCheckpointPayloadRequest,
|
|
75
|
+
): Promise<CheckpointPayload> {
|
|
76
|
+
if (!isJsonObject(input)) {
|
|
77
|
+
throw new SessionCollectionError(
|
|
78
|
+
"invalid-request",
|
|
79
|
+
"collectCheckpointPayload requires an input object with an explicit projectID and SessionCollectionPolicy",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (typeof port?.listSessions !== "function" || typeof port?.listMessages !== "function") {
|
|
83
|
+
throw new SessionCollectionError("invalid-request", "SessionReadPort must provide listSessions and listMessages");
|
|
84
|
+
}
|
|
85
|
+
validateSessionCollectionPolicy(input.policy);
|
|
86
|
+
const projectID = input.projectID;
|
|
87
|
+
if (typeof projectID !== "string" || projectID.length === 0) {
|
|
88
|
+
throw new SessionCollectionError(
|
|
89
|
+
"invalid-request",
|
|
90
|
+
"collectCheckpointPayload requires an explicit non-empty projectID string; current-project resolution is out of scope (OQ-10)",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sessions: SessionRecord[] = [];
|
|
95
|
+
const sessionIds = new Set<string>();
|
|
96
|
+
const usedSessionCursors = new Set<string>();
|
|
97
|
+
let sessionCursor: string | undefined;
|
|
98
|
+
/** Messages accumulated across every session and page so far; enforced against `maxTotalMessages`. */
|
|
99
|
+
let collectedMessageCount = 0;
|
|
100
|
+
|
|
101
|
+
for (;;) {
|
|
102
|
+
const pageRequest: SessionListPageRequest =
|
|
103
|
+
sessionCursor === undefined
|
|
104
|
+
? { project: projectID, limit: input.policy.sessionPageSize }
|
|
105
|
+
: { project: projectID, limit: input.policy.sessionPageSize, cursor: sessionCursor };
|
|
106
|
+
const label = `Session list for project ${JSON.stringify(projectID)}`;
|
|
107
|
+
const rawPage = await callPort(() => port.listSessions(pageRequest), "session-list-failed", `${label} request failed`);
|
|
108
|
+
const page = parsePageEnvelope(rawPage, "malformed-session-page", `${label} response`);
|
|
109
|
+
|
|
110
|
+
for (const rawSession of page.items) {
|
|
111
|
+
const info = snapshotJsonRecord(rawSession, "malformed-session-record", `Session list item ${sessions.length}`);
|
|
112
|
+
const id = info["id"];
|
|
113
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
114
|
+
throw new SessionCollectionError("malformed-session-record", "Session list item did not contain a non-empty string id");
|
|
115
|
+
}
|
|
116
|
+
const boundProjectID = info["projectID"];
|
|
117
|
+
if (typeof boundProjectID !== "string" || boundProjectID !== projectID) {
|
|
118
|
+
throw new SessionCollectionError(
|
|
119
|
+
"session-project-mismatch",
|
|
120
|
+
`${label} returned session ${JSON.stringify(id)} with ${
|
|
121
|
+
boundProjectID === undefined ? "no projectID" : `projectID ${JSON.stringify(boundProjectID)}`
|
|
122
|
+
}; refusing to include a session outside the requested project scope`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (sessionIds.has(id)) {
|
|
126
|
+
throw new SessionCollectionError(
|
|
127
|
+
"duplicate-session-id",
|
|
128
|
+
`Session list returned session id ${JSON.stringify(id)} more than once; refusing to continue`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (sessions.length >= input.policy.maxSessions) {
|
|
132
|
+
throw new SessionCollectionError(
|
|
133
|
+
"session-limit-exceeded",
|
|
134
|
+
`Session list exceeded SessionCollectionPolicy.maxSessions=${input.policy.maxSessions}; refusing to emit a partial checkpoint`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
sessionIds.add(id);
|
|
138
|
+
const messages = await collectSessionMessages(port, id, input.policy, collectedMessageCount);
|
|
139
|
+
collectedMessageCount += messages.length;
|
|
140
|
+
sessions.push({ info, messages });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (page.next === undefined) break;
|
|
144
|
+
if (usedSessionCursors.has(page.next)) {
|
|
145
|
+
throw new SessionCollectionError(
|
|
146
|
+
"repeated-session-cursor",
|
|
147
|
+
`Session list returned a cursor that was already used: ${JSON.stringify(page.next)}; refusing to loop`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
usedSessionCursors.add(page.next);
|
|
151
|
+
sessionCursor = page.next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { sessions };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pages one session's messages to exhaustion in server order.
|
|
159
|
+
*
|
|
160
|
+
* The first request is explicitly ascending; every later request carries only
|
|
161
|
+
* the opaque cursor (the API cannot combine `order` with `cursor`). The
|
|
162
|
+
* `sessionID` in the request is the ownership boundary: the v2.0.22 route
|
|
163
|
+
* scopes records to its `:sessionID` path parameter, and message records
|
|
164
|
+
* carry no `sessionID` of their own, so association is endpoint-scoped and is
|
|
165
|
+
* deliberately not re-asserted or normalized per record. Duplicate message
|
|
166
|
+
* ids, repeated cursors, malformed records, unsettled messages, API failures,
|
|
167
|
+
* the per-session bound, and the cross-session `maxTotalMessages` bound all
|
|
168
|
+
* fail closed. The total bound is checked against
|
|
169
|
+
* `alreadyCollectedMessages + messages collected so far in this session + whole
|
|
170
|
+
* page size` before the page is accumulated: a page that would push the
|
|
171
|
+
* collection over the bound rejects the whole collection instead of being
|
|
172
|
+
* truncated.
|
|
173
|
+
*/
|
|
174
|
+
async function collectSessionMessages(
|
|
175
|
+
port: SessionReadPort,
|
|
176
|
+
sessionID: string,
|
|
177
|
+
policy: SessionCollectionPolicy,
|
|
178
|
+
alreadyCollectedMessages: number,
|
|
179
|
+
): Promise<readonly JsonObject[]> {
|
|
180
|
+
const messages: JsonObject[] = [];
|
|
181
|
+
const messageIds = new Set<string>();
|
|
182
|
+
const usedCursors = new Set<string>();
|
|
183
|
+
let cursor: string | undefined;
|
|
184
|
+
|
|
185
|
+
for (;;) {
|
|
186
|
+
const pageRequest: MessageListPageRequest =
|
|
187
|
+
cursor === undefined
|
|
188
|
+
? { sessionID, limit: policy.messagePageSize, order: "asc" }
|
|
189
|
+
: { sessionID, limit: policy.messagePageSize, cursor };
|
|
190
|
+
const label = `Message list for session ${JSON.stringify(sessionID)}`;
|
|
191
|
+
const rawPage = await callPort(() => port.listMessages(pageRequest), "message-list-failed", `${label} request failed`);
|
|
192
|
+
const page = parsePageEnvelope(rawPage, "malformed-message-page", `${label} response`);
|
|
193
|
+
|
|
194
|
+
// Fail closed before accumulating this page when the collection would
|
|
195
|
+
// exceed the cross-session total. The page is counted whole (every item
|
|
196
|
+
// either becomes a message or rejects the collection), so a partially
|
|
197
|
+
// fitting page is never truncated.
|
|
198
|
+
const pageTotal = alreadyCollectedMessages + messages.length + page.items.length;
|
|
199
|
+
if (pageTotal > policy.maxTotalMessages) {
|
|
200
|
+
throw new SessionCollectionError(
|
|
201
|
+
"total-message-limit-exceeded",
|
|
202
|
+
`${label} would bring the total message count to ${pageTotal}, exceeding SessionCollectionPolicy.maxTotalMessages=${policy.maxTotalMessages}; refusing to emit a partial checkpoint`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const rawMessage of page.items) {
|
|
207
|
+
const message = snapshotJsonRecord(rawMessage, "malformed-message-record", `${label} item ${messages.length}`);
|
|
208
|
+
const id = message["id"];
|
|
209
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
210
|
+
throw new SessionCollectionError("malformed-message-record", `${label} returned a message without a non-empty string id`);
|
|
211
|
+
}
|
|
212
|
+
if (messageIds.has(id)) {
|
|
213
|
+
throw new SessionCollectionError(
|
|
214
|
+
"duplicate-message-id",
|
|
215
|
+
`${label} returned message id ${JSON.stringify(id)} more than once; refusing to continue`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (messages.length >= policy.maxMessagesPerSession) {
|
|
219
|
+
throw new SessionCollectionError(
|
|
220
|
+
"message-limit-exceeded",
|
|
221
|
+
`${label} exceeded SessionCollectionPolicy.maxMessagesPerSession=${policy.maxMessagesPerSession}; refusing to emit a partial checkpoint`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
messageIds.add(id);
|
|
225
|
+
const unsettled = findUnsettledMessageReason(message);
|
|
226
|
+
if (unsettled !== undefined) {
|
|
227
|
+
throw new SessionCollectionError(
|
|
228
|
+
"unsettled-message",
|
|
229
|
+
`${label} message ${JSON.stringify(id)} is unsettled (${unsettled}); official transfer/import would drop it, so the checkpoint fails closed with no artifact`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
messages.push(message);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (page.next === undefined) break;
|
|
236
|
+
if (usedCursors.has(page.next)) {
|
|
237
|
+
throw new SessionCollectionError(
|
|
238
|
+
"repeated-message-cursor",
|
|
239
|
+
`${label} returned a cursor that was already used: ${JSON.stringify(page.next)}; refusing to loop`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
usedCursors.add(page.next);
|
|
243
|
+
cursor = page.next;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return messages;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface ParsedPage {
|
|
250
|
+
readonly items: readonly unknown[];
|
|
251
|
+
/** `cursor.next` when present and non-null; `undefined` means the page sequence is complete. */
|
|
252
|
+
readonly next: string | undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Validates the untrusted `{ data, cursor }` envelope. Unknown *items* are
|
|
257
|
+
* returned as-is and are snapshotted individually afterwards; the envelope's
|
|
258
|
+
* `next` is only ever used as an opaque string or as a stop signal.
|
|
259
|
+
*/
|
|
260
|
+
function parsePageEnvelope(value: unknown, code: SessionCollectionErrorCode, label: string): ParsedPage {
|
|
261
|
+
if (!isJsonObject(value)) {
|
|
262
|
+
throw new SessionCollectionError(code, `${label} was not an object`);
|
|
263
|
+
}
|
|
264
|
+
const data = value["data"];
|
|
265
|
+
if (!Array.isArray(data)) {
|
|
266
|
+
throw new SessionCollectionError(code, `${label} did not contain a data array`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let next: string | undefined;
|
|
270
|
+
const cursor = value["cursor"];
|
|
271
|
+
if (cursor !== undefined && cursor !== null) {
|
|
272
|
+
if (!isJsonObject(cursor)) {
|
|
273
|
+
throw new SessionCollectionError(code, `${label} cursor was not an object`);
|
|
274
|
+
}
|
|
275
|
+
const rawNext = cursor["next"];
|
|
276
|
+
if (rawNext !== undefined && rawNext !== null) {
|
|
277
|
+
if (typeof rawNext !== "string" || rawNext.length === 0) {
|
|
278
|
+
throw new SessionCollectionError(code, `${label} cursor.next was not a non-empty string or null`);
|
|
279
|
+
}
|
|
280
|
+
next = rawNext;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (next !== undefined && data.length === 0) {
|
|
285
|
+
throw new SessionCollectionError(
|
|
286
|
+
code,
|
|
287
|
+
`${label} returned no items with a next cursor; refusing to guess at an unbounded page sequence`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { items: data, next };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Copies one API record into a detached JSON-only object, rejecting anything JSON would omit or coerce. */
|
|
295
|
+
function snapshotJsonRecord(value: unknown, code: SessionCollectionErrorCode, label: string): JsonObject {
|
|
296
|
+
if (!isJsonObject(value)) {
|
|
297
|
+
throw new SessionCollectionError(code, `${label} was not an object`);
|
|
298
|
+
}
|
|
299
|
+
let snapshot: JsonValue;
|
|
300
|
+
try {
|
|
301
|
+
snapshot = snapshotJsonValue(value, label);
|
|
302
|
+
} catch (cause) {
|
|
303
|
+
throw new SessionCollectionError(code, `${label} is not JSON-stable: ${errorText(cause)}`, { cause });
|
|
304
|
+
}
|
|
305
|
+
if (!isJsonObject(snapshot)) {
|
|
306
|
+
throw new SessionCollectionError(code, `${label} did not snapshot to an object`);
|
|
307
|
+
}
|
|
308
|
+
return snapshot as JsonObject;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Awaits one port call and converts non-collection failures into a coded error while preserving the cause. */
|
|
312
|
+
async function callPort(
|
|
313
|
+
call: () => Promise<unknown>,
|
|
314
|
+
code: SessionCollectionErrorCode,
|
|
315
|
+
label: string,
|
|
316
|
+
): Promise<unknown> {
|
|
317
|
+
try {
|
|
318
|
+
return await call();
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
if (cause instanceof SessionCollectionError) throw cause;
|
|
321
|
+
throw new SessionCollectionError(code, `${label}: ${errorText(cause)}`, { cause });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed failure codes for session collection.
|
|
3
|
+
*
|
|
4
|
+
* A thrown `SessionCollectionError` always means the collection stopped without
|
|
5
|
+
* producing a payload or serialized bytes. Callers must never catch it and
|
|
6
|
+
* continue with a partially collected result (P-05: complete history or no
|
|
7
|
+
* artifact; no silent filtering or truncation).
|
|
8
|
+
*/
|
|
9
|
+
export type SessionCollectionErrorCode =
|
|
10
|
+
| "invalid-request"
|
|
11
|
+
| "invalid-policy"
|
|
12
|
+
| "session-list-failed"
|
|
13
|
+
| "message-list-failed"
|
|
14
|
+
| "malformed-session-page"
|
|
15
|
+
| "malformed-message-page"
|
|
16
|
+
| "malformed-session-record"
|
|
17
|
+
| "malformed-message-record"
|
|
18
|
+
| "session-project-mismatch"
|
|
19
|
+
| "duplicate-session-id"
|
|
20
|
+
| "duplicate-message-id"
|
|
21
|
+
| "repeated-session-cursor"
|
|
22
|
+
| "repeated-message-cursor"
|
|
23
|
+
| "session-limit-exceeded"
|
|
24
|
+
| "message-limit-exceeded"
|
|
25
|
+
| "total-message-limit-exceeded"
|
|
26
|
+
| "unsettled-message";
|
|
27
|
+
|
|
28
|
+
/** Collection failure with a stable machine-readable code; `cause` preserves the underlying API/validation error. */
|
|
29
|
+
export class SessionCollectionError extends Error {
|
|
30
|
+
readonly code: SessionCollectionErrorCode;
|
|
31
|
+
|
|
32
|
+
constructor(code: SessionCollectionErrorCode, message: string, options?: ErrorOptions) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.name = "SessionCollectionError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Internal runtime guards shared by the session collection modules. */
|
|
2
|
+
|
|
3
|
+
/** True for JSON-shaped object values (non-null, not an array). */
|
|
4
|
+
export function isJsonObject(value: unknown): value is { readonly [key: string]: unknown } {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Best-effort message text for wrapping a caught `unknown` value. */
|
|
9
|
+
export function errorText(value: unknown): string {
|
|
10
|
+
return value instanceof Error ? value.message : String(value);
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort V1 core session lane (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Bounded session enumeration, detached JSON snapshotting, and payload
|
|
5
|
+
* serialization against the approved shared contracts. Session records are
|
|
6
|
+
* bound to the requested scope fail-closed: each session `projectID` must
|
|
7
|
+
* equal the requested project or the whole collection rejects. Message
|
|
8
|
+
* association is endpoint-scoped through `listMessages(sessionID, ...)`: the
|
|
9
|
+
* v2.0.22 route scopes messages to its `:sessionID` path parameter, message
|
|
10
|
+
* records carry no `sessionID` of their own, and no per-record association
|
|
11
|
+
* check or normalization is invented. No TUI/OpenCode imports; the host API
|
|
12
|
+
* is reached only through the injected `SessionReadPort`.
|
|
13
|
+
*
|
|
14
|
+
* Consistency caveat (O-21 / OQ-23): upstream list APIs document no atomic
|
|
15
|
+
* snapshot. A successful collection is a best-effort page sequence, not a
|
|
16
|
+
* consistent point-in-time capture, and this lane invents no retry policy.
|
|
17
|
+
* Canonical duplicate comparison (O-04) and ID/reference conflict remapping
|
|
18
|
+
* (O-05) are intentionally not implemented here.
|
|
19
|
+
*/
|
|
20
|
+
export { collectCheckpointPayload } from "./collector.ts";
|
|
21
|
+
export type { CollectCheckpointPayloadRequest } from "./collector.ts";
|
|
22
|
+
export { SessionCollectionError } from "./errors.ts";
|
|
23
|
+
export type { SessionCollectionErrorCode } from "./errors.ts";
|
|
24
|
+
export { MESSAGE_PAGE_SIZE_CAP, validateSessionCollectionPolicy } from "./policy.ts";
|
|
25
|
+
export type { SessionCollectionPolicy } from "./policy.ts";
|
|
26
|
+
export type { MessageListPageRequest, SessionListPageRequest, SessionReadPort } from "./port.ts";
|
|
27
|
+
export { serializeCheckpointPayload } from "./serialize.ts";
|
|
28
|
+
export { findUnsettledMessageReason } from "./unsettled.ts";
|
|
29
|
+
export type { UnsettledMessageReason } from "./unsettled.ts";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { SessionCollectionError } from "./errors.ts";
|
|
2
|
+
import { isJsonObject } from "./guards.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Explicit, required collection bounds. Every field has no default and must be
|
|
6
|
+
* supplied by the caller: this lane deliberately ships no production caps or
|
|
7
|
+
* hidden fallbacks (O-03 / OQ-03 remain OPEN and must be benchmarked before
|
|
8
|
+
* freeze). Tests may pass synthetic small values.
|
|
9
|
+
*
|
|
10
|
+
* Exceeding any bound fails the whole collection; the collector never emits a
|
|
11
|
+
* silently truncated or partial payload.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionCollectionPolicy {
|
|
14
|
+
/** Page size for `GET /api/session` requests (v2.0.22 declares a default of 50, not a cap). */
|
|
15
|
+
readonly sessionPageSize: number;
|
|
16
|
+
/** Page size for message list requests; must not exceed the declared v2.0.22 cap of 200. */
|
|
17
|
+
readonly messagePageSize: number;
|
|
18
|
+
/** Hard bound on the number of sessions collected across all pages. */
|
|
19
|
+
readonly maxSessions: number;
|
|
20
|
+
/** Hard bound on messages collected for a single session. */
|
|
21
|
+
readonly maxMessagesPerSession: number;
|
|
22
|
+
/**
|
|
23
|
+
* Hard bound on messages collected across all sessions and pages; checked
|
|
24
|
+
* against the number already collected plus a whole page before that page
|
|
25
|
+
* is accumulated, so a page that would push the collection over the bound
|
|
26
|
+
* rejects it instead of being truncated. Zero is allowed and forbids
|
|
27
|
+
* collecting any message.
|
|
28
|
+
*/
|
|
29
|
+
readonly maxTotalMessages: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Declared v2.0.22 maximum page size for `GET /api/session/:sessionID/message` (E14). */
|
|
33
|
+
export const MESSAGE_PAGE_SIZE_CAP = 200;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates an explicit policy at the collection boundary. Invalid or missing
|
|
37
|
+
* limits fail closed instead of silently substituting a default.
|
|
38
|
+
*/
|
|
39
|
+
export function validateSessionCollectionPolicy(policy: SessionCollectionPolicy): void {
|
|
40
|
+
if (!isJsonObject(policy)) {
|
|
41
|
+
throw new SessionCollectionError(
|
|
42
|
+
"invalid-policy",
|
|
43
|
+
"SessionCollectionPolicy is required and must be an explicit object with sessionPageSize, messagePageSize, maxSessions, maxMessagesPerSession, and maxTotalMessages; no defaults are applied",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
requirePositiveSafeInteger(policy.sessionPageSize, "sessionPageSize");
|
|
47
|
+
requirePositiveSafeInteger(policy.messagePageSize, "messagePageSize");
|
|
48
|
+
if (typeof policy.messagePageSize === "number" && policy.messagePageSize > MESSAGE_PAGE_SIZE_CAP) {
|
|
49
|
+
throw new SessionCollectionError(
|
|
50
|
+
"invalid-policy",
|
|
51
|
+
`SessionCollectionPolicy.messagePageSize must not exceed the declared message page cap of ${MESSAGE_PAGE_SIZE_CAP} (got ${policy.messagePageSize})`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
requirePositiveSafeInteger(policy.maxSessions, "maxSessions");
|
|
55
|
+
requirePositiveSafeInteger(policy.maxMessagesPerSession, "maxMessagesPerSession");
|
|
56
|
+
requireNonNegativeSafeInteger(policy.maxTotalMessages, "maxTotalMessages");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requirePositiveSafeInteger(value: unknown, field: keyof SessionCollectionPolicy): void {
|
|
60
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
61
|
+
throw new SessionCollectionError(
|
|
62
|
+
"invalid-policy",
|
|
63
|
+
`SessionCollectionPolicy.${field} must be an explicit positive safe integer (got ${String(value)})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `maxTotalMessages` is an aggregate cap where zero is a meaningful,
|
|
70
|
+
* fail-closed setting ("collect no messages"), so it accepts zero while the
|
|
71
|
+
* other bounds require at least one.
|
|
72
|
+
*/
|
|
73
|
+
function requireNonNegativeSafeInteger(value: unknown, field: keyof SessionCollectionPolicy): void {
|
|
74
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
75
|
+
throw new SessionCollectionError(
|
|
76
|
+
"invalid-policy",
|
|
77
|
+
`SessionCollectionPolicy.${field} must be an explicit non-negative safe integer (got ${String(value)})`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-injected read surface for session enumeration.
|
|
3
|
+
*
|
|
4
|
+
* This is the only host boundary the session core lane needs (no TUI/OpenCode
|
|
5
|
+
* imports here). Implementations live outside this module and map these calls
|
|
6
|
+
* onto the host client; tests inject synthetic ports.
|
|
7
|
+
*
|
|
8
|
+
* Expected upstream behavior (OpenCode v2.0.22 declarations/source research,
|
|
9
|
+
* E13/E14; declarations vs. runtime behavior still partially unvalidated —
|
|
10
|
+
* OQ-10):
|
|
11
|
+
* - `listSessions` maps to `GET /api/session`: declared filters include
|
|
12
|
+
* `project`/`directory`; default page size 50; opaque cursor pagination with
|
|
13
|
+
* no total/hasMore. Responses are `{ data: [...], cursor: { next?: string | null } }`.
|
|
14
|
+
* - `listMessages` maps to `GET /api/session/:sessionID/message`: declared
|
|
15
|
+
* default page size 50, cap 200, default order `desc`; `order` cannot be
|
|
16
|
+
* combined with `cursor`; secret-type filtering is possible and must be
|
|
17
|
+
* omitted to preserve every message variant. The `:sessionID` path
|
|
18
|
+
* parameter is the ownership boundary: the endpoint scopes returned records
|
|
19
|
+
* to that session, and v2.0.22 message records carry no per-message
|
|
20
|
+
* `sessionID` (their common Base is `id`/metadata/`time`), so a record
|
|
21
|
+
* cannot independently assert session association.
|
|
22
|
+
*
|
|
23
|
+
* Response values are treated as untrusted `unknown` and are validated into
|
|
24
|
+
* detached JSON-only snapshots by the collector; a port must not pre-filter,
|
|
25
|
+
* pre-truncate, or coerce API data.
|
|
26
|
+
*
|
|
27
|
+
* Consistency caveat (O-21 / OQ-23): the upstream list APIs document no atomic
|
|
28
|
+
* snapshot or snapshot-consistency guarantee. A successful collection is a
|
|
29
|
+
* sequence of page reads, not a consistent point-in-time capture; no retry or
|
|
30
|
+
* re-read policy is defined here. Do not describe this utility as a complete
|
|
31
|
+
* checkpoint transaction — compression/encryption/writing remain separate.
|
|
32
|
+
*/
|
|
33
|
+
export interface SessionReadPort {
|
|
34
|
+
/**
|
|
35
|
+
* Requests one page of the session list scoped to the explicit project.
|
|
36
|
+
* Expected response: `{ data: unknown[]; cursor?: { next?: string | null } | null }`.
|
|
37
|
+
*/
|
|
38
|
+
listSessions(request: SessionListPageRequest): Promise<unknown>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Requests one page of messages for exactly one session, addressed by the
|
|
42
|
+
* `sessionID` field of `MessageListPageRequest`. That request target is the
|
|
43
|
+
* ownership boundary: the upstream route scopes returned records to the
|
|
44
|
+
* path session and v2.0.22 message records carry no `sessionID`, so
|
|
45
|
+
* per-record association cannot be independently asserted and must not be
|
|
46
|
+
* inferred, required, or normalized. The first call must use `order: "asc"`;
|
|
47
|
+
* later calls pass the opaque `cursor` and no `order`. No `type` filter is
|
|
48
|
+
* ever sent.
|
|
49
|
+
* Expected response: `{ data: unknown[]; cursor?: { next?: string | null } | null }`.
|
|
50
|
+
*/
|
|
51
|
+
listMessages(request: MessageListPageRequest): Promise<unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One session list page request. The project scope is always explicit; the collector never infers it. */
|
|
55
|
+
export interface SessionListPageRequest {
|
|
56
|
+
/** Explicit project scope; mapped to the list route's `project` filter. */
|
|
57
|
+
readonly project: string;
|
|
58
|
+
/** Explicit page size from `SessionCollectionPolicy`; the API default is never relied on. */
|
|
59
|
+
readonly limit: number;
|
|
60
|
+
/** Opaque `cursor.next` from the previous page; absent on the first request. */
|
|
61
|
+
readonly cursor?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One message list page request.
|
|
66
|
+
*
|
|
67
|
+
* `sessionID` is the endpoint-scoped ownership boundary (the route path
|
|
68
|
+
* parameter) and is always the session currently being collected; returned
|
|
69
|
+
* records are not expected to repeat it (v2.0.22 message records have no
|
|
70
|
+
* `sessionID` field). The union mirrors the declared API constraint that
|
|
71
|
+
* `order` and `cursor` cannot be combined: the first page is `{ order: "asc" }`
|
|
72
|
+
* with no cursor, and every subsequent page carries only the opaque cursor. No
|
|
73
|
+
* `type` filter is ever sent so all message variants and tool content are
|
|
74
|
+
* preserved.
|
|
75
|
+
*/
|
|
76
|
+
export type MessageListPageRequest =
|
|
77
|
+
| {
|
|
78
|
+
readonly sessionID: string;
|
|
79
|
+
readonly limit: number;
|
|
80
|
+
/** First page only: ascending order. */
|
|
81
|
+
readonly order: "asc";
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
readonly sessionID: string;
|
|
85
|
+
readonly limit: number;
|
|
86
|
+
/** Subsequent pages only: opaque cursor; no `order`, no `type` filter. */
|
|
87
|
+
readonly cursor: string;
|
|
88
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { encodeJson, type CheckpointPayload } from "../contracts/index.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serializes a complete, successfully collected `CheckpointPayload` to detached
|
|
5
|
+
* JSON bytes via the shared `encodeJson` contract.
|
|
6
|
+
*
|
|
7
|
+
* This is only the serialization step of a checkpoint; compression, encryption,
|
|
8
|
+
* and artifact writing are separate. The collector always either returns a
|
|
9
|
+
* complete payload or throws, so there is no partial payload to serialize.
|
|
10
|
+
* `encodeJson` additionally rejects non-JSON-stable values instead of silently
|
|
11
|
+
* omitting or coercing them.
|
|
12
|
+
*/
|
|
13
|
+
export function serializeCheckpointPayload(payload: CheckpointPayload): Uint8Array {
|
|
14
|
+
return encodeJson(payload);
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JsonObject } from "../contracts/index.ts";
|
|
2
|
+
import { isJsonObject } from "./guards.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reasons a message is "unsettled" under the official v2.0.22 transfer/import
|
|
6
|
+
* filter (E15). Such messages would be silently dropped by import/export;
|
|
7
|
+
* because completeness is DECIDED (P-05) the checkpoint must fail closed
|
|
8
|
+
* instead of filtering them.
|
|
9
|
+
*/
|
|
10
|
+
export type UnsettledMessageReason =
|
|
11
|
+
| "assistant-without-completion"
|
|
12
|
+
| "shell-running"
|
|
13
|
+
| "compaction-running";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detects messages that official transfer/import would drop:
|
|
17
|
+
* - assistant message without a numeric `time.completed`;
|
|
18
|
+
* - shell message with `status: "running"`;
|
|
19
|
+
* - compaction message with `status: "running"`.
|
|
20
|
+
*
|
|
21
|
+
* Unknown message types, unknown statuses, and unknown fields are preserved,
|
|
22
|
+
* not filtered or coerced. This mirrors the researched v2.0.22 rules only; it
|
|
23
|
+
* is not a general schema validator and it makes no claim beyond E15.
|
|
24
|
+
*/
|
|
25
|
+
export function findUnsettledMessageReason(message: JsonObject): UnsettledMessageReason | undefined {
|
|
26
|
+
const type = message["type"];
|
|
27
|
+
if (type === "assistant") {
|
|
28
|
+
const time = message["time"];
|
|
29
|
+
const completed = isJsonObject(time) ? time["completed"] : undefined;
|
|
30
|
+
if (typeof completed !== "number") return "assistant-without-completion";
|
|
31
|
+
}
|
|
32
|
+
if (type === "shell" && message["status"] === "running") return "shell-running";
|
|
33
|
+
if (type === "compaction" && message["status"] === "running") return "compaction-running";
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** V1 OMO boundary: no stable public session-transfer API exists, so transfer no OMO data. */
|
|
2
|
+
export type OmoNoopResult = {
|
|
3
|
+
readonly status: "no-op";
|
|
4
|
+
readonly reason: "no-public-session-transfer-api";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export interface OmoAdapter {
|
|
8
|
+
capture(): Promise<OmoNoopResult>;
|
|
9
|
+
restore(): Promise<OmoNoopResult>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const RESULT: OmoNoopResult = Object.freeze({
|
|
13
|
+
status: "no-op",
|
|
14
|
+
reason: "no-public-session-transfer-api",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Deliberately does not inspect OMO configuration, package inventory, job-board
|
|
19
|
+
* state, credentials, agents, tools, or host-managed files. Native OpenCode
|
|
20
|
+
* sessions are the complete V1 payload; a future adapter requires an explicit,
|
|
21
|
+
* stable, versioned public transfer API.
|
|
22
|
+
*/
|
|
23
|
+
export function createOmoAdapter(): OmoAdapter {
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
capture: async () => RESULT,
|
|
26
|
+
restore: async () => RESULT,
|
|
27
|
+
});
|
|
28
|
+
}
|