@playcraft/cli 0.0.59 → 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.
@@ -5,10 +5,14 @@ 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';
15
+ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
12
16
  export async function startWorkspaceRuntime(config, options = {}) {
13
17
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
14
18
  const negotiated = negotiateProtocol(config);
@@ -21,8 +25,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
21
25
  }
22
26
  const lock = new RuntimeProcessLock(config.stateDir);
23
27
  lock.acquire();
28
+ let journal;
29
+ let gitCredentials;
24
30
  try {
25
- const journal = new RuntimeJournal(config.stateDir);
31
+ journal = new RuntimeJournal(config.stateDir);
26
32
  await journal.load();
27
33
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
28
34
  const native = options.nativeAdapter ?? defaultNativeAdapter(config);
@@ -36,6 +42,16 @@ export async function startWorkspaceRuntime(config, options = {}) {
36
42
  executionQueue: Promise.resolve(),
37
43
  instanceToken: randomUUID(),
38
44
  };
45
+ await journal.bindRuntime({
46
+ runtimeId: config.runtimeId,
47
+ generation: config.generation,
48
+ runtimeEpoch: state.runtimeEpoch,
49
+ });
50
+ if (config.workspaceMode === 'multi') {
51
+ gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
52
+ await gitCredentials.start();
53
+ }
54
+ attachNativeCollector(state);
39
55
  native?.onUnexpectedExit?.((event) => {
40
56
  void handleExecutionGroupCrash(state, event.pid);
41
57
  });
@@ -51,12 +67,27 @@ export async function startWorkspaceRuntime(config, options = {}) {
51
67
  const server = createServer((request, response) => {
52
68
  void handleRuntimeRequest(state, request, response);
53
69
  });
70
+ server.on('upgrade', (request, socket, head) => {
71
+ const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
72
+ if (/^\/runtime\/terminals\/[^/]+\/connect$/.test(pathname)) {
73
+ void terminalHost(state).handleUpgrade(request, socket, head);
74
+ return;
75
+ }
76
+ if (pathname === '/runtime/desktop/connect') {
77
+ desktopHost(state).handleUpgrade(request, socket, head);
78
+ return;
79
+ }
80
+ socket.destroy();
81
+ });
54
82
  const port = await listenOn(server, config.listen.host, config.listen.port);
55
83
  return {
56
84
  config: state.config,
57
85
  state,
58
86
  url: `http://${formatHost(config.listen.host)}:${port}`,
59
87
  async close() {
88
+ await gitCredentials?.close();
89
+ disposeDesktopHost(state);
90
+ disposeTerminalHost(state);
60
91
  await closeServer(server);
61
92
  try {
62
93
  await native?.close?.();
@@ -64,11 +95,14 @@ export async function startWorkspaceRuntime(config, options = {}) {
64
95
  catch {
65
96
  /* adapter shutdown is best-effort */
66
97
  }
98
+ await journal?.close();
67
99
  lock.release();
68
100
  },
69
101
  };
70
102
  }
71
103
  catch (error) {
104
+ await gitCredentials?.close().catch(() => undefined);
105
+ await journal?.close().catch(() => undefined);
72
106
  lock.release();
73
107
  throw error;
74
108
  }
@@ -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,8 @@ async function executeRestore(state, scopeKey, operation, payload) {
446
469
  }
447
470
  async function executeDestroy(state, scopeKey, operation) {
448
471
  await state.journal.markDestroyed();
472
+ await state.gitCredentials?.destroy();
473
+ disposeTerminalHost(state);
449
474
  try {
450
475
  await state.native?.close?.();
451
476
  }
@@ -796,35 +821,70 @@ function previousFilePaths(state) {
796
821
  function fileCheckpointDir(state, checkpointId) {
797
822
  return join(state.config.stateDir, 'file-checkpoints', checkpointId);
798
823
  }
824
+ export function attachNativeCollector(state) {
825
+ state.native?.onCollected?.((event) => {
826
+ void ingestCollected(state, event);
827
+ });
828
+ }
829
+ async function ingestCollected(state, event) {
830
+ const ids = nativeIds(event.params);
831
+ const recorded = await state.journal.ingestNative({
832
+ runtimeId: state.config.runtimeId,
833
+ generation: state.config.generation,
834
+ runtimeEpoch: state.runtimeEpoch,
835
+ kind: event.kind,
836
+ method: event.method,
837
+ params: event.params,
838
+ threadId: ids.threadId,
839
+ turnId: ids.turnId,
840
+ itemId: ids.itemId,
841
+ occurredAt: event.occurredAt,
842
+ });
843
+ if (recorded.length > 0)
844
+ await publishSourceEvents(state, recorded);
845
+ }
799
846
  export async function recordNativeEvents(state, operation, events) {
800
847
  const recorded = [];
801
848
  for (const event of events) {
802
- const sourceSequence = nextSourceSequence(state.journal.lastSourceSequence());
803
849
  const payload = {
804
850
  ...(asRecord(event.payload)),
805
851
  ...(operation.sessionId ? { sessionId: operation.sessionId } : {}),
806
852
  ...(operation.workspaceId ? { workspaceId: operation.workspaceId } : {}),
807
853
  };
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),
854
+ recorded.push(await state.journal.allocateSourceEvent({
855
+ runtimeId: state.config.runtimeId,
856
+ generation: state.config.generation,
857
+ runtimeEpoch: state.runtimeEpoch,
816
858
  type: event.type,
817
859
  payload,
818
860
  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);
861
+ sessionId: operation.sessionId,
862
+ workspaceId: operation.workspaceId,
863
+ operationId: operation.operationId,
864
+ }));
824
865
  }
825
- void operation;
866
+ if (recorded.length > 0)
867
+ await publishSourceEvents(state, recorded);
826
868
  return recorded;
827
869
  }
870
+ async function publishSourceEvents(state, events) {
871
+ await notifyCallback(state, events).catch(() => undefined);
872
+ }
873
+ function sessionTurnBusy(session) {
874
+ if (session.turnBlock === 'uncertain')
875
+ return true;
876
+ return session.turnStatus === 'starting' || session.turnStatus === 'running' || session.turnStatus === 'waiting_for_user';
877
+ }
878
+ function nativeIds(params) {
879
+ const record = asRecord(params);
880
+ const turn = asRecord(record.turn);
881
+ const thread = asRecord(record.thread);
882
+ return {
883
+ threadId: typeof record.threadId === 'string' ? record.threadId : typeof thread.id === 'string' ? thread.id : undefined,
884
+ turnId: typeof record.turnId === 'string' ? record.turnId : typeof turn.id === 'string' ? turn.id : undefined,
885
+ itemId: typeof record.itemId === 'string' ? record.itemId : typeof record.approvalId === 'string' ? record.approvalId : undefined,
886
+ };
887
+ }
828
888
  async function notifyCallback(state, events) {
829
889
  await fetch(state.config.callbackUrl, {
830
890
  method: 'POST',