@playcraft/cli 0.0.59 → 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.
@@ -5,7 +5,10 @@ import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, } from '@playcraft/common/cl
5
5
  import { RuntimeJournal } from './persistence/journal.js';
6
6
  import { createCodexAppServerAdapter } from './codex/app-server.js';
7
7
  import { createLoopbackNativeAdapter } from './codex/loopback.js';
8
+ import { attachNativeCollector } from './server/dispatch.js';
8
9
  import { handleRuntimeRequest } from './server/http.js';
10
+ import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
11
+ import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
9
12
  import { handleExecutionGroupCrash } from './server/execution-group.js';
10
13
  import { RuntimeProcessLock } from './server/lock.js';
11
14
  import { probeGitBinary } from './workspaces/git.js';
@@ -21,8 +24,9 @@ export async function startWorkspaceRuntime(config, options = {}) {
21
24
  }
22
25
  const lock = new RuntimeProcessLock(config.stateDir);
23
26
  lock.acquire();
27
+ let journal;
24
28
  try {
25
- const journal = new RuntimeJournal(config.stateDir);
29
+ journal = new RuntimeJournal(config.stateDir);
26
30
  await journal.load();
27
31
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
28
32
  const native = options.nativeAdapter ?? defaultNativeAdapter(config);
@@ -36,6 +40,12 @@ export async function startWorkspaceRuntime(config, options = {}) {
36
40
  executionQueue: Promise.resolve(),
37
41
  instanceToken: randomUUID(),
38
42
  };
43
+ await journal.bindRuntime({
44
+ runtimeId: config.runtimeId,
45
+ generation: config.generation,
46
+ runtimeEpoch: state.runtimeEpoch,
47
+ });
48
+ attachNativeCollector(state);
39
49
  native?.onUnexpectedExit?.((event) => {
40
50
  void handleExecutionGroupCrash(state, event.pid);
41
51
  });
@@ -51,12 +61,26 @@ export async function startWorkspaceRuntime(config, options = {}) {
51
61
  const server = createServer((request, response) => {
52
62
  void handleRuntimeRequest(state, request, response);
53
63
  });
64
+ server.on('upgrade', (request, socket, head) => {
65
+ const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
66
+ if (/^\/runtime\/terminals\/[^/]+\/connect$/.test(pathname)) {
67
+ void terminalHost(state).handleUpgrade(request, socket, head);
68
+ return;
69
+ }
70
+ if (pathname === '/runtime/desktop/connect') {
71
+ desktopHost(state).handleUpgrade(request, socket, head);
72
+ return;
73
+ }
74
+ socket.destroy();
75
+ });
54
76
  const port = await listenOn(server, config.listen.host, config.listen.port);
55
77
  return {
56
78
  config: state.config,
57
79
  state,
58
80
  url: `http://${formatHost(config.listen.host)}:${port}`,
59
81
  async close() {
82
+ disposeDesktopHost(state);
83
+ disposeTerminalHost(state);
60
84
  await closeServer(server);
61
85
  try {
62
86
  await native?.close?.();
@@ -64,11 +88,13 @@ export async function startWorkspaceRuntime(config, options = {}) {
64
88
  catch {
65
89
  /* adapter shutdown is best-effort */
66
90
  }
91
+ await journal?.close();
67
92
  lock.release();
68
93
  },
69
94
  };
70
95
  }
71
96
  catch (error) {
97
+ await journal?.close().catch(() => undefined);
72
98
  lock.release();
73
99
  throw error;
74
100
  }
@@ -0,0 +1,314 @@
1
+ import { connect as connectTcp } from 'node:net';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
+ import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { controlTokenMatches, extractControlToken } from './auth.js';
5
+ const sources = new WeakMap();
6
+ const hosts = new WeakMap();
7
+ export function setDesktopSource(state, source) {
8
+ if (source)
9
+ sources.set(state, source);
10
+ else
11
+ sources.delete(state);
12
+ hosts.get(state)?.replaceSource(source);
13
+ }
14
+ export function desktopHost(state) {
15
+ let host = hosts.get(state);
16
+ if (!host) {
17
+ host = new DesktopHost(state);
18
+ hosts.set(state, host);
19
+ }
20
+ return host;
21
+ }
22
+ export function disposeDesktopHost(state) {
23
+ hosts.get(state)?.dispose();
24
+ hosts.delete(state);
25
+ sources.delete(state);
26
+ }
27
+ export class DesktopHost {
28
+ state;
29
+ generation = 0;
30
+ holder = 'none';
31
+ probed = 'display_not_started';
32
+ viewers = new Set();
33
+ sockets = new WebSocketServer({ noServer: true });
34
+ source;
35
+ upstream = loopbackUpstream(process.env.PLAYCRAFT_DESKTOP_UPSTREAM);
36
+ constructor(state) {
37
+ this.state = state;
38
+ this.source = sources.get(state);
39
+ }
40
+ replaceSource(source) {
41
+ this.source = source;
42
+ }
43
+ snapshot() {
44
+ return desktopHealthSchema.parse({
45
+ status: this.currentStatus(),
46
+ streamGeneration: this.generation,
47
+ holder: this.holder,
48
+ agentInput: this.holder === 'human' ? 'revoked' : 'open',
49
+ });
50
+ }
51
+ async refresh() {
52
+ if (!this.source && this.upstream) {
53
+ this.probed = (await probePort(this.upstream.port)) ? 'ready' : 'display_not_started';
54
+ }
55
+ return this.snapshot();
56
+ }
57
+ async handleHttp(request, response, url) {
58
+ if (this.state.journal.destroyed()) {
59
+ writeError(response, 'runtime_destroyed');
60
+ return;
61
+ }
62
+ if (request.method === 'GET' && url.pathname === '/runtime/desktop/health') {
63
+ writeJson(response, 200, await this.refresh());
64
+ return;
65
+ }
66
+ if (request.method === 'POST' && url.pathname === '/runtime/desktop/takeover') {
67
+ await this.refresh();
68
+ if (this.currentStatus() !== 'ready') {
69
+ writeError(response, this.currentStatus());
70
+ return;
71
+ }
72
+ this.holder = 'human';
73
+ this.cutViewers();
74
+ writeJson(response, 200, this.snapshot());
75
+ return;
76
+ }
77
+ if (request.method === 'DELETE' && url.pathname === '/runtime/desktop') {
78
+ this.holder = 'none';
79
+ this.cutViewers();
80
+ writeJson(response, 200, this.snapshot());
81
+ return;
82
+ }
83
+ if (request.method === 'GET' && url.pathname === '/runtime/desktop/connect') {
84
+ await this.refresh();
85
+ this.rejectPlainConnect(response, url);
86
+ return;
87
+ }
88
+ if (request.method === 'GET' && url.pathname.startsWith('/runtime/desktop/resources/')) {
89
+ await this.refresh();
90
+ await this.writeResource(response, url);
91
+ return;
92
+ }
93
+ writeJson(response, 404, { error: 'not_found' });
94
+ }
95
+ handleUpgrade(request, socket, head) {
96
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
97
+ if (!controlTokenMatches(this.state.config.controlToken, extractControlToken(request.headers))) {
98
+ rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
99
+ return;
100
+ }
101
+ if (this.state.journal.destroyed()) {
102
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
103
+ return;
104
+ }
105
+ if (this.currentStatus() !== 'ready') {
106
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', this.currentStatus()));
107
+ return;
108
+ }
109
+ const generation = generationOf(url);
110
+ if (generation === undefined || generation !== this.generation) {
111
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'desktop_stale'));
112
+ return;
113
+ }
114
+ this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
115
+ }
116
+ dispose() {
117
+ this.holder = 'none';
118
+ this.cutViewers();
119
+ this.sockets.close();
120
+ }
121
+ currentStatus() {
122
+ if (this.source)
123
+ return this.source.health().status;
124
+ if (this.upstream)
125
+ return this.probed;
126
+ return 'display_not_started';
127
+ }
128
+ cutViewers() {
129
+ this.generation += 1;
130
+ for (const viewer of this.viewers)
131
+ this.drop(viewer);
132
+ this.viewers.clear();
133
+ }
134
+ drop(viewer) {
135
+ viewer.detach();
136
+ viewer.upstream?.close();
137
+ if (viewer.ws.readyState === WebSocket.OPEN)
138
+ viewer.ws.close();
139
+ }
140
+ attach(ws, generation) {
141
+ const viewer = { generation, ws, detach: () => undefined };
142
+ const send = (chunk, binary = true) => {
143
+ if (this.generation !== generation || ws.readyState !== WebSocket.OPEN)
144
+ return;
145
+ ws.send(chunk, { binary });
146
+ };
147
+ if (this.source) {
148
+ viewer.detach = this.source.attach(generation, (chunk) => send(chunk));
149
+ }
150
+ else if (this.upstream) {
151
+ const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
152
+ viewer.upstream = upstream;
153
+ viewer.detach = () => upstream.close();
154
+ upstream.on('message', (data, isBinary) => {
155
+ if (this.generation !== generation) {
156
+ this.drop(viewer);
157
+ return;
158
+ }
159
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
160
+ send(chunk, isBinary);
161
+ });
162
+ upstream.on('close', () => {
163
+ if (ws.readyState === WebSocket.OPEN)
164
+ ws.close();
165
+ });
166
+ }
167
+ this.viewers.add(viewer);
168
+ let closed = false;
169
+ const close = () => {
170
+ if (closed)
171
+ return;
172
+ closed = true;
173
+ this.viewers.delete(viewer);
174
+ viewer.detach();
175
+ viewer.upstream?.close();
176
+ };
177
+ ws.on('close', close);
178
+ ws.on('error', close);
179
+ ws.on('message', (data, isBinary) => {
180
+ if (this.holder !== 'human' || this.generation !== generation)
181
+ return;
182
+ if (viewer.upstream?.readyState === WebSocket.OPEN)
183
+ viewer.upstream.send(data, { binary: isBinary });
184
+ });
185
+ }
186
+ rejectPlainConnect(response, url) {
187
+ if (this.currentStatus() !== 'ready') {
188
+ writeError(response, this.currentStatus());
189
+ return;
190
+ }
191
+ const generation = generationOf(url);
192
+ if (generation === undefined || generation !== this.generation) {
193
+ writeError(response, 'desktop_stale');
194
+ return;
195
+ }
196
+ writeError(response, 'websocket_required');
197
+ }
198
+ async writeResource(response, url) {
199
+ if (this.currentStatus() !== 'ready') {
200
+ writeError(response, this.currentStatus());
201
+ return;
202
+ }
203
+ const name = desktopResourceName(decodeURIComponent(url.pathname.slice('/runtime/desktop/resources/'.length)));
204
+ if (!name) {
205
+ writeJson(response, 400, { error: 'invalid_desktop_resource' });
206
+ return;
207
+ }
208
+ const generation = url.searchParams.get('generation');
209
+ const document = name === 'vnc.html' || name.endsWith('/vnc.html');
210
+ if (document && Number(generation) !== this.generation) {
211
+ writeError(response, 'desktop_stale');
212
+ return;
213
+ }
214
+ if (generation !== null && Number(generation) !== this.generation) {
215
+ writeError(response, 'desktop_stale');
216
+ return;
217
+ }
218
+ if (this.source) {
219
+ const file = this.source.readResource(name);
220
+ if (!file) {
221
+ writeJson(response, 404, { error: 'not_found' });
222
+ return;
223
+ }
224
+ writeBytes(response, 200, file.contentType, file.body);
225
+ return;
226
+ }
227
+ if (!this.upstream) {
228
+ writeError(response, 'display_not_started');
229
+ return;
230
+ }
231
+ const upstream = await fetch(`http://127.0.0.1:${this.upstream.port}/${name}`, {
232
+ redirect: 'error',
233
+ signal: AbortSignal.timeout(5_000),
234
+ }).catch(() => undefined);
235
+ if (!upstream) {
236
+ writeError(response, 'display_unavailable');
237
+ return;
238
+ }
239
+ const body = Buffer.from(await upstream.arrayBuffer());
240
+ if (!upstream.ok) {
241
+ writeBytes(response, upstream.status, upstream.headers.get('content-type') ?? 'application/octet-stream', body);
242
+ return;
243
+ }
244
+ writeBytes(response, 200, upstream.headers.get('content-type') ?? 'application/octet-stream', body);
245
+ }
246
+ }
247
+ export function desktopRoute(pathname) {
248
+ return (pathname === '/runtime/desktop/health' ||
249
+ pathname === '/runtime/desktop/connect' ||
250
+ pathname === '/runtime/desktop/takeover' ||
251
+ pathname === '/runtime/desktop' ||
252
+ pathname.startsWith('/runtime/desktop/resources/'));
253
+ }
254
+ function generationOf(url) {
255
+ const value = url.searchParams.get('generation');
256
+ if (!value || !/^\d+$/.test(value))
257
+ return undefined;
258
+ return Number(value);
259
+ }
260
+ function loopbackUpstream(value) {
261
+ if (!value)
262
+ return undefined;
263
+ const match = /^(?:127\.0\.0\.1|localhost):(\d+)$/.exec(value.trim());
264
+ if (!match)
265
+ return undefined;
266
+ const port = Number(match[1]);
267
+ if (port < 1 || port > 65535)
268
+ return undefined;
269
+ return { port };
270
+ }
271
+ function probePort(port) {
272
+ return new Promise((resolve) => {
273
+ const socket = connectTcp({ host: '127.0.0.1', port });
274
+ const timer = setTimeout(() => {
275
+ socket.destroy();
276
+ resolve(false);
277
+ }, 200);
278
+ socket.once('connect', () => {
279
+ clearTimeout(timer);
280
+ socket.end();
281
+ resolve(true);
282
+ });
283
+ socket.once('error', () => {
284
+ clearTimeout(timer);
285
+ resolve(false);
286
+ });
287
+ });
288
+ }
289
+ function writeError(response, reason) {
290
+ writeJson(response, 409, runtimeError('capability_unavailable', reason));
291
+ }
292
+ function writeJson(response, status, body) {
293
+ const payload = JSON.stringify(body);
294
+ response.writeHead(status, {
295
+ 'content-type': 'application/json; charset=utf-8',
296
+ 'content-length': Buffer.byteLength(payload),
297
+ 'cache-control': 'no-store',
298
+ });
299
+ response.end(payload);
300
+ }
301
+ function writeBytes(response, status, contentType, body) {
302
+ response.writeHead(status, {
303
+ 'content-type': contentType,
304
+ 'content-length': body.length,
305
+ 'cache-control': 'no-store',
306
+ });
307
+ response.end(body);
308
+ }
309
+ function rejectUpgrade(socket, status, body) {
310
+ const payload = JSON.stringify(body);
311
+ const title = status === 401 ? 'Unauthorized' : 'Conflict';
312
+ socket.write(`HTTP/1.1 ${status} ${title}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(payload)}\r\nconnection: close\r\n\r\n${payload}`);
313
+ socket.destroy();
314
+ }
@@ -2,7 +2,8 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, hashOperationPayload, nextSourceSequence, parameterPatchPayloadSchema, pendingSourceEventSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { disposeTerminalHost } from './terminal-host.js';
6
7
  import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
7
8
  import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
8
9
  import { GitWorkspaceError, commitWorkspaceGit, pushWorkspaceGit } from '../workspaces/git.js';
@@ -148,54 +149,79 @@ async function executeTurn(state, scopeKey, operation, payload) {
148
149
  if (binding.recoverability !== 'native') {
149
150
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'native_not_recoverable' }));
150
151
  }
152
+ if (sessionTurnBusy(binding)) {
153
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
154
+ }
151
155
  const body = asRecord(payload);
152
- let result = await native(state).startTurn({
156
+ const result = await native(state).startTurn({
153
157
  sessionId,
154
158
  threadId: binding.threadId,
155
159
  text: String(body.text ?? ''),
156
160
  clientRequestId: operation.clientRequestId,
157
161
  simulateLostReceipt: body.simulateLostReceipt === true,
162
+ persistReservation: async ({ nativeRequestId }) => {
163
+ await persist(state, scopeKey, {
164
+ ...operation,
165
+ nativeRequestId,
166
+ nativeThreadId: binding.threadId,
167
+ updatedAt: new Date().toISOString(),
168
+ });
169
+ await state.journal.putSession({ ...binding, turnStatus: 'starting' });
170
+ },
158
171
  });
159
- if (result.outcome === 'uncertain') {
160
- await recordNativeEvents(state, operation, result.events);
161
- return persist(state, scopeKey, transitionOperation(operation, 'uncertain', {
162
- error: 'native_receipt_lost',
163
- nativeThreadId: binding.threadId,
164
- }));
165
- }
166
- if (result.outcome === 'failed' || !result.turnId) {
167
- await recordNativeEvents(state, operation, result.events);
168
- return persist(state, scopeKey, transitionOperation(operation, 'failed', {
169
- error: 'native_failed',
172
+ const reserved = state.journal.getOperation(scopeKey) ?? operation;
173
+ if (result.outcome === 'uncertain' || result.outcome === 'failed' || !result.turnId) {
174
+ const failed = result.outcome === 'failed';
175
+ await state.journal.putSession({
176
+ ...(state.journal.getSession(sessionId) ?? binding),
177
+ turnStatus: failed ? undefined : 'starting',
178
+ turnBlock: failed ? undefined : 'uncertain',
179
+ activeTurnId: undefined,
180
+ });
181
+ return persist(state, scopeKey, transitionOperation(reserved, failed ? 'failed' : 'uncertain', {
182
+ error: result.outcome === 'failed' ? result.error : 'native_receipt_lost',
170
183
  nativeThreadId: binding.threadId,
184
+ nativeRequestId: reserved.nativeRequestId,
171
185
  }));
172
186
  }
173
- if (!result.approvalId &&
174
- process.env.PLAYCRAFT_RECORD_PENDING_APPROVAL === '1' &&
187
+ let approvalId;
188
+ if (process.env.PLAYCRAFT_RECORD_PENDING_APPROVAL === '1' &&
175
189
  String(body.text ?? '').includes('[approval]')) {
176
- result = { ...result, approvalId: `approval_${result.turnId}` };
177
- }
178
- if (result.approvalId) {
190
+ approvalId = `approval_${result.turnId}`;
179
191
  await state.journal.putApproval({
180
- approvalId: result.approvalId,
192
+ approvalId,
181
193
  sessionId,
182
194
  runtimeEpoch: state.runtimeEpoch,
183
195
  nativeRequestId: `turn/start:${result.turnId}`,
184
196
  status: 'pending',
185
197
  });
198
+ await state.journal.putSession({
199
+ ...(state.journal.getSession(sessionId) ?? binding),
200
+ turnStatus: 'waiting_for_user',
201
+ activeTurnId: result.turnId,
202
+ });
186
203
  }
187
- const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
188
- nativeRequestId: `turn/start:${result.turnId}`,
204
+ const accepted = await persist(state, scopeKey, transitionOperation(reserved, 'accepted', {
205
+ nativeRequestId: reserved.nativeRequestId ?? `turn/start:${result.turnId}`,
189
206
  nativeThreadId: binding.threadId,
190
207
  nativeTurnId: result.turnId,
191
- nativeApprovalId: result.approvalId,
192
- }));
193
- await recordNativeEvents(state, accepted, result.events);
194
- const terminal = result.outcome === 'interrupted' ? 'interrupted' : 'completed';
195
- return persist(state, scopeKey, transitionOperation(accepted, terminal, {
196
- nativeTurnId: result.turnId,
197
- nativeApprovalId: result.approvalId,
208
+ nativeApprovalId: approvalId,
198
209
  }));
210
+ const released = await state.journal.bindTurn({
211
+ scopeKey,
212
+ operation: accepted,
213
+ sessionId,
214
+ threadId: binding.threadId,
215
+ turnId: result.turnId,
216
+ });
217
+ if (released.length > 0)
218
+ await publishSourceEvents(state, released);
219
+ const latest = state.journal.getOperation(scopeKey) ?? accepted;
220
+ if (approvalId && latest.status === 'accepted') {
221
+ const session = state.journal.getSession(sessionId) ?? binding;
222
+ await state.journal.putSession({ ...session, turnStatus: 'waiting_for_user', activeTurnId: result.turnId });
223
+ }
224
+ return state.journal.getOperation(scopeKey) ?? latest;
199
225
  }
200
226
  async function executeInterrupt(state, scopeKey, operation) {
201
227
  const sessionId = required(operation.sessionId, 'sessionId');
@@ -204,12 +230,10 @@ async function executeInterrupt(state, scopeKey, operation) {
204
230
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
205
231
  }
206
232
  const result = await native(state).interrupt({ sessionId, threadId: binding.threadId });
207
- if (result.outcome === 'uncertain') {
233
+ if (result.outcome !== 'accepted') {
208
234
  return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
209
235
  }
210
- const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { nativeThreadId: binding.threadId }));
211
- await recordNativeEvents(state, accepted, result.events);
212
- return persist(state, scopeKey, transitionOperation(accepted, 'interrupted', { nativeThreadId: binding.threadId }));
236
+ return persist(state, scopeKey, transitionOperation(operation, 'accepted', { nativeThreadId: binding.threadId, nativeTurnId: result.turnId }));
213
237
  }
214
238
  async function executeApproval(state, scopeKey, operation, payload) {
215
239
  const sessionId = required(operation.sessionId, 'sessionId');
@@ -234,7 +258,6 @@ async function executeApproval(state, scopeKey, operation, payload) {
234
258
  });
235
259
  }
236
260
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { nativeApprovalId: approvalId }));
237
- await recordNativeEvents(state, accepted, result.events);
238
261
  const terminal = result.outcome === 'failed' ? 'failed' : 'completed';
239
262
  return persist(state, scopeKey, transitionOperation(accepted, terminal, {
240
263
  nativeApprovalId: approvalId,
@@ -446,6 +469,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
446
469
  }
447
470
  async function executeDestroy(state, scopeKey, operation) {
448
471
  await state.journal.markDestroyed();
472
+ disposeTerminalHost(state);
449
473
  try {
450
474
  await state.native?.close?.();
451
475
  }
@@ -796,35 +820,70 @@ function previousFilePaths(state) {
796
820
  function fileCheckpointDir(state, checkpointId) {
797
821
  return join(state.config.stateDir, 'file-checkpoints', checkpointId);
798
822
  }
823
+ export function attachNativeCollector(state) {
824
+ state.native?.onCollected?.((event) => {
825
+ void ingestCollected(state, event);
826
+ });
827
+ }
828
+ async function ingestCollected(state, event) {
829
+ const ids = nativeIds(event.params);
830
+ const recorded = await state.journal.ingestNative({
831
+ runtimeId: state.config.runtimeId,
832
+ generation: state.config.generation,
833
+ runtimeEpoch: state.runtimeEpoch,
834
+ kind: event.kind,
835
+ method: event.method,
836
+ params: event.params,
837
+ threadId: ids.threadId,
838
+ turnId: ids.turnId,
839
+ itemId: ids.itemId,
840
+ occurredAt: event.occurredAt,
841
+ });
842
+ if (recorded.length > 0)
843
+ await publishSourceEvents(state, recorded);
844
+ }
799
845
  export async function recordNativeEvents(state, operation, events) {
800
846
  const recorded = [];
801
847
  for (const event of events) {
802
- const sourceSequence = nextSourceSequence(state.journal.lastSourceSequence());
803
848
  const payload = {
804
849
  ...(asRecord(event.payload)),
805
850
  ...(operation.sessionId ? { sessionId: operation.sessionId } : {}),
806
851
  ...(operation.workspaceId ? { workspaceId: operation.workspaceId } : {}),
807
852
  };
808
- const pending = pendingSourceEventSchema.parse({
809
- source: {
810
- runtimeId: state.config.runtimeId,
811
- generation: state.config.generation,
812
- runtimeEpoch: state.runtimeEpoch,
813
- sourceSequence,
814
- },
815
- payloadHash: hashOperationPayload(payload),
853
+ recorded.push(await state.journal.allocateSourceEvent({
854
+ runtimeId: state.config.runtimeId,
855
+ generation: state.config.generation,
856
+ runtimeEpoch: state.runtimeEpoch,
816
857
  type: event.type,
817
858
  payload,
818
859
  occurredAt: event.occurredAt,
819
- });
820
- recorded.push(await state.journal.appendEvent(pending));
821
- }
822
- if (recorded.length > 0) {
823
- await notifyCallback(state, recorded).catch(() => undefined);
860
+ sessionId: operation.sessionId,
861
+ workspaceId: operation.workspaceId,
862
+ operationId: operation.operationId,
863
+ }));
824
864
  }
825
- void operation;
865
+ if (recorded.length > 0)
866
+ await publishSourceEvents(state, recorded);
826
867
  return recorded;
827
868
  }
869
+ async function publishSourceEvents(state, events) {
870
+ await notifyCallback(state, events).catch(() => undefined);
871
+ }
872
+ function sessionTurnBusy(session) {
873
+ if (session.turnBlock === 'uncertain')
874
+ return true;
875
+ return session.turnStatus === 'starting' || session.turnStatus === 'running' || session.turnStatus === 'waiting_for_user';
876
+ }
877
+ function nativeIds(params) {
878
+ const record = asRecord(params);
879
+ const turn = asRecord(record.turn);
880
+ const thread = asRecord(record.thread);
881
+ return {
882
+ threadId: typeof record.threadId === 'string' ? record.threadId : typeof thread.id === 'string' ? thread.id : undefined,
883
+ turnId: typeof record.turnId === 'string' ? record.turnId : typeof turn.id === 'string' ? turn.id : undefined,
884
+ itemId: typeof record.itemId === 'string' ? record.itemId : typeof record.approvalId === 'string' ? record.approvalId : undefined,
885
+ };
886
+ }
828
887
  async function notifyCallback(state, events) {
829
888
  await fetch(state.config.callbackUrl, {
830
889
  method: 'POST',