@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.
- package/dist/workspace-runtime/batch/deliver.js +105 -87
- package/dist/workspace-runtime/batch/prepare.js +169 -56
- package/dist/workspace-runtime/batch/source-capture.js +7 -20
- package/dist/workspace-runtime/batch/source-publish.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +19 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
- package/dist/workspace-runtime/codex/tool-response.js +10 -0
- package/dist/workspace-runtime/codex/ui-tools.js +22 -5
- package/dist/workspace-runtime/previews/diagnostics.js +44 -0
- package/dist/workspace-runtime/previews/publish.js +29 -8
- package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
- package/dist/workspace-runtime/serve.js +162 -4
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/asset-transfers.js +63 -0
- package/dist/workspace-runtime/server/dispatch.js +328 -44
- package/dist/workspace-runtime/server/http.js +193 -137
- package/dist/workspace-runtime/server/operation-record.js +33 -0
- package/dist/workspace-runtime/server/submit-operation.js +41 -0
- package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
- package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
- package/dist/workspace-runtime/workspaces/delivery.js +64 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
- package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
- package/dist/workspace-runtime/workspaces/directories.js +9 -4
- package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
- package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
- package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
- package/dist/workspace-runtime/workspaces/parameters.js +77 -54
- package/package.json +3 -3
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, lstat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parameterAgentTaskRecordSchema, parameterAgentTaskRequestSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { ParameterAgentError, parameterDigest } from './parameter-agent-inputs.js';
|
|
6
|
+
export function parameterTaskWorkspace(state, workspaceId) {
|
|
7
|
+
if (!workspaceId && state.config.workspaceMode === 'multi')
|
|
8
|
+
throw new ParameterAgentError('parameter_agent_scope_invalid');
|
|
9
|
+
return workspaceId ?? 'local';
|
|
10
|
+
}
|
|
11
|
+
async function taskDirectory(state, workspaceId, directoryName, root) {
|
|
12
|
+
const parent = resolve(state.config.stateDir, 'parameter-agent');
|
|
13
|
+
if (root) {
|
|
14
|
+
const rel = relative(resolve(root), parent);
|
|
15
|
+
if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)))
|
|
16
|
+
throw new ParameterAgentError('parameter_agent_state_not_private');
|
|
17
|
+
}
|
|
18
|
+
const scope = [state.config.environment, state.config.userId, state.config.runtimeId, workspaceId, directoryName];
|
|
19
|
+
const directory = join(parent, parameterDigest(JSON.stringify(scope)));
|
|
20
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
21
|
+
if ((await lstat(parent)).isSymbolicLink() || (await lstat(directory)).isSymbolicLink())
|
|
22
|
+
throw new ParameterAgentError('parameter_agent_state_invalid');
|
|
23
|
+
return directory;
|
|
24
|
+
}
|
|
25
|
+
export async function readPrivateParameterTask(state, workspaceId, directoryName, taskId, root) {
|
|
26
|
+
if (!/^[a-f0-9-]{36}$/i.test(taskId))
|
|
27
|
+
throw new ParameterAgentError('parameter_agent_task_invalid');
|
|
28
|
+
const file = join(await taskDirectory(state, workspaceId, directoryName, root), `${taskId}.json`);
|
|
29
|
+
try {
|
|
30
|
+
const stat = await lstat(file);
|
|
31
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
|
|
32
|
+
throw new ParameterAgentError('parameter_agent_state_invalid');
|
|
33
|
+
const value = JSON.parse(await readFile(file, 'utf8'));
|
|
34
|
+
value.record = parameterAgentTaskRecordSchema.parse(value.record);
|
|
35
|
+
value.request = parameterAgentTaskRequestSchema.parse(value.request);
|
|
36
|
+
if (value.record.taskId !== taskId || value.request.taskId !== taskId || value.record.workspaceId !== workspaceId || value.record.directoryName !== directoryName || !/^[a-f0-9]{64}$/.test(value.initialDigest))
|
|
37
|
+
throw new ParameterAgentError('parameter_agent_state_invalid');
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT')
|
|
42
|
+
return undefined;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function writePrivateParameterTask(state, task, root) {
|
|
47
|
+
const directory = await taskDirectory(state, task.record.workspaceId, task.record.directoryName, root);
|
|
48
|
+
const record = parameterAgentTaskRecordSchema.parse(task.record);
|
|
49
|
+
const temporary = join(directory, `.${record.taskId}.${randomUUID()}.tmp`);
|
|
50
|
+
await writeFile(temporary, JSON.stringify({ ...task, record }), { mode: 0o600 });
|
|
51
|
+
await rename(temporary, join(directory, `${record.taskId}.json`));
|
|
52
|
+
}
|
|
53
|
+
export async function listPrivateParameterTaskIds(state, workspaceId, directoryName, root) {
|
|
54
|
+
const names = (await readdir(await taskDirectory(state, workspaceId, directoryName, root))).filter(name => /^[a-f0-9-]{36}\.json$/i.test(name));
|
|
55
|
+
if (names.length > 256)
|
|
56
|
+
throw new ParameterAgentError('parameter_agent_task_limit');
|
|
57
|
+
return names.map(name => name.slice(0, -5));
|
|
58
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { parameterAgentReportSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, parameterCatalog, stableStringify, validateParameterChange, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { readWorkspaceParameters } from './parameters.js';
|
|
4
|
+
import { revisionScope } from './registry.js';
|
|
5
|
+
import { runDeveloperCommand } from './developer-command.js';
|
|
6
|
+
import { PARAMETER_AGENT_PROTOCOL } from './parameter-agent-protocol.js';
|
|
7
|
+
import { ParameterAgentError, parameterDigest, parameterInputSnapshot, readParameterAgentFile } from './parameter-agent-inputs.js';
|
|
8
|
+
import { listPrivateParameterTaskIds, parameterTaskWorkspace, readPrivateParameterTask, writePrivateParameterTask } from './parameter-agent-store.js';
|
|
9
|
+
export { ParameterAgentError } from './parameter-agent-inputs.js';
|
|
10
|
+
const reportPath = (taskId) => `.playcraft/parameter-admission/${taskId}.json`;
|
|
11
|
+
const now = () => new Date().toISOString();
|
|
12
|
+
function scope(state, operation, requireSession = true) {
|
|
13
|
+
const workspaceId = parameterTaskWorkspace(state, operation.workspaceId);
|
|
14
|
+
const directoryName = operation.directoryName ?? 'basic';
|
|
15
|
+
if ((requireSession && !operation.sessionId) || directoryName === 'root' || !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
|
|
16
|
+
throw new ParameterAgentError('parameter_agent_scope_invalid');
|
|
17
|
+
if (operation.environment !== state.config.environment || operation.userId !== state.config.userId
|
|
18
|
+
|| (operation.generation !== undefined && operation.generation !== state.config.generation)
|
|
19
|
+
|| (operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== state.runtimeEpoch))
|
|
20
|
+
throw new ParameterAgentError('parameter_agent_scope_expired');
|
|
21
|
+
return { workspaceId, directoryName, sessionId: operation.sessionId ?? '' };
|
|
22
|
+
}
|
|
23
|
+
function current(state, task) {
|
|
24
|
+
return task.record.generation === state.config.generation && task.record.runtimeEpoch === state.runtimeEpoch;
|
|
25
|
+
}
|
|
26
|
+
async function update(state, task, patch, root) {
|
|
27
|
+
task.record = { ...task.record, ...patch, updatedAt: now() };
|
|
28
|
+
await writePrivateParameterTask(state, task, root);
|
|
29
|
+
return task.record;
|
|
30
|
+
}
|
|
31
|
+
async function inspectReport(root, task, snapshot) {
|
|
32
|
+
const bytes = await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024);
|
|
33
|
+
let report;
|
|
34
|
+
try {
|
|
35
|
+
report = parameterAgentReportSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new ParameterAgentError('parameter_agent_report_invalid');
|
|
39
|
+
}
|
|
40
|
+
if (report.taskId !== task.record.taskId)
|
|
41
|
+
throw new ParameterAgentError('parameter_agent_report_task_mismatch');
|
|
42
|
+
const inputs = new Map(snapshot.sources.map(source => [source.path, source.sha256]));
|
|
43
|
+
const declared = new Map(report.sources.map(source => [source.path, source.sha256]));
|
|
44
|
+
for (const source of report.sources) {
|
|
45
|
+
if (!inputs.has(source.path))
|
|
46
|
+
throw new ParameterAgentError('parameter_agent_evidence_invalid');
|
|
47
|
+
if (inputs.get(source.path) !== source.sha256)
|
|
48
|
+
throw new ParameterAgentError('parameter_agent_report_stale');
|
|
49
|
+
}
|
|
50
|
+
for (const candidate of report.candidates) {
|
|
51
|
+
for (const evidence of [...candidate.origins, ...candidate.consumers]) {
|
|
52
|
+
if (!declared.has(evidence.path))
|
|
53
|
+
throw new ParameterAgentError('parameter_agent_evidence_missing');
|
|
54
|
+
const content = await readParameterAgentFile(root, evidence.path);
|
|
55
|
+
if (!content.toString('utf8').trim() || (evidence.line && evidence.line > content.toString('utf8').split('\n').length))
|
|
56
|
+
throw new ParameterAgentError('parameter_agent_evidence_invalid');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return report;
|
|
60
|
+
}
|
|
61
|
+
export async function prepareParameterAgentTask(state, operation, value, root) {
|
|
62
|
+
const request = parameterAgentTaskRequestSchema.parse(value);
|
|
63
|
+
if (operation.kind !== 'session.turn' || operation.clientRequestId !== request.taskId)
|
|
64
|
+
throw new ParameterAgentError('parameter_agent_task_identity_invalid');
|
|
65
|
+
const owner = scope(state, operation);
|
|
66
|
+
const existing = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, request.taskId, root);
|
|
67
|
+
if (existing && (existing.record.sessionId !== owner.sessionId || !current(state, existing) || stableStringify(existing.request) !== stableStringify(request)))
|
|
68
|
+
throw new ParameterAgentError('parameter_agent_task_conflict');
|
|
69
|
+
const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
|
|
70
|
+
if (request.sourceFingerprint && request.sourceFingerprint !== document.sourceFingerprint)
|
|
71
|
+
throw new ParameterAgentError('parameter_source_conflict');
|
|
72
|
+
let selected;
|
|
73
|
+
if (request.candidate) {
|
|
74
|
+
const parent = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, request.candidate.taskId, root);
|
|
75
|
+
if (!parent || !current(state, parent) || ['running', 'uncertain', 'interrupted'].includes(parent.record.status))
|
|
76
|
+
throw new ParameterAgentError('parameter_agent_candidate_unavailable');
|
|
77
|
+
const snapshot = await parameterInputSnapshot(root);
|
|
78
|
+
const report = await inspectReport(root, parent, snapshot);
|
|
79
|
+
selected = report.candidates.find(candidate => candidate.id === request.candidate.candidateId);
|
|
80
|
+
if (!selected || !parent.record.sourceDigest || parent.record.sourceDigest !== snapshot.digest)
|
|
81
|
+
throw new ParameterAgentError('parameter_agent_candidate_stale');
|
|
82
|
+
}
|
|
83
|
+
const initial = await parameterInputSnapshot(root);
|
|
84
|
+
const timestamp = now();
|
|
85
|
+
const task = existing ?? {
|
|
86
|
+
request, initialDigest: initial.digest, instanceToken: state.instanceToken,
|
|
87
|
+
record: { version: 1, taskId: request.taskId, action: request.action, ...(request.goal ? { goal: request.goal } : {}), ...owner, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, status: 'running', reportPath: reportPath(request.taskId), checks: [], createdAt: timestamp, updatedAt: timestamp },
|
|
88
|
+
};
|
|
89
|
+
if (!existing) {
|
|
90
|
+
if ((await listPrivateParameterTaskIds(state, owner.workspaceId, owner.directoryName, root)).length >= 256)
|
|
91
|
+
throw new ParameterAgentError('parameter_agent_task_limit');
|
|
92
|
+
await writePrivateParameterTask(state, task, root);
|
|
93
|
+
}
|
|
94
|
+
const instructions = [
|
|
95
|
+
PARAMETER_AGENT_PROTOCOL,
|
|
96
|
+
`Platform task identity: ${JSON.stringify({ version: 1, taskId: request.taskId, action: request.action, reportPath: task.record.reportPath })}. Write only this exact report identity.`,
|
|
97
|
+
request.action === 'discover' ? 'Discovery is read-only for business source/schema/data/manifest. Write only the requested development report. Discover actual useful candidates and consumer evidence; do not implement them yet.' : 'Complete the requested integration using existing engineering tools: reuse an existing equivalent field or implement declaration, valid defaults, sparse data semantics, labels, real production consumers and meaningful parameter tests. Leave precise blocked work if incomplete.',
|
|
98
|
+
`User goal (task data): ${JSON.stringify(request.goal ?? '')}`,
|
|
99
|
+
...(selected ? [`Selected current candidate (reference data): ${JSON.stringify(selected)}`] : []),
|
|
100
|
+
'After finishing files, calculate current raw-file hashes and write the report. The platform will execute independent checks after this turn. Do not claim those checks already passed.',
|
|
101
|
+
].join('\n\n');
|
|
102
|
+
return { instructions, task: task.record };
|
|
103
|
+
}
|
|
104
|
+
export async function finishParameterAgentTask(state, operation, status, error) {
|
|
105
|
+
if (operation.kind !== 'session.turn' || !operation.sessionId || operation.directoryName === 'root' || !/^[a-f0-9-]{36}$/i.test(operation.clientRequestId))
|
|
106
|
+
return;
|
|
107
|
+
let owner;
|
|
108
|
+
try {
|
|
109
|
+
owner = scope(state, operation);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const task = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, operation.clientRequestId);
|
|
115
|
+
if (!task || !current(state, task) || task.record.sessionId !== owner.sessionId || !['running', 'uncertain'].includes(task.record.status))
|
|
116
|
+
return;
|
|
117
|
+
await update(state, task, { status: status === 'completed' ? 'reported' : status, error: error?.slice(0, 256) });
|
|
118
|
+
}
|
|
119
|
+
async function publicTask(state, task, root, snapshot) {
|
|
120
|
+
if (!current(state, task))
|
|
121
|
+
return { ...task.record, status: 'stale', error: 'parameter_agent_runtime_expired' };
|
|
122
|
+
try {
|
|
123
|
+
const report = await inspectReport(root, task, snapshot);
|
|
124
|
+
if (task.record.sourceDigest && task.record.sourceDigest !== snapshot.digest)
|
|
125
|
+
return { ...task.record, report, status: 'stale', error: 'parameter_agent_inputs_changed' };
|
|
126
|
+
const bytes = await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024);
|
|
127
|
+
if (task.reportHash && task.reportHash !== parameterDigest(bytes))
|
|
128
|
+
return { ...task.record, report, status: 'stale', error: 'parameter_agent_report_changed' };
|
|
129
|
+
return { ...task.record, report };
|
|
130
|
+
}
|
|
131
|
+
catch (cause) {
|
|
132
|
+
const code = errorCode(cause);
|
|
133
|
+
if (code === 'ENOENT' && ['running', 'uncertain', 'interrupted', 'failed'].includes(task.record.status))
|
|
134
|
+
return task.record;
|
|
135
|
+
return { ...task.record, status: code === 'ENOENT' ? 'missing' : code === 'parameter_agent_report_stale' ? 'stale' : 'invalid', error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function listParameterAgentTasks(state, workspaceId, directoryName, root) {
|
|
139
|
+
if (directoryName === 'root')
|
|
140
|
+
return [];
|
|
141
|
+
const workspace = parameterTaskWorkspace(state, workspaceId);
|
|
142
|
+
const ids = await listPrivateParameterTaskIds(state, workspace, directoryName, root);
|
|
143
|
+
if (!ids.length)
|
|
144
|
+
return [];
|
|
145
|
+
const snapshot = await parameterInputSnapshot(root);
|
|
146
|
+
const tasks = [];
|
|
147
|
+
for (const id of ids) {
|
|
148
|
+
let task = await readPrivateParameterTask(state, workspace, directoryName, id, root);
|
|
149
|
+
if (task && ['running', 'uncertain'].includes(task.record.status) && current(state, task)) {
|
|
150
|
+
const operation = state.journal.listOperations?.().map(item => item.operation).find(item => item.kind === 'session.turn'
|
|
151
|
+
&& item.clientRequestId === id && item.sessionId === task.record.sessionId
|
|
152
|
+
&& parameterTaskWorkspace(state, item.workspaceId) === workspace && (item.directoryName ?? 'basic') === directoryName);
|
|
153
|
+
if (operation && ['completed', 'failed', 'interrupted', 'uncertain'].includes(operation.status)) {
|
|
154
|
+
await finishParameterAgentTask(state, operation, operation.status, operation.error);
|
|
155
|
+
task = await readPrivateParameterTask(state, workspace, directoryName, id, root);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (task && current(state, task) && task.instanceToken !== state.instanceToken) {
|
|
159
|
+
if (task.record.status === 'checking') {
|
|
160
|
+
await update(state, task, { status: 'needs-verification', receiptId: undefined, error: 'parameter_agent_check_interrupted' }, root);
|
|
161
|
+
}
|
|
162
|
+
else if (task.record.status === 'running') {
|
|
163
|
+
await update(state, task, { status: 'uncertain', receiptId: undefined, error: 'parameter_agent_turn_unconfirmed_after_restart' }, root);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (task)
|
|
167
|
+
tasks.push(await publicTask(state, task, root, snapshot));
|
|
168
|
+
}
|
|
169
|
+
return tasks.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
170
|
+
}
|
|
171
|
+
function errorCode(cause) {
|
|
172
|
+
return cause instanceof Error && 'code' in cause && typeof cause.code === 'string' ? cause.code : 'parameter_agent_verification_failed';
|
|
173
|
+
}
|
|
174
|
+
export async function verifyParameterAgentTask(state, operation, payload, root, options = {}) {
|
|
175
|
+
if (operation.kind !== 'parameter.verify')
|
|
176
|
+
throw new ParameterAgentError('parameter_agent_task_identity_invalid');
|
|
177
|
+
const owner = scope(state, operation, false);
|
|
178
|
+
const { taskId } = parameterAgentVerifyPayloadSchema.parse(payload);
|
|
179
|
+
const task = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, taskId, root);
|
|
180
|
+
if (!task)
|
|
181
|
+
throw new ParameterAgentError('parameter_agent_task_unknown');
|
|
182
|
+
const instanceToken = state.instanceToken;
|
|
183
|
+
const assertActive = () => {
|
|
184
|
+
options.signal?.throwIfAborted();
|
|
185
|
+
options.assertActive?.();
|
|
186
|
+
if (!current(state, task) || state.journal.destroyed() || instanceToken !== state.instanceToken)
|
|
187
|
+
throw new ParameterAgentError('parameter_agent_scope_expired');
|
|
188
|
+
};
|
|
189
|
+
assertActive();
|
|
190
|
+
if (['running', 'uncertain', 'interrupted'].includes(task.record.status))
|
|
191
|
+
throw new ParameterAgentError('parameter_agent_turn_not_completed');
|
|
192
|
+
try {
|
|
193
|
+
const before = await parameterInputSnapshot(root);
|
|
194
|
+
const report = await inspectReport(root, task, before);
|
|
195
|
+
task.record.report = report;
|
|
196
|
+
task.reportHash = parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024));
|
|
197
|
+
if (task.request.action === 'discover') {
|
|
198
|
+
if (task.initialDigest !== before.digest)
|
|
199
|
+
throw new ParameterAgentError('parameter_agent_discovery_modified_sources');
|
|
200
|
+
assertActive();
|
|
201
|
+
if ((await parameterInputSnapshot(root)).digest !== before.digest || parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024)) !== task.reportHash)
|
|
202
|
+
throw new ParameterAgentError('parameter_agent_inputs_changed');
|
|
203
|
+
return update(state, task, { status: 'candidates', sourceDigest: before.digest, checks: [], error: undefined, receiptId: undefined }, root);
|
|
204
|
+
}
|
|
205
|
+
const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
|
|
206
|
+
if (document.status !== 'ready')
|
|
207
|
+
throw new ParameterAgentError('parameter_agent_parameters_not_ready');
|
|
208
|
+
const catalog = parameterCatalog(document.schema, document.data);
|
|
209
|
+
const implemented = report.candidates.filter(candidate => candidate.status === 'implemented');
|
|
210
|
+
if (!implemented.length || report.candidates.some(candidate => candidate.status !== 'implemented' || candidate.missing.length))
|
|
211
|
+
throw new ParameterAgentError('parameter_agent_integration_incomplete');
|
|
212
|
+
const evidence = new Set(report.sources.map(source => source.path));
|
|
213
|
+
for (const path of [document.schemaPath, document.dataPath, document.platformI18nPath, 'package.json']) {
|
|
214
|
+
if (path && !evidence.has(path))
|
|
215
|
+
throw new ParameterAgentError('parameter_agent_evidence_missing');
|
|
216
|
+
}
|
|
217
|
+
for (const candidate of implemented) {
|
|
218
|
+
const field = catalog.find(entry => candidate.fieldPath && entry.key === candidate.fieldPath.join('.'));
|
|
219
|
+
if (!field || !field.editable || !field.canReset)
|
|
220
|
+
throw new ParameterAgentError('parameter_agent_field_not_ready');
|
|
221
|
+
if (field.hasOverride && validateParameterChange(document.schema, document.data, { fieldPath: field.fieldPath, value: field.value }))
|
|
222
|
+
throw new ParameterAgentError('parameter_agent_field_not_ready');
|
|
223
|
+
if (!candidate.consumers.length)
|
|
224
|
+
throw new ParameterAgentError('parameter_agent_consumer_missing');
|
|
225
|
+
}
|
|
226
|
+
const packageData = JSON.parse((await readParameterAgentFile(root, 'package.json')).toString('utf8'));
|
|
227
|
+
for (const script of ['test:parameters', 'build']) {
|
|
228
|
+
if (typeof packageData.scripts?.[script] !== 'string' || !packageData.scripts[script].trim())
|
|
229
|
+
throw new ParameterAgentError('parameter_agent_check_script_missing');
|
|
230
|
+
}
|
|
231
|
+
task.instanceToken = state.instanceToken;
|
|
232
|
+
await update(state, task, { status: 'checking', checks: [], error: undefined, receiptId: undefined, sourceDigest: before.digest }, root);
|
|
233
|
+
const scripts = ['test:parameters', 'build', ...(typeof packageData.scripts?.typecheck === 'string' ? ['typecheck'] : [])];
|
|
234
|
+
for (const script of scripts) {
|
|
235
|
+
assertActive();
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const abort = () => controller.abort();
|
|
238
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
239
|
+
let activityError;
|
|
240
|
+
const timer = setInterval(() => { try {
|
|
241
|
+
assertActive();
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
activityError = error;
|
|
245
|
+
controller.abort();
|
|
246
|
+
} }, 200);
|
|
247
|
+
const startedAt = now();
|
|
248
|
+
const chunks = [];
|
|
249
|
+
let outputSize = 0;
|
|
250
|
+
let outcome;
|
|
251
|
+
try {
|
|
252
|
+
outcome = await (options.runCommand ?? runDeveloperCommand)(state, {
|
|
253
|
+
processId: `parameter-check-${taskId}-${script.replace(':', '-')}`, cwd: root,
|
|
254
|
+
command: `pnpm run ${script}`, timeoutMs: options.timeoutMs ?? 120_000, signal: controller.signal,
|
|
255
|
+
onOutput(chunk) { const bytes = Buffer.from(chunk); outputSize += bytes.length; if (outputSize <= 1024 * 1024)
|
|
256
|
+
chunks.push(bytes); },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
clearInterval(timer);
|
|
261
|
+
options.signal?.removeEventListener('abort', abort);
|
|
262
|
+
}
|
|
263
|
+
if (activityError)
|
|
264
|
+
throw activityError;
|
|
265
|
+
assertActive();
|
|
266
|
+
task.record.checks.push({ script: script, code: outcome.code ?? null, timedOut: outcome.timedOut, ...(outcome.canceled ? { canceled: true } : {}), logDigest: parameterDigest(Buffer.concat(chunks)), startedAt, finishedAt: now() });
|
|
267
|
+
await writePrivateParameterTask(state, task, root);
|
|
268
|
+
if (outcome.code !== 0 || outcome.timedOut || outcome.canceled)
|
|
269
|
+
throw new ParameterAgentError(outcome.timedOut ? 'parameter_agent_check_timeout' : outcome.canceled ? 'parameter_agent_check_canceled' : 'parameter_agent_check_failed');
|
|
270
|
+
}
|
|
271
|
+
assertActive();
|
|
272
|
+
const after = await parameterInputSnapshot(root);
|
|
273
|
+
if (after.digest !== before.digest || parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024)) !== task.reportHash)
|
|
274
|
+
throw new ParameterAgentError('parameter_agent_inputs_changed');
|
|
275
|
+
return update(state, task, { status: 'verified', sourceDigest: after.digest, receiptId: randomUUID(), error: undefined }, root);
|
|
276
|
+
}
|
|
277
|
+
catch (cause) {
|
|
278
|
+
const code = errorCode(cause);
|
|
279
|
+
const status = code === 'ENOENT' ? 'missing' : /stale|changed|expired/.test(code) ? 'stale' : /(?:not_ready|incomplete|consumer_missing|script_missing|evidence_missing)$/.test(code) ? 'needs-verification' : /report_|evidence_invalid|path_invalid/.test(code) ? 'invalid' : 'failed';
|
|
280
|
+
return update(state, task, { status, error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code, receiptId: undefined }, root);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { relative, resolve, sep } from 'node:path';
|
|
3
4
|
import JSON5 from 'json5';
|
|
4
|
-
import { parameterDocumentSchema, parameterPatchPayloadSchema, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { parameterDocumentSchema, parameterPatchPayloadSchema, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
6
|
import { InvalidWorkspacePathError, writeWorkspaceFile, } from './files.js';
|
|
6
|
-
import {
|
|
7
|
+
import { editJson5Paths, ParameterEditError } from './json5-edit.js';
|
|
7
8
|
const MANIFEST_PATH = '.playcraft/workspace.json';
|
|
8
9
|
const DEFAULT_SCHEMA_PATH = 'src/theme/theme.schema.json5';
|
|
9
10
|
const THEME_INDEX_PATH = 'src/theme/index.ts';
|
|
10
11
|
const THEME_ROOT = 'src/theme';
|
|
11
12
|
export async function readWorkspaceParameters(root, revisions) {
|
|
12
|
-
|
|
13
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
14
|
+
const located = await locateParameterFiles(root);
|
|
15
|
+
const before = await sourceFingerprint(root, located);
|
|
16
|
+
const document = await readParameterDocuments(root, revisions, located);
|
|
17
|
+
const after = await sourceFingerprint(root, await locateParameterFiles(root));
|
|
18
|
+
if (before === after)
|
|
19
|
+
return parameterDocumentSchema.parse({ ...document, sourceFingerprint: after });
|
|
20
|
+
}
|
|
21
|
+
throw new ParameterEditError('parameter_source_changed');
|
|
22
|
+
}
|
|
23
|
+
async function sourceFingerprint(root, located) {
|
|
24
|
+
const sources = await Promise.all([
|
|
25
|
+
located.schemaPath ? readOptionalText(root, located.schemaPath) : undefined,
|
|
26
|
+
located.schemaPath ? readOptionalText(root, platformI18nPath(located.schemaPath)) : undefined,
|
|
27
|
+
readOptionalText(root, MANIFEST_PATH),
|
|
28
|
+
readOptionalText(root, THEME_INDEX_PATH),
|
|
29
|
+
]);
|
|
30
|
+
return createHash('sha256').update(JSON.stringify({
|
|
31
|
+
schemaPath: located.schemaPath ?? null,
|
|
32
|
+
dataPath: located.dataPath ?? null,
|
|
33
|
+
themeId: located.themeId ?? null,
|
|
34
|
+
sources: sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex')),
|
|
35
|
+
})).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
async function readParameterDocuments(root, revisions, located) {
|
|
13
38
|
if (located.errors.some((item) => item.reason === 'parameter_manifest_invalid')) {
|
|
14
39
|
return parameterDocumentSchema.parse({ status: 'invalid', errors: located.errors });
|
|
15
40
|
}
|
|
@@ -35,65 +60,88 @@ export async function readWorkspaceParameters(root, revisions) {
|
|
|
35
60
|
errors: [{ path: located.schemaPath, reason: schemaFile.reason }],
|
|
36
61
|
});
|
|
37
62
|
}
|
|
63
|
+
const i18nPath = platformI18nPath(located.schemaPath);
|
|
64
|
+
const platformFile = await readOptionalJson5(root, i18nPath);
|
|
65
|
+
const schemaDocument = {
|
|
66
|
+
schemaPath: located.schemaPath,
|
|
67
|
+
themeId: located.themeId,
|
|
68
|
+
schema: schemaFile.value,
|
|
69
|
+
schemaHash: schemaFile.contentHash,
|
|
70
|
+
schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
|
|
71
|
+
...(platformFile.status === 'ok' ? { platformI18n: platformFile.value, platformI18nPath: i18nPath } : {}),
|
|
72
|
+
};
|
|
73
|
+
if (platformFile.status === 'invalid') {
|
|
74
|
+
return parameterDocumentSchema.parse({
|
|
75
|
+
...schemaDocument,
|
|
76
|
+
status: 'invalid',
|
|
77
|
+
errors: [{ path: i18nPath, reason: platformFile.reason }],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
38
80
|
if (!located.dataPath) {
|
|
39
81
|
return parameterDocumentSchema.parse({
|
|
82
|
+
...schemaDocument,
|
|
40
83
|
status: 'schema_only',
|
|
41
|
-
schemaPath: located.schemaPath,
|
|
42
|
-
themeId: located.themeId,
|
|
43
|
-
schema: schemaFile.value,
|
|
44
|
-
schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
|
|
45
84
|
...(located.errors.length > 0 ? { errors: located.errors } : {}),
|
|
46
85
|
});
|
|
47
86
|
}
|
|
48
87
|
const dataFile = await readOptionalJson5(root, located.dataPath);
|
|
49
88
|
if (dataFile.status === 'missing') {
|
|
50
89
|
return parameterDocumentSchema.parse({
|
|
90
|
+
...schemaDocument,
|
|
51
91
|
status: 'schema_only',
|
|
52
|
-
schemaPath: located.schemaPath,
|
|
53
92
|
dataPath: located.dataPath,
|
|
54
|
-
themeId: located.themeId,
|
|
55
|
-
schema: schemaFile.value,
|
|
56
|
-
schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
|
|
57
93
|
});
|
|
58
94
|
}
|
|
59
95
|
if (dataFile.status === 'invalid') {
|
|
60
96
|
return parameterDocumentSchema.parse({
|
|
97
|
+
...schemaDocument,
|
|
61
98
|
status: 'invalid',
|
|
62
|
-
schemaPath: located.schemaPath,
|
|
63
99
|
dataPath: located.dataPath,
|
|
64
|
-
themeId: located.themeId,
|
|
65
|
-
schema: schemaFile.value,
|
|
66
|
-
schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
|
|
67
100
|
errors: [{ path: located.dataPath, reason: dataFile.reason }],
|
|
68
101
|
});
|
|
69
102
|
}
|
|
70
103
|
return parameterDocumentSchema.parse({
|
|
104
|
+
...schemaDocument,
|
|
71
105
|
status: 'ready',
|
|
72
|
-
schemaPath: located.schemaPath,
|
|
73
106
|
dataPath: located.dataPath,
|
|
74
|
-
themeId: located.themeId,
|
|
75
|
-
schema: schemaFile.value,
|
|
76
107
|
data: dataFile.value,
|
|
77
|
-
|
|
108
|
+
dataHash: dataFile.contentHash,
|
|
78
109
|
dataRevision: revisions[located.dataPath]?.revision ?? 0,
|
|
79
110
|
});
|
|
80
111
|
}
|
|
81
|
-
export async function patchWorkspaceParameters(root, revisions, payload) {
|
|
112
|
+
export async function patchWorkspaceParameters(root, revisions, payload, observed) {
|
|
82
113
|
const parsed = parameterPatchPayloadSchema.parse(payload);
|
|
83
114
|
const document = await readWorkspaceParameters(root, revisions);
|
|
115
|
+
if (document.status === 'invalid')
|
|
116
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
84
117
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
85
118
|
throw new ParameterEditError('parameter_data_missing');
|
|
86
119
|
}
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint)
|
|
121
|
+
|| (observed && observed.dataPath !== document.dataPath)) {
|
|
122
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
123
|
+
}
|
|
124
|
+
const changes = 'changes' in parsed ? parsed.changes : [{ fieldPath: parsed.fieldPath, value: parsed.value }];
|
|
125
|
+
for (const change of changes) {
|
|
126
|
+
const validationError = validateParameterChange(document.schema, document.data, change);
|
|
127
|
+
if (validationError)
|
|
128
|
+
throw new ParameterEditError(validationError);
|
|
129
|
+
}
|
|
92
130
|
const source = await readOptionalText(root, document.dataPath);
|
|
93
131
|
if (source === undefined)
|
|
94
132
|
throw new ParameterEditError('parameter_data_missing');
|
|
95
|
-
|
|
133
|
+
if (observed && createHash('sha256').update(source).digest('hex') !== observed.contentHash) {
|
|
134
|
+
throw new ParameterEditError('revision_conflict');
|
|
135
|
+
}
|
|
136
|
+
const next = editJson5Paths(source, changes);
|
|
96
137
|
JSON5.parse(next);
|
|
138
|
+
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX) {
|
|
139
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
140
|
+
}
|
|
141
|
+
const current = await locateParameterFiles(root);
|
|
142
|
+
if (await sourceFingerprint(root, current) !== document.sourceFingerprint) {
|
|
143
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
144
|
+
}
|
|
97
145
|
return writeWorkspaceFile(root, document.dataPath, next);
|
|
98
146
|
}
|
|
99
147
|
async function locateParameterFiles(root) {
|
|
@@ -210,7 +258,7 @@ async function readOptionalJson5(root, relativePath) {
|
|
|
210
258
|
if (text === undefined)
|
|
211
259
|
return { status: 'missing' };
|
|
212
260
|
try {
|
|
213
|
-
return { status: 'ok', value: JSON5.parse(text) };
|
|
261
|
+
return { status: 'ok', value: JSON5.parse(text), contentHash: createHash('sha256').update(text).digest('hex') };
|
|
214
262
|
}
|
|
215
263
|
catch {
|
|
216
264
|
return { status: 'invalid', reason: 'parameter_document_invalid' };
|
|
@@ -242,31 +290,6 @@ async function readOptionalText(root, relativePath) {
|
|
|
242
290
|
throw error;
|
|
243
291
|
}
|
|
244
292
|
}
|
|
245
|
-
function
|
|
246
|
-
|
|
247
|
-
for (const key of fieldPath) {
|
|
248
|
-
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
249
|
-
return 'parameter_value_invalid';
|
|
250
|
-
const properties = node.properties;
|
|
251
|
-
if (!properties || typeof properties !== 'object' || Array.isArray(properties))
|
|
252
|
-
return 'parameter_value_invalid';
|
|
253
|
-
node = properties[key];
|
|
254
|
-
if (node === undefined)
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
|
-
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
258
|
-
return undefined;
|
|
259
|
-
const type = node.type;
|
|
260
|
-
if (type === 'string')
|
|
261
|
-
return typeof value === 'string' ? undefined : 'parameter_value_invalid';
|
|
262
|
-
if (type === 'boolean')
|
|
263
|
-
return typeof value === 'boolean' ? undefined : 'parameter_value_invalid';
|
|
264
|
-
if (type === 'integer')
|
|
265
|
-
return Number.isInteger(value) ? undefined : 'parameter_value_invalid';
|
|
266
|
-
if (type === 'number') {
|
|
267
|
-
return typeof value === 'number' && Number.isFinite(value) ? undefined : 'parameter_value_invalid';
|
|
268
|
-
}
|
|
269
|
-
if (type === 'null')
|
|
270
|
-
return value === null ? undefined : 'parameter_value_invalid';
|
|
271
|
-
return undefined;
|
|
293
|
+
function platformI18nPath(schemaPath) {
|
|
294
|
+
return [...schemaPath.split('/').slice(0, -1), 'i18n.platform.json5'].join('/');
|
|
272
295
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.50-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@gltf-transform/core": "^4.3.0",
|
|
52
52
|
"@gltf-transform/extensions": "^4.3.0",
|
|
53
53
|
"@gltf-transform/functions": "^4.3.0",
|
|
54
|
-
"@playcraft/build": "0.1.
|
|
55
|
-
"@playcraft/common": "0.1.
|
|
54
|
+
"@playcraft/build": "0.1.50-beta",
|
|
55
|
+
"@playcraft/common": "0.1.50-beta",
|
|
56
56
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
57
57
|
"chokidar": "^4.0.3",
|
|
58
58
|
"commander": "^13.1.0",
|