@playcraft/cli 0.1.48-beta → 0.1.50-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.
Files changed (39) hide show
  1. package/dist/workspace-runtime/batch/deliver.js +105 -87
  2. package/dist/workspace-runtime/batch/prepare.js +169 -56
  3. package/dist/workspace-runtime/batch/source-capture.js +7 -20
  4. package/dist/workspace-runtime/batch/source-publish.js +2 -4
  5. package/dist/workspace-runtime/codex/app-server.js +19 -1
  6. package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
  7. package/dist/workspace-runtime/codex/tool-response.js +10 -0
  8. package/dist/workspace-runtime/codex/ui-tools.js +22 -5
  9. package/dist/workspace-runtime/previews/diagnostics.js +44 -0
  10. package/dist/workspace-runtime/previews/publish.js +29 -8
  11. package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
  12. package/dist/workspace-runtime/serve.js +162 -4
  13. package/dist/workspace-runtime/server/accept-operation.js +61 -0
  14. package/dist/workspace-runtime/server/asset-transfers.js +63 -0
  15. package/dist/workspace-runtime/server/dispatch.js +328 -44
  16. package/dist/workspace-runtime/server/http.js +193 -137
  17. package/dist/workspace-runtime/server/operation-record.js +33 -0
  18. package/dist/workspace-runtime/server/submit-operation.js +41 -0
  19. package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
  20. package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
  21. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
  22. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
  23. package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
  24. package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
  25. package/dist/workspace-runtime/workspaces/delivery.js +64 -0
  26. package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
  27. package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
  28. package/dist/workspace-runtime/workspaces/directories.js +9 -4
  29. package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
  30. package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
  31. package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
  32. package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
  33. package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
  34. package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
  35. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
  36. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
  37. package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
  38. package/dist/workspace-runtime/workspaces/parameters.js +77 -54
  39. package/package.json +3 -3
@@ -0,0 +1,238 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { directoryDeletePayloadSchema, directoryRenamePayloadSchema, hashOperationPayload, isMutableWorkspaceDirectoryPath, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { safeAssetTransferDirectory, syncAssetTransferDirectory } from './asset-transfer-storage.js';
8
+ export class DirectoryMutationError extends Error {
9
+ code;
10
+ constructor(code) {
11
+ super(code);
12
+ this.code = code;
13
+ }
14
+ }
15
+ const fail = (code) => { throw new DirectoryMutationError(code); };
16
+ const missing = (error) => error?.code === 'ENOENT';
17
+ const identitySchema = z.object({ dev: z.number().nonnegative(), ino: z.number().nonnegative() }).strict();
18
+ const receiptSchema = z.object({ operationId: z.string().min(1).max(128), kind: z.enum(['directory.rename', 'directory.delete']),
19
+ path: z.string(), newPath: z.string().optional(), revision: z.number().int().nonnegative(), recoveryPath: z.string().optional() }).strict();
20
+ const recordSchema = z.object({ version: z.literal(1), scope: z.string(), operationId: z.string().min(1).max(128),
21
+ payloadHash: z.string().regex(/^[a-f0-9]{64}$/), kind: z.enum(['directory.rename', 'directory.delete']),
22
+ operationPayloadHash: z.string().regex(/^[a-f0-9]{64}$/), payload: z.unknown(),
23
+ path: z.string(), newPath: z.string().optional(), source: identitySchema, reservation: identitySchema.optional(),
24
+ revision: z.number().int().nonnegative(), phase: z.enum(['prepared', 'completed']), receipt: receiptSchema.optional() }).strict();
25
+ async function identity(path) {
26
+ const stat = await lstat(path).catch(error => { if (!missing(error))
27
+ throw error; return undefined; });
28
+ if (!stat)
29
+ return undefined;
30
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path)
31
+ fail('invalid_workspace_path');
32
+ return { dev: stat.dev, ino: stat.ino };
33
+ }
34
+ function same(left, right) { return Boolean(left && right && left.dev === right.dev && left.ino === right.ino); }
35
+ /** Reject redirected entries and managed descendants too: renaming their parent must not bypass path protection. */
36
+ async function validateTree(root, path) {
37
+ await safeAssetTransferDirectory(root, path);
38
+ const target = join(root, ...path.split('/'));
39
+ const original = await identity(target);
40
+ if (!original)
41
+ return fail('directory_not_found');
42
+ const visit = async (current, relative) => {
43
+ for (const entry of await readdir(current, { withFileTypes: true })) {
44
+ const child = join(current, entry.name), childPath = `${relative}/${entry.name}`;
45
+ const stat = await lstat(child);
46
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()
47
+ || stat.isDirectory() && !isMutableWorkspaceDirectoryPath(childPath))
48
+ fail('invalid_workspace_path');
49
+ if (stat.isDirectory()) {
50
+ await safeAssetTransferDirectory(root, childPath);
51
+ await visit(child, childPath);
52
+ }
53
+ }
54
+ };
55
+ await visit(target, path);
56
+ if (!same(original, await identity(target)))
57
+ fail('directory_conflict');
58
+ return original;
59
+ }
60
+ async function saveRecord(directory, record) {
61
+ await safeAssetTransferDirectory(directory);
62
+ const temporary = join(directory, `${randomUUID()}.tmp`);
63
+ const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
64
+ try {
65
+ await file.writeFile(JSON.stringify(record));
66
+ await file.sync();
67
+ }
68
+ finally {
69
+ await file.close();
70
+ }
71
+ await safeAssetTransferDirectory(directory);
72
+ await rename(temporary, join(directory, 'record.json'));
73
+ await syncAssetTransferDirectory(directory);
74
+ }
75
+ async function readRecord(directory) {
76
+ let handle;
77
+ try {
78
+ handle = await open(join(directory, 'record.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
79
+ }
80
+ catch (error) {
81
+ if (missing(error))
82
+ return undefined;
83
+ throw error;
84
+ }
85
+ try {
86
+ const stat = await handle.stat();
87
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024)
88
+ fail('directory_recovery_required');
89
+ return recordSchema.parse(JSON.parse(await handle.readFile('utf8')));
90
+ }
91
+ catch {
92
+ return fail('directory_recovery_required');
93
+ }
94
+ finally {
95
+ await handle.close();
96
+ }
97
+ }
98
+ async function cleanupRemoved(directory, record) {
99
+ if (record.kind !== 'directory.delete' || !record.receipt?.recoveryPath)
100
+ return;
101
+ try {
102
+ await safeAssetTransferDirectory(directory);
103
+ const destination = join(directory, 'removed'), stored = await identity(destination);
104
+ if (stored) {
105
+ if (!same(stored, record.source))
106
+ return;
107
+ await rm(destination, { recursive: true, force: false });
108
+ await syncAssetTransferDirectory(directory);
109
+ }
110
+ delete record.receipt.recoveryPath;
111
+ await saveRecord(directory, record);
112
+ }
113
+ catch { /* A retained private backup is disclosed in the receipt, never mistaken for a fresh source. */ }
114
+ }
115
+ /** A durable identity-bound intent makes a retry independent of any directory recreated at the old path. */
116
+ export async function mutateWorkspaceDirectory(root, kind, input, options) {
117
+ const parsed = kind === 'directory.rename' ? directoryRenamePayloadSchema.safeParse(input) : directoryDeletePayloadSchema.safeParse(input);
118
+ if (!parsed.success || !options.operationId || options.operationId.length > 128)
119
+ return fail('invalid_workspace_path');
120
+ const payload = parsed.data, newPath = 'newName' in payload
121
+ ? [...payload.path.split('/').slice(0, -1), payload.newName].join('/') : undefined;
122
+ if (newPath === payload.path)
123
+ return fail('directory_exists');
124
+ if ((await lstat(root)).isSymbolicLink())
125
+ return fail('invalid_workspace_path');
126
+ root = await realpath(root);
127
+ await safeAssetTransferDirectory(root);
128
+ const key = createHash('sha256').update(`${options.scope}\0${options.operationId}`).digest('hex');
129
+ const relativeDirectory = `.playcraft-temp/directory-mutations/${key}`;
130
+ const directory = options.recoverOnly
131
+ ? await safeAssetTransferDirectory(root, relativeDirectory).catch(error => { if (missing(error))
132
+ return undefined; throw error; })
133
+ : await safeAssetTransferDirectory(root, relativeDirectory, true);
134
+ if (!directory)
135
+ return undefined;
136
+ let record = await readRecord(directory);
137
+ const payloadHash = hashOperationPayload({ kind, payload });
138
+ if (record && (record.operationId !== options.operationId || record.scope !== options.scope || record.payloadHash !== payloadHash
139
+ || record.operationPayloadHash !== (options.operationPayloadHash ?? hashOperationPayload(input))
140
+ || record.path !== payload.path || record.kind !== kind || record.newPath !== newPath))
141
+ return fail('directory_recovery_required');
142
+ if (record?.phase === 'completed') {
143
+ const expected = { operationId: options.operationId, kind, path: payload.path, ...(newPath ? { newPath } : {}), revision: record.revision };
144
+ if (!record.receipt || Object.entries(expected).some(([key, value]) => record.receipt[key] !== value)
145
+ || record.receipt.recoveryPath && record.receipt.recoveryPath !== `${relativeDirectory}/removed`)
146
+ return fail('directory_recovery_required');
147
+ await cleanupRemoved(directory, record);
148
+ return record.receipt;
149
+ }
150
+ if (!record) {
151
+ if (options.recoverOnly)
152
+ return undefined;
153
+ let source;
154
+ try {
155
+ source = await validateTree(root, payload.path);
156
+ }
157
+ catch (error) {
158
+ if (missing(error))
159
+ return fail('directory_not_found');
160
+ throw error;
161
+ }
162
+ const revision = await options.observeRevision();
163
+ if (revision === undefined)
164
+ return fail('directory_revision_unavailable');
165
+ if (payload.expectedRevision !== undefined && payload.expectedRevision !== revision)
166
+ return fail('revision_conflict');
167
+ if (newPath && await lstat(join(root, ...newPath.split('/'))).then(() => true, error => { if (missing(error))
168
+ return false; throw error; }))
169
+ return fail('directory_exists');
170
+ record = { version: 1, scope: options.scope, operationId: options.operationId, payloadHash,
171
+ operationPayloadHash: options.operationPayloadHash ?? hashOperationPayload(input), payload, kind, path: payload.path,
172
+ ...(newPath ? { newPath } : {}), source, revision: revision + 1, phase: 'prepared' };
173
+ await saveRecord(directory, record);
174
+ }
175
+ const source = join(root, ...record.path.split('/'));
176
+ const destination = newPath ? join(root, ...newPath.split('/')) : join(directory, 'removed');
177
+ await safeAssetTransferDirectory(root, record.path.split('/').slice(0, -1).join('/'));
178
+ let destinationIdentity = await identity(destination);
179
+ if (!same(destinationIdentity, record.source)) {
180
+ if (!same(await identity(source), record.source))
181
+ return fail('directory_recovery_required');
182
+ await validateTree(root, record.path);
183
+ if (newPath) {
184
+ // Reserve an absent destination atomically; only our recorded empty placeholder may be replaced.
185
+ if (!destinationIdentity) {
186
+ await mkdir(destination, { mode: 0o700 }).catch(error => { if (error.code === 'EEXIST')
187
+ return fail('directory_exists'); throw error; });
188
+ record.reservation = await identity(destination);
189
+ await saveRecord(directory, record);
190
+ }
191
+ else if (!same(destinationIdentity, record.reservation))
192
+ return fail('directory_exists');
193
+ destinationIdentity = await identity(destination);
194
+ if (!same(destinationIdentity, record.reservation) || (await readdir(destination)).length)
195
+ return fail('directory_exists');
196
+ }
197
+ else if (destinationIdentity)
198
+ return fail('directory_recovery_required');
199
+ await options.assertCurrent?.();
200
+ if (!same(await identity(source), record.source))
201
+ return fail('directory_conflict');
202
+ if (newPath && !same(await identity(destination), record.reservation))
203
+ return fail('directory_exists');
204
+ await rename(source, destination);
205
+ await syncAssetTransferDirectory(dirname(source));
206
+ await syncAssetTransferDirectory(dirname(destination));
207
+ }
208
+ await options.onDetached?.();
209
+ const receipt = { operationId: options.operationId, kind, path: record.path,
210
+ ...(newPath ? { newPath } : {}), revision: record.revision };
211
+ if (!newPath) {
212
+ // Mark the detach durably before recursive cleanup; a crash during cleanup cannot revive the old path.
213
+ receipt.recoveryPath = `${relativeDirectory}/removed`;
214
+ }
215
+ record = { ...record, phase: 'completed', receipt };
216
+ await saveRecord(directory, record);
217
+ await cleanupRemoved(directory, record);
218
+ return receipt;
219
+ }
220
+ /** Recover only an existing intent whose scope and original operation payload still agree. Never start new work. */
221
+ export async function recoverWorkspaceDirectoryMutation(root, kind, options) {
222
+ if ((await lstat(root)).isSymbolicLink())
223
+ return fail('invalid_workspace_path');
224
+ root = await realpath(root);
225
+ const key = createHash('sha256').update(`${options.scope}\0${options.operationId}`).digest('hex');
226
+ const directory = await safeAssetTransferDirectory(root, `.playcraft-temp/directory-mutations/${key}`)
227
+ .catch(error => { if (missing(error))
228
+ return undefined; throw error; });
229
+ if (!directory)
230
+ return undefined;
231
+ const record = await readRecord(directory);
232
+ if (!record)
233
+ return undefined;
234
+ const payload = kind === 'directory.rename' ? directoryRenamePayloadSchema.safeParse(record.payload) : directoryDeletePayloadSchema.safeParse(record.payload);
235
+ if (!payload.success)
236
+ return fail('directory_recovery_required');
237
+ return mutateWorkspaceDirectory(root, kind, payload.data, { ...options, recoverOnly: true, observeRevision: async () => undefined });
238
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { isMutableWorkspaceDirectoryPath } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { listWorkspaceFiles } from './files.js';
6
+ /** Reuse the listing's streamed hashes; only directory and preservation-marker metadata is sampled again. */
7
+ export async function observeWorkspaceDirectories(root, journal, scope, listed) {
8
+ const previous = journal.fileRevisions(scope);
9
+ const result = listed ?? await listWorkspaceFiles(root, previous);
10
+ const directoryRevisions = {};
11
+ const fingerprints = new Map();
12
+ for (const path of result.directories ?? []) {
13
+ if (!isMutableWorkspaceDirectoryPath(path)
14
+ || result.unreadablePaths?.some(item => item === path || item.startsWith(`${path}/`)))
15
+ continue;
16
+ const stat = await lstat(join(root, ...path.split('/')));
17
+ if (!stat.isDirectory() || stat.isSymbolicLink())
18
+ continue;
19
+ const marker = await lstat(join(root, ...path.split('/'), '.gitkeep')).catch(error => {
20
+ if (error.code !== 'ENOENT')
21
+ throw error;
22
+ return undefined;
23
+ });
24
+ fingerprints.set(path, JSON.stringify([path, stat.dev, stat.ino, stat.mtimeMs, stat.ctimeMs,
25
+ marker && [marker.dev, marker.ino, marker.size, marker.mtimeMs, marker.ctimeMs]]));
26
+ }
27
+ for (const [path] of fingerprints) {
28
+ const hash = createHash('sha256').update('directory\0');
29
+ for (const [child, metadata] of fingerprints)
30
+ if (child === path || child.startsWith(`${path}/`))
31
+ hash.update(metadata);
32
+ for (const file of result.files)
33
+ if (file.path.startsWith(`${path}/`))
34
+ hash.update(JSON.stringify([file.path, file.contentHash, file.size]));
35
+ const contentHash = hash.digest('hex');
36
+ const old = previous[path];
37
+ const revision = old ? old.revision + Number(old.contentHash !== contentHash) : 0;
38
+ if (!old || old.contentHash !== contentHash)
39
+ await journal.putFileRevision(path, { revision, contentHash }, scope);
40
+ directoryRevisions[path] = revision;
41
+ }
42
+ for (const file of result.files) {
43
+ const old = previous[file.path];
44
+ const revision = old ? old.revision + Number(old.contentHash !== file.contentHash) : 0;
45
+ if (!old || old.contentHash !== file.contentHash)
46
+ await journal.putFileRevision(file.path, { revision, contentHash: file.contentHash }, scope);
47
+ file.revision = revision;
48
+ }
49
+ // Retain tombstones so recreating the same path cannot satisfy an old revision.
50
+ const present = new Set([...result.files.map(file => file.path), ...(result.directories ?? [])]);
51
+ for (const [path, old] of Object.entries(previous)) {
52
+ if (old.contentHash && !present.has(path) && !result.unreadablePaths?.some(item => path === item || path.startsWith(`${item}/`)))
53
+ await journal.putFileRevision(path, { revision: old.revision + 1, contentHash: '' }, scope);
54
+ }
55
+ return { ...result, directoryRevisions };
56
+ }
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { open } from 'node:fs/promises';
4
- import { WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { ASSET_TRANSFER_FILE_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
5
5
  export async function sampleDiskFile(path) {
6
6
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
7
7
  try {
8
8
  const before = await handle.stat();
9
- if (!before.isFile() || before.size > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
9
+ if (!before.isFile() || before.size > ASSET_TRANSFER_FILE_MAX_BYTES)
10
10
  throw new Error('snapshot_body_missing');
11
11
  const digest = createHash('sha256');
12
12
  const chunk = Buffer.allocUnsafe(64 * 1024);
@@ -17,7 +17,7 @@ export async function sampleDiskFile(path) {
17
17
  break;
18
18
  digest.update(chunk.subarray(0, bytesRead));
19
19
  position += bytesRead;
20
- if (position > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
20
+ if (position > ASSET_TRANSFER_FILE_MAX_BYTES)
21
21
  throw new Error('snapshot_body_missing');
22
22
  }
23
23
  const after = await handle.stat();
@@ -7,6 +7,127 @@ export class ParameterEditError extends Error {
7
7
  this.name = 'ParameterEditError';
8
8
  }
9
9
  }
10
+ /** Apply local JSON5 (JSON5 Data Interchange Format,JSON5 数据交换格式) edits without serializing unrelated fields. */
11
+ export function editJson5Paths(source, changes) {
12
+ const parsed = JSON5.parse(source);
13
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
14
+ throw new ParameterEditError('parameter_document_invalid');
15
+ }
16
+ const paths = [];
17
+ for (const change of changes) {
18
+ if (change.fieldPath.length === 0 || change.fieldPath.some((key) => !key || ['__proto__', 'prototype', 'constructor'].includes(key))) {
19
+ throw new ParameterEditError('parameter_value_invalid');
20
+ }
21
+ if (paths.some((other) => other.every((key, index) => key === change.fieldPath[index])
22
+ || change.fieldPath.every((key, index) => key === other[index]))) {
23
+ throw new ParameterEditError('parameter_changes_overlap');
24
+ }
25
+ if (change.op !== 'remove' && typeof change.value === 'number' && !Number.isFinite(change.value)) {
26
+ throw new ParameterEditError('parameter_value_invalid');
27
+ }
28
+ paths.push(change.fieldPath);
29
+ }
30
+ let next = source;
31
+ for (const change of changes)
32
+ next = editJson5Object(next, skipTrivia(next, 0, next.length), change, 0);
33
+ JSON5.parse(next);
34
+ return next;
35
+ }
36
+ function editJson5Object(source, braceIndex, change, depth) {
37
+ const object = readJson5Object(source, braceIndex);
38
+ const memberIndex = object.members.findIndex((member) => member.name === change.fieldPath[depth]);
39
+ const member = object.members[memberIndex];
40
+ if (member && depth < change.fieldPath.length - 1) {
41
+ if (source[member.value.start] !== '{')
42
+ throw new ParameterEditError('parameter_value_invalid');
43
+ return editJson5Object(source, member.value.start, change, depth + 1);
44
+ }
45
+ if (change.op === 'remove') {
46
+ if (!member)
47
+ return source;
48
+ if (source[member.value.start] === '{' || source[member.value.start] === '[') {
49
+ throw new ParameterEditError('parameter_value_invalid');
50
+ }
51
+ const comma = member.comma ?? object.members[memberIndex - 1]?.comma;
52
+ const spans = [{ start: member.keyStart, end: member.value.end },
53
+ ...(comma === undefined ? [] : [{ start: comma, end: comma + 1 }])].sort((left, right) => right.start - left.start);
54
+ let next = source;
55
+ for (const span of spans)
56
+ next = `${next.slice(0, span.start)}${next.slice(span.end)}`;
57
+ return next;
58
+ }
59
+ if (member) {
60
+ if (source[member.value.start] === '{' || source[member.value.start] === '[') {
61
+ throw new ParameterEditError('parameter_value_invalid');
62
+ }
63
+ return `${source.slice(0, member.value.start)}${formatJson5Value(change.value, source.slice(member.value.start, member.value.end))}${source.slice(member.value.end)}`;
64
+ }
65
+ const objectIndent = lineIndent(source, braceIndex);
66
+ const first = object.members[0];
67
+ const firstIndent = first ? lineIndent(source, first.keyStart) : undefined;
68
+ const indentUnit = firstIndent && firstIndent.length > objectIndent.length
69
+ ? firstIndent.slice(objectIndent.length) : ' ';
70
+ const multiline = /[\r\n]/.test(source.slice(braceIndex, object.close));
71
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
72
+ const render = (index, indent) => {
73
+ const key = formatJson5Key(change.fieldPath[index]);
74
+ if (index === change.fieldPath.length - 1)
75
+ return `${key}: ${formatJson5Value(change.value, '')}`;
76
+ return multiline
77
+ ? `${key}: {${eol}${indent}${indentUnit}${render(index + 1, indent + indentUnit)},${eol}${indent}}`
78
+ : `${key}: { ${render(index + 1, indent + indentUnit)} }`;
79
+ };
80
+ let tailStart = object.close;
81
+ while (tailStart > braceIndex + 1 && /\s/.test(source[tailStart - 1]))
82
+ tailStart -= 1;
83
+ const last = object.members.at(-1);
84
+ const insertion = multiline
85
+ ? `${eol}${objectIndent}${indentUnit}${render(depth, objectIndent + indentUnit)},${eol}${objectIndent}`
86
+ : `${object.members.length > 0 ? ' ' : ''}${render(depth, objectIndent + indentUnit)}${last?.comma === undefined ? '' : ','}${source.slice(tailStart, object.close)}`;
87
+ let next = `${source.slice(0, tailStart)}${insertion}${source.slice(object.close)}`;
88
+ if (last && last.comma === undefined)
89
+ next = `${next.slice(0, last.value.end)},${next.slice(last.value.end)}`;
90
+ return next;
91
+ }
92
+ function readJson5Object(source, braceIndex) {
93
+ if (source[braceIndex] !== '{')
94
+ throw new ParameterEditError('parameter_document_invalid');
95
+ const close = balancedEnd(source, braceIndex, source.length) - 1;
96
+ const members = [];
97
+ let index = skipTrivia(source, braceIndex + 1, close);
98
+ while (index < close) {
99
+ const keyStart = index;
100
+ let keyEnd;
101
+ if (source[index] === '"' || source[index] === "'")
102
+ keyEnd = stringEnd(source, index, close);
103
+ else {
104
+ keyEnd = index;
105
+ while (keyEnd < close && !/[\s:/]/.test(source[keyEnd]))
106
+ keyEnd += 1;
107
+ }
108
+ const key = JSON5.parse(`{${source.slice(index, keyEnd)}: null}`);
109
+ const name = Object.keys(key)[0];
110
+ if (members.some((member) => member.name === name))
111
+ throw new ParameterEditError('parameter_document_invalid');
112
+ index = skipTrivia(source, keyEnd, close);
113
+ if (source[index] !== ':')
114
+ throw new ParameterEditError('parameter_document_invalid');
115
+ const value = scanValue(source, index + 1, close);
116
+ index = skipTrivia(source, value.end, close);
117
+ const comma = source[index] === ',' ? index : undefined;
118
+ members.push({ name, keyStart, value, comma });
119
+ if (comma === undefined && index < close)
120
+ throw new ParameterEditError('parameter_document_invalid');
121
+ index = skipTrivia(source, comma === undefined ? index : index + 1, close);
122
+ }
123
+ return { close, members };
124
+ }
125
+ function lineIndent(source, index) {
126
+ return source.slice(source.lastIndexOf('\n', index - 1) + 1, index).match(/^[\t ]*/)?.[0] ?? '';
127
+ }
128
+ function formatJson5Key(key) {
129
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
130
+ }
10
131
  export function replaceJson5Path(source, fieldPath, value) {
11
132
  const span = findJson5ValueSpan(source, fieldPath);
12
133
  if (!span)
@@ -17,6 +138,26 @@ export function replaceJson5Path(source, fieldPath, value) {
17
138
  }
18
139
  return `${source.slice(0, span.start)}${formatJson5Value(value, source.slice(span.start, span.end))}${source.slice(span.end)}`;
19
140
  }
141
+ export function replaceJson5Paths(source, changes) {
142
+ const replacements = changes.map(({ fieldPath, value }) => {
143
+ const span = findJson5ValueSpan(source, fieldPath);
144
+ if (!span)
145
+ throw new ParameterEditError('parameter_field_missing');
146
+ const current = skipTrivia(source, span.start, span.end);
147
+ if (source[current] === '{' || source[current] === '[')
148
+ throw new ParameterEditError('parameter_value_invalid');
149
+ return { ...span, value: formatJson5Value(value, source.slice(span.start, span.end)) };
150
+ }).sort((left, right) => right.start - left.start);
151
+ let next = source;
152
+ let previousStart = source.length;
153
+ for (const replacement of replacements) {
154
+ if (replacement.end > previousStart)
155
+ throw new ParameterEditError('parameter_changes_overlap');
156
+ next = `${next.slice(0, replacement.start)}${replacement.value}${next.slice(replacement.end)}`;
157
+ previousStart = replacement.start;
158
+ }
159
+ return next;
160
+ }
20
161
  export function findJson5ValueSpan(source, fieldPath) {
21
162
  const start = skipTrivia(source, 0, source.length);
22
163
  if (source[start] !== '{')
@@ -153,7 +294,9 @@ function formatJson5Value(value, previous) {
153
294
  if (typeof value === 'string') {
154
295
  const trimmed = previous.trim();
155
296
  if (trimmed.startsWith("'")) {
156
- return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
297
+ const escaped = value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")
298
+ .replace(/[\u0000-\u001f\u2028\u2029\ud800-\udfff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
299
+ return `'${escaped}'`;
157
300
  }
158
301
  return JSON.stringify(value);
159
302
  }
@@ -0,0 +1,23 @@
1
+ import { join } from 'node:path';
2
+ import { AppendOnlyLog } from './append-only-log.js';
3
+ export const OPERATION_LOG_MAX_BYTES = 2 * 1024 * 1024;
4
+ const TRUNCATED = '[playcraft] log truncated; showing the tail\n';
5
+ const OPERATION_ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
6
+ /** Full build output for one operation, kept outside the checkout and capped at 2 MiB. */
7
+ export class OperationLogStore {
8
+ stateDir;
9
+ constructor(stateDir) {
10
+ this.stateDir = stateDir;
11
+ }
12
+ log(operationId) {
13
+ if (!OPERATION_ID.test(operationId))
14
+ throw new Error('invalid_operation_id');
15
+ return new AppendOnlyLog(join(this.stateDir, 'operation-logs', `${operationId}.log`), OPERATION_LOG_MAX_BYTES, TRUNCATED);
16
+ }
17
+ async append(operationId, text) {
18
+ await this.log(operationId).append(text);
19
+ }
20
+ async read(operationId, offset = 0) {
21
+ return this.log(operationId).read(offset);
22
+ }
23
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
3
+ import { relative, resolve, sep } from 'node:path';
4
+ import { workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ export const parameterDigest = (content) => createHash('sha256').update(content).digest('hex');
6
+ const skippedDirectories = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.vite', '.cache', '.playcraft-editor-preview', '.playcraft-delivery', '.playcraft-temp', 'parameter-admission']);
7
+ const inputExtension = /\.(?:[cm]?[jt]sx?|json5?|ya?ml|html|css|scss|glsl|wgsl|vert|frag|toml)$/i;
8
+ export class ParameterAgentError extends Error {
9
+ code;
10
+ constructor(code) {
11
+ super(code);
12
+ this.code = code;
13
+ this.name = 'ParameterAgentError';
14
+ }
15
+ }
16
+ export function parameterInputPath(path) {
17
+ if (!workspaceRelativePathSchema.safeParse(path).success)
18
+ return false;
19
+ const segments = path.split('/');
20
+ if (segments.some(segment => skippedDirectories.has(segment)))
21
+ return false;
22
+ if (segments.some(segment => segment.startsWith('.') && segment !== '.playcraft'))
23
+ return false;
24
+ if (segments[0] === '.playcraft' && path !== '.playcraft/workspace.json')
25
+ return false;
26
+ if (segments.some(segment => /(?:secret|credential|private[-_.]?key|\.pem$|\.key$)/i.test(segment)))
27
+ return false;
28
+ return inputExtension.test(path);
29
+ }
30
+ export async function readParameterAgentFile(root, path, limit = 2 * 1024 * 1024) {
31
+ const parsed = workspaceRelativePathSchema.safeParse(path);
32
+ if (!parsed.success)
33
+ throw new ParameterAgentError('parameter_agent_path_invalid');
34
+ const base = await realpath(root);
35
+ let target = base;
36
+ const segments = parsed.data.split('/');
37
+ for (let index = 0; index < segments.length; index++) {
38
+ target = resolve(target, segments[index]);
39
+ const stat = await lstat(target);
40
+ if (stat.isSymbolicLink() || (index < segments.length - 1 ? !stat.isDirectory() : !stat.isFile()))
41
+ throw new ParameterAgentError('parameter_agent_path_invalid');
42
+ if (index === segments.length - 1 && stat.size > limit)
43
+ throw new ParameterAgentError('parameter_agent_input_too_large');
44
+ }
45
+ const rel = relative(base, await realpath(target));
46
+ if (rel === '..' || rel.startsWith(`..${sep}`))
47
+ throw new ParameterAgentError('parameter_agent_path_invalid');
48
+ const bytes = await readFile(target);
49
+ if (bytes.byteLength > limit)
50
+ throw new ParameterAgentError('parameter_agent_input_too_large');
51
+ return bytes;
52
+ }
53
+ export async function parameterInputSnapshot(root) {
54
+ const base = await realpath(root);
55
+ const sources = [];
56
+ let total = 0;
57
+ let directories = 0;
58
+ const walk = async (path, depth) => {
59
+ if (depth > 24 || ++directories > 4096)
60
+ throw new ParameterAgentError('parameter_agent_input_limit');
61
+ const entries = await readdir(resolve(base, path), { withFileTypes: true });
62
+ if (entries.length > 4096)
63
+ throw new ParameterAgentError('parameter_agent_input_limit');
64
+ for (const entry of entries) {
65
+ const child = path ? `${path}/${entry.name}` : entry.name;
66
+ if (entry.isSymbolicLink())
67
+ continue;
68
+ if (entry.isDirectory()) {
69
+ if (skippedDirectories.has(entry.name) || (entry.name.startsWith('.') && entry.name !== '.playcraft'))
70
+ continue;
71
+ if (child.startsWith('.playcraft/'))
72
+ continue;
73
+ await walk(child, depth + 1);
74
+ }
75
+ else if (entry.isFile() && parameterInputPath(child)) {
76
+ const bytes = await readParameterAgentFile(base, child);
77
+ total += bytes.byteLength;
78
+ if (total > 32 * 1024 * 1024 || sources.length >= 2048)
79
+ throw new ParameterAgentError('parameter_agent_input_limit');
80
+ sources.push({ path: child, sha256: parameterDigest(bytes) });
81
+ }
82
+ }
83
+ };
84
+ await walk('', 0);
85
+ sources.sort((left, right) => left.path.localeCompare(right.path));
86
+ return { sources, digest: parameterDigest(JSON.stringify(sources)) };
87
+ }
@@ -0,0 +1,29 @@
1
+ /** Versioned product protocol reference, compiled into the published CLI (Command Line Interface, 命令行接口). */
2
+ export const PARAMETER_AGENT_PROTOCOL = `PlayCraft parameter integration protocol v1
3
+ This is a product configuration reference, not a skill activation. Preserve the current project skill lock and permission/work modes.
4
+ Work only in the current authorized engineering directory. Never edit Remix Editor or a different workspace directory.
5
+
6
+ Configuration:
7
+ - Reuse existing schema and sparse theme data. .playcraft/workspace.json may declare parameters:{schemaPath,dataPath,themeId}; preserve all unrelated manifest keys. parameters accepts only those three keys.
8
+ - A schema already declares configurable fields. Do not add a parallel xplatform.parameter protocol. Reuse existing field paths and synonymous parameters first.
9
+ - For a new project, create a schema, a sparse data file ({} is valid), matching platform text, and explicit manifest paths. Use gamePlay.preloaded / gamePlay.sections / gamePlay.settlement for the three-screen tree. Preserve existing project paths instead of migrating gratuitously.
10
+ - Object nodes use type:object/properties; leaves declare a supported primitive type and a valid default. Preserve units, known bounds/enum and meaning; do not invent verified limits without code evidence. Basic leaves support string, finite number, integer, boolean and null. Do not add unsupported required/$ref/conditional composition merely for this workflow.
11
+ - Missing theme values inherit schema defaults; editing creates a raw override and restoring defaults removes that override. Never flatten resolved values into theme.data. Preserve comments, unknown data and unrelated overrides.
12
+ - Platform labels use i18n.platform.json5 beside the schema, same tree, with _self.title/description text for zh/en. Preserve xplatform.ext.readonly and xplatform.showIf/showIfAny. Resources (xplatform.value_type:path), multilingual references (i18n), arrays and complex structures retain their semantics; do not disguise them as primitive controls.
13
+
14
+ Consumption and checks:
15
+ - Modify real game consumers and the neutral configuration resolver, shared by production and preview. A schema-only field or a new getter with no real consumer is incomplete.
16
+ - The development preview runtime is separate from advertising code. Do not route debugging through advertising configuration caches. Production must not depend on the preview runtime.
17
+ - Instant capability requires real field registration and a version-matched preview handshake. Reports cannot declare live capability. A newly integrated field may require one preview build first.
18
+ - Add or extend package.json script test:parameters with meaningful tests of schema/default/sparse overrides and real consumer behavior; changing the requested value must affect its consumer. Preserve an existing test script. Ensure the production build script build exists and succeeds. Optional typecheck is also run by the platform.
19
+ - The platform independently runs only pnpm run test:parameters, pnpm run build, and optional pnpm run typecheck. Reports cannot choose executable commands. Do not replace checks with successful echo statements or self-reported flags.
20
+ - Do not claim a complete production module isolation proof without a trustworthy module report. Passing build/checks proves only their stated coverage.
21
+
22
+ Report JSON contract (no second copy of parameter values):
23
+ {version:1,taskId:"<exact platform task id>",sources:[{path:"relative/input.ts",sha256:"<current raw-file SHA-256>"}],candidates:[{id:"stable-id",title:"User-facing title",description:"Optional purpose",fieldPath:["gamePlay","sections","board","speed"],origins:[{path:"relative/input.ts",line:1,summary:"Actual origin evidence"}],consumers:[{path:"src/game.ts",line:1,summary:"Actual production consumer and behavior"}],status:"candidate",missing:["Concrete incomplete steps"]}]}
24
+ - Candidate status is exactly candidate, implemented, or blocked. Completed integration candidates use implemented with an empty missing array; discovery keeps candidates unimplemented.
25
+ - Include current file digests for every origin and consumer, and the actual schema/data/platform labels/manifest/check scripts/package and lock files when present. Paths must stay inside the engineering directory. Do not read credentials, dependency trees or build outputs as evidence.
26
+ - Summaries and source snippets are evidence claims, not proof of semantics. The platform checks current files, declared defaults, write support, and independently executed check exits.
27
+ - No passed/ready/live/checks fields are accepted in the report. All implementation and validation statements remain claims until platform verification.
28
+ - If blocked or partial, preserve reviewable changes and list concrete missing work. Do not overwrite later user edits or claim multi-file changes were atomic.
29
+ `;