@playcraft/cli 0.1.26 → 0.1.28
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/workspace-runtime/codex/app-server.js +51 -34
- package/dist/workspace-runtime/codex/launcher-client.js +15 -1
- package/dist/workspace-runtime/production/native.js +115 -11
- package/dist/workspace-runtime/server/dispatch.js +11 -6
- package/dist/workspace-runtime/server/http.js +14 -10
- package/dist/workspace-runtime/server/terminal-host.js +82 -10
- package/dist/workspace-runtime/workspaces/git.js +36 -0
- package/dist/workspace-runtime/workspaces/registry.js +28 -2
- package/package.json +3 -3
|
@@ -18,21 +18,32 @@ function asRecord(value) {
|
|
|
18
18
|
? value
|
|
19
19
|
: {};
|
|
20
20
|
}
|
|
21
|
-
function waitForChildExit(child) {
|
|
22
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
21
|
+
function waitForChildExit(child, processGroup = false) {
|
|
22
|
+
if ((child.exitCode !== null || child.signalCode !== null) && child.stdout?.destroyed && child.stderr?.destroyed)
|
|
23
23
|
return Promise.resolve();
|
|
24
|
-
return new Promise((resolve) => {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let drainTimer;
|
|
25
26
|
const done = () => {
|
|
26
27
|
clearTimeout(timer);
|
|
28
|
+
clearTimeout(drainTimer);
|
|
27
29
|
resolve();
|
|
28
30
|
};
|
|
29
31
|
const timer = setTimeout(() => {
|
|
30
|
-
if (
|
|
32
|
+
if (processGroup && child.pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
35
|
+
}
|
|
36
|
+
catch { /* The group may have exited already. */ }
|
|
37
|
+
}
|
|
38
|
+
else if (child.exitCode === null && child.signalCode === null)
|
|
31
39
|
child.kill('SIGKILL');
|
|
40
|
+
drainTimer = setTimeout(() => {
|
|
41
|
+
child.off('close', done);
|
|
42
|
+
reject(new Error('codex_stdio_stop_unconfirmed'));
|
|
43
|
+
}, 3000);
|
|
32
44
|
}, 3_000);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
done();
|
|
45
|
+
// A process exit can precede its final stdout data; close follows the drained pipes.
|
|
46
|
+
child.once('close', done);
|
|
36
47
|
});
|
|
37
48
|
}
|
|
38
49
|
function spawnCommand(bin) {
|
|
@@ -54,6 +65,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
54
65
|
let rpc;
|
|
55
66
|
let readyFlag = false;
|
|
56
67
|
let closed = false;
|
|
68
|
+
let closing;
|
|
57
69
|
let startPromise;
|
|
58
70
|
let catalogDirectory;
|
|
59
71
|
let skillsRestartFailed = false;
|
|
@@ -186,8 +198,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
186
198
|
if (previous)
|
|
187
199
|
previous.superseded = true;
|
|
188
200
|
readyFlag = false;
|
|
189
|
-
await previous?.rpc.close();
|
|
190
201
|
await previous?.child.stop();
|
|
202
|
+
await previous?.rpc.close();
|
|
191
203
|
if (closed)
|
|
192
204
|
throw new Error('codex_not_started');
|
|
193
205
|
rpc = undefined;
|
|
@@ -217,8 +229,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
217
229
|
skillsRestartFailed = true;
|
|
218
230
|
if (live) {
|
|
219
231
|
live.superseded = true;
|
|
220
|
-
await live.rpc.close();
|
|
221
232
|
await live.child.stop();
|
|
233
|
+
await live.rpc.close();
|
|
222
234
|
}
|
|
223
235
|
throw new NativePreparationError('workspace_skills_unavailable');
|
|
224
236
|
}
|
|
@@ -267,11 +279,15 @@ export function createCodexAppServerAdapter(options) {
|
|
|
267
279
|
sandbox_mode: 'danger-full-access',
|
|
268
280
|
}))
|
|
269
281
|
args.push('-c', `${key}=${JSON.stringify(value)}`);
|
|
282
|
+
// Prepare isolated Codex profiles as the development identity before replacing the shell.
|
|
283
|
+
const argv = process.platform === 'linux' && command === '/usr/local/bin/codex'
|
|
284
|
+
? ['/usr/bin/bash', '-c', 'umask 077; mkdir -p -- "$CODEX_HOME" && exec "$@"', 'playcraft-codex', command, ...args]
|
|
285
|
+
: [command, ...args];
|
|
270
286
|
child = await startLauncherProcess({
|
|
271
287
|
socketPath: launcherSocket,
|
|
272
288
|
processId: `codex-${randomUUID()}`,
|
|
273
289
|
runtimeEpoch: executionEpoch,
|
|
274
|
-
argv
|
|
290
|
+
argv,
|
|
275
291
|
// The launcher admits the dev home, not the shared /workspaces root.
|
|
276
292
|
// Each thread supplies its registered workspace separately.
|
|
277
293
|
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
@@ -292,7 +308,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
292
308
|
await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
|
|
293
309
|
child = {
|
|
294
310
|
pid: local.pid, stdin: local.stdin, stdout: local.stdout, stderr: local.stderr,
|
|
295
|
-
onExit(listener) { local.once('
|
|
311
|
+
onExit(listener) { local.once('close', listener); },
|
|
296
312
|
async stop() {
|
|
297
313
|
if (local.exitCode === null && local.signalCode === null) {
|
|
298
314
|
if (options.processGroup && local.pid) {
|
|
@@ -304,7 +320,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
304
320
|
else
|
|
305
321
|
local.kill('SIGTERM');
|
|
306
322
|
}
|
|
307
|
-
await waitForChildExit(local);
|
|
323
|
+
await waitForChildExit(local, options.processGroup);
|
|
308
324
|
},
|
|
309
325
|
};
|
|
310
326
|
}
|
|
@@ -381,8 +397,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
381
397
|
}
|
|
382
398
|
catch (error) {
|
|
383
399
|
current.superseded = true;
|
|
384
|
-
await current.rpc.close();
|
|
385
400
|
await current.child.stop();
|
|
401
|
+
await current.rpc.close();
|
|
386
402
|
throw error;
|
|
387
403
|
}
|
|
388
404
|
}
|
|
@@ -412,9 +428,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
412
428
|
startPromise = undefined;
|
|
413
429
|
threads.clear();
|
|
414
430
|
approvals.clear();
|
|
415
|
-
await previous?.rpc.close().catch(() => undefined);
|
|
416
431
|
if (previous)
|
|
417
432
|
await previous.child.stop();
|
|
433
|
+
await previous?.rpc.close().catch(() => undefined);
|
|
418
434
|
executionEpoch += 1;
|
|
419
435
|
skillsRestartFailed = false;
|
|
420
436
|
if (closed)
|
|
@@ -611,26 +627,27 @@ export function createCodexAppServerAdapter(options) {
|
|
|
611
627
|
}));
|
|
612
628
|
return { outcome: 'accepted', approvalId: input.approvalId, turnId: pending.turnId };
|
|
613
629
|
},
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
630
|
+
close() {
|
|
631
|
+
closing ??= (async () => {
|
|
632
|
+
closed = true;
|
|
633
|
+
readyFlag = false;
|
|
634
|
+
if (live)
|
|
635
|
+
live.superseded = true;
|
|
636
|
+
const current = child, currentRpc = rpc;
|
|
637
|
+
if (current)
|
|
638
|
+
await current.stop();
|
|
639
|
+
await currentRpc?.close().catch(() => undefined);
|
|
640
|
+
rpc = undefined;
|
|
641
|
+
child = undefined;
|
|
642
|
+
live = undefined;
|
|
643
|
+
await options.modelBroker?.close();
|
|
644
|
+
if (catalogDirectory)
|
|
645
|
+
await rm(catalogDirectory, { recursive: true, force: true });
|
|
646
|
+
for (const directory of coordinatorDirectories.values())
|
|
647
|
+
await rm(directory, { recursive: true, force: true });
|
|
648
|
+
coordinatorDirectories.clear();
|
|
649
|
+
})();
|
|
650
|
+
return closing;
|
|
634
651
|
},
|
|
635
652
|
};
|
|
636
653
|
}
|
|
@@ -43,6 +43,8 @@ export async function startLauncherProcess(input) {
|
|
|
43
43
|
let buffer = '';
|
|
44
44
|
let identity;
|
|
45
45
|
let stopPromise;
|
|
46
|
+
let finishTransport;
|
|
47
|
+
const transportClosed = new Promise(resolve => { finishTransport = resolve; });
|
|
46
48
|
const stdin = new Writable({
|
|
47
49
|
write(chunk, _encoding, callback) {
|
|
48
50
|
socket.write(`${JSON.stringify({ action: 'stdin', data: chunk.toString('base64') })}\n`, callback);
|
|
@@ -122,6 +124,7 @@ export async function startLauncherProcess(input) {
|
|
|
122
124
|
stderr.end();
|
|
123
125
|
stdin.destroy();
|
|
124
126
|
exit();
|
|
127
|
+
finishTransport();
|
|
125
128
|
});
|
|
126
129
|
});
|
|
127
130
|
return {
|
|
@@ -149,7 +152,18 @@ export async function startLauncherProcess(input) {
|
|
|
149
152
|
while (Date.now() < deadline) {
|
|
150
153
|
const status = await request(input.socketPath, { ...identity, action: 'status' });
|
|
151
154
|
if (status.running === false) {
|
|
152
|
-
|
|
155
|
+
let timeout;
|
|
156
|
+
try {
|
|
157
|
+
await Promise.race([transportClosed, new Promise((_resolve, reject) => {
|
|
158
|
+
timeout = setTimeout(() => {
|
|
159
|
+
socket.destroy();
|
|
160
|
+
reject(new Error('launcher_output_drain_unconfirmed'));
|
|
161
|
+
}, 3000);
|
|
162
|
+
})]);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
}
|
|
153
167
|
return;
|
|
154
168
|
}
|
|
155
169
|
await delay(50);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { lstat, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { hashOperationPayload, operationScopeKey, productionSessionId, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
4
|
import { createCodexAppServerAdapter } from '../codex/app-server.js';
|
|
5
|
+
import { ingestCollected, publishSourceEvents, recordNativeEvents } from '../server/dispatch.js';
|
|
4
6
|
import { verifyProductionLease } from './storage.js';
|
|
5
7
|
import { processBirth, stopProductionGroup } from './processes.js';
|
|
6
8
|
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
@@ -42,16 +44,74 @@ export async function runProductionNative(state, input, root, record, track) {
|
|
|
42
44
|
await record(group);
|
|
43
45
|
},
|
|
44
46
|
});
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
+
const sessionId = productionSessionId(input.task.attemptId);
|
|
48
|
+
const prompt = String(input.payload.prompt ?? '');
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
const operation = {
|
|
51
|
+
operationId: `${input.task.attemptId}:generate`, clientRequestId: `${input.task.attemptId}:generate`,
|
|
52
|
+
environment: input.task.environment, userId: input.task.userId, workspaceId: input.task.workspaceId,
|
|
53
|
+
directoryName: input.directoryName, sessionId, kind: 'session.turn', status: 'dispatched',
|
|
54
|
+
generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
55
|
+
payloadHash: hashOperationPayload({ text: prompt }), createdAt: now, updatedAt: now,
|
|
56
|
+
};
|
|
57
|
+
const scopeKey = operationScopeKey(operation);
|
|
58
|
+
let collecting = Promise.resolve();
|
|
59
|
+
let notifying;
|
|
60
|
+
let pendingNotifications = [];
|
|
61
|
+
const notify = (events) => {
|
|
62
|
+
// Notifications only wake the source archive; every event already belongs to the durable journal.
|
|
63
|
+
pendingNotifications = [...pendingNotifications, ...events].slice(-100);
|
|
64
|
+
notifying ??= (async () => {
|
|
65
|
+
while (pendingNotifications.length) {
|
|
66
|
+
const page = pendingNotifications;
|
|
67
|
+
pendingNotifications = [];
|
|
68
|
+
await publishSourceEvents(state, page);
|
|
69
|
+
}
|
|
70
|
+
})().finally(() => {
|
|
71
|
+
notifying = undefined;
|
|
72
|
+
if (pendingNotifications.length)
|
|
73
|
+
notify([]);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
let archiveError;
|
|
77
|
+
let shutdownError;
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
let rootThreadId;
|
|
80
|
+
let rootTurnId;
|
|
81
|
+
let completedTurn;
|
|
82
|
+
let timeout;
|
|
83
|
+
let leaseCheck;
|
|
47
84
|
let completion;
|
|
48
85
|
const done = new Promise(resolve => { completion = resolve; });
|
|
86
|
+
track(async () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
completion('unknown');
|
|
89
|
+
await adapter.close?.();
|
|
90
|
+
await collecting;
|
|
91
|
+
});
|
|
49
92
|
const unsubscribe = adapter.onCollected?.(event => {
|
|
50
93
|
if (event.kind === 'process_exit')
|
|
51
94
|
completion('unknown');
|
|
95
|
+
else {
|
|
96
|
+
const params = event.params && typeof event.params === 'object'
|
|
97
|
+
? event.params : {};
|
|
98
|
+
// The prompt is recorded before dispatch; Codex may echo the same user message afterward.
|
|
99
|
+
if (params.item?.type !== 'userMessage' || params.threadId !== rootThreadId) {
|
|
100
|
+
collecting = collecting.then(async () => notify(await ingestCollected(state, {
|
|
101
|
+
...event, params: { ...params, directoryName: input.directoryName },
|
|
102
|
+
}, false))).catch(error => {
|
|
103
|
+
archiveError ??= error;
|
|
104
|
+
completion('unknown');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
52
108
|
if (event.method === 'turn/completed') {
|
|
53
109
|
const value = event.params;
|
|
54
|
-
|
|
110
|
+
if (value.threadId === rootThreadId && value.turn?.id) {
|
|
111
|
+
completedTurn = value.turn;
|
|
112
|
+
if (value.turn.id === rootTurnId)
|
|
113
|
+
completion(value.turn.status === 'completed' ? 'completed' : 'failed');
|
|
114
|
+
}
|
|
55
115
|
}
|
|
56
116
|
});
|
|
57
117
|
try {
|
|
@@ -63,28 +123,72 @@ export async function runProductionNative(state, input, root, record, track) {
|
|
|
63
123
|
groups.push(group);
|
|
64
124
|
await record(group);
|
|
65
125
|
const thread = await adapter.startThread({ sessionId, cwd: root });
|
|
66
|
-
|
|
67
|
-
|
|
126
|
+
rootThreadId = thread.threadId;
|
|
127
|
+
// A production thread lives in its own Codex home and must never resume through the main adapter.
|
|
128
|
+
await state.journal.putSession({ sessionId, workspaceId: input.task.workspaceId, directoryName: input.directoryName,
|
|
129
|
+
threadId: thread.threadId, cwd: root, recoverability: 'history_only', turnStatus: 'starting' });
|
|
130
|
+
const creation = { ...operation, operationId: `${input.task.attemptId}:session.create`,
|
|
131
|
+
clientRequestId: `${input.task.attemptId}:session.create`, kind: 'session.create', status: 'completed',
|
|
132
|
+
payloadHash: hashOperationPayload({}), nativeThreadId: thread.threadId };
|
|
133
|
+
await state.journal.putOperation(operationScopeKey(creation), creation);
|
|
134
|
+
await state.journal.putOperation(scopeKey, { ...operation, nativeThreadId: thread.threadId });
|
|
135
|
+
notify(await recordNativeEvents(state, creation, [{ type: 'session.created', occurredAt: now, payload: {
|
|
136
|
+
sessionId, threadId: thread.threadId, batchId: input.task.batchId, attemptId: input.task.attemptId,
|
|
137
|
+
taskId: input.task.taskId, readonly: true, recoverability: 'history_only',
|
|
138
|
+
} }], false));
|
|
139
|
+
notify(await recordNativeEvents(state, operation, [{ type: 'item/completed', occurredAt: now, payload: {
|
|
140
|
+
item: { id: `${sessionId}-prompt`, type: 'userMessage', content: [{ type: 'text', text: prompt }] },
|
|
141
|
+
} }], false));
|
|
142
|
+
const turn = await adapter.startTurn({ sessionId, threadId: thread.threadId, text: prompt,
|
|
143
|
+
clientRequestId: operation.clientRequestId, persistReservation: async ({ nativeRequestId }) => {
|
|
144
|
+
await state.journal.putOperation(scopeKey, { ...operation, nativeThreadId: thread.threadId, nativeRequestId });
|
|
145
|
+
} });
|
|
68
146
|
if (turn.outcome !== 'accepted')
|
|
69
|
-
throw new Error('production_native_start_unknown');
|
|
70
|
-
|
|
147
|
+
throw new Error(turn.outcome === 'failed' ? 'production_native_failed' : 'production_native_start_unknown');
|
|
148
|
+
rootTurnId = turn.turnId;
|
|
149
|
+
const reserved = state.journal.getOperation(scopeKey);
|
|
150
|
+
const released = await state.journal.bindTurn({ scopeKey, sessionId, threadId: thread.threadId, turnId: turn.turnId,
|
|
151
|
+
operation: transitionOperation(reserved, 'accepted', { nativeThreadId: thread.threadId, nativeTurnId: turn.turnId }) });
|
|
152
|
+
if (released.length)
|
|
153
|
+
notify(released);
|
|
154
|
+
if (completedTurn?.id === rootTurnId)
|
|
155
|
+
completion(completedTurn.status === 'completed' ? 'completed' : 'failed');
|
|
156
|
+
timeout = setTimeout(() => completion('unknown'), 30 * 60_000);
|
|
71
157
|
let checking = false;
|
|
72
|
-
|
|
158
|
+
leaseCheck = setInterval(() => {
|
|
73
159
|
if (checking)
|
|
74
160
|
return;
|
|
75
161
|
checking = true;
|
|
76
162
|
void verifyProductionLease(input).catch(async () => { completion('unknown'); await adapter.close?.().catch(() => undefined); }).finally(() => { checking = false; });
|
|
77
163
|
}, 5000);
|
|
78
164
|
const status = await done;
|
|
79
|
-
clearInterval(leaseCheck);
|
|
80
|
-
clearTimeout(timeout);
|
|
81
165
|
if (status !== 'completed')
|
|
82
166
|
throw new Error(status === 'failed' ? 'production_native_failed' : 'production_native_unknown');
|
|
83
167
|
}
|
|
84
168
|
finally {
|
|
169
|
+
clearInterval(leaseCheck);
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
await adapter.close?.().catch(error => { shutdownError = error; });
|
|
85
172
|
unsubscribe?.();
|
|
86
|
-
await
|
|
173
|
+
await collecting;
|
|
87
174
|
for (const group of groups)
|
|
88
175
|
await record({ ...group, stopped: await stopProductionGroup(group) });
|
|
176
|
+
const latest = state.journal.getOperation(scopeKey);
|
|
177
|
+
if (latest && ['dispatched', 'accepted', 'uncertain'].includes(latest.status)) {
|
|
178
|
+
const status = cancelled ? 'interrupted' : 'failed';
|
|
179
|
+
await state.journal.putOperation(scopeKey, transitionOperation(latest, status, {
|
|
180
|
+
error: cancelled ? 'production_execution_stopped' : 'production_native_unknown',
|
|
181
|
+
}));
|
|
182
|
+
const session = state.journal.getSession(sessionId);
|
|
183
|
+
if (session) {
|
|
184
|
+
await state.journal.putSession({ ...session, turnStatus: status, turnBlock: undefined });
|
|
185
|
+
notify(await recordNativeEvents(state, latest, [{ type: `turn.${status}`, occurredAt: new Date().toISOString(),
|
|
186
|
+
payload: { turnId: latest.nativeTurnId, error: cancelled ? 'production_execution_stopped' : 'production_native_unknown' } }], false));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (archiveError)
|
|
190
|
+
throw new Error('production_chat_archive_failed');
|
|
191
|
+
if (shutdownError)
|
|
192
|
+
throw new Error('production_native_stop_unknown');
|
|
89
193
|
}
|
|
90
194
|
}
|
|
@@ -2,7 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
7
7
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
8
8
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -62,6 +62,9 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
62
62
|
}
|
|
63
63
|
async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
|
|
64
64
|
let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
|
|
65
|
+
if (operation.kind.startsWith('session.') && operation.sessionId && productionSessionAttemptId(operation.sessionId)) {
|
|
66
|
+
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'production_session_readonly' }));
|
|
67
|
+
}
|
|
65
68
|
if (operation.kind === 'runtime.ensure') {
|
|
66
69
|
current = await persist(state, scopeKey, transitionOperation(current, 'accepted'));
|
|
67
70
|
return persist(state, scopeKey, transitionOperation(current, 'completed'));
|
|
@@ -807,7 +810,7 @@ export function attachNativeCollector(state) {
|
|
|
807
810
|
void ingestCollected(state, event);
|
|
808
811
|
});
|
|
809
812
|
}
|
|
810
|
-
async function ingestCollected(state, event) {
|
|
813
|
+
export async function ingestCollected(state, event, publish = true) {
|
|
811
814
|
const ids = nativeIds(event.params);
|
|
812
815
|
const recorded = await state.journal.ingestNative({
|
|
813
816
|
runtimeId: state.config.runtimeId,
|
|
@@ -821,10 +824,11 @@ async function ingestCollected(state, event) {
|
|
|
821
824
|
itemId: ids.itemId,
|
|
822
825
|
occurredAt: event.occurredAt,
|
|
823
826
|
});
|
|
824
|
-
if (recorded.length > 0)
|
|
827
|
+
if (publish && recorded.length > 0)
|
|
825
828
|
await publishSourceEvents(state, recorded);
|
|
829
|
+
return recorded;
|
|
826
830
|
}
|
|
827
|
-
export async function recordNativeEvents(state, operation, events) {
|
|
831
|
+
export async function recordNativeEvents(state, operation, events, publish = true) {
|
|
828
832
|
const recorded = [];
|
|
829
833
|
for (const event of events) {
|
|
830
834
|
const payload = {
|
|
@@ -845,11 +849,11 @@ export async function recordNativeEvents(state, operation, events) {
|
|
|
845
849
|
operationId: operation.operationId,
|
|
846
850
|
}));
|
|
847
851
|
}
|
|
848
|
-
if (recorded.length > 0)
|
|
852
|
+
if (publish && recorded.length > 0)
|
|
849
853
|
await publishSourceEvents(state, recorded);
|
|
850
854
|
return recorded;
|
|
851
855
|
}
|
|
852
|
-
async function publishSourceEvents(state, events) {
|
|
856
|
+
export async function publishSourceEvents(state, events) {
|
|
853
857
|
await notifyCallback(state, events).catch(() => undefined);
|
|
854
858
|
}
|
|
855
859
|
function sessionTurnBusy(session) {
|
|
@@ -870,6 +874,7 @@ function nativeIds(params) {
|
|
|
870
874
|
async function notifyCallback(state, events) {
|
|
871
875
|
await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
872
876
|
method: 'POST',
|
|
877
|
+
signal: AbortSignal.timeout(5000),
|
|
873
878
|
headers: {
|
|
874
879
|
authorization: `Bearer ${state.config.controlToken}`,
|
|
875
880
|
'x-playcraft-control-token': state.config.controlToken,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
@@ -365,6 +365,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
365
365
|
return;
|
|
366
366
|
}
|
|
367
367
|
if (request.method === 'POST' && url.pathname === '/runtime/operations') {
|
|
368
|
+
// Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
|
|
369
|
+
terminalHost(state);
|
|
368
370
|
await acceptOperation(state, request, response);
|
|
369
371
|
return;
|
|
370
372
|
}
|
|
@@ -527,6 +529,10 @@ async function acceptOperation(state, request, response) {
|
|
|
527
529
|
writeJson(response, 200, decision.operation);
|
|
528
530
|
return;
|
|
529
531
|
}
|
|
532
|
+
if (parsed.data.kind.startsWith('session.') && parsed.data.sessionId && productionSessionAttemptId(parsed.data.sessionId)) {
|
|
533
|
+
writeError(response, runtimeError('capability_unavailable', 'production_session_readonly'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
530
536
|
if (parsed.data.kind === 'session.create') {
|
|
531
537
|
const creation = state.journal.findSessionCreation(parsed.data.sessionId);
|
|
532
538
|
const session = state.journal.getSession(parsed.data.sessionId);
|
|
@@ -702,15 +708,13 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
702
708
|
return;
|
|
703
709
|
}
|
|
704
710
|
if (request.method === 'DELETE' && !action) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
:
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
|
|
713
|
-
writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
|
|
711
|
+
try {
|
|
712
|
+
const destroyed = await terminalHost(state).destroy(terminalId);
|
|
713
|
+
writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
writeError(response, runtimeError('capability_unavailable', 'terminal_stop_unconfirmed'));
|
|
717
|
+
}
|
|
714
718
|
return;
|
|
715
719
|
}
|
|
716
720
|
writeJson(response, 404, { error: 'not_found' });
|
|
@@ -32,9 +32,16 @@ export class TerminalHost {
|
|
|
32
32
|
disposed = false;
|
|
33
33
|
sessions = new Map();
|
|
34
34
|
spawning = new Map();
|
|
35
|
+
restored;
|
|
36
|
+
attempted = new Set();
|
|
37
|
+
closing = new Set();
|
|
38
|
+
stops = new Map();
|
|
39
|
+
confirmed = new Set();
|
|
35
40
|
sockets = new WebSocketServer({ noServer: true });
|
|
36
41
|
constructor(state) {
|
|
37
42
|
this.state = state;
|
|
43
|
+
// An absent in-memory session cannot prove a restored terminal's old process group stopped.
|
|
44
|
+
this.restored = new Set((state.journal?.listTerminals?.() ?? []).map(terminal => terminal.terminalId));
|
|
38
45
|
}
|
|
39
46
|
async handleUpgrade(request, socket, head) {
|
|
40
47
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
@@ -68,6 +75,10 @@ export class TerminalHost {
|
|
|
68
75
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
69
76
|
return;
|
|
70
77
|
}
|
|
78
|
+
if (this.closing.has(terminalId) || this.restored.has(terminalId) && !this.sessions.has(terminalId)) {
|
|
79
|
+
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_stop_unconfirmed'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
71
82
|
let session = this.sessions.get(terminal.terminalId);
|
|
72
83
|
if (!session) {
|
|
73
84
|
try {
|
|
@@ -84,14 +95,41 @@ export class TerminalHost {
|
|
|
84
95
|
return;
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
|
-
if (this.disposed || this.state.journal.destroyed() || this.state.journal.getTerminal(terminalId)?.status === 'destroyed') {
|
|
88
|
-
this.
|
|
98
|
+
if (this.disposed || this.state.journal.destroyed() || this.closing.has(terminalId) || this.state.journal.getTerminal(terminalId)?.status === 'destroyed') {
|
|
99
|
+
if (!this.closing.has(terminalId))
|
|
100
|
+
this.revoke(terminalId, 'terminal_destroyed');
|
|
89
101
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
90
102
|
return;
|
|
91
103
|
}
|
|
92
104
|
const active = session;
|
|
93
105
|
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
|
|
94
106
|
}
|
|
107
|
+
destroy(terminalId) {
|
|
108
|
+
const pending = this.stops.get(terminalId);
|
|
109
|
+
if (pending)
|
|
110
|
+
return pending;
|
|
111
|
+
this.closing.add(terminalId);
|
|
112
|
+
const stopped = (async () => {
|
|
113
|
+
await this.spawning.get(terminalId);
|
|
114
|
+
const session = this.sessions.get(terminalId);
|
|
115
|
+
if (session)
|
|
116
|
+
await session.stop();
|
|
117
|
+
else if (!this.confirmed.has(terminalId) && (this.restored.has(terminalId) || this.attempted.has(terminalId)))
|
|
118
|
+
throw new Error('terminal_stop_unconfirmed');
|
|
119
|
+
const terminal = this.state.journal.getTerminal(terminalId);
|
|
120
|
+
if (!terminal)
|
|
121
|
+
throw new Error('terminal_not_found');
|
|
122
|
+
const destroyed = terminal.status === 'destroyed' ? terminal : await this.state.journal.putTerminal({
|
|
123
|
+
...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
|
|
124
|
+
});
|
|
125
|
+
this.confirmed.add(terminalId);
|
|
126
|
+
this.revoke(terminalId, 'terminal_destroyed');
|
|
127
|
+
this.closing.delete(terminalId);
|
|
128
|
+
return destroyed;
|
|
129
|
+
})().finally(() => this.stops.delete(terminalId));
|
|
130
|
+
this.stops.set(terminalId, stopped);
|
|
131
|
+
return stopped;
|
|
132
|
+
}
|
|
95
133
|
revoke(terminalId, reason) {
|
|
96
134
|
const session = this.sessions.get(terminalId);
|
|
97
135
|
if (!session)
|
|
@@ -124,13 +162,15 @@ export class TerminalHost {
|
|
|
124
162
|
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
125
163
|
if (!socketPath)
|
|
126
164
|
throw new Error('pty_unavailable');
|
|
165
|
+
this.attempted.add(terminal.terminalId);
|
|
127
166
|
const child = await startLauncherProcess({ socketPath, processId: `terminal-${randomUUID()}`,
|
|
128
167
|
runtimeEpoch: this.state.runtimeEpoch, cwd, argv: [], env: {}, terminal: { cols: 80, rows: 24 } });
|
|
129
168
|
if (this.disposed) {
|
|
130
169
|
await child.stop();
|
|
131
170
|
throw new Error('pty_unavailable');
|
|
132
171
|
}
|
|
133
|
-
const session = { streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
172
|
+
const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
173
|
+
stop: () => child.stop(),
|
|
134
174
|
pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
|
|
135
175
|
kill: () => { void child.stop().catch(() => undefined); } } };
|
|
136
176
|
const decoder = new StringDecoder('utf8');
|
|
@@ -140,14 +180,20 @@ export class TerminalHost {
|
|
|
140
180
|
const tail = decoder.end();
|
|
141
181
|
if (tail)
|
|
142
182
|
publish(session, tail);
|
|
143
|
-
|
|
183
|
+
const stopped = code === undefined ? Promise.resolve(false) : child.stop().then(() => true, () => false);
|
|
184
|
+
void stopped.then(confirmed => this.exited(terminal.terminalId, session, confirmed ? code : undefined)).catch(() => undefined);
|
|
144
185
|
});
|
|
145
186
|
this.sessions.set(terminal.terminalId, session);
|
|
146
187
|
return session;
|
|
147
188
|
}
|
|
148
189
|
const pty = require('node-pty');
|
|
149
190
|
const command = commands.get(this.state) ?? defaultShell();
|
|
191
|
+
let finishExit;
|
|
192
|
+
let didExit = false;
|
|
193
|
+
const knownExit = new Promise(resolve => { finishExit = () => { didExit = true; resolve(); }; });
|
|
194
|
+
this.attempted.add(terminal.terminalId);
|
|
150
195
|
const session = {
|
|
196
|
+
terminalId: terminal.terminalId,
|
|
151
197
|
streamId: randomUUID(),
|
|
152
198
|
log: createTerminalOutputLog(),
|
|
153
199
|
listeners: new Set(),
|
|
@@ -158,17 +204,43 @@ export class TerminalHost {
|
|
|
158
204
|
cwd,
|
|
159
205
|
env: shellEnv(command.file, cwd),
|
|
160
206
|
}),
|
|
207
|
+
stop: async () => {
|
|
208
|
+
if (!didExit)
|
|
209
|
+
session.pty.kill();
|
|
210
|
+
let timer;
|
|
211
|
+
try {
|
|
212
|
+
await Promise.race([knownExit, new Promise((_resolve, reject) => {
|
|
213
|
+
timer = setTimeout(() => reject(new Error('terminal_stop_unconfirmed')), 3000);
|
|
214
|
+
})]);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
161
220
|
};
|
|
162
221
|
const local = session.pty;
|
|
163
222
|
local.onData((data) => publish(session, data));
|
|
164
|
-
local.onExit(({ exitCode, signal }) => this.exited(session, exitCode, signal));
|
|
223
|
+
local.onExit(({ exitCode, signal }) => { finishExit(); void this.exited(terminal.terminalId, session, exitCode, signal).catch(() => undefined); });
|
|
165
224
|
this.sessions.set(terminal.terminalId, session);
|
|
166
225
|
return session;
|
|
167
226
|
}
|
|
168
|
-
exited(session, code, signal) {
|
|
169
|
-
session.exited = { code, ...(signal === undefined ? {} : { signal: String(signal) }) };
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
async exited(terminalId, session, code, signal) {
|
|
228
|
+
session.exited = { code: code ?? null, ...(signal === undefined ? {} : { signal: String(signal) }) };
|
|
229
|
+
try {
|
|
230
|
+
if (code !== undefined) {
|
|
231
|
+
const terminal = this.state.journal.getTerminal(terminalId);
|
|
232
|
+
if (terminal && terminal.status !== 'destroyed')
|
|
233
|
+
await this.state.journal.putTerminal({
|
|
234
|
+
...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
|
|
235
|
+
});
|
|
236
|
+
if (terminal)
|
|
237
|
+
this.confirmed.add(terminalId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
for (const listener of session.listeners)
|
|
242
|
+
listener(exitFrame(session));
|
|
243
|
+
}
|
|
172
244
|
}
|
|
173
245
|
attach(session, ws) {
|
|
174
246
|
let open = true;
|
|
@@ -199,7 +271,7 @@ export class TerminalHost {
|
|
|
199
271
|
return;
|
|
200
272
|
}
|
|
201
273
|
const parsed = terminalClientFrameSchema.safeParse(body);
|
|
202
|
-
if (!parsed.success || session.exited && parsed.data.type !== 'resume')
|
|
274
|
+
if (!parsed.success || (session.exited || this.closing.has(session.terminalId)) && parsed.data.type !== 'resume')
|
|
203
275
|
return;
|
|
204
276
|
const frame = parsed.data;
|
|
205
277
|
if (frame.type === 'resume') {
|
|
@@ -292,6 +292,42 @@ export async function saveWorkspaceGit(root, input) {
|
|
|
292
292
|
await recordPushedHead(rootReal);
|
|
293
293
|
return { ...(commit ? { commit } : {}), pushed: true, withheld };
|
|
294
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Connects a standalone checkout to the repository it was just given, keeping all its history and
|
|
297
|
+
* uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
|
|
298
|
+
* unborn checkout, or is merged into existing history, so the next push is a plain fast-forward.
|
|
299
|
+
* Credentials come from the workspace's configured helper and never enter the remote URL.
|
|
300
|
+
* Safe to run again after a failure part way.
|
|
301
|
+
*/
|
|
302
|
+
export async function attachWorkspaceRemote(root, repositoryUrl, branch) {
|
|
303
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
304
|
+
const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
|
|
305
|
+
if (origin.code !== 0)
|
|
306
|
+
await git(rootReal, ['remote', 'add', 'origin', repositoryUrl]);
|
|
307
|
+
else if (origin.stdout.trim() !== repositoryUrl)
|
|
308
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin_mismatch');
|
|
309
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
310
|
+
const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
|
|
311
|
+
if (fetched.code !== 0)
|
|
312
|
+
throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_command_failed', 'git_fetch_failed');
|
|
313
|
+
const current = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
|
|
314
|
+
const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
|
|
315
|
+
if (current !== branch) {
|
|
316
|
+
await git(rootReal, born ? ['branch', '-M', branch] : ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
|
|
317
|
+
}
|
|
318
|
+
if (!born) {
|
|
319
|
+
// Nothing committed yet: start the branch at the remote commit; the files stay as changes.
|
|
320
|
+
await git(rootReal, ['update-ref', `refs/heads/${branch}`, remoteRef]);
|
|
321
|
+
}
|
|
322
|
+
else if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code !== 0) {
|
|
323
|
+
const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--allow-unrelated-histories', '--no-edit', remoteRef], 60000);
|
|
324
|
+
if (merged.code !== 0) {
|
|
325
|
+
await gitAllowFail(rootReal, ['merge', '--abort']);
|
|
326
|
+
throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
await git(rootReal, ['branch', `--set-upstream-to=origin/${branch}`, branch]);
|
|
330
|
+
}
|
|
295
331
|
async function unstage(rootReal, paths) {
|
|
296
332
|
if (paths.length === 0)
|
|
297
333
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { lstat, mkdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -9,6 +9,7 @@ import { scheduleDependencyInstall } from './dependencies.js';
|
|
|
9
9
|
import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
10
10
|
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
|
+
import { attachWorkspaceRemote } from './git.js';
|
|
12
13
|
export { WorkspaceContextError } from './context-error.js';
|
|
13
14
|
const exec = promisify(execFile);
|
|
14
15
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -106,8 +107,11 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
106
107
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
107
108
|
const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
|
|
108
109
|
const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
|
|
109
|
-
|
|
110
|
+
// A standalone workspace promoted to a project's main keeps its checkout and gains that remote.
|
|
111
|
+
const remoteUpgrade = !preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
|
|
112
|
+
if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade && !remoteUpgrade)
|
|
110
113
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
114
|
+
const attachMarker = join(state.config.stateDir, 'workspace-remote-attach', `${binding.workspaceId}.json`);
|
|
111
115
|
const directories = binding.directories ?? existing?.directories;
|
|
112
116
|
if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
|
|
113
117
|
// Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
|
|
@@ -142,6 +146,28 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
142
146
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
143
147
|
},
|
|
144
148
|
})).stdout.trim();
|
|
149
|
+
// Unfinished if it failed part way: the marker outlives the journal entry written below.
|
|
150
|
+
if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
151
|
+
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
152
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
153
|
+
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
154
|
+
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
155
|
+
// The credential helper only serves a workspace the journal knows to be bound.
|
|
156
|
+
await state.journal.putWorkspace({ ...binding, directories });
|
|
157
|
+
try {
|
|
158
|
+
await state.gitCredentials.configure(binding, preparation);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await attachWorkspaceRemote(root, binding.repositoryUrl, binding.branchName);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new WorkspaceContextError('workspace_remote_attach_failed');
|
|
168
|
+
}
|
|
169
|
+
await rm(attachMarker, { force: true });
|
|
170
|
+
}
|
|
145
171
|
try {
|
|
146
172
|
let checkoutMatches;
|
|
147
173
|
if (binding.targetKind === 'standalone' || preservedLocalBasic) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@gltf-transform/core": "^4.3.0",
|
|
51
51
|
"@gltf-transform/extensions": "^4.3.0",
|
|
52
52
|
"@gltf-transform/functions": "^4.3.0",
|
|
53
|
-
"@playcraft/build": "^0.1.
|
|
54
|
-
"@playcraft/common": "^0.1.
|
|
53
|
+
"@playcraft/build": "^0.1.28",
|
|
54
|
+
"@playcraft/common": "^0.1.28",
|
|
55
55
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
56
56
|
"chokidar": "^4.0.3",
|
|
57
57
|
"commander": "^13.1.0",
|