@playcraft/cli 0.1.70 → 0.1.72
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/README.md +20 -1
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +3 -0
- package/dist/commands/skills.js +68 -4
- package/dist/commands/tools-batch.js +2 -0
- package/dist/project-skills/discovery.js +248 -0
- package/dist/project-skills/messages.js +80 -0
- package/dist/project-skills/remote-activation.js +500 -0
- package/dist/project-skills/remote-client.js +117 -0
- package/dist/project-skills/remote-commands.js +87 -0
- package/dist/project-skills/remote-prepare.js +223 -0
- package/dist/project-skills/search-command.js +47 -0
- package/dist/project-skills/search.js +96 -0
- package/dist/recording/project-digest.js +1 -1
- package/dist/references/messages.js +2 -0
- package/dist/references/protocol.js +39 -12
- package/dist/workspace-runtime/batch/freeze.js +94 -24
- package/dist/workspace-runtime/batch/records.js +12 -6
- package/dist/workspace-runtime/batch/routes.js +31 -11
- package/dist/workspace-runtime/batch/source-candidate.js +29 -16
- package/dist/workspace-runtime/batch/source-capture.js +251 -20
- package/dist/workspace-runtime/codex/app-server.js +30 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +10 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +345 -117
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
- package/dist/workspace-runtime/workspaces/files.js +59 -23
- package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
- package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
- package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
- package/dist/workspace-runtime/workspaces/skills.js +13 -176
- package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
- package/package.json +4 -3
- package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/empty/gitignore +1 -0
- package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
|
-
import { chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { WORKSPACE_REMIX_REFERENCE_MAX_BYTES, WORKSPACE_REMIX_REFERENCE_MAX_FILES, WORKSPACE_REMIX_REFERENCES_DIRECTORY, WORKSPACE_REMIX_REFERENCES_MANIFEST, remixReferenceContentHashInput, workspaceDirectoryNameSchema, workspaceRemixReferenceExportSchema, workspaceRemixReferenceSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -12,24 +12,51 @@ const ATTRIBUTES = '.gitattributes';
|
|
|
12
12
|
const SNAPSHOT_ATTRIBUTES = '* -filter -text -ident -working-tree-encoding -eol\n';
|
|
13
13
|
const INERT_DIRECTORY = '_source_metadata';
|
|
14
14
|
const MANIFEST_MAX_BYTES = 16 * 1024 * 1024;
|
|
15
|
-
const EXCLUDED = new Set([
|
|
16
|
-
|
|
17
|
-
'
|
|
15
|
+
const EXCLUDED = new Set([
|
|
16
|
+
'.git',
|
|
17
|
+
'node_modules',
|
|
18
|
+
'.next',
|
|
19
|
+
'.cache',
|
|
20
|
+
'.tmp',
|
|
21
|
+
'.turbo',
|
|
22
|
+
'.pnpm-store',
|
|
23
|
+
'.ssh',
|
|
24
|
+
'.aws',
|
|
25
|
+
]);
|
|
26
|
+
const INERT = new Set([
|
|
27
|
+
'.agents',
|
|
28
|
+
'.claude',
|
|
29
|
+
'.codex',
|
|
30
|
+
'.cursor',
|
|
31
|
+
'.playcraft',
|
|
32
|
+
'.gitignore',
|
|
33
|
+
'.gitattributes',
|
|
34
|
+
'.gitmodules',
|
|
35
|
+
'agents.md',
|
|
36
|
+
'claude.md',
|
|
37
|
+
'gemini.md',
|
|
38
|
+
'.cursorrules',
|
|
39
|
+
'.mcp.json',
|
|
40
|
+
]);
|
|
18
41
|
const locks = new Map();
|
|
19
42
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
20
43
|
export class RemixReferenceError extends Error {
|
|
21
|
-
constructor(code) {
|
|
44
|
+
constructor(code) {
|
|
45
|
+
super(code);
|
|
46
|
+
this.name = 'RemixReferenceError';
|
|
47
|
+
}
|
|
22
48
|
}
|
|
23
49
|
async function safeDirectory(root, relative = '', create = false) {
|
|
24
50
|
let current = root;
|
|
25
51
|
for (const segment of relative.split('/').filter(Boolean)) {
|
|
26
52
|
current = join(current, segment);
|
|
27
|
-
|
|
28
|
-
|
|
53
|
+
if (create) {
|
|
54
|
+
// shared
|
|
29
55
|
await mkdir(current, { mode: 0o755 }).catch((error) => {
|
|
30
56
|
if (error.code !== 'EEXIST')
|
|
31
57
|
throw error;
|
|
32
58
|
});
|
|
59
|
+
}
|
|
33
60
|
const info = await lstat(current);
|
|
34
61
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
35
62
|
throw new RemixReferenceError('remix_reference_path_conflict');
|
|
@@ -52,7 +79,10 @@ async function readFileSafely(root, path, limit) {
|
|
|
52
79
|
length += bytesRead;
|
|
53
80
|
}
|
|
54
81
|
const after = await handle.stat();
|
|
55
|
-
if (length !== before.size ||
|
|
82
|
+
if (length !== before.size ||
|
|
83
|
+
after.size !== before.size ||
|
|
84
|
+
after.mtimeMs !== before.mtimeMs ||
|
|
85
|
+
after.ctimeMs !== before.ctimeMs)
|
|
56
86
|
throw new RemixReferenceError('remix_reference_changed');
|
|
57
87
|
return data.subarray(0, length);
|
|
58
88
|
}
|
|
@@ -62,20 +92,28 @@ async function readFileSafely(root, path, limit) {
|
|
|
62
92
|
}
|
|
63
93
|
function identity(snapshot) {
|
|
64
94
|
const { source, contentHash } = snapshot;
|
|
65
|
-
|
|
95
|
+
const sourceIdentity = 'builtinTemplate' in source ? ['builtinTemplate', source.builtinTemplate] : source.projectUuid;
|
|
96
|
+
return hash(JSON.stringify([
|
|
97
|
+
source.environment,
|
|
98
|
+
sourceIdentity,
|
|
99
|
+
source.branchName,
|
|
100
|
+
source.commit,
|
|
101
|
+
contentHash,
|
|
102
|
+
]));
|
|
66
103
|
}
|
|
67
104
|
function referencePath(referenceId, commit) {
|
|
68
105
|
return `${WORKSPACE_REMIX_REFERENCES_DIRECTORY}/${referenceId.slice(0, 20)}-${commit.slice(0, 12)}`;
|
|
69
106
|
}
|
|
70
107
|
/** Project instructions and Git filters remain readable source material without becoming active configuration. */
|
|
71
108
|
function materializedPath(sourcePath) {
|
|
72
|
-
const parts = sourcePath.split('/').map(part => part.toLowerCase());
|
|
73
|
-
if (parts.some(part => EXCLUDED.has(part)) ||
|
|
74
|
-
|
|
109
|
+
const parts = sourcePath.split('/').map((part) => part.toLowerCase());
|
|
110
|
+
if (parts.some((part) => EXCLUDED.has(part)) ||
|
|
111
|
+
isCredentialFile(sourcePath) ||
|
|
112
|
+
parts.some((part) => ['.npmrc', '.yarnrc', '.netrc', '.git-credentials'].includes(part)))
|
|
75
113
|
return undefined;
|
|
76
114
|
if (parts[0] === INERT_DIRECTORY || parts[0] === RECEIPT)
|
|
77
115
|
throw new RemixReferenceError('remix_reference_path_conflict');
|
|
78
|
-
if (parts.some(part => INERT.has(part)))
|
|
116
|
+
if (parts.some((part) => INERT.has(part)))
|
|
79
117
|
return `${INERT_DIRECTORY}/${hash(sourcePath)}.txt`;
|
|
80
118
|
return sourcePath;
|
|
81
119
|
}
|
|
@@ -85,7 +123,7 @@ async function readManifest(root) {
|
|
|
85
123
|
if (value?.version !== 1 || !Array.isArray(value.items) || value.items.length > 100)
|
|
86
124
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
87
125
|
const items = value.items.map((item) => workspaceRemixReferenceSchema.parse(item));
|
|
88
|
-
if (new Set(items.map(item => item.referenceId)).size !== items.length)
|
|
126
|
+
if (new Set(items.map((item) => item.referenceId)).size !== items.length)
|
|
89
127
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
90
128
|
return items;
|
|
91
129
|
}
|
|
@@ -98,8 +136,10 @@ async function readManifest(root) {
|
|
|
98
136
|
}
|
|
99
137
|
}
|
|
100
138
|
function validateRecord(record, directoryName) {
|
|
101
|
-
if (record.directoryName !== directoryName ||
|
|
102
|
-
record.
|
|
139
|
+
if (record.directoryName !== directoryName ||
|
|
140
|
+
record.referenceId !== identity(record) ||
|
|
141
|
+
record.path !== referencePath(record.referenceId, record.source.commit) ||
|
|
142
|
+
record.name !== record.source.name)
|
|
103
143
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
104
144
|
const paths = new Set();
|
|
105
145
|
for (const file of record.files) {
|
|
@@ -107,10 +147,14 @@ function validateRecord(record, directoryName) {
|
|
|
107
147
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
108
148
|
paths.add(file.path.toLowerCase());
|
|
109
149
|
}
|
|
110
|
-
if ((record.omittedFiles ?? []).some(file => materializedPath(file.path) !== undefined))
|
|
150
|
+
if ((record.omittedFiles ?? []).some((file) => materializedPath(file.path) !== undefined))
|
|
111
151
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
112
|
-
const originalFiles = [
|
|
113
|
-
|
|
152
|
+
const originalFiles = [
|
|
153
|
+
...record.files.map((file) => ({ ...file, path: file.sourcePath })),
|
|
154
|
+
...(record.omittedFiles ?? []),
|
|
155
|
+
];
|
|
156
|
+
if (originalFiles.length > WORKSPACE_REMIX_REFERENCE_MAX_FILES ||
|
|
157
|
+
new Set(originalFiles.map((file) => file.path.toLowerCase())).size !== originalFiles.length ||
|
|
114
158
|
hash(remixReferenceContentHashInput(originalFiles)) !== record.contentHash ||
|
|
115
159
|
originalFiles.reduce((sum, file) => sum + file.size, 0) > WORKSPACE_REMIX_REFERENCE_MAX_BYTES)
|
|
116
160
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
@@ -120,8 +164,10 @@ async function verifyReferenceMetadata(root, record, directoryName, alternateDir
|
|
|
120
164
|
const target = await safeDirectory(root, record.path);
|
|
121
165
|
const receipt = JSON.parse((await readFileSafely(target, RECEIPT, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
122
166
|
const parsedReceipt = workspaceRemixReferenceSchema.parse(receipt);
|
|
123
|
-
if (JSON.stringify(parsedReceipt) !== JSON.stringify(record)
|
|
124
|
-
|
|
167
|
+
if (JSON.stringify(parsedReceipt) !== JSON.stringify(record) &&
|
|
168
|
+
(!alternateDirectory ||
|
|
169
|
+
JSON.stringify(parsedReceipt) !==
|
|
170
|
+
JSON.stringify({ ...record, directoryName: alternateDirectory })))
|
|
125
171
|
throw new RemixReferenceError('remix_reference_changed');
|
|
126
172
|
return target;
|
|
127
173
|
}
|
|
@@ -129,7 +175,7 @@ async function verifyReference(root, record, directoryName, alternateDirectory)
|
|
|
129
175
|
const target = await verifyReferenceMetadata(root, record, directoryName, alternateDirectory);
|
|
130
176
|
if ((await readFileSafely(target, ATTRIBUTES, 256)).toString('utf8') !== SNAPSHOT_ATTRIBUTES)
|
|
131
177
|
throw new RemixReferenceError('remix_reference_changed');
|
|
132
|
-
const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map(file => file.path)]);
|
|
178
|
+
const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map((file) => file.path)]);
|
|
133
179
|
const walk = async (directory, prefix = '') => {
|
|
134
180
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
135
181
|
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
@@ -165,7 +211,9 @@ export async function batchReferenceMetadata(root) {
|
|
|
165
211
|
await verifyReferenceMetadata(root, record, record.directoryName);
|
|
166
212
|
verified.push(record);
|
|
167
213
|
}
|
|
168
|
-
catch {
|
|
214
|
+
catch {
|
|
215
|
+
/* An unrelated invalid reference cannot block source capture. */
|
|
216
|
+
}
|
|
169
217
|
}
|
|
170
218
|
return verified;
|
|
171
219
|
}
|
|
@@ -180,8 +228,8 @@ export async function batchReferenceEnvExamples(root) {
|
|
|
180
228
|
return examples;
|
|
181
229
|
}
|
|
182
230
|
for (const record of items) {
|
|
183
|
-
const files = record.files.filter(file => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '')
|
|
184
|
-
|
|
231
|
+
const files = record.files.filter((file) => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '') &&
|
|
232
|
+
!isCredentialFile(file.sourcePath));
|
|
185
233
|
if (!files.length)
|
|
186
234
|
continue;
|
|
187
235
|
try {
|
|
@@ -189,7 +237,9 @@ export async function batchReferenceEnvExamples(root) {
|
|
|
189
237
|
for (const file of files)
|
|
190
238
|
examples.add(`${record.path}/${file.path}`);
|
|
191
239
|
}
|
|
192
|
-
catch {
|
|
240
|
+
catch {
|
|
241
|
+
/* An unselected damaged reference must not stop freezing the project. */
|
|
242
|
+
}
|
|
193
243
|
}
|
|
194
244
|
return examples;
|
|
195
245
|
}
|
|
@@ -208,13 +258,16 @@ export async function rebindBatchRemixReferences(root, sourceDirectory, memberDi
|
|
|
208
258
|
throw error;
|
|
209
259
|
}
|
|
210
260
|
for (const reference of selected) {
|
|
211
|
-
const record = items.find(item => item.referenceId === reference.referenceId);
|
|
212
|
-
if (reference.directoryName !== sourceDirectory ||
|
|
261
|
+
const record = items.find((item) => item.referenceId === reference.referenceId);
|
|
262
|
+
if (reference.directoryName !== sourceDirectory ||
|
|
263
|
+
!record ||
|
|
264
|
+
record.contentHash !== reference.snapshotHash)
|
|
213
265
|
throw new RemixReferenceError('context_remix_reference_changed');
|
|
214
266
|
}
|
|
215
267
|
if (selected.length) {
|
|
216
268
|
const manifest = JSON.parse((await readFileSafely(rootReal, WORKSPACE_REMIX_REFERENCES_MANIFEST, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
217
|
-
if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) &&
|
|
269
|
+
if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) &&
|
|
270
|
+
selected.some((reference) => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
|
|
218
271
|
throw new RemixReferenceError('batch_reference_files_withheld');
|
|
219
272
|
}
|
|
220
273
|
// Selected references must be complete. Other records need only valid metadata to be rebound;
|
|
@@ -222,7 +275,7 @@ export async function rebindBatchRemixReferences(root, sourceDirectory, memberDi
|
|
|
222
275
|
const rebound = [];
|
|
223
276
|
const writable = [];
|
|
224
277
|
for (const record of items) {
|
|
225
|
-
const isSelected = selected.some(reference => reference.referenceId === record.referenceId);
|
|
278
|
+
const isSelected = selected.some((reference) => reference.referenceId === record.referenceId);
|
|
226
279
|
try {
|
|
227
280
|
if (record.directoryName !== sourceDirectory && record.directoryName !== memberDirectory)
|
|
228
281
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
@@ -274,7 +327,7 @@ export async function resolveWorkspaceRemixReference(root, directoryName, refere
|
|
|
274
327
|
workspaceDirectoryNameSchema.parse(directoryName);
|
|
275
328
|
try {
|
|
276
329
|
const rootReal = await realpath(root);
|
|
277
|
-
const item = (await readManifest(rootReal)).find(item => item.referenceId === referenceId);
|
|
330
|
+
const item = (await readManifest(rootReal)).find((item) => item.referenceId === referenceId);
|
|
278
331
|
if (!item)
|
|
279
332
|
throw new RemixReferenceError('context_remix_reference_unavailable');
|
|
280
333
|
if (item.contentHash !== snapshotHash)
|
|
@@ -289,15 +342,38 @@ export async function resolveWorkspaceRemixReference(root, directoryName, refere
|
|
|
289
342
|
}
|
|
290
343
|
}
|
|
291
344
|
async function trackReference(root, record) {
|
|
292
|
-
const environment = {
|
|
345
|
+
const environment = {
|
|
346
|
+
PATH: process.env.PATH,
|
|
347
|
+
HOME: root,
|
|
348
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
349
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
350
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
351
|
+
};
|
|
293
352
|
const gitOptions = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root];
|
|
294
|
-
const paths = [
|
|
295
|
-
|
|
353
|
+
const paths = [
|
|
354
|
+
WORKSPACE_REMIX_REFERENCES_MANIFEST,
|
|
355
|
+
`${record.path}/${RECEIPT}`,
|
|
356
|
+
`${record.path}/${ATTRIBUTES}`,
|
|
357
|
+
...record.files.map((file) => `${record.path}/${file.path}`),
|
|
358
|
+
];
|
|
296
359
|
// Reject higher-priority repository attributes before Git can execute a conversion filter.
|
|
297
360
|
const attributes = await new Promise((resolve, reject) => {
|
|
298
|
-
const child = execFile('git', [
|
|
299
|
-
|
|
300
|
-
|
|
361
|
+
const child = execFile('git', [
|
|
362
|
+
...gitOptions,
|
|
363
|
+
'check-attr',
|
|
364
|
+
'-z',
|
|
365
|
+
'--stdin',
|
|
366
|
+
'filter',
|
|
367
|
+
'text',
|
|
368
|
+
'ident',
|
|
369
|
+
'working-tree-encoding',
|
|
370
|
+
'eol',
|
|
371
|
+
], {
|
|
372
|
+
timeout: 60_000,
|
|
373
|
+
maxBuffer: 40 * 1024 * 1024,
|
|
374
|
+
env: environment,
|
|
375
|
+
encoding: 'utf8',
|
|
376
|
+
}, (error, stdout) => (error ? reject(error) : resolve(stdout)));
|
|
301
377
|
child.stdin?.end(`${paths.join('\0')}\0`);
|
|
302
378
|
});
|
|
303
379
|
const fields = attributes.split('\0');
|
|
@@ -305,15 +381,24 @@ async function trackReference(root, record) {
|
|
|
305
381
|
throw new RemixReferenceError('remix_reference_git_attributes');
|
|
306
382
|
for (let index = 0; index < fields.length - 1; index += 3) {
|
|
307
383
|
const [path, attribute, value] = fields.slice(index, index + 3);
|
|
308
|
-
if (path === WORKSPACE_REMIX_REFERENCES_MANIFEST &&
|
|
384
|
+
if (path === WORKSPACE_REMIX_REFERENCES_MANIFEST &&
|
|
385
|
+
(attribute === 'text' || attribute === 'eol'))
|
|
309
386
|
continue;
|
|
310
387
|
if (value !== 'unset' && value !== 'unspecified')
|
|
311
388
|
throw new RemixReferenceError('remix_reference_git_attributes');
|
|
312
389
|
}
|
|
313
390
|
// A source file may have been tracked despite its old ignore rules. Track this approved snapshot explicitly.
|
|
314
|
-
await exec('git', [
|
|
315
|
-
|
|
316
|
-
|
|
391
|
+
await exec('git', [
|
|
392
|
+
...gitOptions,
|
|
393
|
+
'--literal-pathspecs',
|
|
394
|
+
'add',
|
|
395
|
+
'--force',
|
|
396
|
+
'--',
|
|
397
|
+
record.path,
|
|
398
|
+
WORKSPACE_REMIX_REFERENCES_MANIFEST,
|
|
399
|
+
], {
|
|
400
|
+
timeout: 60_000,
|
|
401
|
+
maxBuffer: 1024 * 1024,
|
|
317
402
|
env: environment,
|
|
318
403
|
});
|
|
319
404
|
}
|
|
@@ -325,15 +410,20 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
325
410
|
const snapshot = parsed.data;
|
|
326
411
|
if (snapshot.contentHash !== hash(remixReferenceContentHashInput(snapshot.files)))
|
|
327
412
|
throw new RemixReferenceError('remix_reference_changed');
|
|
328
|
-
const plannedFiles = snapshot.files.map(file => {
|
|
413
|
+
const plannedFiles = snapshot.files.map((file) => {
|
|
329
414
|
const bytes = Buffer.from(file.content, 'base64');
|
|
330
|
-
if (bytes.toString('base64') !== file.content ||
|
|
415
|
+
if (bytes.toString('base64') !== file.content ||
|
|
416
|
+
bytes.length !== file.size ||
|
|
417
|
+
hash(bytes) !== file.sha256)
|
|
331
418
|
throw new RemixReferenceError('remix_reference_changed');
|
|
332
|
-
if (bytes
|
|
419
|
+
if (bytes
|
|
420
|
+
.subarray(0, 128)
|
|
421
|
+
.toString('utf8')
|
|
422
|
+
.startsWith('version https://git-lfs.github.com/spec/v1'))
|
|
333
423
|
throw new RemixReferenceError('remix_reference_external_resource');
|
|
334
424
|
return { ...file, bytes, targetPath: materializedPath(file.path) };
|
|
335
425
|
});
|
|
336
|
-
const files = plannedFiles.filter(file => file.targetPath !== undefined);
|
|
426
|
+
const files = plannedFiles.filter((file) => file.targetPath !== undefined);
|
|
337
427
|
if (!files.length)
|
|
338
428
|
throw new RemixReferenceError('remix_reference_empty');
|
|
339
429
|
const rootReal = await realpath(root);
|
|
@@ -341,7 +431,7 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
341
431
|
const path = referencePath(referenceId, snapshot.source.commit);
|
|
342
432
|
const items = await readManifest(rootReal);
|
|
343
433
|
await safeDirectory(rootReal, '.playcraft', true);
|
|
344
|
-
let record = items.find(item => item.referenceId === referenceId);
|
|
434
|
+
let record = items.find((item) => item.referenceId === referenceId);
|
|
345
435
|
if (record) {
|
|
346
436
|
await verifyReference(rootReal, record, directoryName);
|
|
347
437
|
assertCurrent();
|
|
@@ -352,8 +442,11 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
352
442
|
throw new RemixReferenceError('remix_reference_limit');
|
|
353
443
|
await safeDirectory(rootReal, WORKSPACE_REMIX_REFERENCES_DIRECTORY, true);
|
|
354
444
|
const target = join(rootReal, path);
|
|
355
|
-
const existing = await lstat(target).catch((error) => {
|
|
356
|
-
|
|
445
|
+
const existing = await lstat(target).catch((error) => {
|
|
446
|
+
if (error.code === 'ENOENT')
|
|
447
|
+
return undefined;
|
|
448
|
+
throw error;
|
|
449
|
+
});
|
|
357
450
|
if (existing) {
|
|
358
451
|
// A complete published directory is its recovery receipt when the process died before writing the manifest.
|
|
359
452
|
if (!existing.isDirectory() || existing.isSymbolicLink())
|
|
@@ -364,10 +457,24 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
364
457
|
await verifyReference(rootReal, record, directoryName);
|
|
365
458
|
}
|
|
366
459
|
else {
|
|
367
|
-
record = workspaceRemixReferenceSchema.parse({
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
460
|
+
record = workspaceRemixReferenceSchema.parse({
|
|
461
|
+
referenceId,
|
|
462
|
+
directoryName,
|
|
463
|
+
path,
|
|
464
|
+
name: snapshot.source.name,
|
|
465
|
+
source: snapshot.source,
|
|
466
|
+
contentHash: snapshot.contentHash,
|
|
467
|
+
importedAt: new Date().toISOString(),
|
|
468
|
+
files: files.map((file) => ({
|
|
469
|
+
path: file.targetPath,
|
|
470
|
+
sourcePath: file.path,
|
|
471
|
+
sha256: file.sha256,
|
|
472
|
+
size: file.size,
|
|
473
|
+
})),
|
|
474
|
+
omittedFiles: plannedFiles
|
|
475
|
+
.filter((file) => file.targetPath === undefined)
|
|
476
|
+
.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
|
|
477
|
+
});
|
|
371
478
|
const stage = await mkdtemp(join(dirname(rootReal), '.remix-reference-'));
|
|
372
479
|
try {
|
|
373
480
|
for (const file of files) {
|
|
@@ -411,7 +518,9 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
411
518
|
}
|
|
412
519
|
export async function importWorkspaceRemixReference(root, directoryName, snapshot, assertCurrent = () => { }) {
|
|
413
520
|
const previous = locks.get(root) ?? Promise.resolve();
|
|
414
|
-
const current = previous
|
|
521
|
+
const current = previous
|
|
522
|
+
.catch(() => undefined)
|
|
523
|
+
.then(() => importReference(root, directoryName, snapshot, assertCurrent));
|
|
415
524
|
locks.set(root, current);
|
|
416
525
|
try {
|
|
417
526
|
return await current;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import { skillActivationBindingHash, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { recoverPreparedSkillActivationTransactions } from '../../project-skills/remote-activation.js';
|
|
5
|
+
import { workspaceRoot } from './registry.js';
|
|
6
|
+
import { listSkillActivationRequests, reconcileSkillActivationRequestReceipts } from './skill-activation-requests.js';
|
|
7
|
+
/** Runs before native startup: restore owned pairs and settle only provably unsent continuations. */
|
|
8
|
+
export async function recoverInterruptedSkillActivations(state) {
|
|
9
|
+
if (state.config.workspaceMode !== 'multi' || !await lstat(join(state.config.stateDir, 'skill-activation-requests'))
|
|
10
|
+
.catch((error) => { if (error.code === 'ENOENT')
|
|
11
|
+
return undefined; throw error; }))
|
|
12
|
+
return;
|
|
13
|
+
const transactionDirectory = join(state.config.stateDir, 'skill-activation-requests/transactions');
|
|
14
|
+
const requests = await listSkillActivationRequests(state);
|
|
15
|
+
const unsentContinuations = (request) => {
|
|
16
|
+
const source = state.journal.getOperationById(request.sourceOperationId);
|
|
17
|
+
if (!source || source.kind !== 'session.turn' || source.sessionId !== request.sourceSessionId
|
|
18
|
+
|| source.environment !== state.config.environment || source.userId !== state.config.userId
|
|
19
|
+
|| source.workspaceId !== request.workspaceId || (source.directoryName ?? 'basic') !== request.directoryName
|
|
20
|
+
|| source.generation !== request.generation || source.runtimeEpoch !== request.runtimeEpoch
|
|
21
|
+
|| source.nativeThreadId !== request.sourceThreadId || source.nativeTurnId !== request.sourceTurnId)
|
|
22
|
+
return [];
|
|
23
|
+
return state.journal.listOperations().filter(({ operation }) => operation.kind === 'session.turn' && operation.clientRequestId === request.requestId
|
|
24
|
+
&& operation.environment === state.config.environment && operation.userId === state.config.userId
|
|
25
|
+
&& operation.sessionId === request.sourceSessionId && operation.workspaceId === request.workspaceId
|
|
26
|
+
&& (operation.directoryName ?? 'basic') === request.directoryName && operation.generation === request.generation
|
|
27
|
+
&& operation.runtimeEpoch === request.runtimeEpoch && operation.status === 'dispatched'
|
|
28
|
+
&& !operation.nativeRequestId && !operation.nativeTurnId);
|
|
29
|
+
};
|
|
30
|
+
for (const workspaceId of new Set(requests.map(request => request.workspaceId))) {
|
|
31
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
32
|
+
if (binding) {
|
|
33
|
+
for (const directoryName of new Set(['basic', ...(binding.directories ?? []).map(directory => directory.directoryName)])) {
|
|
34
|
+
const root = await workspaceRoot(state, binding.workspaceId, directoryName).catch(error => {
|
|
35
|
+
if (error instanceof Error && error.message === 'workspace_not_prepared')
|
|
36
|
+
return undefined;
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
if (root)
|
|
40
|
+
await recoverPreparedSkillActivationTransactions(root, { transactionDirectory,
|
|
41
|
+
orphanTransactionIds: requests.filter(request => request.workspaceId === workspaceId && request.directoryName === directoryName
|
|
42
|
+
&& request.registrationHash === skillActivationBindingHash(binding, directoryName) && unsentContinuations(request).length > 0)
|
|
43
|
+
.map(request => request.requestId) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const request of requests) {
|
|
48
|
+
for (const { scopeKey, operation } of unsentContinuations(request)) {
|
|
49
|
+
// Reservation is persisted before native send. Without it this interrupted preparation never sent a turn.
|
|
50
|
+
await state.journal.putOperation(scopeKey, transitionOperation(operation, 'failed', { error: 'skill_activation_interrupted' }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await reconcileSkillActivationRequestReceipts(state);
|
|
54
|
+
}
|