@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
|
@@ -4,6 +4,7 @@ import { constants } from 'node:fs';
|
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
7
|
+
import { adoptDirectoryAccess, holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
|
|
7
8
|
/** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
|
|
8
9
|
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
9
10
|
const LOG_TAIL_LINES = 50;
|
|
@@ -44,17 +45,18 @@ export function scheduleDependencyInstall(state, workspaceId, root, shouldStart)
|
|
|
44
45
|
running.set(state, inFlight);
|
|
45
46
|
const existing = inFlight.get(workspaceId);
|
|
46
47
|
if (existing?.root === root) {
|
|
48
|
+
void adoptDirectoryAccess(state, `managed:${existing.writeId}`).catch(() => undefined);
|
|
47
49
|
if (!shouldStart)
|
|
48
50
|
existing.guards = undefined;
|
|
49
51
|
else
|
|
50
52
|
existing.guards?.push(shouldStart);
|
|
51
53
|
return existing.task;
|
|
52
54
|
}
|
|
53
|
-
const scheduled = { root, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
55
|
+
const scheduled = { root, writeId: `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
54
56
|
const begin = () => {
|
|
55
57
|
if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
|
|
56
58
|
return;
|
|
57
|
-
return installOnce(state, workspaceId, root);
|
|
59
|
+
return installOnce(state, workspaceId, root, scheduled.writeId);
|
|
58
60
|
};
|
|
59
61
|
const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
|
|
60
62
|
.catch((error) => {
|
|
@@ -91,8 +93,8 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
|
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
95
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
+
* A lost controller makes the install result unknown. Keep its writer claim until a supervisor
|
|
97
|
+
* confirms that the process group stopped; a closed connection alone cannot prove that.
|
|
96
98
|
*/
|
|
97
99
|
export async function recoverInterruptedInstalls(journal) {
|
|
98
100
|
const now = new Date().toISOString();
|
|
@@ -106,81 +108,96 @@ export async function recoverInterruptedInstalls(journal) {
|
|
|
106
108
|
finishedAt: now,
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
|
-
for (const write of journal.listManagedWrites()) {
|
|
110
|
-
if (write.writeId.startsWith(WRITE_PREFIX) && !write.stoppedAt) {
|
|
111
|
-
await journal.putManagedWrite({ ...write, stoppedAt: now });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
111
|
}
|
|
115
|
-
async function installOnce(state, workspaceId, root) {
|
|
116
|
-
|
|
112
|
+
async function installOnce(state, workspaceId, root, writeId) {
|
|
113
|
+
const startedAt = new Date().toISOString();
|
|
114
|
+
const release = await holdDirectoryAccess(state, managedDirectoryScope(state, workspaceId), `managed:${writeId}`, 'shared');
|
|
115
|
+
let processMayRun = false;
|
|
116
|
+
let processStopped = false;
|
|
117
117
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
catch {
|
|
121
|
-
await state.journal.putWorkspaceDependencies({
|
|
122
|
-
workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
|
|
123
|
-
});
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const current = state.journal.getWorkspaceDependencies(workspaceId);
|
|
127
|
-
if (plan.kind === 'skip') {
|
|
128
|
-
if (current?.status !== 'none') {
|
|
118
|
+
if (state.journal.listManagedWrites().some(writer => writer.workspaceId === workspaceId &&
|
|
119
|
+
writer.writeId !== writeId && writer.writeId.startsWith(WRITE_PREFIX) && !writer.stoppedAt)) {
|
|
129
120
|
await state.journal.putWorkspaceDependencies({
|
|
130
|
-
workspaceId,
|
|
131
|
-
status: 'none',
|
|
132
|
-
finishedAt: new Date().toISOString(),
|
|
121
|
+
workspaceId, status: 'failed', error: 'dependency_run_unconfirmed', finishedAt: new Date().toISOString(),
|
|
133
122
|
});
|
|
123
|
+
return;
|
|
134
124
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
125
|
+
let plan;
|
|
126
|
+
try {
|
|
127
|
+
plan = await planInstall(root);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
await state.journal.putWorkspaceDependencies({
|
|
131
|
+
workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const current = state.journal.getWorkspaceDependencies(workspaceId);
|
|
136
|
+
if (plan.kind === 'skip') {
|
|
137
|
+
if (current?.status !== 'none') {
|
|
138
|
+
await state.journal.putWorkspaceDependencies({
|
|
139
|
+
workspaceId,
|
|
140
|
+
status: 'none',
|
|
141
|
+
finishedAt: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (current?.status === 'ready' &&
|
|
147
|
+
current.lockHash === plan.lockHash &&
|
|
148
|
+
(await isDirectory(join(root, 'node_modules')))) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await excludeFromGit(root);
|
|
152
|
+
// A checkpoint waits for this write: node_modules is half written until the install ends.
|
|
153
|
+
await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
|
|
154
|
+
await state.journal.putWorkspaceDependencies({
|
|
164
155
|
workspaceId,
|
|
165
|
-
|
|
156
|
+
status: 'installing',
|
|
166
157
|
startedAt,
|
|
167
|
-
|
|
158
|
+
});
|
|
159
|
+
let outcome;
|
|
160
|
+
try {
|
|
161
|
+
// Through the launcher the install runs as the development group, which cannot enter the
|
|
162
|
+
// checkout until Runtime has opened it; a local install runs as the owner and need not wait.
|
|
163
|
+
const shared = !launcherSocket(state) || (await waitUntilShared(root));
|
|
164
|
+
if (shared) {
|
|
165
|
+
processMayRun = true;
|
|
166
|
+
outcome = await runInstall(state, workspaceId, root);
|
|
167
|
+
processStopped = outcome.code !== undefined;
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
outcome = { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (!processMayRun || processStopped)
|
|
174
|
+
await state.journal.putManagedWrite({
|
|
175
|
+
writeId,
|
|
176
|
+
workspaceId,
|
|
177
|
+
source: 'background',
|
|
178
|
+
startedAt,
|
|
179
|
+
stoppedAt: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const ok = outcome.code === 0 && !outcome.timedOut;
|
|
183
|
+
await state.journal.putWorkspaceDependencies({
|
|
184
|
+
workspaceId,
|
|
185
|
+
status: ok ? 'ready' : 'failed',
|
|
186
|
+
...(ok
|
|
187
|
+
? { lockHash: plan.lockHash }
|
|
188
|
+
: {
|
|
189
|
+
error: outcome.error ??
|
|
190
|
+
(outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
|
|
191
|
+
}),
|
|
192
|
+
startedAt,
|
|
193
|
+
finishedAt: new Date().toISOString(),
|
|
194
|
+
logTail: tail(outcome.log),
|
|
168
195
|
});
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
...(ok
|
|
175
|
-
? { lockHash: plan.lockHash }
|
|
176
|
-
: {
|
|
177
|
-
error: outcome.error ??
|
|
178
|
-
(outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
|
|
179
|
-
}),
|
|
180
|
-
startedAt,
|
|
181
|
-
finishedAt: new Date().toISOString(),
|
|
182
|
-
logTail: tail(outcome.log),
|
|
183
|
-
});
|
|
197
|
+
finally {
|
|
198
|
+
if (!processMayRun || processStopped)
|
|
199
|
+
release();
|
|
200
|
+
}
|
|
184
201
|
}
|
|
185
202
|
/**
|
|
186
203
|
* Runs as the development user through the launcher, so the packages belong to the same user as
|
|
@@ -190,8 +207,10 @@ async function runInstall(state, workspaceId, root) {
|
|
|
190
207
|
// Tests replace the install; a real registry is never reached from them.
|
|
191
208
|
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
|
|
192
209
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
210
|
+
// private
|
|
193
211
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
194
212
|
const logPath = join(logDir, `${workspaceId}.log`);
|
|
213
|
+
// private
|
|
195
214
|
await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
|
|
196
215
|
let log = '';
|
|
197
216
|
let pending = Promise.resolve();
|
|
@@ -254,6 +273,7 @@ export async function excludeFromGit(root, entry = '/node_modules/') {
|
|
|
254
273
|
});
|
|
255
274
|
const info = await open(join(gitPath, 'info'), flags);
|
|
256
275
|
handles.push(info);
|
|
276
|
+
// shared
|
|
257
277
|
file = await open(join(pinned(info, join(root, '.git/info')), 'exclude'), constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
|
|
258
278
|
const metadata = await file.stat();
|
|
259
279
|
if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
3
|
+
import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
3
4
|
export function launcherSocket(state) {
|
|
4
5
|
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
5
6
|
return socketPath && state.config.workspaceMode === 'multi' ? socketPath : undefined;
|
|
@@ -94,5 +95,15 @@ export async function runDeveloperCommand(state, input) {
|
|
|
94
95
|
local.once('close', (value) => finish(value));
|
|
95
96
|
}
|
|
96
97
|
});
|
|
97
|
-
|
|
98
|
+
let stopped = !launched && !local?.pid;
|
|
99
|
+
try {
|
|
100
|
+
if (launched) {
|
|
101
|
+
await launched.stop();
|
|
102
|
+
stopped = true;
|
|
103
|
+
}
|
|
104
|
+
else if (local?.pid)
|
|
105
|
+
stopped = await stopBackgroundWriter(local.pid);
|
|
106
|
+
}
|
|
107
|
+
catch { /* Transport loss cannot prove the process group has stopped. */ }
|
|
108
|
+
return { code: stopped ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}) };
|
|
98
109
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chmod, chown, lstat, readdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, } from './shared-write.js';
|
|
3
4
|
/** Grant development-group traversal of the workspace container without touching source checkouts. */
|
|
4
5
|
export async function grantWorkspaceTraversal(root, groupId) {
|
|
5
6
|
const stat = await lstat(root);
|
|
@@ -18,9 +19,7 @@ export async function openScratchToDevelopment(path, groupId) {
|
|
|
18
19
|
const stat = await lstat(path);
|
|
19
20
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
20
21
|
throw new Error('invalid_scratch_directory');
|
|
21
|
-
|
|
22
|
-
await chown(path, stat.uid, groupId);
|
|
23
|
-
await chmod(path, 0o2770);
|
|
22
|
+
await sharePathWithGroup(path, groupId, SHARED_DIRECTORY_MODE);
|
|
24
23
|
}
|
|
25
24
|
/** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
|
|
26
25
|
export async function openDirectoryToDevelopment(root, groupId) {
|
|
@@ -29,22 +28,18 @@ export async function openDirectoryToDevelopment(root, groupId) {
|
|
|
29
28
|
const stat = await lstat(path);
|
|
30
29
|
if (stat.isSymbolicLink())
|
|
31
30
|
return;
|
|
31
|
+
// Entries owned by the other identity cannot be re-shaped from here; the owner repairs them.
|
|
32
32
|
const canModify = currentUid === undefined || currentUid === 0 || stat.uid === currentUid;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
for (const entry of entries)
|
|
44
|
-
await visit(join(path, entry));
|
|
45
|
-
}
|
|
46
|
-
else if (stat.isFile() && canModify)
|
|
47
|
-
await chmod(path, (stat.mode & 0o777) | 0o660);
|
|
33
|
+
await sharePathWithGroup(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE, stat.isFile());
|
|
34
|
+
if (!stat.isDirectory())
|
|
35
|
+
return;
|
|
36
|
+
const entries = await readdir(path).catch(error => {
|
|
37
|
+
if (!canModify && error.code === 'EACCES')
|
|
38
|
+
return [];
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
for (const entry of entries)
|
|
42
|
+
await visit(join(path, entry));
|
|
48
43
|
};
|
|
49
44
|
await visit(root);
|
|
50
45
|
}
|
|
@@ -25,6 +25,7 @@ async function safeDirectory(root, relative, create = false) {
|
|
|
25
25
|
let directory = root;
|
|
26
26
|
for (const part of relative.split('/')) {
|
|
27
27
|
directory = join(directory, part);
|
|
28
|
+
// shared
|
|
28
29
|
if (create)
|
|
29
30
|
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
30
31
|
if (error.code !== 'EEXIST')
|
|
@@ -59,10 +60,14 @@ export async function beginWorkspaceDirectoryAttachment(root, body) {
|
|
|
59
60
|
const staging = await safeDirectory(root, stagingPath, true);
|
|
60
61
|
const id = randomUUID();
|
|
61
62
|
const stage = join(staging, id);
|
|
63
|
+
// shared
|
|
62
64
|
await mkdir(stage, { mode: 0o755 });
|
|
63
65
|
try {
|
|
66
|
+
// shared
|
|
64
67
|
await mkdir(join(stage, 'data'), { mode: 0o755 });
|
|
68
|
+
// shared
|
|
65
69
|
await mkdir(join(stage, 'data', manifest.name), { mode: 0o755 });
|
|
70
|
+
// shared
|
|
66
71
|
await writeFile(join(stage, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx', mode: 0o644 });
|
|
67
72
|
return { uploadId: id };
|
|
68
73
|
}
|
|
@@ -84,6 +89,7 @@ export async function uploadWorkspaceDirectoryFile(root, id, path, request) {
|
|
|
84
89
|
throw new Error('invalid_attachment');
|
|
85
90
|
const relative = `${stagingPath}/${id}/data/${manifest.name}/${path}`;
|
|
86
91
|
await safeDirectory(root, dirname(relative), true);
|
|
92
|
+
// shared
|
|
87
93
|
const handle = await open(join(root, relative), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
|
|
88
94
|
try {
|
|
89
95
|
let size = 0;
|
|
@@ -164,9 +170,11 @@ export async function completeWorkspaceDirectoryAttachment(root, id) {
|
|
|
164
170
|
path: `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${manifest.name}`, files };
|
|
165
171
|
workspaceAttachmentSchema.parse(attachment);
|
|
166
172
|
const target = join(uploads, id);
|
|
173
|
+
// shared
|
|
167
174
|
await mkdir(target, { mode: 0o755 });
|
|
168
175
|
try {
|
|
169
176
|
await rename(join(stage, 'data', manifest.name), join(target, manifest.name));
|
|
177
|
+
// shared
|
|
170
178
|
await writeFile(join(metadata, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
|
|
171
179
|
}
|
|
172
180
|
catch (error) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
-
import { lstat, mkdir,
|
|
2
|
+
import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { directoryCreatePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { ensureWorkspaceRoot, InvalidWorkspacePathError } from './files.js';
|
|
6
|
+
import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
|
|
6
7
|
const RESERVED_DIRECTORIES = new Set(['.git', 'node_modules', '.playcraft-temp']);
|
|
7
8
|
export class WorkspaceDirectoryCreateError extends Error {
|
|
8
9
|
code = 'directory_exists';
|
|
@@ -18,18 +19,29 @@ export async function createWorkspaceDirectory(root, input) {
|
|
|
18
19
|
if (parts.some(part => RESERVED_DIRECTORIES.has(part)))
|
|
19
20
|
throw new InvalidWorkspacePathError(path);
|
|
20
21
|
const rootReal = await ensureWorkspaceRoot(root);
|
|
22
|
+
const share = async (path) => {
|
|
23
|
+
try {
|
|
24
|
+
await ensureSharedDirectory(rootReal, path);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.message === 'invalid_shared_directory')
|
|
28
|
+
throw new InvalidWorkspacePathError(path);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
21
32
|
let parent = rootReal;
|
|
22
33
|
for (const part of parts.slice(0, -1)) {
|
|
23
34
|
parent = join(parent, part);
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
// Created directories are visible to the development identity that runs agents and builds.
|
|
36
|
+
await share(parent);
|
|
26
37
|
const stat = await lstat(parent);
|
|
27
38
|
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(parent) !== parent)
|
|
28
39
|
throw new InvalidWorkspacePathError(path);
|
|
29
40
|
}
|
|
30
41
|
const target = join(parent, parts.at(-1));
|
|
42
|
+
// shared
|
|
31
43
|
try {
|
|
32
|
-
await mkdir(target, { mode:
|
|
44
|
+
await mkdir(target, { mode: 0o2770 });
|
|
33
45
|
}
|
|
34
46
|
catch (error) {
|
|
35
47
|
if (error.code !== 'EEXIST')
|
|
@@ -41,7 +53,8 @@ export async function createWorkspaceDirectory(root, input) {
|
|
|
41
53
|
try {
|
|
42
54
|
if (!(await lstat(target)).isDirectory() || await realpath(target) !== target)
|
|
43
55
|
throw new InvalidWorkspacePathError(path);
|
|
44
|
-
|
|
56
|
+
await share(target);
|
|
57
|
+
const marker = await openSharedFile(rootReal, join(target, '.gitkeep'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
45
58
|
try {
|
|
46
59
|
await marker.sync();
|
|
47
60
|
}
|
|
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
8
8
|
import { workspaceRoot, workspaceSessions } from './registry.js';
|
|
9
|
+
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
9
10
|
const execute = promisify(execFile);
|
|
10
11
|
const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
11
12
|
timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
@@ -39,7 +40,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
|
|
|
39
40
|
const blocked = [];
|
|
40
41
|
for (const directoryName of names) {
|
|
41
42
|
try {
|
|
42
|
-
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
43
|
+
await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
43
44
|
if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
|
|
44
45
|
throw new Error('workspace_directory_occupied');
|
|
45
46
|
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
@@ -51,7 +52,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
|
|
|
51
52
|
await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
|
|
52
53
|
await rm(root, { recursive: true });
|
|
53
54
|
cleaned.push(directoryName);
|
|
54
|
-
});
|
|
55
|
+
}));
|
|
55
56
|
}
|
|
56
57
|
catch (error) {
|
|
57
58
|
blocked.push({ directoryName, reason: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'workspace_cleanup_failed' });
|
|
@@ -70,42 +71,52 @@ export async function syncWorkspaceDirectories(state, workspaceId, commitHash, t
|
|
|
70
71
|
for (const directoryName of [...new Set(targetDirectoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))]) {
|
|
71
72
|
if (directoryName === 'basic')
|
|
72
73
|
throw new Error('workspace_sync_target_invalid');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
79
|
-
const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
|
|
80
|
-
const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
81
|
-
await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
|
|
82
|
-
if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
|
|
83
|
-
synced.push(directoryName);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
|
|
87
|
-
if (containing) {
|
|
88
|
-
synced.push(directoryName);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Import the exact preserved basic commit, without fetching a moving remote branch.
|
|
92
|
-
await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
93
|
-
const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
|
|
94
|
-
if (!equivalent) {
|
|
95
|
-
try {
|
|
96
|
-
await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
|
|
74
|
+
try {
|
|
75
|
+
await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
76
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
|
|
77
|
+
skipped.push({ directoryName, reason: 'directory_busy' });
|
|
78
|
+
return;
|
|
97
79
|
}
|
|
98
|
-
|
|
99
|
-
|
|
80
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
81
|
+
const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
|
|
82
|
+
const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
83
|
+
await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
|
|
84
|
+
if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
|
|
85
|
+
synced.push(directoryName);
|
|
100
86
|
return;
|
|
101
87
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
88
|
+
const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
|
|
89
|
+
if (containing) {
|
|
90
|
+
synced.push(directoryName);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Import the exact preserved basic commit, without fetching a moving remote branch.
|
|
94
|
+
await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
95
|
+
const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
|
|
96
|
+
if (!equivalent) {
|
|
97
|
+
try {
|
|
98
|
+
await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
conflicts.push(directoryName);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// private
|
|
106
|
+
await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
|
|
107
|
+
const temporary = `${ledger}.${randomUUID()}.tmp`;
|
|
108
|
+
// private
|
|
109
|
+
await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
|
|
110
|
+
await rename(temporary, ledger);
|
|
111
|
+
synced.push(directoryName);
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof Error && error.message === 'directory_busy')
|
|
116
|
+
skipped.push({ directoryName, reason: 'directory_busy' });
|
|
117
|
+
else
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
109
120
|
}
|
|
110
121
|
return { synced, skipped, conflicts, previewStale: true };
|
|
111
122
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat,
|
|
3
|
+
import { lstat, open, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { directoryDeletePayloadSchema, directoryRenamePayloadSchema, hashOperationPayload, isMutableWorkspaceDirectoryPath, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { safeAssetTransferDirectory, syncAssetTransferDirectory } from './asset-transfer-storage.js';
|
|
8
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
8
9
|
export class DirectoryMutationError extends Error {
|
|
9
10
|
code;
|
|
10
11
|
constructor(code) {
|
|
@@ -60,6 +61,7 @@ async function validateTree(root, path) {
|
|
|
60
61
|
async function saveRecord(directory, record) {
|
|
61
62
|
await safeAssetTransferDirectory(directory);
|
|
62
63
|
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
64
|
+
// private
|
|
63
65
|
const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
64
66
|
try {
|
|
65
67
|
await file.writeFile(JSON.stringify(record));
|
|
@@ -183,8 +185,11 @@ export async function mutateWorkspaceDirectory(root, kind, input, options) {
|
|
|
183
185
|
if (newPath) {
|
|
184
186
|
// Reserve an absent destination atomically; only our recorded empty placeholder may be replaced.
|
|
185
187
|
if (!destinationIdentity) {
|
|
186
|
-
await
|
|
187
|
-
return
|
|
188
|
+
if (await lstat(destination).catch(error => { if (missing(error))
|
|
189
|
+
return undefined; throw error; }))
|
|
190
|
+
return fail('directory_exists');
|
|
191
|
+
// A renamed directory is a workspace entry both identities must reach.
|
|
192
|
+
await ensureSharedDirectory(root, destination);
|
|
188
193
|
record.reservation = await identity(destination);
|
|
189
194
|
await saveRecord(directory, record);
|
|
190
195
|
}
|
|
@@ -13,6 +13,7 @@ export class InvalidWorkspacePathError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export async function ensureWorkspaceRoot(root) {
|
|
16
|
+
// private
|
|
16
17
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
17
18
|
return realpath(root);
|
|
18
19
|
}
|
|
@@ -37,8 +38,10 @@ export async function resolveWorkspaceFile(root, relativePath) {
|
|
|
37
38
|
}
|
|
38
39
|
export async function writeWorkspaceFile(root, relativePath, content) {
|
|
39
40
|
const target = await resolveWorkspaceFile(root, relativePath);
|
|
41
|
+
// private
|
|
40
42
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
41
43
|
const bytes = Buffer.from(content, 'utf8');
|
|
44
|
+
// private
|
|
42
45
|
await writeFile(target, bytes, { mode: 0o600 });
|
|
43
46
|
return { path: relativePath, contentHash: hashWorkspaceFileContent(content), size: bytes.byteLength };
|
|
44
47
|
}
|
|
@@ -134,6 +137,7 @@ async function assertParentInsideRoot(rootReal, parent) {
|
|
|
134
137
|
catch (error) {
|
|
135
138
|
if (error.code !== 'ENOENT')
|
|
136
139
|
throw error;
|
|
140
|
+
// private
|
|
137
141
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
138
142
|
const parentReal = await realpath(parent);
|
|
139
143
|
assertInsideRoot(rootReal, parentReal);
|
|
@@ -240,6 +240,7 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
|
|
|
240
240
|
gitDirectory = await open(join(pinnedRoot, '.git'), directoryFlags);
|
|
241
241
|
const gitPath = process.platform === 'linux' ? `/proc/self/fd/${gitDirectory.fd}` : join(root, '.git');
|
|
242
242
|
const configPath = join(gitPath, 'config');
|
|
243
|
+
// private
|
|
243
244
|
lock = await open(join(gitPath, 'config.lock'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o640);
|
|
244
245
|
lockPath = join(gitPath, 'config.lock');
|
|
245
246
|
const source = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -254,9 +255,11 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
|
|
|
254
255
|
finally {
|
|
255
256
|
await source.close();
|
|
256
257
|
}
|
|
258
|
+
// private
|
|
257
259
|
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
258
260
|
temporary = await mkdtemp(join(stateDir, 'git-config-'));
|
|
259
261
|
const staging = join(temporary, 'config');
|
|
262
|
+
// private
|
|
260
263
|
await writeFile(staging, content, { mode: 0o600 });
|
|
261
264
|
await clearManagedGitConfig(staging, repositoryUrl);
|
|
262
265
|
for (const args of commands)
|
|
@@ -39,6 +39,7 @@ export class WorkspaceGitDirectStore {
|
|
|
39
39
|
throw new Error('git_control_directory_rejected');
|
|
40
40
|
}
|
|
41
41
|
for (const path of [stateDir, directory]) {
|
|
42
|
+
// private
|
|
42
43
|
if (path === directory && create)
|
|
43
44
|
await mkdir(path, { mode: 0o700 }).catch(error => {
|
|
44
45
|
if (error.code !== 'EEXIST')
|
|
@@ -105,6 +106,7 @@ export class WorkspaceGitDirectStore {
|
|
|
105
106
|
if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
|
|
106
107
|
return;
|
|
107
108
|
const temporary = join(path, `${randomUUID()}.tmp`);
|
|
109
|
+
// private
|
|
108
110
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
109
111
|
try {
|
|
110
112
|
await handle.writeFile(JSON.stringify({ identity: this.identity(), binding, access }));
|