@playcraft/cli 0.1.57-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/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- 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/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +13 -3
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +55 -3
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -11,6 +11,7 @@ export class RuntimeJournal {
|
|
|
11
11
|
legacyPath;
|
|
12
12
|
client;
|
|
13
13
|
listeners = new Set();
|
|
14
|
+
changeListeners = new Set();
|
|
14
15
|
maintenance;
|
|
15
16
|
engineInfo;
|
|
16
17
|
epochs = [];
|
|
@@ -40,11 +41,13 @@ export class RuntimeJournal {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
async load() {
|
|
44
|
+
// private
|
|
43
45
|
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
44
46
|
const legacy = await readLegacy(this.legacyPath, this.filePath);
|
|
45
47
|
const started = await SqliteWorkerClient.open(this.filePath, legacy?.value ?? null);
|
|
46
48
|
this.client = started.client;
|
|
47
49
|
this.apply(started.opened);
|
|
50
|
+
// private
|
|
48
51
|
if (legacy?.readable)
|
|
49
52
|
await writeFile(this.legacyPath, LEGACY_JOURNAL_SENTINEL, { mode: 0o600 });
|
|
50
53
|
this.maintenance = setInterval(() => {
|
|
@@ -76,6 +79,33 @@ export class RuntimeJournal {
|
|
|
76
79
|
this.listeners.add(listener);
|
|
77
80
|
return () => this.listeners.delete(listener);
|
|
78
81
|
}
|
|
82
|
+
onChange(listener) {
|
|
83
|
+
this.changeListeners.add(listener);
|
|
84
|
+
return () => this.changeListeners.delete(listener);
|
|
85
|
+
}
|
|
86
|
+
directoryBarriers() { return Object.values(this.snapshot.directoryBarriers); }
|
|
87
|
+
async putDirectoryBarrier(barrier) {
|
|
88
|
+
await this.run('upsertRecord', { bucket: 'directory_barriers', key: barrier.owner,
|
|
89
|
+
body: { ...barrier, queuedAt: this.snapshot.directoryBarriers[barrier.owner]?.queuedAt ?? barrier.queuedAt ?? new Date().toISOString() } });
|
|
90
|
+
}
|
|
91
|
+
async deleteDirectoryBarrier(owner) {
|
|
92
|
+
await this.run('deleteRecord', { bucket: 'directory_barriers', key: owner });
|
|
93
|
+
}
|
|
94
|
+
deferredDirectoryOperations() { return Object.values(this.snapshot.deferredDirectoryOperations); }
|
|
95
|
+
async deferDirectoryOperation(input) {
|
|
96
|
+
await this.run('upsertRecord', { bucket: 'directory_deferred_operations', key: input.operationId,
|
|
97
|
+
body: { ...input, queuedAt: this.snapshot.deferredDirectoryOperations[input.operationId]?.queuedAt ?? input.queuedAt ?? new Date().toISOString() } });
|
|
98
|
+
}
|
|
99
|
+
/** The accepted command and its replayable input commit together before any writer can start. */
|
|
100
|
+
async putPendingOperation(scopeKey, operation, payload, recovery) {
|
|
101
|
+
const parsed = operationRecordSchema.parse(operation);
|
|
102
|
+
await this.run('putPendingOperation', { scopeKey, operation: parsed,
|
|
103
|
+
pending: { operationId: parsed.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}), queuedAt: parsed.createdAt } });
|
|
104
|
+
return this.snapshot.operations[scopeKey];
|
|
105
|
+
}
|
|
106
|
+
async deleteDeferredDirectoryOperation(operationId) {
|
|
107
|
+
await this.run('deleteRecord', { bucket: 'directory_deferred_operations', key: operationId });
|
|
108
|
+
}
|
|
79
109
|
getWorkspace(workspaceId) {
|
|
80
110
|
return this.snapshot.workspaces[workspaceId];
|
|
81
111
|
}
|
|
@@ -288,6 +318,8 @@ export class RuntimeJournal {
|
|
|
288
318
|
throw new Error('sqlite_not_open');
|
|
289
319
|
const result = await this.client.request(type, payload);
|
|
290
320
|
this.apply(result);
|
|
321
|
+
for (const listener of this.changeListeners)
|
|
322
|
+
listener();
|
|
291
323
|
for (const event of result.committedEvents ?? []) {
|
|
292
324
|
for (const listener of this.listeners)
|
|
293
325
|
listener(event);
|
|
@@ -356,6 +388,8 @@ function emptySnapshot() {
|
|
|
356
388
|
fileRevisions: {},
|
|
357
389
|
approvals: {},
|
|
358
390
|
managedWrites: {},
|
|
391
|
+
directoryBarriers: {},
|
|
392
|
+
deferredDirectoryOperations: {},
|
|
359
393
|
workspaceDependencies: {},
|
|
360
394
|
executionGroup: { stopConfirmed: false, replaced: false },
|
|
361
395
|
lastSourceSequence: 0,
|
|
@@ -64,6 +64,15 @@ function dispatch(type, payload) {
|
|
|
64
64
|
case 'putOperation':
|
|
65
65
|
putOperation(database, String(body.scopeKey), body.operation);
|
|
66
66
|
return;
|
|
67
|
+
case 'putPendingOperation': {
|
|
68
|
+
const operation = operationRecordSchema.parse(body.operation);
|
|
69
|
+
const pending = asRecord(body.pending);
|
|
70
|
+
if (operation.status !== 'queued' || pending.operationId !== operation.operationId || pending.scopeKey !== body.scopeKey)
|
|
71
|
+
throw new Error('invalid_pending_operation');
|
|
72
|
+
putOperation(database, String(body.scopeKey), operation);
|
|
73
|
+
upsertRecord(database, 'directory_deferred_operations', operation.operationId, pending);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
67
76
|
case 'setAcceptedFence':
|
|
68
77
|
setMeta(database, 'accepted_lease_fence', String(Math.max(metaNumber(database, 'accepted_lease_fence'), Number(body.value) || 0)));
|
|
69
78
|
return;
|
|
@@ -158,6 +167,8 @@ function openDatabase(payload) {
|
|
|
158
167
|
ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
|
|
159
168
|
CREATE INDEX IF NOT EXISTS records_native_session_thread
|
|
160
169
|
ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
|
|
170
|
+
CREATE INDEX IF NOT EXISTS operation_intents_sandbox_operation
|
|
171
|
+
ON operation_intents(sandbox_operation_id);
|
|
161
172
|
CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
|
|
162
173
|
ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
|
|
163
174
|
json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
|
|
@@ -353,7 +364,7 @@ function allocateEvent(database, body) {
|
|
|
353
364
|
WHERE runtime_id = ? AND generation = ? AND runtime_epoch = ?
|
|
354
365
|
RETURNING next_sequence`)
|
|
355
366
|
.get(runtimeId, generation, runtimeEpoch);
|
|
356
|
-
const payload = body
|
|
367
|
+
const payload = operationEventPayload(database, body);
|
|
357
368
|
const event = pendingSourceEventSchema.parse({
|
|
358
369
|
source: { runtimeId, generation, runtimeEpoch, sourceSequence: allocated.next_sequence },
|
|
359
370
|
payloadHash: hashOperationPayload(payload),
|
|
@@ -832,6 +843,8 @@ function dump(database, includeEvents = true) {
|
|
|
832
843
|
fileRevisions: bucketObject(database, 'file_revisions'),
|
|
833
844
|
approvals: bucketObject(database, 'approvals'),
|
|
834
845
|
managedWrites: bucketObject(database, 'managed_writes'),
|
|
846
|
+
directoryBarriers: bucketObject(database, 'directory_barriers'),
|
|
847
|
+
deferredDirectoryOperations: bucketObject(database, 'directory_deferred_operations'),
|
|
835
848
|
workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
|
|
836
849
|
executionGroup: executionGroup(database),
|
|
837
850
|
stoppedRuntimeEpochs: stoppedExecutionEpochs(database, metaString(database, 'runtime_id'), metaNumber(database, 'generation')),
|
|
@@ -878,7 +891,16 @@ function operationsObject(database) {
|
|
|
878
891
|
return operations;
|
|
879
892
|
}
|
|
880
893
|
function putOperation(database, scopeKey, value) {
|
|
881
|
-
|
|
894
|
+
let operation = operationRecordSchema.parse(value);
|
|
895
|
+
if (operation.status !== 'queued')
|
|
896
|
+
database.prepare("DELETE FROM records WHERE bucket = 'directory_deferred_operations' AND record_key = ?").run(operation.operationId);
|
|
897
|
+
const existing = database.prepare('SELECT gateway_operation_id FROM operation_intents WHERE scope_key = ?')
|
|
898
|
+
.get(scopeKey);
|
|
899
|
+
if (existing?.gateway_operation_id) {
|
|
900
|
+
if (operation.gatewayOperationId && operation.gatewayOperationId !== existing.gateway_operation_id)
|
|
901
|
+
throw new Error('gateway_operation_mismatch');
|
|
902
|
+
operation = { ...operation, gatewayOperationId: existing.gateway_operation_id };
|
|
903
|
+
}
|
|
882
904
|
if (metaString(database, 'runtime_id') && operation.generation === metaNumber(database, 'generation') && operation.runtimeEpoch === metaNumber(database, 'runtime_epoch')) {
|
|
883
905
|
const key = JSON.stringify([scopeKey, operation.operationId]);
|
|
884
906
|
database.prepare("INSERT INTO records (bucket, record_key, body_json) VALUES ('operation_execution_scopes', ?, ?) ON CONFLICT DO NOTHING")
|
|
@@ -898,7 +920,7 @@ function putOperation(database, scopeKey, value) {
|
|
|
898
920
|
thread_id = excluded.thread_id,
|
|
899
921
|
turn_id = excluded.turn_id,
|
|
900
922
|
reconcile_status = excluded.reconcile_status`)
|
|
901
|
-
.run(scopeKey, JSON.stringify(operation), null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
923
|
+
.run(scopeKey, JSON.stringify(operation), operation.gatewayOperationId ?? null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
902
924
|
}
|
|
903
925
|
function insertEventRow(database, event, links) {
|
|
904
926
|
database
|
|
@@ -1017,17 +1039,57 @@ function operationByTurn(database, turnId, threadId) {
|
|
|
1017
1039
|
}
|
|
1018
1040
|
function linkedPayload(params, session, subAgent) {
|
|
1019
1041
|
const payload = { ...asRecord(params) };
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1042
|
+
// Native notifications cannot assert the platform identity or override durable ownership.
|
|
1043
|
+
for (const key of ['operationId', 'gatewayOperationId', 'platformOperationId', 'sandboxOperationId',
|
|
1044
|
+
'workspaceId', 'sessionId', 'directoryName', 'runtimeId', 'generation', 'runtimeEpoch', 'userId', 'environment', 'execution', 'subAgent',
|
|
1045
|
+
'directoryAccessMode', 'dispatchAttempted'])
|
|
1046
|
+
delete payload[key];
|
|
1023
1047
|
if (typeof session?.workspaceId === 'string')
|
|
1024
1048
|
payload.workspaceId = session.workspaceId;
|
|
1025
1049
|
if (typeof session?.sessionId === 'string')
|
|
1026
1050
|
payload.sessionId = session.sessionId;
|
|
1051
|
+
if (session)
|
|
1052
|
+
payload.directoryName = optionalString(session.directoryName) ?? 'basic';
|
|
1027
1053
|
if (subAgent)
|
|
1028
1054
|
payload.subAgent = subAgent;
|
|
1029
1055
|
return payload;
|
|
1030
1056
|
}
|
|
1057
|
+
/** Identity enters the immutable event body before hashing, including synthetic stop events. */
|
|
1058
|
+
function operationEventPayload(database, body) {
|
|
1059
|
+
const operationId = optionalString(body.operationId);
|
|
1060
|
+
if (!operationId)
|
|
1061
|
+
return body.payload ?? {};
|
|
1062
|
+
const row = database.prepare('SELECT operation_json FROM operation_intents WHERE sandbox_operation_id = ? LIMIT 1')
|
|
1063
|
+
.get(operationId);
|
|
1064
|
+
if (!row)
|
|
1065
|
+
return body.payload ?? {};
|
|
1066
|
+
const operation = operationRecordSchema.parse(JSON.parse(row.operation_json));
|
|
1067
|
+
if ((operation.generation !== undefined && operation.generation !== Number(body.generation)) ||
|
|
1068
|
+
(operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== Number(body.runtimeEpoch)))
|
|
1069
|
+
return body.payload ?? {};
|
|
1070
|
+
const payload = { ...asRecord(body.payload) };
|
|
1071
|
+
delete payload.gatewayOperationId;
|
|
1072
|
+
delete payload.platformOperationId;
|
|
1073
|
+
payload.operationId = operation.gatewayOperationId ?? operation.operationId;
|
|
1074
|
+
payload.sandboxOperationId = operation.operationId;
|
|
1075
|
+
if (operation.gatewayOperationId)
|
|
1076
|
+
payload.gatewayOperationId = operation.gatewayOperationId;
|
|
1077
|
+
if (operation.workspaceId)
|
|
1078
|
+
payload.workspaceId = operation.workspaceId;
|
|
1079
|
+
if (operation.sessionId)
|
|
1080
|
+
payload.sessionId = operation.sessionId;
|
|
1081
|
+
payload.directoryName = operation.directoryName ?? 'basic';
|
|
1082
|
+
const subAgent = asRecord(payload.subAgent);
|
|
1083
|
+
const threadId = optionalString(body.threadId) ?? nativeIds(payload).threadId ??
|
|
1084
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeThreadId);
|
|
1085
|
+
const turnId = optionalString(body.turnId) ?? nativeIds(payload).turnId ??
|
|
1086
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeTurnId);
|
|
1087
|
+
if (threadId)
|
|
1088
|
+
payload.threadId = threadId;
|
|
1089
|
+
if (turnId)
|
|
1090
|
+
payload.turnId = turnId;
|
|
1091
|
+
return payload;
|
|
1092
|
+
}
|
|
1031
1093
|
function nativeIds(params) {
|
|
1032
1094
|
const record = asRecord(params);
|
|
1033
1095
|
const turn = asRecord(record.turn);
|
|
@@ -8,6 +8,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
8
8
|
let directory = workspace;
|
|
9
9
|
for (const segment of dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH).split('/')) {
|
|
10
10
|
directory = join(directory, segment);
|
|
11
|
+
// shared
|
|
11
12
|
const created = await mkdir(directory, { mode: 0o2775 }).then(() => true, error => {
|
|
12
13
|
if (error.code !== 'EEXIST')
|
|
13
14
|
throw error;
|
|
@@ -22,6 +23,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
22
23
|
const target = join(workspace, WORKSPACE_PLAYABLE_OUTPUT_PATH);
|
|
23
24
|
const temporary = join(directory, `.playable-${randomUUID()}.tmp`);
|
|
24
25
|
try {
|
|
26
|
+
// shared
|
|
25
27
|
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o664 });
|
|
26
28
|
await chmod(temporary, 0o664);
|
|
27
29
|
const stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
|
|
@@ -17,6 +17,7 @@ import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
|
17
17
|
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
18
18
|
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
19
19
|
import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
|
|
20
|
+
import { driftScope, permissionDriftFromOutput, repairWorkspaceShares, } from '../workspaces/workspace-shares.js';
|
|
20
21
|
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
21
22
|
/** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
|
|
22
23
|
const OUTPUT_FOLDER = '.playcraft-preview-out';
|
|
@@ -60,12 +61,12 @@ export function trackPreviewPublish(state, task) {
|
|
|
60
61
|
export async function settlePreviewPublishes(state) {
|
|
61
62
|
await Promise.all([...(running.get(state) ?? [])]);
|
|
62
63
|
}
|
|
63
|
-
/**
|
|
64
|
+
/** Restart alone does not prove the old builder stopped; retain its exclusive directory claim. */
|
|
64
65
|
export async function recoverInterruptedPreviews(journal) {
|
|
65
66
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
66
67
|
if (operation.kind !== 'preview.publish' || operation.status !== 'accepted')
|
|
67
68
|
continue;
|
|
68
|
-
await journal.putOperation(scopeKey, transitionOperation(operation, '
|
|
69
|
+
await journal.putOperation(scopeKey, transitionOperation(operation, 'uncertain', { error: 'preview_interrupted' }));
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
export async function runPreviewPublish(state, operation, root) {
|
|
@@ -215,7 +216,7 @@ async function ensureDependencies(state, workspaceId, root, output) {
|
|
|
215
216
|
async function buildPreview(state, root, outputDir, operationId, output, onLog, runtimeCommand) {
|
|
216
217
|
// Tests replace the build; the real one needs a project and the published CLI.
|
|
217
218
|
const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
|
|
218
|
-
const
|
|
219
|
+
const run = () => runDeveloperCommand(state, {
|
|
219
220
|
processId: `preview-${operationId}`,
|
|
220
221
|
cwd: root,
|
|
221
222
|
command: runtimeCommand ?? `${planned} --output ${shellQuote(outputDir)} .`,
|
|
@@ -226,8 +227,22 @@ async function buildPreview(state, root, outputDir, operationId, output, onLog,
|
|
|
226
227
|
onLog?.(text);
|
|
227
228
|
},
|
|
228
229
|
});
|
|
229
|
-
|
|
230
|
-
|
|
230
|
+
let outcome = await run();
|
|
231
|
+
if (outcome.code === undefined)
|
|
232
|
+
throw new PreviewFailure('preview_process_unknown');
|
|
233
|
+
if (outcome.code !== 0) {
|
|
234
|
+
// The build runs as the development identity; a directory only that identity can repair makes
|
|
235
|
+
// it fail on its own cwd. Repair the shared subtree as its owner and retry exactly once.
|
|
236
|
+
const drift = permissionDriftFromOutput(output.log);
|
|
237
|
+
if (drift) {
|
|
238
|
+
await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
|
|
239
|
+
outcome = await run();
|
|
240
|
+
if (outcome.code === undefined)
|
|
241
|
+
throw new PreviewFailure('preview_process_unknown');
|
|
242
|
+
}
|
|
243
|
+
if (outcome.code !== 0)
|
|
244
|
+
throw new PreviewFailure(drift ? 'workspace_permission_drift' : 'preview_build_failed');
|
|
245
|
+
}
|
|
231
246
|
}
|
|
232
247
|
/** Reads the one HTML file without following links, so the uploaded bytes are what was checked. */
|
|
233
248
|
async function readSingleHtml(outputDir) {
|
|
@@ -166,6 +166,8 @@ export async function prepareRuntimePreview(state, operation, root) {
|
|
|
166
166
|
dataRevision: before.dataRevision ?? 0,
|
|
167
167
|
schemaRevision: before.schemaRevision ?? 0,
|
|
168
168
|
dataHash: before.dataHash,
|
|
169
|
+
...(before.runtimeI18n ? { runtimeI18nRevision: before.runtimeI18n.themeRevision,
|
|
170
|
+
...(before.runtimeI18n.themeHash ? { runtimeI18nHash: before.runtimeI18n.themeHash } : {}) } : {}),
|
|
169
171
|
},
|
|
170
172
|
};
|
|
171
173
|
const context = { buildId, ...profileBase };
|
|
@@ -187,6 +189,8 @@ export async function prepareRuntimePreview(state, operation, root) {
|
|
|
187
189
|
after.schemaHash !== before.schemaHash ||
|
|
188
190
|
after.dataRevision !== before.dataRevision ||
|
|
189
191
|
after.schemaRevision !== before.schemaRevision ||
|
|
192
|
+
after.runtimeI18n?.themeHash !== before.runtimeI18n?.themeHash ||
|
|
193
|
+
after.runtimeI18n?.themeRevision !== before.runtimeI18n?.themeRevision ||
|
|
190
194
|
digest(await insideFile(root, manifestPath)) !== digest(manifest))
|
|
191
195
|
throw new RuntimePreviewBuildError('runtime_preview_source_changed');
|
|
192
196
|
for (const [path, hash] of gameHashes) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
|
|
3
4
|
export const MANAGED_WRITE_SOURCES = ['manual', 'agent', 'background'];
|
|
4
5
|
const BACKGROUND_WRITER = `
|
|
5
6
|
const fs = require('node:fs');
|
|
@@ -23,16 +24,48 @@ export function probeProcess(pid) {
|
|
|
23
24
|
return undefined;
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
export function spawnBackgroundWriter(absolutePath) {
|
|
27
|
+
export function spawnBackgroundWriter(absolutePath, stopped) {
|
|
27
28
|
const child = spawn(process.execPath, ['-e', BACKGROUND_WRITER, absolutePath], {
|
|
28
29
|
detached: true,
|
|
29
30
|
stdio: 'ignore',
|
|
30
31
|
});
|
|
31
32
|
child.unref();
|
|
33
|
+
child.once('error', () => undefined);
|
|
34
|
+
child.once('exit', () => stopped?.());
|
|
32
35
|
if (!child.pid)
|
|
33
36
|
throw new Error('background_write_failed');
|
|
34
37
|
return child.pid;
|
|
35
38
|
}
|
|
39
|
+
/** A writer keeps its directory claim after the request that created it has finished. */
|
|
40
|
+
export async function startManagedBackgroundWriter(state, record, absolutePath) {
|
|
41
|
+
if (state.journal.listManagedWrites().some(item => item.writeId === record.writeId && !item.stoppedAt))
|
|
42
|
+
throw new Error('write_already_active');
|
|
43
|
+
const release = await holdDirectoryAccess(state, managedDirectoryScope(state, record.workspaceId), `managed:${record.writeId}`, 'shared');
|
|
44
|
+
let pid;
|
|
45
|
+
let persisted = Promise.resolve();
|
|
46
|
+
const finish = async () => {
|
|
47
|
+
await persisted;
|
|
48
|
+
const current = state.journal.listManagedWrites().find(item => item.writeId === record.writeId);
|
|
49
|
+
if (current && current.startedAt === record.startedAt && !current.stoppedAt) {
|
|
50
|
+
await state.journal.putManagedWrite({ ...current, stoppedAt: new Date().toISOString() });
|
|
51
|
+
}
|
|
52
|
+
release();
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await state.journal.putManagedWrite(record);
|
|
56
|
+
pid = spawnBackgroundWriter(absolutePath, () => { void finish().catch(() => undefined); });
|
|
57
|
+
persisted = state.journal.putManagedWrite({ ...record, pid });
|
|
58
|
+
await persisted;
|
|
59
|
+
return pid;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (pid === undefined || await stopBackgroundWriter(pid)) {
|
|
63
|
+
persisted = Promise.resolve();
|
|
64
|
+
await finish();
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
36
69
|
export async function stopBackgroundWriter(pid) {
|
|
37
70
|
signal(pid, 'SIGTERM');
|
|
38
71
|
if (await waitUntilStopped(pid, 2_000))
|
|
@@ -58,12 +91,21 @@ function signal(pid, name) {
|
|
|
58
91
|
async function waitUntilStopped(pid, timeoutMs) {
|
|
59
92
|
const deadline = Date.now() + timeoutMs;
|
|
60
93
|
while (Date.now() < deadline) {
|
|
61
|
-
const alive =
|
|
94
|
+
const alive = probeProcessGroup(pid);
|
|
62
95
|
if (alive === false)
|
|
63
96
|
return true;
|
|
64
|
-
if (alive === undefined)
|
|
65
|
-
return false;
|
|
66
97
|
await delay(50);
|
|
67
98
|
}
|
|
68
|
-
return
|
|
99
|
+
return probeProcessGroup(pid) === false;
|
|
100
|
+
}
|
|
101
|
+
function probeProcessGroup(pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(-pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== 'ESRCH')
|
|
108
|
+
return undefined;
|
|
109
|
+
return probeProcess(pid);
|
|
110
|
+
}
|
|
69
111
|
}
|
|
@@ -8,10 +8,11 @@ import { createCodexAppServerAdapter } from './codex/app-server.js';
|
|
|
8
8
|
import { trustedMemberPolicy } from './batch/policy.js';
|
|
9
9
|
import { batchDirectoryHeld } from './batch/occupancy.js';
|
|
10
10
|
import { createLoopbackNativeAdapter } from './codex/loopback.js';
|
|
11
|
-
import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
|
|
11
|
+
import { attachNativeCollector, executeOperation, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
|
|
12
|
+
import { restoreDeferredDirectoryOperations } from './server/directory-access.js';
|
|
12
13
|
import { handleRuntimeRequest } from './server/http.js';
|
|
13
14
|
import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
|
|
14
|
-
import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
15
|
+
import { disposeTerminalHost, retireStoppedTerminals, terminalHost } from './server/terminal-host.js';
|
|
15
16
|
import { handleExecutionGroupCrash } from './server/execution-group.js';
|
|
16
17
|
import { RuntimeProcessLock } from './server/lock.js';
|
|
17
18
|
import { probeGitBinary } from './workspaces/git.js';
|
|
@@ -57,7 +58,9 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
57
58
|
await recoverInterruptedPreviews(journal);
|
|
58
59
|
await recoverInterruptedParameterVerifications(journal);
|
|
59
60
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
60
|
-
await
|
|
61
|
+
await retireStoppedTerminals(journal, config.runtimeEpoch, previousEpoch);
|
|
62
|
+
// shared: in multi-workspace mode this is /workspaces, the container both identities traverse.
|
|
63
|
+
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o2770 });
|
|
61
64
|
const state = runtimeState = {
|
|
62
65
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
63
66
|
configPath: options.configPath,
|
|
@@ -99,6 +102,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
99
102
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
100
103
|
if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
101
104
|
continue;
|
|
105
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
106
|
+
continue;
|
|
102
107
|
const published = recoveredImports.get(operation.operationId);
|
|
103
108
|
const result = operation.result && 'paths' in operation.result ? { ...operation.result,
|
|
104
109
|
paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
|
|
@@ -150,6 +155,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
150
155
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
151
156
|
if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
152
157
|
continue;
|
|
158
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
159
|
+
continue;
|
|
153
160
|
const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
|
|
154
161
|
let next = operation;
|
|
155
162
|
if (receipt) {
|
|
@@ -181,6 +188,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
181
188
|
if (!['directory.rename', 'directory.delete'].includes(operation.kind)
|
|
182
189
|
|| !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
183
190
|
continue;
|
|
191
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
192
|
+
continue;
|
|
184
193
|
const kind = operation.kind;
|
|
185
194
|
let next = operation;
|
|
186
195
|
try {
|
|
@@ -272,6 +281,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
272
281
|
state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
|
|
273
282
|
console.error(`[workspace-runtime] ${state.nativeError}`);
|
|
274
283
|
}
|
|
284
|
+
await restoreDeferredDirectoryOperations(state, (scopeKey, operation, payload, recovery) => executeOperation(state, scopeKey, operation, payload, recovery));
|
|
275
285
|
const server = createServer((request, response) => {
|
|
276
286
|
void handleRuntimeRequest(state, request, response);
|
|
277
287
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
|
+
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
3
3
|
import { buildOperationRecord } from './operation-record.js';
|
|
4
4
|
/**
|
|
@@ -29,6 +29,18 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
29
29
|
operationId: decision.operation.operationId,
|
|
30
30
|
}));
|
|
31
31
|
}
|
|
32
|
+
if (decision.action === 'reuse' && parsed.gatewayOperationId) {
|
|
33
|
+
if (decision.operation.gatewayOperationId && decision.operation.gatewayOperationId !== parsed.gatewayOperationId) {
|
|
34
|
+
return rejected(409, runtimeError('idempotency_conflict', 'gateway_operation_mismatch', {
|
|
35
|
+
operationId: decision.operation.operationId,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
if (!decision.operation.gatewayOperationId) {
|
|
39
|
+
decision.operation = await state.journal.putOperation(scopeKey, {
|
|
40
|
+
...decision.operation, gatewayOperationId: parsed.gatewayOperationId,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
32
44
|
const retryWithRecovery = decision.action === 'reuse' && parsed.kind === 'session.turn'
|
|
33
45
|
&& Boolean(parsed.sessionRecovery) && decision.operation.status === 'failed'
|
|
34
46
|
&& isLostThreadError(decision.operation.error);
|
|
@@ -38,6 +50,12 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
38
50
|
operationId: decision.operation.operationId,
|
|
39
51
|
}));
|
|
40
52
|
}
|
|
53
|
+
if (decision.operation.status === 'queued' && takesWorkspaceDirectory(decision.operation.kind) &&
|
|
54
|
+
!isTurnControl(decision.operation.kind) && !state.journal.deferredDirectoryOperations().some(item => item.operationId === decision.operation.operationId)) {
|
|
55
|
+
const queued = await state.journal.putPendingOperation(scopeKey, decision.operation, options.executionPayload, parsed.sessionRecovery);
|
|
56
|
+
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
57
|
+
return { rejected: false, operation: executed, executed, reused: true };
|
|
58
|
+
}
|
|
41
59
|
return { rejected: false, operation: decision.operation, executed: decision.operation, reused: true };
|
|
42
60
|
}
|
|
43
61
|
const validation = await options.validate?.(scopeKey);
|
|
@@ -51,7 +69,9 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
51
69
|
}
|
|
52
70
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
|
|
53
71
|
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
54
|
-
const queued =
|
|
72
|
+
const queued = takesWorkspaceDirectory(operation.kind) && !isTurnControl(operation.kind) && operation.kind !== 'runtime.destroy'
|
|
73
|
+
? await state.journal.putPendingOperation(scopeKey, operation, options.executionPayload, parsed.sessionRecovery)
|
|
74
|
+
: await state.journal.putOperation(scopeKey, operation);
|
|
55
75
|
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
56
76
|
return { rejected: false, operation: queued, executed, reused: false };
|
|
57
77
|
});
|