@sealant/sdk 0.5.2 → 0.7.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.
@@ -117,6 +117,14 @@ export declare const execWorkspaceOp: (workspaceId: string, payload: {
117
117
  readonly mode: "interactive" | "one-shot";
118
118
  readonly status: "cancelled" | "completed" | "failed" | "queued" | "running";
119
119
  readonly prompt?: string | undefined;
120
+ readonly command?: {
121
+ readonly executable: string;
122
+ readonly args: readonly string[];
123
+ readonly cwd?: string | undefined;
124
+ } | undefined;
125
+ readonly metadata?: {
126
+ readonly [x: string]: unknown;
127
+ } | undefined;
120
128
  readonly exitCode?: number | undefined;
121
129
  readonly errorMessage?: string | undefined;
122
130
  readonly startedAt?: string | undefined;
@@ -156,6 +164,9 @@ export declare const createRunOp: (payload: {
156
164
  readonly args: readonly string[];
157
165
  readonly cwd?: string | undefined;
158
166
  } | undefined;
167
+ readonly metadata?: {
168
+ readonly [x: string]: unknown;
169
+ } | undefined;
159
170
  }) => Effect.Effect<{
160
171
  readonly runId: string;
161
172
  readonly workspaceId: string;
@@ -165,6 +176,14 @@ export declare const createRunOp: (payload: {
165
176
  readonly mode: "interactive" | "one-shot";
166
177
  readonly status: "cancelled" | "completed" | "failed" | "queued" | "running";
167
178
  readonly prompt?: string | undefined;
179
+ readonly command?: {
180
+ readonly executable: string;
181
+ readonly args: readonly string[];
182
+ readonly cwd?: string | undefined;
183
+ } | undefined;
184
+ readonly metadata?: {
185
+ readonly [x: string]: unknown;
186
+ } | undefined;
168
187
  readonly exitCode?: number | undefined;
169
188
  readonly errorMessage?: string | undefined;
170
189
  readonly startedAt?: string | undefined;
@@ -181,6 +200,14 @@ export declare const getRunOp: (runId: string) => Effect.Effect<{
181
200
  readonly mode: "interactive" | "one-shot";
182
201
  readonly status: "cancelled" | "completed" | "failed" | "queued" | "running";
183
202
  readonly prompt?: string | undefined;
203
+ readonly command?: {
204
+ readonly executable: string;
205
+ readonly args: readonly string[];
206
+ readonly cwd?: string | undefined;
207
+ } | undefined;
208
+ readonly metadata?: {
209
+ readonly [x: string]: unknown;
210
+ } | undefined;
184
211
  readonly exitCode?: number | undefined;
185
212
  readonly errorMessage?: string | undefined;
186
213
  readonly startedAt?: string | undefined;
@@ -203,6 +230,14 @@ export declare const listRunsOp: (query: {
203
230
  readonly mode: "interactive" | "one-shot";
204
231
  readonly status: "cancelled" | "completed" | "failed" | "queued" | "running";
205
232
  readonly prompt?: string | undefined;
233
+ readonly command?: {
234
+ readonly executable: string;
235
+ readonly args: readonly string[];
236
+ readonly cwd?: string | undefined;
237
+ } | undefined;
238
+ readonly metadata?: {
239
+ readonly [x: string]: unknown;
240
+ } | undefined;
206
241
  readonly exitCode?: number | undefined;
207
242
  readonly errorMessage?: string | undefined;
208
243
  readonly startedAt?: string | undefined;
@@ -230,6 +265,14 @@ export declare const updateRunOp: (runId: string, payload: {
230
265
  readonly mode: "interactive" | "one-shot";
231
266
  readonly status: "cancelled" | "completed" | "failed" | "queued" | "running";
232
267
  readonly prompt?: string | undefined;
268
+ readonly command?: {
269
+ readonly executable: string;
270
+ readonly args: readonly string[];
271
+ readonly cwd?: string | undefined;
272
+ } | undefined;
273
+ readonly metadata?: {
274
+ readonly [x: string]: unknown;
275
+ } | undefined;
233
276
  readonly exitCode?: number | undefined;
234
277
  readonly errorMessage?: string | undefined;
235
278
  readonly startedAt?: string | undefined;
@@ -255,11 +298,13 @@ export declare const getRunTimelineOp: (runId: string, query: {
255
298
  }[], import("effect/unstable/http/HttpClientError").HttpClientError | import("@sealant/api-contracts").RunBadRequestError | import("@sealant/api-contracts").RunInternalServerError | import("@sealant/api-contracts").RunNotFoundError | import("effect/Schema").SchemaError, SealantApiClient>;
256
299
  export declare const getRunScrollbackOp: (runId: string, query: {
257
300
  readonly processId: string;
258
- readonly stream: "stderr" | "stdout";
301
+ readonly stream: "pty" | "stderr" | "stdout";
259
302
  readonly atSequence?: string | undefined;
303
+ readonly fromSequence?: string | undefined;
304
+ readonly limit?: string | undefined;
260
305
  }) => Effect.Effect<{
261
306
  readonly processId: string;
262
- readonly stream: "stderr" | "stdout";
307
+ readonly stream: "pty" | "stderr" | "stdout";
263
308
  readonly byteCount: number;
264
309
  readonly contentBase64: string;
265
310
  }, import("effect/unstable/http/HttpClientError").HttpClientError | import("@sealant/api-contracts").RunBadRequestError | import("@sealant/api-contracts").RunInternalServerError | import("@sealant/api-contracts").RunNotFoundError | import("effect/Schema").SchemaError, SealantApiClient>;
@@ -286,6 +331,158 @@ export declare const getRunChangesOp: (runId: string) => Effect.Effect<{
286
331
  }[];
287
332
  readonly diff: string;
288
333
  }, import("effect/unstable/http/HttpClientError").HttpClientError | import("@sealant/api-contracts").RunInternalServerError | import("@sealant/api-contracts").RunNotFoundError | import("effect/Schema").SchemaError, SealantApiClient>;
334
+ export declare const createSessionOp: (payload: {
335
+ readonly workspaceId: string;
336
+ readonly ownerUserId: string;
337
+ readonly argv: readonly string[];
338
+ readonly cwd?: string | undefined;
339
+ readonly env?: {
340
+ readonly [x: string]: string;
341
+ } | undefined;
342
+ readonly cols?: number | undefined;
343
+ readonly rows?: number | undefined;
344
+ readonly term?: string | undefined;
345
+ readonly metadata?: {
346
+ readonly [x: string]: unknown;
347
+ } | undefined;
348
+ }) => Effect.Effect<{
349
+ readonly sessionId: string;
350
+ readonly workspaceId: string;
351
+ readonly runId: string;
352
+ readonly ownerUserId: string;
353
+ readonly status: "exited" | "failed" | "running" | "starting";
354
+ readonly argv: readonly string[];
355
+ readonly cwd?: string | undefined;
356
+ readonly cols: number;
357
+ readonly rows: number;
358
+ readonly exitCode?: number | undefined;
359
+ readonly exitSignal?: number | undefined;
360
+ readonly errorMessage?: string | undefined;
361
+ readonly metadata?: {
362
+ readonly [x: string]: unknown;
363
+ } | undefined;
364
+ readonly outputHighWater: string;
365
+ readonly createdAt: string;
366
+ readonly endedAt?: string | undefined;
367
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadGatewayError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionConflictError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
368
+ export declare const getSessionOp: (sessionId: string, ownerUserId?: string | undefined) => Effect.Effect<{
369
+ readonly sessionId: string;
370
+ readonly workspaceId: string;
371
+ readonly runId: string;
372
+ readonly ownerUserId: string;
373
+ readonly status: "exited" | "failed" | "running" | "starting";
374
+ readonly argv: readonly string[];
375
+ readonly cwd?: string | undefined;
376
+ readonly cols: number;
377
+ readonly rows: number;
378
+ readonly exitCode?: number | undefined;
379
+ readonly exitSignal?: number | undefined;
380
+ readonly errorMessage?: string | undefined;
381
+ readonly metadata?: {
382
+ readonly [x: string]: unknown;
383
+ } | undefined;
384
+ readonly outputHighWater: string;
385
+ readonly createdAt: string;
386
+ readonly endedAt?: string | undefined;
387
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
388
+ export declare const listSessionsOp: (query: {
389
+ readonly ownerUserId: string;
390
+ readonly workspaceId?: string | undefined;
391
+ readonly status?: "exited" | "failed" | "running" | "starting" | undefined;
392
+ readonly limit?: string | undefined;
393
+ }) => Effect.Effect<{
394
+ readonly items: readonly {
395
+ readonly sessionId: string;
396
+ readonly workspaceId: string;
397
+ readonly runId: string;
398
+ readonly ownerUserId: string;
399
+ readonly status: "exited" | "failed" | "running" | "starting";
400
+ readonly argv: readonly string[];
401
+ readonly cwd?: string | undefined;
402
+ readonly cols: number;
403
+ readonly rows: number;
404
+ readonly exitCode?: number | undefined;
405
+ readonly exitSignal?: number | undefined;
406
+ readonly errorMessage?: string | undefined;
407
+ readonly metadata?: {
408
+ readonly [x: string]: unknown;
409
+ } | undefined;
410
+ readonly outputHighWater: string;
411
+ readonly createdAt: string;
412
+ readonly endedAt?: string | undefined;
413
+ }[];
414
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
415
+ export declare const getSessionOutputOp: (sessionId: string, query: {
416
+ readonly ownerUserId?: string | undefined;
417
+ readonly from?: string | undefined;
418
+ readonly limit?: string | undefined;
419
+ }) => Effect.Effect<{
420
+ readonly sessionId: string;
421
+ readonly chunks: readonly {
422
+ readonly sequence: string;
423
+ readonly dataBase64: string;
424
+ }[];
425
+ readonly nextFrom: string;
426
+ readonly status: "exited" | "failed" | "running" | "starting";
427
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
428
+ export declare const sendSessionInputOp: (sessionId: string, payload: {
429
+ readonly ownerUserId?: string | undefined;
430
+ readonly dataBase64: string;
431
+ }) => Effect.Effect<{
432
+ readonly ok: boolean;
433
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadGatewayError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionConflictError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
434
+ export declare const resizeSessionOp: (sessionId: string, payload: {
435
+ readonly ownerUserId?: string | undefined;
436
+ readonly cols: number;
437
+ readonly rows: number;
438
+ }) => Effect.Effect<{
439
+ readonly ok: boolean;
440
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadGatewayError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionConflictError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
441
+ export declare const signalSessionOp: (sessionId: string, payload: {
442
+ readonly ownerUserId?: string | undefined;
443
+ readonly signal: number;
444
+ }) => Effect.Effect<{
445
+ readonly ok: boolean;
446
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadGatewayError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionConflictError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
447
+ export declare const closeSessionOp: (sessionId: string, payload: {
448
+ readonly ownerUserId?: string | undefined;
449
+ }) => Effect.Effect<{
450
+ readonly sessionId: string;
451
+ readonly workspaceId: string;
452
+ readonly runId: string;
453
+ readonly ownerUserId: string;
454
+ readonly status: "exited" | "failed" | "running" | "starting";
455
+ readonly argv: readonly string[];
456
+ readonly cwd?: string | undefined;
457
+ readonly cols: number;
458
+ readonly rows: number;
459
+ readonly exitCode?: number | undefined;
460
+ readonly exitSignal?: number | undefined;
461
+ readonly errorMessage?: string | undefined;
462
+ readonly metadata?: {
463
+ readonly [x: string]: unknown;
464
+ } | undefined;
465
+ readonly outputHighWater: string;
466
+ readonly createdAt: string;
467
+ readonly endedAt?: string | undefined;
468
+ }, import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError | import("@sealant/api-contracts").SessionBadGatewayError | import("@sealant/api-contracts").SessionBadRequestError | import("@sealant/api-contracts").SessionForbiddenError | import("@sealant/api-contracts").SessionInternalServerError | import("@sealant/api-contracts").SessionNotFoundError | import("@sealant/api-contracts").SessionUnauthorizedError, SealantApiClient>;
469
+ export declare const createAccessTokenOp: (payload: {
470
+ readonly ownerUserId: string;
471
+ readonly scopes: readonly ("session:input" | "session:read" | "workspace:exec")[];
472
+ readonly name?: string | undefined;
473
+ readonly workspaceId?: string | undefined;
474
+ readonly ttlSeconds?: number | undefined;
475
+ }) => Effect.Effect<{
476
+ readonly tokenId: string;
477
+ readonly ownerUserId: string;
478
+ readonly name?: string | undefined;
479
+ readonly scopes: readonly ("session:input" | "session:read" | "workspace:exec")[];
480
+ readonly workspaceId?: string | undefined;
481
+ readonly expiresAt?: string | undefined;
482
+ readonly revokedAt?: string | undefined;
483
+ readonly createdAt: string;
484
+ readonly token: string;
485
+ }, import("@sealant/api-contracts").AccessTokenBadRequestError | import("@sealant/api-contracts").AccessTokenInternalServerError | import("@sealant/api-contracts").AccessTokenNotFoundError | import("effect/unstable/http/HttpClientError").HttpClientError | import("effect/Schema").SchemaError, SealantApiClient>;
289
486
  export declare const inferenceRespondOp: (payload: {
290
487
  readonly ownerUserId: string;
291
488
  readonly credentials?: {
@@ -20,5 +20,20 @@ export const getRunTimelineOp = (runId, query) => Effect.flatMap(SealantApiClien
20
20
  export const getRunScrollbackOp = (runId, query) => Effect.flatMap(SealantApiClient, (client) => client.runs.getRunScrollback({ params: { runId }, query }));
21
21
  export const getRunLossOp = (runId) => Effect.flatMap(SealantApiClient, (client) => client.runs.getRunLoss({ params: { runId } }));
22
22
  export const getRunChangesOp = (runId) => Effect.flatMap(SealantApiClient, (client) => client.runs.getRunChanges({ params: { runId } }));
23
+ // ---- sessions ----
24
+ export const createSessionOp = (payload) => Effect.flatMap(SealantApiClient, (client) => client.sessions.createSession({ payload, headers: {} }));
25
+ export const getSessionOp = (sessionId, ownerUserId) => Effect.flatMap(SealantApiClient, (client) => client.sessions.getSession({
26
+ params: { sessionId },
27
+ headers: {},
28
+ query: ownerUserId === undefined ? {} : { ownerUserId },
29
+ }));
30
+ export const listSessionsOp = (query) => Effect.flatMap(SealantApiClient, (client) => client.sessions.listSessions({ query, headers: {} }));
31
+ export const getSessionOutputOp = (sessionId, query) => Effect.flatMap(SealantApiClient, (client) => client.sessions.getSessionOutput({ params: { sessionId }, headers: {}, query }));
32
+ export const sendSessionInputOp = (sessionId, payload) => Effect.flatMap(SealantApiClient, (client) => client.sessions.sendSessionInput({ params: { sessionId }, headers: {}, payload }));
33
+ export const resizeSessionOp = (sessionId, payload) => Effect.flatMap(SealantApiClient, (client) => client.sessions.resizeSession({ params: { sessionId }, headers: {}, payload }));
34
+ export const signalSessionOp = (sessionId, payload) => Effect.flatMap(SealantApiClient, (client) => client.sessions.signalSession({ params: { sessionId }, headers: {}, payload }));
35
+ export const closeSessionOp = (sessionId, payload) => Effect.flatMap(SealantApiClient, (client) => client.sessions.closeSession({ params: { sessionId }, headers: {}, payload }));
36
+ // ---- access tokens ----
37
+ export const createAccessTokenOp = (payload) => Effect.flatMap(SealantApiClient, (client) => client.accessTokens.createAccessToken({ payload }));
23
38
  // ---- inference ----
24
39
  export const inferenceRespondOp = (payload) => Effect.flatMap(SealantApiClient, (client) => client.inference.respond({ payload }));
@@ -11,31 +11,50 @@
11
11
  import { Effect } from "effect";
12
12
  import { SealantError } from "../errors.js";
13
13
  import { makeRun, toRunChangesData } from "../facade/run.js";
14
- import { createRunOp, getRunChangesOp, getRunOp } from "./operations.js";
14
+ import { createRunOp, getRunChangesOp, getRunOp, getWorkspaceOp } from "./operations.js";
15
15
  const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
16
16
  const POLL_INTERVAL = "500 millis";
17
17
  const RUN_TIMEOUT_MS = 30 * 60 * 1_000;
18
18
  /**
19
- * Registers the run WITH the harness command — the control plane executes it server-side (the worker
20
- * docker-execs it and ingests telemetry). The cwd is the workspace repo, which the worker defaults to.
19
+ * Registers the run — the control plane executes it server-side (the worker docker-execs it and
20
+ * ingests telemetry). With a client harness value (the creating handle), the client-built command
21
+ * rides along — the escape hatch that keeps `customHarness()` working. WITHOUT one (a handle from
22
+ * `workspaces.get(id)`), the harness id is read from the workspace's own spec and NO command is
23
+ * sent: the control plane constructs the invocation server-side, so re-fetched handles are fully
24
+ * capable.
21
25
  */
22
- const createHarnessRunEffect = (ctx, init, prompt) => Effect.gen(function* () {
26
+ const createHarnessRunEffect = (ctx, init, prompt, options) => Effect.gen(function* () {
27
+ const metadata = options?.metadata === undefined ? {} : { metadata: { ...options.metadata } };
23
28
  const harness = init.harness;
24
- if (harness === undefined) {
25
- return yield* Effect.fail(new SealantError("This workspace handle has no harness; use the handle returned by workspaces.create().", { code: "harness_required" }));
29
+ if (harness !== undefined) {
30
+ const command = harness.buildRunCommand(prompt);
31
+ return yield* createRunOp({
32
+ workspaceId: init.id,
33
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
34
+ harnessId: harness.id,
35
+ mode: "one-shot",
36
+ prompt,
37
+ command: { executable: command.executable, args: [...command.args] },
38
+ ...metadata,
39
+ });
40
+ }
41
+ const details = yield* getWorkspaceOp(init.id);
42
+ const spec = details.spec;
43
+ const harnessId = spec?.harness?.id;
44
+ if (harnessId === undefined) {
45
+ return yield* Effect.fail(new SealantError(`Workspace ${init.id} has no harness in its spec; pass a harness at workspaces.create() or use workspace.exec().`, { code: "harness_required" }));
26
46
  }
27
- const command = harness.buildRunCommand(prompt);
28
47
  return yield* createRunOp({
29
48
  workspaceId: init.id,
30
49
  ownerUserId: ctx.config.hostLocal.ownerUserId,
31
- harnessId: harness.id,
50
+ harnessId,
32
51
  mode: "one-shot",
33
52
  prompt,
34
- command: { executable: command.executable, args: [...command.args] },
53
+ ...metadata,
35
54
  });
36
55
  });
37
- const runHarnessEffect = (ctx, init, prompt) => Effect.gen(function* () {
38
- const created = yield* createHarnessRunEffect(ctx, init, prompt);
56
+ const runHarnessEffect = (ctx, init, prompt, options) => Effect.gen(function* () {
57
+ const created = yield* createHarnessRunEffect(ctx, init, prompt, options);
39
58
  const runId = created.runId;
40
59
  // Block until the run is terminal, polling the control plane.
41
60
  const deadline = Date.now() + RUN_TIMEOUT_MS;
@@ -54,10 +73,10 @@ const runHarnessEffect = (ctx, init, prompt) => Effect.gen(function* () {
54
73
  return makeRun(ctx, { wire, changes });
55
74
  });
56
75
  /** The BLOCKING `harness.run()` implementation, registered into the Workspace facade by the client. */
57
- export const runHarness = (ctx, init, prompt) => ctx.runtime.run(runHarnessEffect(ctx, init, prompt));
76
+ export const runHarness = (ctx, init, prompt, options) => ctx.runtime.run(runHarnessEffect(ctx, init, prompt, options));
58
77
  /**
59
78
  * The NON-BLOCKING `harness.start()` implementation: register the run and return the live handle
60
79
  * immediately. Callers stream progress via `run.record.stream()` and settle via `run.wait()` (which
61
80
  * fetches the captured changes once terminal).
62
81
  */
63
- export const startHarness = (ctx, init, prompt) => ctx.runtime.run(Effect.map(createHarnessRunEffect(ctx, init, prompt), (created) => makeRun(ctx, { wire: created })));
82
+ export const startHarness = (ctx, init, prompt, options) => ctx.runtime.run(Effect.map(createHarnessRunEffect(ctx, init, prompt, options), (created) => makeRun(ctx, { wire: created })));
@@ -0,0 +1,22 @@
1
+ import type { InteractiveSession } from "../types.js";
2
+ import type { SdkContext } from "./context.js";
3
+ export declare const makeInteractiveSession: (ctx: SdkContext, wire: {
4
+ readonly sessionId: string;
5
+ readonly workspaceId: string;
6
+ readonly runId: string;
7
+ readonly ownerUserId: string;
8
+ readonly status: "exited" | "failed" | "running" | "starting";
9
+ readonly argv: readonly string[];
10
+ readonly cwd?: string | undefined;
11
+ readonly cols: number;
12
+ readonly rows: number;
13
+ readonly exitCode?: number | undefined;
14
+ readonly exitSignal?: number | undefined;
15
+ readonly errorMessage?: string | undefined;
16
+ readonly metadata?: {
17
+ readonly [x: string]: unknown;
18
+ } | undefined;
19
+ readonly outputHighWater: string;
20
+ readonly createdAt: string;
21
+ readonly endedAt?: string | undefined;
22
+ }) => InteractiveSession;
@@ -0,0 +1,171 @@
1
+ import { closeSessionOp, getSessionOp, getSessionOutputOp, resizeSessionOp, sendSessionInputOp, signalSessionOp, } from "../effect/operations.js";
2
+ const OUTPUT_POLL_INTERVAL_MS = 250;
3
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const decodeBase64 = (value) => {
5
+ const buffer = Buffer.from(value, "base64");
6
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
7
+ };
8
+ /** Parse one SSE frame block ("event: ...\nid: ...\ndata: ...") into its fields. */
9
+ const parseSseFrame = (block) => {
10
+ let event = "message";
11
+ let id;
12
+ const dataLines = [];
13
+ for (const line of block.split("\n")) {
14
+ if (line.startsWith(":")) {
15
+ continue; // comment / keep-alive
16
+ }
17
+ if (line.startsWith("event:")) {
18
+ event = line.slice(6).trim();
19
+ }
20
+ else if (line.startsWith("id:")) {
21
+ id = line.slice(3).trim();
22
+ }
23
+ else if (line.startsWith("data:")) {
24
+ dataLines.push(line.slice(5).trimStart());
25
+ }
26
+ }
27
+ if (dataLines.length === 0 && id === undefined) {
28
+ return undefined;
29
+ }
30
+ return { event, id, data: dataLines.join("\n") };
31
+ };
32
+ /** Stream the session's output over SSE. Throws on transport failure (caller falls back). */
33
+ async function* streamOverSse(ctx, sessionId, from, signal) {
34
+ const config = ctx.config;
35
+ const fetchImpl = config.fetch ?? fetch;
36
+ const url = new URL(`/v1/sessions/${sessionId}/output/stream`, config.baseUrl);
37
+ url.searchParams.set("from", from.toString());
38
+ if (config.apiKey === undefined) {
39
+ url.searchParams.set("ownerUserId", config.hostLocal.ownerUserId);
40
+ }
41
+ const response = await fetchImpl(url, {
42
+ headers: {
43
+ accept: "text/event-stream",
44
+ ...(config.apiKey === undefined ? {} : { authorization: `Bearer ${config.apiKey}` }),
45
+ },
46
+ ...(signal === undefined ? {} : { signal }),
47
+ });
48
+ if (!response.ok || response.body === null) {
49
+ throw new Error(`session output stream failed: HTTP ${response.status}`);
50
+ }
51
+ const reader = response.body.getReader();
52
+ const decoder = new TextDecoder();
53
+ let buffered = "";
54
+ try {
55
+ for (;;) {
56
+ const { done, value } = await reader.read();
57
+ if (done) {
58
+ return;
59
+ }
60
+ buffered += decoder.decode(value, { stream: true });
61
+ for (;;) {
62
+ const boundary = buffered.indexOf("\n\n");
63
+ if (boundary === -1) {
64
+ break;
65
+ }
66
+ const block = buffered.slice(0, boundary);
67
+ buffered = buffered.slice(boundary + 2);
68
+ const frame = parseSseFrame(block);
69
+ if (frame === undefined) {
70
+ continue;
71
+ }
72
+ if (frame.event === "end") {
73
+ return;
74
+ }
75
+ if (frame.event === "output" && frame.id !== undefined) {
76
+ yield { sequence: BigInt(frame.id), data: decodeBase64(frame.data) };
77
+ }
78
+ }
79
+ }
80
+ }
81
+ finally {
82
+ await reader.cancel().catch(() => undefined);
83
+ }
84
+ }
85
+ /** Poll-backed fallback: page the range endpoint until the session settles. */
86
+ async function* streamByPolling(ctx, sessionId, from, signal) {
87
+ let cursor = from;
88
+ for (;;) {
89
+ if (signal?.aborted === true) {
90
+ return;
91
+ }
92
+ const page = await ctx.runtime.run(getSessionOutputOp(sessionId, {
93
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
94
+ from: cursor.toString(),
95
+ }));
96
+ for (const chunk of page.chunks) {
97
+ yield { sequence: BigInt(chunk.sequence), data: decodeBase64(chunk.dataBase64) };
98
+ }
99
+ cursor = BigInt(page.nextFrom);
100
+ if (page.chunks.length === 0) {
101
+ if (page.status === "exited" || page.status === "failed") {
102
+ return;
103
+ }
104
+ await delay(OUTPUT_POLL_INTERVAL_MS);
105
+ }
106
+ }
107
+ }
108
+ export const makeInteractiveSession = (ctx, wire) => {
109
+ const sessionId = wire.sessionId;
110
+ return {
111
+ id: sessionId,
112
+ workspaceId: wire.workspaceId,
113
+ runId: wire.runId,
114
+ send: async (input) => {
115
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
116
+ await ctx.runtime.run(sendSessionInputOp(sessionId, {
117
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
118
+ dataBase64: Buffer.from(bytes).toString("base64"),
119
+ }));
120
+ },
121
+ output: (options) => {
122
+ const from = options?.from ?? 0n;
123
+ const signal = options?.signal;
124
+ async function* iterate() {
125
+ let cursor = from;
126
+ try {
127
+ for await (const chunk of streamOverSse(ctx, sessionId, cursor, signal)) {
128
+ yield chunk;
129
+ cursor = chunk.sequence + 1n;
130
+ }
131
+ return;
132
+ }
133
+ catch (error) {
134
+ if (signal?.aborted === true) {
135
+ return;
136
+ }
137
+ // SSE transport unavailable (proxy, fetch impl, older server) — fall back to polling
138
+ // from the cursor; the sequence keying makes the switch seamless.
139
+ void error;
140
+ }
141
+ yield* streamByPolling(ctx, sessionId, cursor, signal);
142
+ }
143
+ return iterate();
144
+ },
145
+ resize: async (cols, rows) => {
146
+ await ctx.runtime.run(resizeSessionOp(sessionId, {
147
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
148
+ cols,
149
+ rows,
150
+ }));
151
+ },
152
+ signal: async (signalNumber) => {
153
+ await ctx.runtime.run(signalSessionOp(sessionId, {
154
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
155
+ signal: signalNumber,
156
+ }));
157
+ },
158
+ status: async () => {
159
+ const current = await ctx.runtime.run(getSessionOp(sessionId, ctx.config.hostLocal.ownerUserId));
160
+ return {
161
+ status: current.status,
162
+ ...(current.exitCode === undefined ? {} : { exitCode: current.exitCode }),
163
+ ...(current.exitSignal === undefined ? {} : { exitSignal: current.exitSignal }),
164
+ outputHighWater: BigInt(current.outputHighWater),
165
+ };
166
+ },
167
+ close: async () => {
168
+ await ctx.runtime.run(closeSessionOp(sessionId, { ownerUserId: ctx.config.hostLocal.ownerUserId }));
169
+ },
170
+ };
171
+ };
@@ -1,7 +1,8 @@
1
1
  import { execWorkspace } from "../effect/exec-workspace.js";
2
- import { expireWorkspaceOp, getWorkspaceOp, restartWorkspaceOp, stopWorkspaceOp, } from "../effect/operations.js";
2
+ import { createSessionOp, expireWorkspaceOp, getSessionOp, getWorkspaceOp, listSessionsOp, restartWorkspaceOp, stopWorkspaceOp, } from "../effect/operations.js";
3
3
  import { SealantError, SealantNotImplementedError } from "../errors.js";
4
4
  import { parseTtlSeconds } from "../internal/duration.js";
5
+ import { makeInteractiveSession } from "./session.js";
5
6
  // Terminal statuses a workspace can never leave: ready()/events() fail fast (or end the stream)
6
7
  // on these instead of polling out their deadline. "stopped" is terminal too — a TTL expiry or a
7
8
  // concurrent stop while ready() polls must surface immediately, not as a 10-minute timeout.
@@ -15,7 +16,60 @@ let harnessExecutors;
15
16
  export const registerHarnessExecutors = (executors) => {
16
17
  harnessExecutors = executors;
17
18
  };
19
+ // Launch commands for the built-in harnesses — used when a RE-FETCHED handle (no client harness
20
+ // value) opens a harness session; the workspace's own spec names the harness id.
21
+ const BUILTIN_LAUNCH_COMMANDS = {
22
+ opencode: "opencode",
23
+ codex: "codex",
24
+ "claude-code": "claude",
25
+ };
18
26
  export const makeWorkspace = (ctx, init) => {
27
+ const openSession = async (argv, options) => {
28
+ const created = await ctx.runtime.run(createSessionOp({
29
+ workspaceId: init.id,
30
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
31
+ argv: [...argv],
32
+ ...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
33
+ ...(options?.env === undefined ? {} : { env: options.env }),
34
+ ...(options?.cols === undefined ? {} : { cols: options.cols }),
35
+ ...(options?.rows === undefined ? {} : { rows: options.rows }),
36
+ ...(options?.term === undefined ? {} : { term: options.term }),
37
+ ...(options?.metadata === undefined ? {} : { metadata: { ...options.metadata } }),
38
+ }));
39
+ return makeInteractiveSession(ctx, created);
40
+ };
41
+ const sessions = {
42
+ open: (argv, options) => openSession(argv, options),
43
+ get: async (sessionId) => {
44
+ const wire = await ctx.runtime.run(getSessionOp(sessionId, ctx.config.hostLocal.ownerUserId));
45
+ if (wire.workspaceId !== init.id) {
46
+ throw new SealantError(`Session ${sessionId} does not belong to workspace ${init.id}.`, {
47
+ code: "session_not_found",
48
+ });
49
+ }
50
+ return makeInteractiveSession(ctx, wire);
51
+ },
52
+ list: async () => {
53
+ const response = await ctx.runtime.run(listSessionsOp({
54
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
55
+ workspaceId: init.id,
56
+ }));
57
+ return response.items.map((item) => makeInteractiveSession(ctx, item));
58
+ },
59
+ };
60
+ /** The harness's interactive launch argv — client value when present, else from the spec. */
61
+ const resolveHarnessLaunchArgv = async () => {
62
+ if (init.harness !== undefined) {
63
+ return [init.harness.launchCommand ?? init.harness.id];
64
+ }
65
+ const details = await ctx.runtime.run(getWorkspaceOp(init.id));
66
+ const spec = details.spec;
67
+ const harnessId = spec?.harness?.id;
68
+ if (harnessId === undefined) {
69
+ throw new SealantError(`Workspace ${init.id} has no harness in its spec; open a session with workspace.sessions.open(argv) instead.`, { code: "harness_required" });
70
+ }
71
+ return [BUILTIN_LAUNCH_COMMANDS[harnessId] ?? harnessId];
72
+ };
19
73
  const harness = {
20
74
  run: (prompt, options) => {
21
75
  if (harnessExecutors === undefined) {
@@ -29,7 +83,10 @@ export const makeWorkspace = (ctx, init) => {
29
83
  }
30
84
  return harnessExecutors.start(ctx, init, prompt, options);
31
85
  },
32
- session: () => Promise.reject(new SealantNotImplementedError("harness.session (interactive, Phase 3)")),
86
+ session: async (options) => {
87
+ const argv = await resolveHarnessLaunchArgv();
88
+ return openSession(argv, options);
89
+ },
33
90
  };
34
91
  const workspace = {
35
92
  id: init.id,
@@ -60,6 +117,7 @@ export const makeWorkspace = (ctx, init) => {
60
117
  }
61
118
  },
62
119
  harness,
120
+ sessions,
63
121
  exec: (argv, options) => execWorkspace(ctx, init, argv, options),
64
122
  // Poll-backed lifecycle stream: emit a coarse event on each status transition until the workspace
65
123
  // reaches a terminal/ready state. Swaps to SSE over Postgres LISTEN/NOTIFY in Stage 5 (same shape).