@playcraft/cli 0.1.58-beta → 0.1.59-beta
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/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace-runtime/batch/generate.js +1 -1
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/codex/app-server.js +277 -20
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/serve.js +2 -1
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +13 -6
- package/dist/workspace-runtime/server/http.js +29 -1
- package/dist/workspace-runtime/server/terminal-host.js +82 -1
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/workspaces/registry.js +44 -3
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/package.json +3 -3
|
@@ -87,6 +87,8 @@ export function createLoopbackNativeAdapter() {
|
|
|
87
87
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
88
88
|
return { outcome: 'uncertain' };
|
|
89
89
|
const turnId = thread.turnId;
|
|
90
|
+
if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
|
|
91
|
+
return { outcome: 'failed', error: 'turn_changed' };
|
|
90
92
|
queueMicrotask(() => {
|
|
91
93
|
emit(collectedEvent('notification', 'turn/completed', {
|
|
92
94
|
threadId: input.threadId,
|
|
@@ -52,7 +52,7 @@ export class WorkspaceModelBroker {
|
|
|
52
52
|
activate();
|
|
53
53
|
return settings;
|
|
54
54
|
}
|
|
55
|
-
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
|
|
55
|
+
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd) {
|
|
56
56
|
if (!this.server)
|
|
57
57
|
throw new Error('workspace_not_registered');
|
|
58
58
|
const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
|
|
@@ -61,11 +61,12 @@ export class WorkspaceModelBroker {
|
|
|
61
61
|
const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
|
|
62
62
|
let route = this.routes.get(routeKey);
|
|
63
63
|
const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
|
|
64
|
-
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}`;
|
|
64
|
+
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}:${sessionId ?? ''}:${clientRequestId ?? ''}`;
|
|
65
65
|
let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
|
|
66
|
+
&& item.sessionId === sessionId && item.clientRequestId === clientRequestId
|
|
66
67
|
&& item.reasoningEffort === reasoningEffort);
|
|
67
68
|
if (!entry || entry.epoch !== this.state.runtimeEpoch) {
|
|
68
|
-
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort };
|
|
69
|
+
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort, sessionId, clientRequestId };
|
|
69
70
|
this.entries.set(cacheKey, entry);
|
|
70
71
|
}
|
|
71
72
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
@@ -82,7 +83,7 @@ export class WorkspaceModelBroker {
|
|
|
82
83
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
83
84
|
throw new Error('model_access_denied');
|
|
84
85
|
route.selected = { entry, model: credential.model,
|
|
85
|
-
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
|
|
86
|
+
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, ...(originCwd ? { originCwd } : {}) } } : {}) };
|
|
86
87
|
},
|
|
87
88
|
model: credential.model,
|
|
88
89
|
modelConfigId: credential.configId,
|
|
@@ -125,6 +126,8 @@ export class WorkspaceModelBroker {
|
|
|
125
126
|
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
126
127
|
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
127
128
|
runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
|
|
129
|
+
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
130
|
+
...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
|
|
128
131
|
...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
|
|
129
132
|
...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
|
|
130
133
|
});
|
|
@@ -12,7 +12,7 @@ import { attachNativeCollector, executeOperation, recoverInterruptedParameterVer
|
|
|
12
12
|
import { restoreDeferredDirectoryOperations } from './server/directory-access.js';
|
|
13
13
|
import { handleRuntimeRequest } from './server/http.js';
|
|
14
14
|
import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
|
|
15
|
-
import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
15
|
+
import { disposeTerminalHost, retireStoppedTerminals, terminalHost } from './server/terminal-host.js';
|
|
16
16
|
import { handleExecutionGroupCrash } from './server/execution-group.js';
|
|
17
17
|
import { RuntimeProcessLock } from './server/lock.js';
|
|
18
18
|
import { probeGitBinary } from './workspaces/git.js';
|
|
@@ -58,6 +58,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
58
58
|
await recoverInterruptedPreviews(journal);
|
|
59
59
|
await recoverInterruptedParameterVerifications(journal);
|
|
60
60
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
61
|
+
await retireStoppedTerminals(journal, config.runtimeEpoch, previousEpoch);
|
|
61
62
|
// shared: in multi-workspace mode this is /workspaces, the container both identities traverse.
|
|
62
63
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o2770 });
|
|
63
64
|
const state = runtimeState = {
|
|
@@ -8,7 +8,7 @@ const instances = new WeakMap();
|
|
|
8
8
|
const executing = new WeakMap();
|
|
9
9
|
const freezing = new WeakMap();
|
|
10
10
|
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
-
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', '
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'directory.create',
|
|
12
12
|
'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
|
|
13
13
|
'checkpoint.create', 'checkpoint.restore']);
|
|
14
14
|
export function managedDirectoryScope(state, value) {
|
|
@@ -33,6 +33,7 @@ import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPrevi
|
|
|
33
33
|
import { startManagedBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
34
34
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
35
35
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
36
|
+
import { trustedMemberPolicy } from '../batch/policy.js';
|
|
36
37
|
import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
|
|
37
38
|
import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
|
|
38
39
|
import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
|
|
@@ -217,7 +218,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
217
218
|
if (operation.kind === 'session.steer')
|
|
218
219
|
return executeSteer(state, scopeKey, current, payload);
|
|
219
220
|
if (operation.kind === 'session.interrupt') {
|
|
220
|
-
return executeInterrupt(state, scopeKey, current);
|
|
221
|
+
return executeInterrupt(state, scopeKey, current, payload);
|
|
221
222
|
}
|
|
222
223
|
if (operation.kind === 'session.approval.respond') {
|
|
223
224
|
return executeApproval(state, scopeKey, current, payload);
|
|
@@ -455,7 +456,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
455
456
|
...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}\n\n`
|
|
456
457
|
: '';
|
|
457
458
|
const engineeringBatchContext = state.config.workspaceMode === 'multi' && operation.workspaceId
|
|
458
|
-
&& operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId)
|
|
459
|
+
&& operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId) && !trustedMemberPolicy(state, sessionId)
|
|
459
460
|
? `PlayCraft batch request context (trusted runtime identity): ${JSON.stringify({ workspaceId: operation.workspaceId,
|
|
460
461
|
clientRequestId: operation.clientRequestId, sourceDirectoryName: binding.directoryName ?? 'basic',
|
|
461
462
|
...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}`
|
|
@@ -849,13 +850,18 @@ async function executeSteer(state, scopeKey, operation, payload) {
|
|
|
849
850
|
} }]);
|
|
850
851
|
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
851
852
|
}
|
|
852
|
-
async function executeInterrupt(state, scopeKey, operation) {
|
|
853
|
+
async function executeInterrupt(state, scopeKey, operation, payload) {
|
|
853
854
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
854
855
|
const binding = state.journal.getSession(sessionId);
|
|
855
856
|
if (!binding) {
|
|
856
857
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
857
858
|
}
|
|
858
|
-
const
|
|
859
|
+
const expectedTurnId = asRecord(payload).expectedTurnId;
|
|
860
|
+
if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
|
|
861
|
+
|| binding.activeTurnId !== expectedTurnId))
|
|
862
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
|
|
863
|
+
const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
|
|
864
|
+
...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
|
|
859
865
|
if (result.outcome === 'failed') {
|
|
860
866
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: result.error ?? 'turn_not_running' }));
|
|
861
867
|
}
|
|
@@ -1273,11 +1279,12 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
1273
1279
|
}
|
|
1274
1280
|
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
1275
1281
|
try {
|
|
1276
|
-
|
|
1282
|
+
// Saves share the directory, and a deferred one runs outside the workspace queue; commit one at a time.
|
|
1283
|
+
const saved = await state.journal.exclusive(`workspace-save:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, async () => saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
|
|
1277
1284
|
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
1278
1285
|
push: savePushesBranch(binding?.targetKind, operation.directoryName),
|
|
1279
1286
|
boundBranch: boundBranch(state, operation),
|
|
1280
|
-
});
|
|
1287
|
+
}));
|
|
1281
1288
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1282
1289
|
await recordNativeEvents(state, accepted, [
|
|
1283
1290
|
{
|
|
@@ -41,6 +41,7 @@ import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-wor
|
|
|
41
41
|
import { searchWorkspaceFiles } from '../workspaces/files-search.js';
|
|
42
42
|
import { serveWorkspaceArchive } from '../workspaces/archive.js';
|
|
43
43
|
import { handleWorkspaceTransfer } from './workspace-transfer.js';
|
|
44
|
+
import { handleWorkspaceStorage } from './workspace-storage.js';
|
|
44
45
|
import { serveSessionImage } from '../workspaces/session-image.js';
|
|
45
46
|
import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
|
|
46
47
|
import { handleAssetTransferRequest } from './asset-transfers.js';
|
|
@@ -51,7 +52,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
51
52
|
const workspaceId = url.searchParams.get('workspaceId');
|
|
52
53
|
// Body-scoped operations, members and terminal control take their own lifetime-aware leases.
|
|
53
54
|
const ownLease = url.pathname.startsWith('/runtime/operations') || url.pathname.startsWith('/runtime/terminals/') ||
|
|
54
|
-
url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname)
|
|
55
|
+
url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname) ||
|
|
56
|
+
/^\/runtime\/sessions\/[^/]+\/prediction$/.test(url.pathname);
|
|
55
57
|
if (authorizeRuntime(state, request) === 'control' && workspaceId &&
|
|
56
58
|
(!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') || url.pathname === '/runtime/git-bundle') && !ownLease) {
|
|
57
59
|
try {
|
|
@@ -82,6 +84,8 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
82
84
|
return;
|
|
83
85
|
}
|
|
84
86
|
try {
|
|
87
|
+
if (await handleWorkspaceStorage(state, request, response, url))
|
|
88
|
+
return;
|
|
85
89
|
const transfer = await handleAssetTransferRequest(state, request, url);
|
|
86
90
|
if (transfer) {
|
|
87
91
|
writeJson(response, transfer.status, transfer.body);
|
|
@@ -128,6 +132,8 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
128
132
|
instanceToken: state.instanceToken,
|
|
129
133
|
permissionsVersion: 1,
|
|
130
134
|
composerContextVersion: 1,
|
|
135
|
+
scheduledTaskIdentityVersion: 1,
|
|
136
|
+
scheduledTaskCancellationVersion: 1,
|
|
131
137
|
parameterAgentVersion: 1,
|
|
132
138
|
directoryConcurrencyVersion: 1,
|
|
133
139
|
parameterInitializationVersion: 1,
|
|
@@ -136,6 +142,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
136
142
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
137
143
|
...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
|
|
138
144
|
...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
|
|
145
|
+
...(state.native?.startPrediction ? { predictionVersion: 1 } : {}),
|
|
139
146
|
sourceReadToken: state.journal.sourceReadToken(),
|
|
140
147
|
workspaceMode: state.config.workspaceMode,
|
|
141
148
|
executionGroup: {
|
|
@@ -208,6 +215,27 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
208
215
|
writeJson(response, result.outcome === 'accepted' ? 200 : 409, result);
|
|
209
216
|
return;
|
|
210
217
|
}
|
|
218
|
+
const predictionRoute = /^\/runtime\/sessions\/([^/]+)\/prediction$/.exec(url.pathname);
|
|
219
|
+
if (request.method === 'POST' && predictionRoute) {
|
|
220
|
+
if (writeDestroyed(state, response))
|
|
221
|
+
return;
|
|
222
|
+
const sessionId = decodeURIComponent(predictionRoute[1]);
|
|
223
|
+
const session = state.journal.getSession(sessionId);
|
|
224
|
+
if (!session || productionSessionAttemptId(sessionId) || session.workspaceId !== url.searchParams.get('workspaceId')
|
|
225
|
+
|| (session.directoryName ?? 'basic') !== directoryName) {
|
|
226
|
+
writeJson(response, 404, { error: 'session_not_found' });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!state.native?.startPrediction) {
|
|
230
|
+
writeJson(response, 409, { error: 'prediction_unavailable' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Best-effort and read-only: no instruction/journal reservation, and the ephemeral
|
|
234
|
+
// fork never becomes a user-visible thread.
|
|
235
|
+
const result = await state.native.startPrediction({ sessionId, threadId: session.threadId });
|
|
236
|
+
writeJson(response, result.outcome === 'ok' ? 200 : 409, result);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
211
239
|
if (request.method === 'POST' && url.pathname === '/runtime/workspaces/register') {
|
|
212
240
|
if (writeDestroyed(state, response))
|
|
213
241
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
5
|
import { lstat } from 'node:fs/promises';
|
|
5
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
@@ -12,11 +13,19 @@ import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
|
12
13
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
13
14
|
import { cancelPendingDirectoryAccess, holdDirectoryAccess } from './directory-access.js';
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
16
|
+
/** A shell nobody views, silent this long and running nothing besides itself, is closed. */
|
|
17
|
+
const IDLE_RECLAIM_MS = 30 * 60_000;
|
|
18
|
+
const IDLE_SWEEP_MS = 120_000;
|
|
15
19
|
const commands = new WeakMap();
|
|
20
|
+
const sessionProbes = new WeakMap();
|
|
16
21
|
const hosts = new WeakMap();
|
|
17
22
|
export function setTerminalCommand(state, command) {
|
|
18
23
|
commands.set(state, command);
|
|
19
24
|
}
|
|
25
|
+
/** Replaces the /proc session scan, for platforms without /proc. */
|
|
26
|
+
export function setTerminalSessionProbe(state, probe) {
|
|
27
|
+
sessionProbes.set(state, probe);
|
|
28
|
+
}
|
|
20
29
|
export function terminalHost(state) {
|
|
21
30
|
let host = hosts.get(state);
|
|
22
31
|
if (!host) {
|
|
@@ -25,6 +34,20 @@ export function terminalHost(state) {
|
|
|
25
34
|
}
|
|
26
35
|
return host;
|
|
27
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The supervisor advances the runtime epoch only after the launcher confirms that every process it
|
|
39
|
+
* started, terminals included, has exited. A terminal from an earlier epoch is gone whatever its record says.
|
|
40
|
+
*/
|
|
41
|
+
export async function retireStoppedTerminals(journal, runtimeEpoch, previousEpoch) {
|
|
42
|
+
if (previousEpoch === undefined || runtimeEpoch <= previousEpoch)
|
|
43
|
+
return;
|
|
44
|
+
const destroyedAt = new Date().toISOString();
|
|
45
|
+
for (const terminal of journal.listTerminals()) {
|
|
46
|
+
if (terminal.status === 'destroyed' || (terminal.runtimeEpoch ?? 0) >= runtimeEpoch)
|
|
47
|
+
continue;
|
|
48
|
+
await journal.putTerminal({ ...terminal, status: 'destroyed', destroyedAt });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
28
51
|
export function disposeTerminalHost(state) {
|
|
29
52
|
hosts.get(state)?.dispose();
|
|
30
53
|
hosts.delete(state);
|
|
@@ -41,10 +64,14 @@ export class TerminalHost {
|
|
|
41
64
|
confirmed = new Set();
|
|
42
65
|
directoryReleases = new Map();
|
|
43
66
|
sockets = new WebSocketServer({ noServer: true });
|
|
67
|
+
idleSweep;
|
|
44
68
|
constructor(state) {
|
|
45
69
|
this.state = state;
|
|
70
|
+
this.idleSweep = setInterval(() => void this.reclaimIdle(), IDLE_SWEEP_MS);
|
|
71
|
+
this.idleSweep.unref?.();
|
|
46
72
|
// An absent in-memory session cannot prove a restored terminal's old process group stopped.
|
|
47
|
-
this.restored = new Set((state.journal?.listTerminals?.() ?? [])
|
|
73
|
+
this.restored = new Set((state.journal?.listTerminals?.() ?? [])
|
|
74
|
+
.filter(terminal => terminal.status !== 'destroyed').map(terminal => terminal.terminalId));
|
|
48
75
|
}
|
|
49
76
|
async handleUpgrade(request, socket, head) {
|
|
50
77
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
@@ -152,7 +179,28 @@ export class TerminalHost {
|
|
|
152
179
|
session.listeners.clear();
|
|
153
180
|
void session.stop().then(() => this.exited(terminalId, session, null)).catch(() => undefined);
|
|
154
181
|
}
|
|
182
|
+
/** Closes shells with no browser, no input or output for the idle window, and nothing else running. */
|
|
183
|
+
async reclaimIdle(now = Date.now()) {
|
|
184
|
+
const reclaimed = [];
|
|
185
|
+
for (const [terminalId, session] of [...this.sessions]) {
|
|
186
|
+
if (session.exited || session.listeners.size > 0 || this.closing.has(terminalId)
|
|
187
|
+
|| now - session.lastActivity < IDLE_RECLAIM_MS)
|
|
188
|
+
continue;
|
|
189
|
+
// A background job keeps the shell; a session that cannot be inspected is kept too.
|
|
190
|
+
if (!session.pid || (sessionProbes.get(this.state) ?? runsOtherProcesses)(session.pid) !== false)
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
await this.destroy(terminalId);
|
|
194
|
+
reclaimed.push(terminalId);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// The next sweep tries again.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return reclaimed;
|
|
201
|
+
}
|
|
155
202
|
dispose() {
|
|
203
|
+
clearInterval(this.idleSweep);
|
|
156
204
|
this.disposed = true;
|
|
157
205
|
for (const terminalId of [...this.sessions.keys()])
|
|
158
206
|
this.revoke(terminalId, 'runtime_destroyed');
|
|
@@ -199,6 +247,7 @@ export class TerminalHost {
|
|
|
199
247
|
throw new Error('pty_unavailable');
|
|
200
248
|
}
|
|
201
249
|
const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
250
|
+
pid: child.pid ?? 0, lastActivity: Date.now(),
|
|
202
251
|
stop: () => child.stop(),
|
|
203
252
|
pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
|
|
204
253
|
kill: () => { void child.stop().catch(() => undefined); } } };
|
|
@@ -233,6 +282,8 @@ export class TerminalHost {
|
|
|
233
282
|
cwd,
|
|
234
283
|
env: shellEnv(command.file, cwd),
|
|
235
284
|
}),
|
|
285
|
+
pid: 0,
|
|
286
|
+
lastActivity: Date.now(),
|
|
236
287
|
stop: async () => {
|
|
237
288
|
if (!didExit)
|
|
238
289
|
session.pty.kill();
|
|
@@ -250,6 +301,7 @@ export class TerminalHost {
|
|
|
250
301
|
},
|
|
251
302
|
};
|
|
252
303
|
const local = session.pty;
|
|
304
|
+
session.pid = local.pid;
|
|
253
305
|
local.onData((data) => publish(session, data));
|
|
254
306
|
local.onExit(({ exitCode, signal }) => {
|
|
255
307
|
finishExit();
|
|
@@ -297,6 +349,7 @@ export class TerminalHost {
|
|
|
297
349
|
send(frame);
|
|
298
350
|
};
|
|
299
351
|
session.listeners.add(listener);
|
|
352
|
+
session.lastActivity = Date.now();
|
|
300
353
|
ws.on('message', (raw) => {
|
|
301
354
|
let body;
|
|
302
355
|
try {
|
|
@@ -332,6 +385,7 @@ export class TerminalHost {
|
|
|
332
385
|
}
|
|
333
386
|
if (!resumed)
|
|
334
387
|
return;
|
|
388
|
+
session.lastActivity = Date.now();
|
|
335
389
|
if (frame.type === 'resize') {
|
|
336
390
|
session.pty.resize(frame.cols, frame.rows);
|
|
337
391
|
return;
|
|
@@ -341,12 +395,14 @@ export class TerminalHost {
|
|
|
341
395
|
const detach = () => {
|
|
342
396
|
open = false;
|
|
343
397
|
session.listeners.delete(listener);
|
|
398
|
+
session.lastActivity = Date.now();
|
|
344
399
|
};
|
|
345
400
|
ws.on('close', detach);
|
|
346
401
|
ws.on('error', detach);
|
|
347
402
|
}
|
|
348
403
|
}
|
|
349
404
|
function publish(session, text) {
|
|
405
|
+
session.lastActivity = Date.now();
|
|
350
406
|
const before = session.log.end;
|
|
351
407
|
session.log = appendTerminalOutput(session.log, text);
|
|
352
408
|
const since = terminalOutputSince(session.log, before);
|
|
@@ -361,6 +417,31 @@ function publish(session, text) {
|
|
|
361
417
|
listener(frame);
|
|
362
418
|
}
|
|
363
419
|
}
|
|
420
|
+
/** `true` while a process other than the shell runs in its session; `undefined` when /proc cannot tell. */
|
|
421
|
+
function runsOtherProcesses(shellPid) {
|
|
422
|
+
let names;
|
|
423
|
+
try {
|
|
424
|
+
names = readdirSync('/proc');
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
for (const name of names) {
|
|
430
|
+
if (!/^\d+$/.test(name) || Number(name) === shellPid)
|
|
431
|
+
continue;
|
|
432
|
+
try {
|
|
433
|
+
const stat = readFileSync(`/proc/${name}/stat`, 'utf8');
|
|
434
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
435
|
+
if (Number(fields[3]) === shellPid && !['Z', 'X'].includes(fields[0] ?? ''))
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (!['ENOENT', 'ESRCH'].includes(error.code ?? ''))
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
364
445
|
function exitFrame(session) {
|
|
365
446
|
return {
|
|
366
447
|
type: 'exit',
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
|
|
8
|
+
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
9
|
+
import { createStorageSnapshot, extractStorageSnapshot, STORAGE_SNAPSHOT_MAX_BYTES } from '../workspaces/storage-snapshot.js';
|
|
10
|
+
import { withDirectoryWrite } from './directory-access.js';
|
|
11
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
12
|
+
/** Private control routes. Workspace identity survives disk eviction in the runtime journal. */
|
|
13
|
+
export async function handleWorkspaceStorage(state, request, response, url) {
|
|
14
|
+
const route = /^\/runtime\/workspaces\/([^/]+)\/storage-(status|snapshot|evict|restore)$/.exec(url.pathname);
|
|
15
|
+
if (!route)
|
|
16
|
+
return false;
|
|
17
|
+
const [, workspaceId, action] = route;
|
|
18
|
+
const json = (status, body) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'private, no-store' }); response.end(JSON.stringify(body)); };
|
|
19
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
20
|
+
let download;
|
|
21
|
+
let result;
|
|
22
|
+
const closed = () => { if (!response.writableEnded)
|
|
23
|
+
abort(); };
|
|
24
|
+
request.once('aborted', abort);
|
|
25
|
+
response.once('close', closed);
|
|
26
|
+
try {
|
|
27
|
+
if (state.config.workspaceMode !== 'multi' || !/^[0-9a-f-]{36}$/i.test(workspaceId))
|
|
28
|
+
throw new Error('storage_snapshot_invalid');
|
|
29
|
+
if (state.journal.destroyed())
|
|
30
|
+
throw new Error('runtime_destroyed');
|
|
31
|
+
if ((action === 'status' || action === 'snapshot') ? request.method !== 'GET' : request.method !== 'POST') {
|
|
32
|
+
json(405, { error: 'method_not_allowed' });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
for (const [name, expected] of [['x-playcraft-generation', state.config.generation], ['x-playcraft-runtime-epoch', state.runtimeEpoch]]) {
|
|
36
|
+
const value = request.headers[name];
|
|
37
|
+
if (value !== undefined && value !== String(expected))
|
|
38
|
+
throw new Error('generation_mismatch');
|
|
39
|
+
}
|
|
40
|
+
const parent = await realpath(state.config.workspaceRoot), root = join(parent, workspaceId);
|
|
41
|
+
const stateRoot = await realpath(state.config.stateDir);
|
|
42
|
+
if (stateRoot === root || stateRoot.startsWith(`${root}${sep}`))
|
|
43
|
+
throw new Error('storage_snapshot_invalid');
|
|
44
|
+
const present = async () => {
|
|
45
|
+
const info = await lstat(root).catch(error => { if (error.code === 'ENOENT')
|
|
46
|
+
return undefined; throw error; });
|
|
47
|
+
if (!info)
|
|
48
|
+
return false;
|
|
49
|
+
if (!info.isDirectory() || info.isSymbolicLink() || relative(parent, await realpath(root)) !== workspaceId)
|
|
50
|
+
throw new Error('storage_snapshot_invalid');
|
|
51
|
+
return (await readdir(root)).length > 0;
|
|
52
|
+
};
|
|
53
|
+
if (action === 'status') {
|
|
54
|
+
json(200, { present: await present() });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if ((action === 'snapshot' || action === 'evict') && !state.journal.getWorkspace(workspaceId))
|
|
58
|
+
throw new Error('workspace_not_registered');
|
|
59
|
+
const directories = [...new Set(['root', 'basic', ...(state.journal.getWorkspace(workspaceId)?.directories ?? []).map(item => item.directoryName)])];
|
|
60
|
+
const assertIdle = async () => {
|
|
61
|
+
controller.signal.throwIfAborted();
|
|
62
|
+
if (state.journal.destroyed())
|
|
63
|
+
throw new Error('runtime_destroyed');
|
|
64
|
+
for (const directory of directories)
|
|
65
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, directory))
|
|
66
|
+
throw new Error('workspace_directory_occupied');
|
|
67
|
+
};
|
|
68
|
+
await withDirectoryWrite(state, { workspaceId, directoryName: 'root', mode: 'exclusive', wait: false }, async () => {
|
|
69
|
+
await assertIdle();
|
|
70
|
+
const markerDir = join(state.config.stateDir, 'workspace-storage'), marker = join(markerDir, `${workspaceId}.json`);
|
|
71
|
+
if (action === 'snapshot') {
|
|
72
|
+
if (!(await present()))
|
|
73
|
+
throw new Error('workspace_not_prepared');
|
|
74
|
+
const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
|
|
75
|
+
try {
|
|
76
|
+
await assertIdle();
|
|
77
|
+
download = snapshot;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await snapshot.cleanup();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action === 'evict') {
|
|
86
|
+
let body = '';
|
|
87
|
+
for await (const part of request) {
|
|
88
|
+
body += part.toString();
|
|
89
|
+
if (body.length > 1024)
|
|
90
|
+
throw new Error('storage_snapshot_invalid');
|
|
91
|
+
}
|
|
92
|
+
const sha256 = JSON.parse(body).sha256;
|
|
93
|
+
if (typeof sha256 !== 'string' || !SHA256.test(sha256))
|
|
94
|
+
throw new Error('storage_snapshot_invalid');
|
|
95
|
+
const removed = join(parent, `.storage-evicted-${workspaceId}-${sha256}`);
|
|
96
|
+
if (!(await present())) {
|
|
97
|
+
const saved = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
98
|
+
if (saved?.sha256 !== sha256)
|
|
99
|
+
throw new Error('storage_snapshot_conflict');
|
|
100
|
+
await rm(removed, { recursive: true, force: true });
|
|
101
|
+
result = { evicted: true };
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
|
|
105
|
+
try {
|
|
106
|
+
if (snapshot.sha256 !== sha256)
|
|
107
|
+
throw new Error('storage_snapshot_conflict');
|
|
108
|
+
await assertIdle();
|
|
109
|
+
// private
|
|
110
|
+
await mkdir(markerDir, { recursive: true, mode: 0o700 });
|
|
111
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
112
|
+
// private
|
|
113
|
+
await writeFile(temporary, JSON.stringify({ sha256 }), { flag: 'wx', mode: 0o600 });
|
|
114
|
+
await rename(temporary, marker);
|
|
115
|
+
// Moving first makes a partially-deleted directory invisible to readers and safe to retry.
|
|
116
|
+
await rename(root, removed);
|
|
117
|
+
await rm(removed, { recursive: true, force: true });
|
|
118
|
+
result = { evicted: true };
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await snapshot.cleanup();
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (await present())
|
|
126
|
+
throw new Error('storage_snapshot_destination_exists');
|
|
127
|
+
const sha256 = request.headers['x-playcraft-snapshot-sha256'];
|
|
128
|
+
if (typeof sha256 !== 'string' || !SHA256.test(sha256))
|
|
129
|
+
throw new Error('storage_snapshot_invalid');
|
|
130
|
+
const rawLength = request.headers['content-length'];
|
|
131
|
+
if (rawLength !== undefined && (typeof rawLength !== 'string' || !/^\d+$/.test(rawLength) || !Number.isSafeInteger(Number(rawLength)) || Number(rawLength) > STORAGE_SNAPSHOT_MAX_BYTES))
|
|
132
|
+
throw new Error('storage_snapshot_too_large');
|
|
133
|
+
const temporary = await mkdtemp(join(parent, '.storage-restore-'));
|
|
134
|
+
try {
|
|
135
|
+
const incoming = join(temporary, 'incoming.gz'), staged = join(temporary, 'workspace');
|
|
136
|
+
const hash = createHash('sha256');
|
|
137
|
+
let size = 0;
|
|
138
|
+
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), new Transform({ transform(chunk, _encoding, done) {
|
|
139
|
+
size += chunk.length;
|
|
140
|
+
hash.update(chunk);
|
|
141
|
+
done(size > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk);
|
|
142
|
+
} }), createWriteStream(incoming, { flags: 'wx', mode: 0o600 }), { signal: controller.signal }); // private
|
|
143
|
+
if (hash.digest('hex') !== sha256 || rawLength !== undefined && size !== Number(rawLength))
|
|
144
|
+
throw new Error('storage_snapshot_checksum_mismatch');
|
|
145
|
+
// private
|
|
146
|
+
await mkdir(staged, { mode: 0o700 });
|
|
147
|
+
await extractStorageSnapshot(incoming, staged, { signal: controller.signal });
|
|
148
|
+
await openDirectoryToDevelopment(staged, (await lstat(parent)).gid);
|
|
149
|
+
await assertIdle();
|
|
150
|
+
if (await present())
|
|
151
|
+
throw new Error('storage_snapshot_destination_exists');
|
|
152
|
+
const evicted = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
153
|
+
if (typeof evicted?.sha256 === 'string' && SHA256.test(evicted.sha256))
|
|
154
|
+
await rm(join(parent, `.storage-evicted-${workspaceId}-${evicted.sha256}`), { recursive: true, force: true });
|
|
155
|
+
await rmdir(root).catch(error => { if (error.code !== 'ENOENT')
|
|
156
|
+
throw error; });
|
|
157
|
+
await rename(staged, root);
|
|
158
|
+
await rm(marker, { force: true });
|
|
159
|
+
result = { restored: true };
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await rm(temporary, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (result)
|
|
166
|
+
json(200, result);
|
|
167
|
+
if (download) {
|
|
168
|
+
const snapshot = download;
|
|
169
|
+
try {
|
|
170
|
+
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': snapshot.bytes,
|
|
171
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-playcraft-snapshot-sha256': snapshot.sha256 });
|
|
172
|
+
await pipeline(createReadStream(snapshot.path), response, { signal: controller.signal });
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await snapshot.cleanup();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (response.headersSent)
|
|
181
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
182
|
+
else if (!controller.signal.aborted) {
|
|
183
|
+
const reason = error instanceof Error ? error.message : '';
|
|
184
|
+
const code = /^(?:storage_snapshot_[a-z_]+|workspace_(?:not_registered|not_prepared|directory_occupied)|directory_busy|runtime_destroyed|generation_mismatch)$/.test(reason) ? reason : 'storage_snapshot_failed';
|
|
185
|
+
json(code === 'storage_snapshot_too_large' ? 413 : code === 'storage_snapshot_invalid' ? 400 : 409, { error: code });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
request.off('aborted', abort);
|
|
190
|
+
response.off('close', closed);
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|