@playcraft/cli 0.1.57-beta → 0.1.58-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/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/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 +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- 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 +11 -2
- 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 +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- 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 +11 -0
- 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/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
|
@@ -2,9 +2,9 @@ import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { posix, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import JSON5 from 'json5';
|
|
5
|
-
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, parameterI18nCatalog, parameterRuntimeTranslations, PARAMETER_RUNTIME_LOCALES, validateParameterChange, validateParameterI18nChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { InvalidWorkspacePathError, writeWorkspaceFile } from './files.js';
|
|
7
|
-
import { editJson5Paths, ParameterEditError } from './json5-edit.js';
|
|
7
|
+
import { editJson5Paths, editJson5StringRecords, ParameterEditError } from './json5-edit.js';
|
|
8
8
|
import { prepareParameterAssetRegistration, writeParameterAssetRegistration, } from './parameter-assets.js';
|
|
9
9
|
import { themeAssetIndexFingerprint } from './theme-asset-index.js';
|
|
10
10
|
const MANIFEST_PATH = '.playcraft/workspace.json';
|
|
@@ -32,6 +32,7 @@ async function sourceFingerprint(root, located, ownIndexWrite) {
|
|
|
32
32
|
readOptionalText(root, MANIFEST_PATH),
|
|
33
33
|
readOptionalText(root, THEME_INDEX_PATH),
|
|
34
34
|
selectedIndex ? readOptionalText(root, selectedIndex) : undefined,
|
|
35
|
+
located.schemaPath ? readOptionalText(root, runtimeI18nGlobalPath(located.schemaPath)) : undefined,
|
|
35
36
|
]);
|
|
36
37
|
const hashes = sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
|
|
37
38
|
const rawIndexHash = hashes[4];
|
|
@@ -106,6 +107,10 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
106
107
|
? { platformI18n: platformFile.value, platformI18nPath: i18nPath }
|
|
107
108
|
: {}),
|
|
108
109
|
};
|
|
110
|
+
const runtimeI18n = await readRuntimeI18n(root, revisions, located);
|
|
111
|
+
if (runtimeI18n.errors.length)
|
|
112
|
+
return parameterDocumentSchema.parse({ ...schemaDocument, status: 'invalid', errors: runtimeI18n.errors });
|
|
113
|
+
Object.assign(schemaDocument, runtimeI18n.document ? { runtimeI18n: runtimeI18n.document } : {});
|
|
109
114
|
if (platformFile.status === 'invalid') {
|
|
110
115
|
return parameterDocumentSchema.parse({
|
|
111
116
|
...schemaDocument,
|
|
@@ -148,6 +153,8 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
148
153
|
export async function patchWorkspaceParameters(root, revisions, payload, observed, options = {}) {
|
|
149
154
|
const parsed = parameterPatchPayloadSchema.parse(payload);
|
|
150
155
|
const document = await readWorkspaceParameters(root, revisions);
|
|
156
|
+
if ('documentId' in parsed)
|
|
157
|
+
return patchWorkspaceParameterI18n(root, document, parsed, observed);
|
|
151
158
|
if (document.status === 'invalid')
|
|
152
159
|
throw new ParameterEditError('parameter_document_invalid');
|
|
153
160
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
@@ -169,7 +176,7 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
169
176
|
if (observed && createHash('sha256').update(source).digest('hex') !== observed.contentHash) {
|
|
170
177
|
throw new ParameterEditError('revision_conflict');
|
|
171
178
|
}
|
|
172
|
-
const next = editJson5Paths(source, changes);
|
|
179
|
+
const next = formatParameterData(document.dataPath, source, editJson5Paths(source, changes));
|
|
173
180
|
JSON5.parse(next);
|
|
174
181
|
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX) {
|
|
175
182
|
throw new ParameterEditError('parameter_document_invalid');
|
|
@@ -209,6 +216,91 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
209
216
|
const written = await writeWorkspaceFile(root, document.dataPath, next);
|
|
210
217
|
return { ...written, ...(additionalWrites.length ? { additionalWrites } : {}) };
|
|
211
218
|
}
|
|
219
|
+
/** Strict data files must remain consumable by the project's original production importer. */
|
|
220
|
+
function formatParameterData(path, source, edited) {
|
|
221
|
+
if (!/\.json$/i.test(path))
|
|
222
|
+
return edited;
|
|
223
|
+
const indentation = source.match(/^[\t ]+(?=")/m)?.[0] ?? (/[\r\n]/.test(source.trim()) ? ' ' : undefined);
|
|
224
|
+
const next = JSON.stringify(JSON5.parse(edited), (_key, value) => {
|
|
225
|
+
// Unknown authored values must not silently change from non-finite numbers into null.
|
|
226
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
227
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
228
|
+
return value;
|
|
229
|
+
}, indentation);
|
|
230
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
231
|
+
return next.replaceAll('\n', eol) + (source.endsWith('\n') ? eol : '');
|
|
232
|
+
}
|
|
233
|
+
async function readRuntimeI18n(root, revisions, located) {
|
|
234
|
+
if (!located.schemaPath || !located.dataPath)
|
|
235
|
+
return { errors: [] };
|
|
236
|
+
const globalPath = runtimeI18nGlobalPath(located.schemaPath);
|
|
237
|
+
const themePath = runtimeI18nThemePath(located.dataPath);
|
|
238
|
+
const [globalFile, themeFile] = await Promise.all([readOptionalJson5(root, globalPath), readOptionalJson5(root, themePath)]);
|
|
239
|
+
const errors = [];
|
|
240
|
+
if (globalFile.status === 'invalid')
|
|
241
|
+
errors.push({ path: globalPath, reason: globalFile.reason });
|
|
242
|
+
if (themeFile.status === 'invalid')
|
|
243
|
+
errors.push({ path: themePath, reason: themeFile.reason });
|
|
244
|
+
if (errors.length || globalFile.status === 'missing' && themeFile.status === 'missing')
|
|
245
|
+
return { errors };
|
|
246
|
+
return { errors, document: {
|
|
247
|
+
globalPath, themePath,
|
|
248
|
+
global: globalFile.status === 'ok' ? globalFile.value : {},
|
|
249
|
+
theme: themeFile.status === 'ok' ? themeFile.value : {},
|
|
250
|
+
...(globalFile.status === 'ok' ? { globalHash: globalFile.contentHash } : {}),
|
|
251
|
+
...(themeFile.status === 'ok' ? { themeHash: themeFile.contentHash } : {}),
|
|
252
|
+
globalRevision: revisions[globalPath]?.revision ?? 0,
|
|
253
|
+
themeRevision: revisions[themePath]?.revision ?? 0,
|
|
254
|
+
locales: [...PARAMETER_RUNTIME_LOCALES],
|
|
255
|
+
} };
|
|
256
|
+
}
|
|
257
|
+
async function patchWorkspaceParameterI18n(root, document, payload, observed) {
|
|
258
|
+
if (document.status !== 'ready' || !document.runtimeI18n)
|
|
259
|
+
throw new ParameterEditError('parameter_i18n_global_invalid');
|
|
260
|
+
const runtime = document.runtimeI18n;
|
|
261
|
+
if (payload.sourceFingerprint !== document.sourceFingerprint || observed && observed.dataPath !== runtime.themePath)
|
|
262
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
263
|
+
for (const change of payload.changes) {
|
|
264
|
+
const error = validateParameterI18nChange(document, change);
|
|
265
|
+
if (error)
|
|
266
|
+
throw new ParameterEditError(error);
|
|
267
|
+
}
|
|
268
|
+
const source = await readOptionalText(root, runtime.themePath);
|
|
269
|
+
if (observed && (source === undefined ? '' : createHash('sha256').update(source).digest('hex')) !== observed.contentHash)
|
|
270
|
+
throw new ParameterEditError('revision_conflict');
|
|
271
|
+
const globals = runtime.global;
|
|
272
|
+
const themes = runtime.theme;
|
|
273
|
+
const nextEntries = new Map();
|
|
274
|
+
const changedLocales = new Set();
|
|
275
|
+
for (const change of payload.changes) {
|
|
276
|
+
const field = parameterI18nCatalog(document, change.locale).find(entry => entry.key === change.fieldPath.join('.'));
|
|
277
|
+
const key = field.runtimeI18nKey;
|
|
278
|
+
const identity = JSON.stringify([key, change.locale]);
|
|
279
|
+
if (changedLocales.has(identity))
|
|
280
|
+
throw new ParameterEditError('parameter_changes_overlap');
|
|
281
|
+
changedLocales.add(identity);
|
|
282
|
+
const defaults = parameterRuntimeTranslations(globals[key]);
|
|
283
|
+
const previous = parameterRuntimeTranslations(themes[key]) ?? defaults;
|
|
284
|
+
const entry = nextEntries.get(key) ?? { ...previous };
|
|
285
|
+
entry[change.locale] = change.op === 'remove' ? defaults[change.locale] : change.value;
|
|
286
|
+
nextEntries.set(key, entry);
|
|
287
|
+
}
|
|
288
|
+
const edits = [...nextEntries].map(([key, value]) => PARAMETER_RUNTIME_LOCALES.every(locale => value[locale] === globals[key][locale]) ? { key, op: 'remove' } : { key, value });
|
|
289
|
+
const next = editJson5StringRecords(source ?? '{}\n', edits);
|
|
290
|
+
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX)
|
|
291
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
292
|
+
if (await sourceFingerprint(root, await locateParameterFiles(root)) !== document.sourceFingerprint)
|
|
293
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
294
|
+
if (await readOptionalText(root, runtime.themePath) !== source)
|
|
295
|
+
throw new ParameterEditError('revision_conflict');
|
|
296
|
+
return writeWorkspaceFile(root, runtime.themePath, next);
|
|
297
|
+
}
|
|
298
|
+
function runtimeI18nGlobalPath(schemaPath) {
|
|
299
|
+
return posix.join(posix.dirname(schemaPath), 'i18n.runtime.json5');
|
|
300
|
+
}
|
|
301
|
+
function runtimeI18nThemePath(dataPath) {
|
|
302
|
+
return posix.join(posix.dirname(dataPath), 'theme.i18n.runtime.json5');
|
|
303
|
+
}
|
|
212
304
|
async function locateParameterFiles(root) {
|
|
213
305
|
const manifest = await readOptionalJson5(root, MANIFEST_PATH);
|
|
214
306
|
if (manifest.status === 'invalid') {
|
|
@@ -100,6 +100,11 @@ export async function startPlatformExport(state, input) {
|
|
|
100
100
|
clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
|
|
101
101
|
payload: {},
|
|
102
102
|
});
|
|
103
|
+
if (['queued', 'dispatched', 'accepted'].includes(saved.status))
|
|
104
|
+
return {
|
|
105
|
+
status: 'pending', operationId: saved.operationId, stage: 'workspace.save', builds: [],
|
|
106
|
+
nextAction: 'Finish the current turn so the queued save can run. Do not poll or wait for it within this turn. In a later turn, call the delivery tool again to continue the export.',
|
|
107
|
+
};
|
|
103
108
|
if (saved.status !== 'completed')
|
|
104
109
|
throw new PlatformExportError('workspace_save_failed', saved.error);
|
|
105
110
|
const status = await readWorkspaceGitStatus(target.root);
|
|
@@ -38,6 +38,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
38
38
|
workspaceDirectoryNameSchema.parse(directoryName);
|
|
39
39
|
const target = join(workspace, directoryName);
|
|
40
40
|
const receiptDir = join(state.config.stateDir, 'workspace-preparations');
|
|
41
|
+
// private
|
|
41
42
|
await mkdir(receiptDir, { recursive: true, mode: 0o700 });
|
|
42
43
|
const receiptPath = join(receiptDir, `${binding.workspaceId}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`);
|
|
43
44
|
let previous = await readReceipt(receiptPath, binding);
|
|
@@ -73,6 +74,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
73
74
|
// Persist ownership before creating/removing a staging directory, so a crash can be retried.
|
|
74
75
|
await writeReceipt(receiptPath, receipt);
|
|
75
76
|
await rm(staging, { recursive: true, force: true });
|
|
77
|
+
// private
|
|
76
78
|
await mkdir(staging, { mode: 0o700 });
|
|
77
79
|
const access = clone?.gitAccess;
|
|
78
80
|
const credential = clone?.directAccess ?? access;
|
|
@@ -163,6 +165,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
163
165
|
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
|
|
164
166
|
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
165
167
|
}
|
|
168
|
+
// shared
|
|
166
169
|
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
167
170
|
await rename(staging, target);
|
|
168
171
|
// The terminal and Codex run as the development user, so a checkout only the control user can
|
|
@@ -234,6 +237,7 @@ async function readReceipt(path, binding) {
|
|
|
234
237
|
}
|
|
235
238
|
async function writeReceipt(path, value) {
|
|
236
239
|
const temp = `${path}.tmp`;
|
|
240
|
+
// private
|
|
237
241
|
await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
238
242
|
await rename(temp, path);
|
|
239
243
|
}
|
|
@@ -11,6 +11,7 @@ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skil
|
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
12
|
import { attachWorkspaceRemote } from './git.js';
|
|
13
13
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
14
|
+
import { repairWorkspaceShares } from './workspace-shares.js';
|
|
14
15
|
export { WorkspaceContextError } from './context-error.js';
|
|
15
16
|
const exec = promisify(execFile);
|
|
16
17
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -128,6 +129,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
128
129
|
throw new WorkspaceContextError('invalid_workspace_registration');
|
|
129
130
|
// Production restores its assigned result from durable bytes, without touching basic.
|
|
130
131
|
const parent = await realpath(state.config.workspaceRoot);
|
|
132
|
+
// shared
|
|
131
133
|
await mkdir(join(parent, binding.workspaceId), { mode: 0o2770 }).catch(error => {
|
|
132
134
|
if (error.code !== 'EEXIST')
|
|
133
135
|
throw error;
|
|
@@ -141,6 +143,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
141
143
|
// Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
|
|
142
144
|
const parent = await realpath(state.config.workspaceRoot);
|
|
143
145
|
const container = join(parent, binding.workspaceId);
|
|
146
|
+
// shared
|
|
144
147
|
await mkdir(container, { mode: 0o2770 }).catch(error => { if (error.code !== 'EEXIST')
|
|
145
148
|
throw error; });
|
|
146
149
|
const registered = { ...binding, directories };
|
|
@@ -174,7 +177,9 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
174
177
|
if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
175
178
|
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
176
179
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
180
|
+
// private
|
|
177
181
|
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
182
|
+
// private
|
|
178
183
|
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
179
184
|
// The credential helper only serves a workspace the journal knows to be bound.
|
|
180
185
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
@@ -240,6 +245,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
240
245
|
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
241
246
|
await ensureWorkspaceArtifactInstructions(root);
|
|
242
247
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
248
|
+
// Registration is the one point every reused container reaches: the root repair is skipped
|
|
249
|
+
// when the binding did not change, so the shared subtrees repair themselves here (idempotent).
|
|
250
|
+
// Keep registration latency independent of workspace size; build and scan paths also repair.
|
|
251
|
+
void repairWorkspaceShares(state, root).catch(error => {
|
|
252
|
+
console.warn('[workspace-runtime] share repair failed:', error instanceof Error ? error.message : error);
|
|
253
|
+
});
|
|
243
254
|
try {
|
|
244
255
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
245
256
|
}
|
|
@@ -24,6 +24,7 @@ async function safeDirectory(root, relative = '', create = false) {
|
|
|
24
24
|
let current = root;
|
|
25
25
|
for (const segment of relative.split('/').filter(Boolean)) {
|
|
26
26
|
current = join(current, segment);
|
|
27
|
+
// shared
|
|
27
28
|
if (create)
|
|
28
29
|
await mkdir(current, { mode: 0o755 }).catch((error) => {
|
|
29
30
|
if (error.code !== 'EEXIST')
|
|
@@ -255,9 +256,12 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
255
256
|
for (const file of files) {
|
|
256
257
|
assertCurrent();
|
|
257
258
|
await safeDirectory(stage, dirname(file.targetPath) === '.' ? '' : dirname(file.targetPath), true);
|
|
259
|
+
// shared
|
|
258
260
|
await writeFile(join(stage, file.targetPath), file.bytes, { flag: 'wx', mode: 0o644 });
|
|
259
261
|
}
|
|
262
|
+
// shared
|
|
260
263
|
await writeFile(join(stage, RECEIPT), JSON.stringify(record), { flag: 'wx', mode: 0o644 });
|
|
264
|
+
// shared
|
|
261
265
|
await writeFile(join(stage, ATTRIBUTES), SNAPSHOT_ATTRIBUTES, { flag: 'wx', mode: 0o644 });
|
|
262
266
|
await chmod(stage, 0o755);
|
|
263
267
|
assertCurrent();
|
|
@@ -276,6 +280,7 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
276
280
|
await safeDirectory(rootReal, '.playcraft', true);
|
|
277
281
|
const temporary = join(rootReal, '.playcraft', `.remix-references-${randomUUID()}.tmp`);
|
|
278
282
|
try {
|
|
283
|
+
// shared
|
|
279
284
|
await writeFile(temporary, serialized, { flag: 'wx', mode: 0o644 });
|
|
280
285
|
assertCurrent();
|
|
281
286
|
await safeDirectory(rootReal, '.playcraft');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { chmod, chown, lstat, mkdir, open, rename, rm, } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Cloud workspaces are shared by two Linux identities: the controller (`playcraft-control`) and the
|
|
7
|
+
* development user that runs agents and builds (`playcraft-dev`). Every inode either of them must
|
|
8
|
+
* read or write has to carry the development group plus group read/write (and setgid on
|
|
9
|
+
* directories, so new entries inherit that group).
|
|
10
|
+
*
|
|
11
|
+
* Only the owner of an inode can chmod/chown it, so a call that finds a foreign owner leaves the
|
|
12
|
+
* entry alone instead of failing the operation; `repairWorkspaceShares` re-runs the same shaping as
|
|
13
|
+
* the owning identity.
|
|
14
|
+
*/
|
|
15
|
+
export const SHARED_DIRECTORY_MODE = 0o2770;
|
|
16
|
+
export const SHARED_FILE_MODE = 0o660;
|
|
17
|
+
const PERMISSION_BITS = 0o7777;
|
|
18
|
+
const OWNER_ONLY_ERRORS = new Set(['EPERM', 'EACCES']);
|
|
19
|
+
/** The development group, read from the workspace container that owns the tree. */
|
|
20
|
+
export async function workspaceGroup(workspaceRoot) {
|
|
21
|
+
const stat = await lstat(workspaceRoot);
|
|
22
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
23
|
+
throw new Error('invalid_workspace_directory');
|
|
24
|
+
}
|
|
25
|
+
return stat.gid;
|
|
26
|
+
}
|
|
27
|
+
function currentUid() {
|
|
28
|
+
return typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Another identity owns this inode; only its owner can re-shape it. */
|
|
31
|
+
function canShape(owner) {
|
|
32
|
+
const current = currentUid();
|
|
33
|
+
return current === undefined || current === 0 || owner === current;
|
|
34
|
+
}
|
|
35
|
+
function isOwnerOnly(error) {
|
|
36
|
+
const code = error?.code;
|
|
37
|
+
return typeof code === 'string' && OWNER_ONLY_ERRORS.has(code);
|
|
38
|
+
}
|
|
39
|
+
async function shapePath(path, groupId, mode, union = false) {
|
|
40
|
+
const stat = await lstat(path);
|
|
41
|
+
if (stat.isSymbolicLink())
|
|
42
|
+
throw new Error('invalid_shared_path');
|
|
43
|
+
const desired = union ? (stat.mode & 0o777) | mode : mode;
|
|
44
|
+
if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === desired)
|
|
45
|
+
return false;
|
|
46
|
+
if (!canShape(stat.uid))
|
|
47
|
+
return false;
|
|
48
|
+
try {
|
|
49
|
+
if (stat.gid !== groupId)
|
|
50
|
+
await chown(path, stat.uid, groupId);
|
|
51
|
+
await chmod(path, desired);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (isOwnerOnly(error))
|
|
56
|
+
return false;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function shapeHandle(handle, groupId, mode) {
|
|
61
|
+
const stat = await handle.stat();
|
|
62
|
+
if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === mode)
|
|
63
|
+
return false;
|
|
64
|
+
if (!canShape(stat.uid))
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
if (stat.gid !== groupId)
|
|
68
|
+
await handle.chown(stat.uid, groupId);
|
|
69
|
+
await handle.chmod(mode);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (isOwnerOnly(error))
|
|
74
|
+
return false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** True when `target` is the root itself or lives below it. */
|
|
79
|
+
export function isInsideRoot(root, target) {
|
|
80
|
+
const base = resolve(root), path = resolve(target);
|
|
81
|
+
return path === base || path.startsWith(`${base}${sep}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Chain of directories below `root` down to `target`; empty when `target` is the root itself or
|
|
85
|
+
* outside it. The root is shared by checkout preparation, never as a side effect of a write.
|
|
86
|
+
*/
|
|
87
|
+
function directoryChain(root, target) {
|
|
88
|
+
if (!isInsideRoot(root, target))
|
|
89
|
+
return [];
|
|
90
|
+
const parts = relative(resolve(root), resolve(target)).split(sep).filter(Boolean);
|
|
91
|
+
return parts.map((_, index) => resolve(root, ...parts.slice(0, index + 1)));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Shares one existing path with the development group. Returns whether the mode changed; an entry
|
|
95
|
+
* owned by the other identity is reported as unchanged because this process cannot re-shape it.
|
|
96
|
+
*/
|
|
97
|
+
export async function shareIfNeeded(workspaceRoot, path) {
|
|
98
|
+
const groupId = await workspaceGroup(workspaceRoot);
|
|
99
|
+
const stat = await lstat(path);
|
|
100
|
+
if (stat.isSymbolicLink())
|
|
101
|
+
throw new Error('invalid_shared_path');
|
|
102
|
+
return shapePath(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Shapes an existing path with an explicit group and mode. Used by the legacy helpers that receive
|
|
106
|
+
* the group from their caller (scratch trees and checkout preparation) so there is one implementation.
|
|
107
|
+
*/
|
|
108
|
+
export async function sharePathWithGroup(path, groupId, mode, union = false) {
|
|
109
|
+
return shapePath(path, groupId, mode, union);
|
|
110
|
+
}
|
|
111
|
+
/** Shapes an already open file. Cross-owner failures are tolerated like `shareIfNeeded`. */
|
|
112
|
+
export async function shareHandleWithGroup(handle, groupId, mode) {
|
|
113
|
+
return shapeHandle(handle, groupId, mode);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Creates a directory tree both identities can use. Missing ancestors are created with the shared
|
|
117
|
+
* mode and every segment below `workspaceRoot` is re-checked, so a pre-existing private parent is
|
|
118
|
+
* repaired when this process owns it.
|
|
119
|
+
*/
|
|
120
|
+
export async function ensureSharedDirectory(workspaceRoot, path) {
|
|
121
|
+
const root = resolve(workspaceRoot), target = resolve(path);
|
|
122
|
+
const groupId = await workspaceGroup(root);
|
|
123
|
+
const chain = directoryChain(root, target);
|
|
124
|
+
const shapes = isInsideRoot(root, target) ? chain : [target];
|
|
125
|
+
// Reject redirected ancestors before mkdir follows them.
|
|
126
|
+
for (const directory of chain) {
|
|
127
|
+
const stat = await lstat(directory).catch((error) => {
|
|
128
|
+
if (error.code === 'ENOENT')
|
|
129
|
+
return undefined;
|
|
130
|
+
throw error;
|
|
131
|
+
});
|
|
132
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
|
|
133
|
+
throw new Error('invalid_shared_directory');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await mkdir(target, { recursive: true, mode: SHARED_DIRECTORY_MODE });
|
|
137
|
+
for (const directory of shapes) {
|
|
138
|
+
const stat = await lstat(directory);
|
|
139
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
140
|
+
throw new Error('invalid_shared_directory');
|
|
141
|
+
}
|
|
142
|
+
await shapePath(directory, groupId, SHARED_DIRECTORY_MODE);
|
|
143
|
+
}
|
|
144
|
+
return target;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Opens (optionally creating) a file the development group can read and write. The handle keeps
|
|
148
|
+
* the descriptor, so a concurrent `chmod` cannot redirect what is written.
|
|
149
|
+
*/
|
|
150
|
+
export async function openSharedFile(workspaceRoot, path, flags) {
|
|
151
|
+
const groupId = await workspaceGroup(workspaceRoot);
|
|
152
|
+
const handle = await open(path, flags, SHARED_FILE_MODE);
|
|
153
|
+
try {
|
|
154
|
+
const stat = await handle.stat();
|
|
155
|
+
if (!stat.isFile())
|
|
156
|
+
throw new Error('invalid_shared_file');
|
|
157
|
+
await shapeHandle(handle, groupId, SHARED_FILE_MODE);
|
|
158
|
+
return handle;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await handle.close().catch(() => undefined);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Writes through a temporary file in the same directory, then renames, so readers never see a partial file. */
|
|
166
|
+
export async function writeSharedFile(workspaceRoot, path, data) {
|
|
167
|
+
const root = resolve(workspaceRoot), target = resolve(path);
|
|
168
|
+
if (isInsideRoot(root, target))
|
|
169
|
+
await ensureSharedDirectory(root, dirname(target));
|
|
170
|
+
const groupId = await workspaceGroup(root);
|
|
171
|
+
const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
|
|
172
|
+
try {
|
|
173
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, SHARED_FILE_MODE);
|
|
174
|
+
try {
|
|
175
|
+
await shapeHandle(handle, groupId, SHARED_FILE_MODE);
|
|
176
|
+
await handle.writeFile(data);
|
|
177
|
+
await handle.sync();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
await rename(temporary, target);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await rm(temporary, { force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import { cp, lstat,
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { load } from 'js-yaml';
|
|
5
5
|
import { canonicalProjectSkillsJson, createCanonicalBundleArchive, effectiveProjectSkills, extractBundleArchive, inspectProjectSkills, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
|
|
@@ -9,6 +9,7 @@ import { selectPlatformSkillSnapshot } from '../../project-skills/builtin-import
|
|
|
9
9
|
import { EMPTY_WORKSPACE_REPOSITORY_SKILLS, EMPTY_WORKSPACE_PLATFORM_SKILLS } from './empty-template.js';
|
|
10
10
|
import { excludeFromGit } from './dependencies.js';
|
|
11
11
|
import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
|
|
12
|
+
import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
|
|
12
13
|
/** Runtime availability is determined by the locked metadata, independent of unsynced source edits. */
|
|
13
14
|
export async function readWorkspaceSkillSnapshot(root) {
|
|
14
15
|
const management = await lstat(join(root, '.playcraft'));
|
|
@@ -42,7 +43,7 @@ export async function ensureWorkspaceSkills(root, cache, access, options = {}) {
|
|
|
42
43
|
continue;
|
|
43
44
|
await safeDirectory(root, '.agents/skills');
|
|
44
45
|
const source = `.agents/skills/${name}`;
|
|
45
|
-
await
|
|
46
|
+
await ensureSharedDirectory(root, join(root, source));
|
|
46
47
|
added.add(source);
|
|
47
48
|
const template = join(resolveCliPackageRoot(), 'project-template/.claude/skills', name);
|
|
48
49
|
for (const child of await readdir(template))
|
|
@@ -61,12 +62,16 @@ async function safeDirectory(root, path) {
|
|
|
61
62
|
let current = root;
|
|
62
63
|
for (const segment of path.split('/')) {
|
|
63
64
|
current = join(current, segment);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Validate every existing segment before creating anything, so a link never redirects the tree.
|
|
66
|
+
const stat = await lstat(current).catch((error) => {
|
|
67
|
+
if (error.code === 'ENOENT')
|
|
68
|
+
return undefined;
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
68
72
|
throw new Error('workspace_skills_invalid');
|
|
69
73
|
}
|
|
74
|
+
await ensureSharedDirectory(root, current);
|
|
70
75
|
return current;
|
|
71
76
|
}
|
|
72
77
|
/** Cache misses may reconstruct only the exact locked repository bytes, never a newer source. */
|
|
@@ -111,14 +116,7 @@ async function lockedFiles(root, cache, entry, view, loadArtifact) {
|
|
|
111
116
|
throw new Error('workspace_skills_invalid_bundle');
|
|
112
117
|
if (entry.revision.kind === 'source' && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.sourceTreeHash)
|
|
113
118
|
throw new Error('workspace_skills_digest_mismatch');
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
await writeFile(temporary, archive, { flag: 'wx', mode: 0o644 });
|
|
117
|
-
await rename(temporary, saved);
|
|
118
|
-
}
|
|
119
|
-
finally {
|
|
120
|
-
await rm(temporary, { force: true });
|
|
121
|
-
}
|
|
119
|
+
await writeSharedFile(root, saved, archive);
|
|
122
120
|
return extracted.files;
|
|
123
121
|
}
|
|
124
122
|
function metadata(entryKey, content) {
|
|
@@ -155,6 +153,7 @@ export async function freezeWorkspaceSkills(root, cache, loadArtifact) {
|
|
|
155
153
|
const relative = join(entry.entryKey, file.path);
|
|
156
154
|
expectedFiles.add(relative);
|
|
157
155
|
await safeDirectory(generation, dirname(relative));
|
|
156
|
+
// shared
|
|
158
157
|
await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(async (error) => {
|
|
159
158
|
if (error.code !== 'EEXIST')
|
|
160
159
|
throw error;
|
|
@@ -138,7 +138,9 @@ async function copySnapshot(source, target, signal, hashOnly = false) {
|
|
|
138
138
|
size += chunk.length;
|
|
139
139
|
callback(size > before.size ? new WorkspaceTransferError('workspace_transfer_conflict') : null, chunk);
|
|
140
140
|
} });
|
|
141
|
-
await pipeline(handle.createReadStream({ autoClose: false }), guard,
|
|
141
|
+
await pipeline(handle.createReadStream({ autoClose: false }), guard,
|
|
142
|
+
// private
|
|
143
|
+
createWriteStream(to, { flags: 'wx', mode: 0o600 }), { signal });
|
|
142
144
|
}
|
|
143
145
|
else {
|
|
144
146
|
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
@@ -366,6 +368,7 @@ export async function applyWorkspaceTransfer(root, bundle, expectedTree, options
|
|
|
366
368
|
try {
|
|
367
369
|
const bundlePath = typeof bundle === 'string' ? resolve(bundle) : join(temporary, 'incoming.bundle');
|
|
368
370
|
const incoming = join(temporary, 'checkout');
|
|
371
|
+
// private
|
|
369
372
|
if (typeof bundle !== 'string')
|
|
370
373
|
await writeFile(bundlePath, bundle, { mode: 0o600 });
|
|
371
374
|
const bundleInfo = await stat(bundlePath);
|