@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.
- package/dist/agent/local-backend.js +1 -1
- package/dist/workspace-runtime/codex/app-server.js +46 -121
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/loopback.js +90 -39
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +27 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +107 -48
- package/dist/workspace-runtime/server/http.js +196 -11
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- package/package.json +6 -3
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return persist(state, scopeKey, transitionOperation(
|
|
169
|
-
error: '
|
|
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
|
-
|
|
174
|
-
|
|
187
|
+
let approvalId;
|
|
188
|
+
if (process.env.PLAYCRAFT_RECORD_PENDING_APPROVAL === '1' &&
|
|
175
189
|
String(body.text ?? '').includes('[approval]')) {
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
if (result.approvalId) {
|
|
190
|
+
approvalId = `approval_${result.turnId}`;
|
|
179
191
|
await state.journal.putApproval({
|
|
180
|
-
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(
|
|
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:
|
|
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
|
|
233
|
+
if (result.outcome !== 'accepted') {
|
|
208
234
|
return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
|
|
209
235
|
}
|
|
210
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
await notifyCallback(state, recorded).catch(() => undefined);
|
|
860
|
+
sessionId: operation.sessionId,
|
|
861
|
+
workspaceId: operation.workspaceId,
|
|
862
|
+
operationId: operation.operationId,
|
|
863
|
+
}));
|
|
824
864
|
}
|
|
825
|
-
|
|
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',
|