@playcraft/cli 0.1.68 → 0.1.70
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/asset-library/provenance.js +169 -0
- package/dist/commands/library-messages.js +27 -9
- package/dist/commands/library.js +62 -10
- package/dist/model-adaptation/project-template.js +56 -3
- package/dist/model-adaptation/project.js +15 -1
- package/dist/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/freeze.js +15 -2
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/git.js +16 -4
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/publication.js +96 -0
- package/dist/workspace-runtime/batch/source-candidate.js +2 -1
- package/dist/workspace-runtime/batch/source-capture.js +42 -3
- package/dist/workspace-runtime/batch/source-publish.js +26 -15
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
- package/dist/workspace-runtime/serve.js +50 -18
- package/dist/workspace-runtime/server/dispatch.js +42 -9
- package/dist/workspace-runtime/server/http.js +13 -2
- package/dist/workspace-runtime/verify-artifact.js +3 -1
- package/dist/workspace-runtime/workspaces/archive.js +28 -3
- package/dist/workspace-runtime/workspaces/asset-library-import.js +33 -2
- package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
- package/dist/workspace-runtime/workspaces/registry.js +3 -5
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.playcraft/skills.lock.json +2 -2
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, realpath } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { ASSET_TRANSFER_CHUNK_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, assetLibraryModelProvenanceSchema, workspaceAssetProvenanceDocumentSchema, canonicalAssetLibraryModelProvenance, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { createAssetTransfer, putAssetTransferChunk, withAssetTransfer, safeAssetTransferDirectory, } from '../workspace-runtime/workspaces/asset-transfer-storage.js';
|
|
7
|
+
import { publishAssetTransfer } from '../workspace-runtime/workspaces/asset-transfer-publish.js';
|
|
8
|
+
import { observeWorkspaceBinaryFile } from '../workspace-runtime/workspaces/binary-write.js';
|
|
9
|
+
export const WORKSPACE_ASSET_PROVENANCE_PATH = '.playcraft/asset-provenance.json';
|
|
10
|
+
const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const NOTICE_PATH = 'THIRD_PARTY_ASSETS.md';
|
|
12
|
+
const START = '<!-- playcraft-asset-sources:start -->';
|
|
13
|
+
const END = '<!-- playcraft-asset-sources:end -->';
|
|
14
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
15
|
+
const json = (value) => Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
|
16
|
+
const missing = (error) => error?.code === 'ENOENT';
|
|
17
|
+
async function projectFile(root, path, maxBytes = MAX_DOCUMENT_BYTES) {
|
|
18
|
+
try {
|
|
19
|
+
await safeAssetTransferDirectory(root, dirname(path) === '.' ? '' : dirname(path));
|
|
20
|
+
const observed = await observeWorkspaceBinaryFile(root, path);
|
|
21
|
+
if (!observed)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (observed.size > maxBytes)
|
|
24
|
+
throw new Error('asset_provenance_too_large');
|
|
25
|
+
const handle = await open(join(root, path), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
const stat = await handle.stat();
|
|
28
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== observed.size)
|
|
29
|
+
throw new Error('asset_provenance_changed');
|
|
30
|
+
const bytes = await handle.readFile();
|
|
31
|
+
if (digest(bytes) !== observed.contentHash || (await observeWorkspaceBinaryFile(root, path))?.contentHash !== observed.contentHash)
|
|
32
|
+
throw new Error('asset_provenance_changed');
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (missing(error))
|
|
41
|
+
return undefined;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function readWorkspaceAssetProvenance(root) {
|
|
46
|
+
const bytes = await projectFile(await realpath(root), WORKSPACE_ASSET_PROVENANCE_PATH);
|
|
47
|
+
return bytes ? workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(bytes.toString('utf8'))) : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Generate portable records. Authority still comes from the immutable server version. */
|
|
50
|
+
export function buildWorkspaceAssetProvenanceFiles(document, existingNotice = '') {
|
|
51
|
+
const parsed = workspaceAssetProvenanceDocumentSchema.parse(document);
|
|
52
|
+
const files = {};
|
|
53
|
+
const rows = [];
|
|
54
|
+
const clean = (value) => value.replace(/[\r\n|]/g, ' ');
|
|
55
|
+
for (const asset of [...parsed.assets].sort((a, b) => a.path.localeCompare(b.path, 'en'))) {
|
|
56
|
+
const licensePath = `licenses/assets/${digest(asset.provenance.licenseText)}.txt`;
|
|
57
|
+
if (asset.licensePath !== licensePath)
|
|
58
|
+
throw new Error('asset_provenance_license_path_mismatch');
|
|
59
|
+
files[licensePath] = Buffer.from(asset.provenance.licenseText);
|
|
60
|
+
rows.push(`| ${clean(asset.path)} | ${clean(asset.provenance.author)} | ${asset.provenance.sourceUrl} | [CC0-1.0](${licensePath}) | ${asset.provenance.revisionId} |`);
|
|
61
|
+
}
|
|
62
|
+
const managed = `${START}\n## Third-party model sources / 第三方模型来源\n\nThese models are third-party CC0 assets. Attribution is not required by CC0; this record preserves their provenance.\n这些模型为第三方 CC0 资产。CC0 不要求署名,本清单用于保留来源记录。\n\n| Path / 路径 | Author / 作者 | Source / 来源 | License / 许可 | Revision / 版本 |\n| --- | --- | --- | --- | --- |\n${rows.join('\n')}\n${END}`;
|
|
63
|
+
const start = existingNotice.indexOf(START), end = existingNotice.indexOf(END);
|
|
64
|
+
if ((start < 0) !== (end < 0) || start >= 0 && end < start)
|
|
65
|
+
throw new Error('asset_provenance_notice_conflict');
|
|
66
|
+
const notice = start >= 0
|
|
67
|
+
? existingNotice.slice(0, start) + managed + existingNotice.slice(end + END.length)
|
|
68
|
+
: [existingNotice.trimEnd(), managed].filter(Boolean).join('\n\n') + '\n';
|
|
69
|
+
files[WORKSPACE_ASSET_PROVENANCE_PATH] = json(parsed);
|
|
70
|
+
files[NOTICE_PATH] = Buffer.from(notice);
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
/** Verify source and bytes before declaring an export source-complete. */
|
|
74
|
+
export async function verifyWorkspaceAssetProvenance(root, options = {}) {
|
|
75
|
+
const rootReal = await realpath(root), document = await readWorkspaceAssetProvenance(rootReal);
|
|
76
|
+
const recordedPaths = new Set(document?.assets.map(asset => asset.path));
|
|
77
|
+
for (const path of new Set(options.expectedModelPaths ?? [])) {
|
|
78
|
+
if (!path.toLowerCase().endsWith('.glb'))
|
|
79
|
+
throw new Error('asset_provenance_expected_path_invalid');
|
|
80
|
+
if (await observeWorkspaceBinaryFile(rootReal, path) && !recordedPaths.has(path))
|
|
81
|
+
throw new Error('asset_provenance_record_missing');
|
|
82
|
+
}
|
|
83
|
+
if (!document)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (!options.verify)
|
|
86
|
+
throw new Error('asset_provenance_authority_required');
|
|
87
|
+
buildWorkspaceAssetProvenanceFiles(document);
|
|
88
|
+
const verified = new Set();
|
|
89
|
+
for (const asset of document.assets) {
|
|
90
|
+
const identity = canonicalAssetLibraryModelProvenance(asset.provenance);
|
|
91
|
+
if (!verified.has(identity)) {
|
|
92
|
+
await options.verify(asset.provenance);
|
|
93
|
+
verified.add(identity);
|
|
94
|
+
}
|
|
95
|
+
const model = await observeWorkspaceBinaryFile(rootReal, asset.path);
|
|
96
|
+
if (model?.contentHash !== asset.provenance.modelSha256)
|
|
97
|
+
throw new Error('asset_provenance_model_changed');
|
|
98
|
+
const license = await projectFile(rootReal, asset.licensePath, 64 * 1024);
|
|
99
|
+
if (!license || !license.equals(Buffer.from(asset.provenance.licenseText)))
|
|
100
|
+
throw new Error('asset_provenance_license_changed');
|
|
101
|
+
}
|
|
102
|
+
return document;
|
|
103
|
+
}
|
|
104
|
+
function transferIdentifier(value) {
|
|
105
|
+
const hash = digest(value);
|
|
106
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
107
|
+
}
|
|
108
|
+
/** Reuse the existing multi-file publication journal for model bytes and provenance together. */
|
|
109
|
+
export async function publishLibraryModels(root, input, options = {}) {
|
|
110
|
+
if (!input.length || new Set(input.map(model => model.path.toLowerCase())).size !== input.length)
|
|
111
|
+
throw new Error('invalid_library_model_publication');
|
|
112
|
+
const rootReal = await realpath(root);
|
|
113
|
+
for (const model of input) {
|
|
114
|
+
model.provenance = assetLibraryModelProvenanceSchema.parse(model.provenance);
|
|
115
|
+
if (!model.path.toLowerCase().endsWith('.glb') || digest(model.bytes) !== model.provenance.modelSha256)
|
|
116
|
+
throw new Error('library_asset_changed');
|
|
117
|
+
}
|
|
118
|
+
const operationId = options.operationId ?? `library-model:${digest(JSON.stringify(input.map(model => [model.path, model.provenance.revisionId])))}`;
|
|
119
|
+
const transferId = transferIdentifier(operationId);
|
|
120
|
+
const scope = options.scope ?? { environment: 'local-library', userId: 'local-library',
|
|
121
|
+
workspaceId: digest(rootReal), directoryName: 'library' };
|
|
122
|
+
const existing = await withAssetTransfer(rootReal, scope, transferId, async (transfer) => ({
|
|
123
|
+
manifest: transfer.record.manifest, manifestHash: transfer.record.manifestHash,
|
|
124
|
+
})).catch(error => { if (error.code === 'asset_transfer_not_found')
|
|
125
|
+
return undefined; throw error; });
|
|
126
|
+
if (existing) {
|
|
127
|
+
for (const model of input)
|
|
128
|
+
if (!existing.manifest.files.some(file => file.path === model.path && file.contentHash === model.provenance.modelSha256))
|
|
129
|
+
throw new Error('asset_transfer_manifest_conflict');
|
|
130
|
+
const receipt = await publishAssetTransfer(rootReal, scope, { transferId, manifestHash: existing.manifestHash }, { operationId, assertCurrent: options.assertCurrent });
|
|
131
|
+
const saved = await readWorkspaceAssetProvenance(rootReal);
|
|
132
|
+
for (const model of input) {
|
|
133
|
+
const entry = saved?.assets.find(asset => asset.path === model.path);
|
|
134
|
+
if (!entry || canonicalAssetLibraryModelProvenance(entry.provenance) !== canonicalAssetLibraryModelProvenance(model.provenance)
|
|
135
|
+
|| (await observeWorkspaceBinaryFile(rootReal, model.path))?.contentHash !== model.provenance.modelSha256)
|
|
136
|
+
throw new Error('asset_provenance_model_changed');
|
|
137
|
+
}
|
|
138
|
+
return receipt;
|
|
139
|
+
}
|
|
140
|
+
if (options.requireExisting)
|
|
141
|
+
throw new Error('asset_transfer_not_found');
|
|
142
|
+
const sourceRecord = await projectFile(rootReal, WORKSPACE_ASSET_PROVENANCE_PATH);
|
|
143
|
+
const current = sourceRecord ? workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(sourceRecord.toString('utf8')))
|
|
144
|
+
: { schemaVersion: 1, assets: [] };
|
|
145
|
+
const paths = new Set(input.map(model => model.path.toLowerCase()));
|
|
146
|
+
const document = workspaceAssetProvenanceDocumentSchema.parse({ schemaVersion: 1,
|
|
147
|
+
assets: [...current.assets.filter(asset => !paths.has(asset.path.toLowerCase())), ...input.map(model => ({ path: model.path,
|
|
148
|
+
licensePath: `licenses/assets/${digest(model.provenance.licenseText)}.txt`, provenance: model.provenance }))] });
|
|
149
|
+
const notice = await projectFile(rootReal, NOTICE_PATH);
|
|
150
|
+
const records = buildWorkspaceAssetProvenanceFiles(document, notice?.toString('utf8'));
|
|
151
|
+
const files = [...input.map(model => ({ path: model.path, bytes: model.bytes, expectedContentHash: model.expectedContentHash })),
|
|
152
|
+
...await Promise.all(Object.entries(records).map(async ([path, bytes]) => ({ path, bytes,
|
|
153
|
+
expectedContentHash: path === WORKSPACE_ASSET_PROVENANCE_PATH ? sourceRecord ? digest(sourceRecord) : null
|
|
154
|
+
: path === NOTICE_PATH ? notice ? digest(notice) : null
|
|
155
|
+
: (await observeWorkspaceBinaryFile(rootReal, path))?.contentHash ?? null })))];
|
|
156
|
+
if (files.reduce((sum, file) => sum + file.bytes.length, 0) > WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES)
|
|
157
|
+
throw new Error('library_asset_too_large');
|
|
158
|
+
const manifest = { transferId, files: files.map(file => ({ path: file.path, size: file.bytes.length,
|
|
159
|
+
chunkHashes: Array.from({ length: Math.ceil(file.bytes.length / ASSET_TRANSFER_CHUNK_BYTES) }, (_, index) => digest(file.bytes.subarray(index * ASSET_TRANSFER_CHUNK_BYTES, (index + 1) * ASSET_TRANSFER_CHUNK_BYTES))),
|
|
160
|
+
contentHash: digest(file.bytes), expectedContentHash: file.expectedContentHash })) };
|
|
161
|
+
const transfer = await createAssetTransfer(rootReal, scope, manifest);
|
|
162
|
+
for (const [fileIndex, file] of files.entries())
|
|
163
|
+
for (const [chunkIndex] of manifest.files[fileIndex].chunkHashes.entries()) {
|
|
164
|
+
await options.assertCurrent?.();
|
|
165
|
+
await putAssetTransferChunk(rootReal, scope, transferId, { fileIndex, chunkIndex,
|
|
166
|
+
contentBase64: file.bytes.subarray(chunkIndex * ASSET_TRANSFER_CHUNK_BYTES, (chunkIndex + 1) * ASSET_TRANSFER_CHUNK_BYTES).toString('base64') });
|
|
167
|
+
}
|
|
168
|
+
return publishAssetTransfer(rootReal, scope, { transferId, manifestHash: transfer.manifestHash }, { operationId, assertCurrent: options.assertCurrent, onPublishedFile: options.onPublishedFile });
|
|
169
|
+
}
|
|
@@ -2,14 +2,23 @@ const messages = {
|
|
|
2
2
|
en: {
|
|
3
3
|
topic: 'Find and download authorized shared asset library content',
|
|
4
4
|
list: 'List library assets with filters and pagination', search: 'Search library names and content metadata',
|
|
5
|
-
get: 'Read asset metadata', files: 'List every file in an asset or bundle', facets: 'Inspect available categories and tags',
|
|
6
|
-
|
|
5
|
+
get: 'Read asset metadata', files: 'List every file in an asset or file bundle', facets: 'Inspect available categories and tags',
|
|
6
|
+
kits: 'Find asset kits', kit: 'Read an asset kit and check which needs its models cover',
|
|
7
|
+
kitId: 'Asset kit identifier', kitNeeds: 'Up to 12 comma-separated model needs, each at most 80 characters',
|
|
8
|
+
invalidKitId: 'Expected a lowercase asset kit identifier with letters, numbers and single hyphens, up to 80 characters.',
|
|
9
|
+
invalidKitNeeds: 'Expected 1–12 nonempty model needs, each at most 80 characters and at most 1024 characters in total, without control characters.',
|
|
10
|
+
download: 'Download an asset or a complete file bundle with relative dependencies',
|
|
7
11
|
query: 'Search text', id: 'Library asset identifier', type: 'Asset type', path: 'Library path prefix',
|
|
8
12
|
category: 'Category', subcategory: 'Subcategory', tags: 'Comma-separated tags',
|
|
9
13
|
visibility: 'public | team', team: 'Authorized team identifier', page: 'Page number, starting at 1',
|
|
10
|
-
limit: 'Results per page (1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size', order: 'asc | desc',
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
limit: 'Results per page (1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size | relevance', order: 'asc | desc',
|
|
15
|
+
license: 'Required model license', modelCapability: 'Required verified model capability', modelStyle: 'Model style group',
|
|
16
|
+
maxBytes: 'Maximum model file bytes', maxTriangles: 'Maximum triangle count', revision: 'Exact immutable model revision',
|
|
17
|
+
projectRoot: 'Project root for model provenance records', invalidRevision: 'Invalid model revision identifier.',
|
|
18
|
+
projectOutputRequired: 'A curated model must be saved inside its project root. Use --project-root to select it.',
|
|
19
|
+
resume: 'Resume the saved curated-model transaction for the same revision and destination',
|
|
20
|
+
expand: 'Return individual files instead of collapsed file bundles',
|
|
21
|
+
output: 'New file path, or a new directory for a file bundle', outputDir: 'New directory for the downloaded content',
|
|
13
22
|
json: 'Print machine-readable JSON (JavaScript Object Notation, 数据交换格式; always enabled)',
|
|
14
23
|
invalidNumber: 'Expected a positive integer.', invalidId: 'Invalid library asset identifier.',
|
|
15
24
|
invalidManifest: 'The library returned an invalid or unsafe file manifest.',
|
|
@@ -20,13 +29,22 @@ const messages = {
|
|
|
20
29
|
zh: {
|
|
21
30
|
topic: '检索和下载有权访问的共享素材库内容',
|
|
22
31
|
list: '按条件分页列出素材库内容', search: '检索素材名称和内容元数据',
|
|
23
|
-
get: '读取素材详情', files: '
|
|
24
|
-
|
|
32
|
+
get: '读取素材详情', files: '列出素材或文件集合内的全部文件', facets: '查看可用分类与标签',
|
|
33
|
+
kits: '查找素材包', kit: '读取素材包,并检查模型对所需素材的覆盖情况',
|
|
34
|
+
kitId: '素材包编号', kitNeeds: '所需素材,逗号分隔,最多 12 项,每项最多 80 个字符',
|
|
35
|
+
invalidKitId: '素材包编号须使用小写字母、数字和单个连字符,最多 80 个字符。',
|
|
36
|
+
invalidKitNeeds: '请输入 1~12 项非空素材需求,每项最多 80 个字符,总长最多 1024 个字符,不能包含控制字符。',
|
|
37
|
+
download: '下载素材或完整文件集合,保留依赖文件的相对路径',
|
|
25
38
|
query: '检索文本', id: '素材库中的素材编号', type: '素材类型', path: '素材库路径前缀',
|
|
26
39
|
category: '分类', subcategory: '子分类', tags: '标签,逗号分隔',
|
|
27
40
|
visibility: 'public(公共)或 team(团队)', team: '有权访问的团队编号', page: '页码,从 1 开始',
|
|
28
|
-
limit: '每页数量(1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size', order: 'asc | desc',
|
|
29
|
-
|
|
41
|
+
limit: '每页数量(1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size | relevance', order: 'asc | desc',
|
|
42
|
+
license: '模型必须满足的许可', modelCapability: '必须通过验证的模型能力', modelStyle: '模型风格组',
|
|
43
|
+
maxBytes: '模型文件的最大字节数', maxTriangles: '最大三角面数', revision: '确切的不可变模型版本',
|
|
44
|
+
projectRoot: '保存模型来源记录的工程根目录', invalidRevision: '模型版本编号无效。',
|
|
45
|
+
projectOutputRequired: '精选模型需要保存到工程根目录内,可使用 --project-root 指定工程。',
|
|
46
|
+
resume: '恢复相同版本与目标路径的精选模型保存事务',
|
|
47
|
+
expand: '返回单独文件,不折叠文件集合', output: '新的文件路径;文件集合则为新的目录', outputDir: '下载内容的新目录',
|
|
30
48
|
json: '输出机器可读的 JSON (JavaScript Object Notation, 数据交换格式),始终启用',
|
|
31
49
|
invalidNumber: '请输入正整数。', invalidId: '素材库编号无效。',
|
|
32
50
|
invalidManifest: '素材库返回的文件清单无效或包含不安全路径。',
|
package/dist/commands/library.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { InvalidArgumentError, Option } from 'commander';
|
|
2
|
-
import { link, lstat, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join, posix, resolve } from 'node:path';
|
|
2
|
+
import { link, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { assetLibraryModelProvenanceSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
5
|
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
6
|
import { resolveSandboxPath } from '../utils/sandbox-path.js';
|
|
6
7
|
import { libraryMessage as t, LibraryError } from './library-messages.js';
|
|
8
|
+
import { publishLibraryModels, WORKSPACE_ASSET_PROVENANCE_PATH } from '../asset-library/provenance.js';
|
|
7
9
|
function assetId(value) {
|
|
8
10
|
if (!/^(?:\d{1,20}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(value))
|
|
9
11
|
throw new InvalidArgumentError(t('invalidId'));
|
|
10
12
|
return value;
|
|
11
13
|
}
|
|
14
|
+
function kitId(value) {
|
|
15
|
+
if (value.length > 80 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
|
|
16
|
+
throw new InvalidArgumentError(t('invalidKitId'));
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function kitNeeds(value) {
|
|
20
|
+
if ([...value].length > 1024 || /\p{Cc}/u.test(value))
|
|
21
|
+
throw new InvalidArgumentError(t('invalidKitNeeds'));
|
|
22
|
+
const needs = value.split(/[,,]/).map(need => need.trim());
|
|
23
|
+
if (needs.length > 12 || needs.some(need => !need || [...need].length > 80))
|
|
24
|
+
throw new InvalidArgumentError(t('invalidKitNeeds'));
|
|
25
|
+
return needs.join(',');
|
|
26
|
+
}
|
|
12
27
|
function positiveInteger(value) {
|
|
13
28
|
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
14
29
|
throw new InvalidArgumentError(t('invalidNumber'));
|
|
@@ -26,9 +41,13 @@ function filters(command) {
|
|
|
26
41
|
throw new InvalidArgumentError(t('limit'));
|
|
27
42
|
return value;
|
|
28
43
|
}, '30')
|
|
29
|
-
.addOption(new Option('--sort-by <field>', t('sort')).choices(['modifiedAt', 'createdAt', 'name', 'referenceCount', 'size']))
|
|
44
|
+
.addOption(new Option('--sort-by <field>', t('sort')).choices(['modifiedAt', 'createdAt', 'name', 'referenceCount', 'size', 'relevance']))
|
|
30
45
|
.addOption(new Option('--sort-order <order>', t('order')).choices(['asc', 'desc']))
|
|
31
|
-
.option('--expand-prefabs', t('expand'))
|
|
46
|
+
.option('--expand-prefabs', t('expand'))
|
|
47
|
+
.addOption(new Option('--license <license>', t('license')).choices(['CC0-1.0']))
|
|
48
|
+
.addOption(new Option('--model-capability <capability>', t('modelCapability')).choices(['static-prop', 'rigid-parts']))
|
|
49
|
+
.option('--model-style <style>', t('modelStyle')).option('--max-bytes <bytes>', t('maxBytes'), positiveInteger)
|
|
50
|
+
.option('--max-triangles <count>', t('maxTriangles'), positiveInteger).option('--json', t('json'));
|
|
32
51
|
}
|
|
33
52
|
function query(options) {
|
|
34
53
|
return Object.fromEntries(Object.entries(options)
|
|
@@ -73,18 +92,30 @@ function validatedFiles(manifest) {
|
|
|
73
92
|
throw new LibraryError('invalidManifest');
|
|
74
93
|
}
|
|
75
94
|
}
|
|
95
|
+
if (manifest.provenance) {
|
|
96
|
+
const provenance = assetLibraryModelProvenanceSchema.safeParse(manifest.provenance);
|
|
97
|
+
if (!provenance.success || provenance.data.assetId !== manifest.assetId || manifest.kind !== 'file'
|
|
98
|
+
|| manifest.files[0]?.sha256 !== provenance.data.modelSha256 || !manifest.files[0]?.path.toLowerCase().endsWith('.glb'))
|
|
99
|
+
throw new LibraryError('invalidManifest');
|
|
100
|
+
manifest.provenance = provenance.data;
|
|
101
|
+
}
|
|
76
102
|
return manifest.files;
|
|
77
103
|
}
|
|
78
104
|
export async function downloadLibraryAsset(client, id, options) {
|
|
79
105
|
if (Boolean(options.output) === Boolean(options.outputDir))
|
|
80
106
|
throw new LibraryError('outputRequired');
|
|
81
107
|
assetId(id);
|
|
82
|
-
|
|
108
|
+
if (options.revisionId && !/^[a-f0-9]{64}$/.test(options.revisionId))
|
|
109
|
+
throw new LibraryError('invalidRevision');
|
|
110
|
+
const manifestPath = `/asset-library/${encodeURIComponent(id)}/files`;
|
|
111
|
+
const manifest = await client.get(manifestPath, { provenanceProtocol: '1', ...(options.revisionId ? { revisionId: options.revisionId } : {}) });
|
|
83
112
|
const files = validatedFiles(manifest);
|
|
113
|
+
if (options.revisionId && manifest.provenance?.revisionId !== options.revisionId)
|
|
114
|
+
throw new LibraryError('invalidManifest');
|
|
84
115
|
const destination = resolve(resolveSandboxPath(options.output ?? options.outputDir));
|
|
85
116
|
const existing = await lstat(destination).catch(error => { if (error.code === 'ENOENT')
|
|
86
117
|
return undefined; throw error; });
|
|
87
|
-
if (existing)
|
|
118
|
+
if (existing && !(manifest.provenance && options.resume))
|
|
88
119
|
throw new LibraryError('outputExists');
|
|
89
120
|
await mkdir(dirname(destination), { recursive: true });
|
|
90
121
|
const staging = await mkdtemp(join(dirname(destination), '.playcraft-library-'));
|
|
@@ -93,13 +124,26 @@ export async function downloadLibraryAsset(client, id, options) {
|
|
|
93
124
|
try {
|
|
94
125
|
for (const file of files) {
|
|
95
126
|
const target = directory ? join(staging, file.path) : join(staging, 'content');
|
|
96
|
-
const
|
|
127
|
+
const revision = manifest.provenance ? `?revisionId=${manifest.provenance.revisionId}&provenanceProtocol=1` : '';
|
|
128
|
+
const { bytes } = await client.download(`/asset-library/${encodeURIComponent(file.id)}/file${revision}`, target);
|
|
97
129
|
downloaded.push({ id: file.id, path: directory ? join(destination, file.path) : destination, bytes });
|
|
98
130
|
}
|
|
99
131
|
// Recheck before publishing the complete bundle; failures never expose a partial download.
|
|
100
|
-
if (await lstat(destination).catch(error => { if (error.code === 'ENOENT')
|
|
132
|
+
if (!(manifest.provenance && options.resume) && await lstat(destination).catch(error => { if (error.code === 'ENOENT')
|
|
101
133
|
return undefined; throw error; }))
|
|
102
134
|
throw new LibraryError('outputExists');
|
|
135
|
+
if (manifest.provenance) {
|
|
136
|
+
const projectRoot = await realpath(resolve(options.projectRoot ?? process.cwd()));
|
|
137
|
+
const canonicalDestination = join(await realpath(dirname(destination)), basename(destination));
|
|
138
|
+
const target = directory ? join(canonicalDestination, files[0].path) : canonicalDestination;
|
|
139
|
+
const path = relative(projectRoot, target).split(sep).join('/');
|
|
140
|
+
if (!path || path.startsWith('../') || posix.isAbsolute(path))
|
|
141
|
+
throw new LibraryError('projectOutputRequired');
|
|
142
|
+
const bytes = await readFile(directory ? join(staging, files[0].path) : join(staging, 'content'));
|
|
143
|
+
const receipt = await publishLibraryModels(projectRoot, [{ path, bytes, provenance: manifest.provenance, expectedContentHash: null }], { requireExisting: options.resume });
|
|
144
|
+
return { assetId: manifest.assetId, kind: manifest.kind, output: destination, files: downloaded,
|
|
145
|
+
provenance: { path: join(projectRoot, WORKSPACE_ASSET_PROVENANCE_PATH), revisionId: manifest.provenance.revisionId, receipt } };
|
|
146
|
+
}
|
|
103
147
|
if (directory)
|
|
104
148
|
await rename(staging, destination);
|
|
105
149
|
else
|
|
@@ -112,6 +156,11 @@ export async function downloadLibraryAsset(client, id, options) {
|
|
|
112
156
|
}
|
|
113
157
|
export function registerLibraryCommands(program) {
|
|
114
158
|
const library = program.command('library').description(t('topic'));
|
|
159
|
+
library.command('kits').description(t('kits')).option('--search <text>', t('query')).option('--json', t('json'))
|
|
160
|
+
.action((options) => action(() => new AgentApiClient().get('/asset-library/kits', options.search === undefined ? undefined : { search: options.search })));
|
|
161
|
+
library.command('kit').description(t('kit')).argument('<kitId>', t('kitId'), kitId)
|
|
162
|
+
.option('--needs <items>', t('kitNeeds'), kitNeeds).option('--json', t('json'))
|
|
163
|
+
.action((id, options) => action(() => new AgentApiClient().get(`/asset-library/kits/${encodeURIComponent(id)}`, options.needs === undefined ? undefined : { needs: options.needs })));
|
|
115
164
|
filters(library.command('list').description(t('list')))
|
|
116
165
|
.action((options) => action(() => new AgentApiClient().get('/asset-library', query(options))));
|
|
117
166
|
filters(library.command('search').description(t('search')).argument('<query>', t('query')))
|
|
@@ -122,9 +171,12 @@ export function registerLibraryCommands(program) {
|
|
|
122
171
|
.action((options) => action(() => new AgentApiClient().get('/asset-library/facets', query(options))));
|
|
123
172
|
for (const command of ['get', 'files']) {
|
|
124
173
|
library.command(command).description(t(command)).argument('<assetId>', t('id'), assetId).option('--json', t('json'))
|
|
125
|
-
.
|
|
174
|
+
.option('--revision-id <revision>', t('revision'))
|
|
175
|
+
.action((id, options) => action(() => new AgentApiClient().get(`/asset-library/${encodeURIComponent(id)}${command === 'files' ? '/files' : ''}`, { ...(command === 'files' ? { provenanceProtocol: '1' } : {}), ...(options.revisionId ? { revisionId: options.revisionId } : {}) })));
|
|
126
176
|
}
|
|
127
177
|
library.command('download').description(t('download')).argument('<assetId>', t('id'), assetId)
|
|
128
|
-
.option('--output <path>', t('output')).option('--output-dir <directory>', t('outputDir'))
|
|
178
|
+
.option('--output <path>', t('output')).option('--output-dir <directory>', t('outputDir'))
|
|
179
|
+
.option('--project-root <directory>', t('projectRoot')).option('--revision-id <revision>', t('revision')).option('--json', t('json'))
|
|
180
|
+
.option('--resume', t('resume'))
|
|
129
181
|
.action((id, options) => action(() => downloadLibraryAsset(new AgentApiClient(), id, options)));
|
|
130
182
|
}
|
|
@@ -125,6 +125,40 @@ import { join, relative } from 'node:path';
|
|
|
125
125
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
126
126
|
const canonical = value => Array.isArray(value) ? '[' + value.map(canonical).join(',') + ']' : value && typeof value === 'object'
|
|
127
127
|
? '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}' : JSON.stringify(value);
|
|
128
|
+
export async function prepareModelAssetSources(root) {
|
|
129
|
+
const read = async (path, limit = 64 * 1024 * 1024) => {
|
|
130
|
+
if (typeof path !== 'string' || !path || path.length > 1024 || /[\\\0:]/.test(path) || path.startsWith('/') ||
|
|
131
|
+
path.split('/').some(part => !part || part === '.' || part === '..')) throw Error('asset_provenance_path_invalid');
|
|
132
|
+
let current = root;
|
|
133
|
+
const parts = path.split('/');
|
|
134
|
+
for (const [index, part] of parts.entries()) {
|
|
135
|
+
current = join(current, part); const stat = await lstat(current);
|
|
136
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > limit))
|
|
137
|
+
throw Error('asset_provenance_file_invalid');
|
|
138
|
+
}
|
|
139
|
+
return readFile(current);
|
|
140
|
+
};
|
|
141
|
+
let raw;
|
|
142
|
+
try { raw = await read('.playcraft/asset-provenance.json', 16 * 1024 * 1024); }
|
|
143
|
+
catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
144
|
+
const document = JSON.parse(raw.toString('utf8'));
|
|
145
|
+
if (document.schemaVersion !== 1 || !Array.isArray(document.assets) || document.assets.length > 1024) throw Error('asset_provenance_invalid');
|
|
146
|
+
const paths = new Set(), licenses = new Map();
|
|
147
|
+
for (const asset of document.assets) {
|
|
148
|
+
const provenance = asset.provenance;
|
|
149
|
+
if (!provenance || provenance.schemaVersion !== 1 || provenance.license !== 'CC0-1.0' ||
|
|
150
|
+
!/^[a-f0-9]{64}$/.test(provenance.modelSha256) || typeof provenance.licenseText !== 'string' ||
|
|
151
|
+
Buffer.byteLength(provenance.licenseText) > 64 * 1024 || paths.has(asset.path)) throw Error('asset_provenance_invalid');
|
|
152
|
+
paths.add(asset.path);
|
|
153
|
+
if (hash(await read(asset.path)) !== provenance.modelSha256) throw Error('asset_provenance_model_changed');
|
|
154
|
+
const expectedLicense = 'licenses/assets/' + hash(provenance.licenseText) + '.txt';
|
|
155
|
+
if (asset.licensePath !== expectedLicense) throw Error('asset_provenance_license_path_mismatch');
|
|
156
|
+
const license = await read(asset.licensePath, 64 * 1024);
|
|
157
|
+
if (!license.equals(Buffer.from(provenance.licenseText))) throw Error('asset_provenance_license_changed');
|
|
158
|
+
licenses.set(asset.licensePath, license);
|
|
159
|
+
}
|
|
160
|
+
return { document, licenses, sourceRecordHash: hash(raw), verification: 'matches-recorded-inputs' };
|
|
161
|
+
}
|
|
128
162
|
export async function prepareModelBuild(root) {
|
|
129
163
|
const configRaw = await readFile(join(root, '.playcraft/model-adaptation.json'), 'utf8'), plan = JSON.parse(configRaw);
|
|
130
164
|
const meta = JSON.parse(await readFile(join(root, '.playcraft/model-adaptation-lineage.json'), 'utf8'));
|
|
@@ -186,9 +220,28 @@ export async function prepareModelBuild(root) {
|
|
|
186
220
|
`;
|
|
187
221
|
export const modelProjectVite = String.raw `import { defineConfig } from 'vite';
|
|
188
222
|
import { fileURLToPath } from 'node:url';
|
|
189
|
-
import {
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
import { createHash } from 'node:crypto';
|
|
224
|
+
import { prepareModelBuild, prepareModelAssetSources } from './scripts/prepare-build.mjs';
|
|
225
|
+
const root = fileURLToPath(new URL('./', import.meta.url));
|
|
226
|
+
export default defineConfig({ base: './', plugins: [{ name: 'model-adaptation-frozen-inputs', enforce: 'post', async transformIndexHtml() {
|
|
227
|
+
const evidence = await prepareModelBuild(root);
|
|
192
228
|
return [{ tag: 'script', attrs: { id: 'playcraft-source-evidence', type: 'application/json' }, children: JSON.stringify(evidence).replace(/</g, '\\u003c'), injectTo: 'head' }];
|
|
229
|
+
}, async generateBundle(_options, bundle) {
|
|
230
|
+
const sources = await prepareModelAssetSources(root);
|
|
231
|
+
if (!sources) return;
|
|
232
|
+
const html = bundle['index.html'];
|
|
233
|
+
if (!html || html.type !== 'asset') throw Error('asset_provenance_delivery_missing');
|
|
234
|
+
const digest = source => createHash('sha256').update(source).digest('hex');
|
|
235
|
+
const artifactHash = digest(html.source);
|
|
236
|
+
const artifacts = Object.values(bundle).map(entry => ({ path: entry.fileName,
|
|
237
|
+
sha256: digest(entry.type === 'asset' ? entry.source : entry.code) }));
|
|
238
|
+
for (const [path, bytes] of sources.licenses) artifacts.push({ path, sha256: digest(bytes) });
|
|
239
|
+
artifacts.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
240
|
+
this.emitFile({ type: 'asset', fileName: 'asset-sources.json', source: JSON.stringify({
|
|
241
|
+
schemaVersion: 1, sourceRecordHash: sources.sourceRecordHash, verification: sources.verification,
|
|
242
|
+
artifact: { path: 'index.html', sha256: artifactHash }, artifacts,
|
|
243
|
+
assets: sources.document.assets.map(asset => ({ ...asset, deliveryRepresentation: 'embedded-model' }))
|
|
244
|
+
}, null, 2) + '\n' });
|
|
245
|
+
for (const [fileName, source] of sources.licenses) this.emitFile({ type: 'asset', fileName, source });
|
|
193
246
|
} }], build: { outDir: 'dist', emptyOutDir: true, assetsInlineLimit: Infinity, sourcemap: false } });
|
|
194
247
|
`;
|
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import { canonicalModelAdaptation, workspaceModelAdaptationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { inspectModelSourceFiles, modelSourceDigest } from './source.js';
|
|
6
6
|
import { modelProjectAdapter, modelProjectHtml, modelProjectMain, modelProjectPrepare, modelProjectStrings, modelProjectStyle, modelProjectVite } from './project-template.js';
|
|
7
|
+
import { buildWorkspaceAssetProvenanceFiles, readWorkspaceAssetProvenance } from '../asset-library/provenance.js';
|
|
7
8
|
export async function generateModelAdaptationProject(input) {
|
|
8
9
|
input.signal?.throwIfAborted();
|
|
9
10
|
const root = await realpath(resolve(input.projectRoot)), planPath = resolve(root, input.planPath), info = await lstat(planPath);
|
|
@@ -31,6 +32,18 @@ export async function generateModelAdaptationProject(input) {
|
|
|
31
32
|
if (await realpath(dirname(outputRoot)) !== dirname(outputRoot))
|
|
32
33
|
throw new Error('model_adaptation_output_parent_must_be_canonical');
|
|
33
34
|
const json = (value) => JSON.stringify(value, null, 2) + '\n';
|
|
35
|
+
const importedSources = await readWorkspaceAssetProvenance(root);
|
|
36
|
+
const sourcePaths = new Set(source.source.files.map(file => file.path));
|
|
37
|
+
const provenanceAssets = importedSources?.assets.filter(asset => sourcePaths.has(asset.path)).flatMap(asset => {
|
|
38
|
+
const bytes = source.originalFiles.get(asset.path);
|
|
39
|
+
if (!bytes || modelSourceDigest(bytes) !== asset.provenance.modelSha256)
|
|
40
|
+
throw new Error('asset_provenance_model_changed');
|
|
41
|
+
const original = { ...asset, path: `source/${asset.path}` };
|
|
42
|
+
return asset.path === parentPlan.source.path && source.packedHash === asset.provenance.modelSha256
|
|
43
|
+
? [original, { ...asset, path: 'src/assets/packed-model.glb' }] : [original];
|
|
44
|
+
}) ?? [];
|
|
45
|
+
const provenanceFiles = provenanceAssets.length
|
|
46
|
+
? buildWorkspaceAssetProvenanceFiles({ schemaVersion: 1, assets: provenanceAssets }) : {};
|
|
34
47
|
const files = {
|
|
35
48
|
'package.json': json({ name: 'playcraft-adapted-model-project', version: '0.0.0', private: true, type: 'module',
|
|
36
49
|
scripts: { dev: 'vite --host 0.0.0.0', build: 'vite build' }, dependencies: { three: '0.183.2', '@noble/hashes': '2.0.1' }, devDependencies: { vite: '7.1.3' } }),
|
|
@@ -50,9 +63,10 @@ export async function generateModelAdaptationProject(input) {
|
|
|
50
63
|
} }),
|
|
51
64
|
'AGENTS.md': '# Adapted model project\n\nSource files and the parent adaptation bytes are immutable copies. Save derived configuration through normal workspace operations. Workspace artifacts belong in root dist/, including the preview at dist/preview/index.html; every resource they depend on must be packaged there. Channel packages come from platform export and never go in dist/. Private inputs and failed evidence stay outside dist/. A static model has no inferred opening capability.\n',
|
|
52
65
|
'README.md': '# Saved-model adaptation project\n\nThis prepares source without installation, building, inference or acceptance. Install pinned dependencies and build normally. The workspace parameter panel edits scalar production values; structural mappings and arrays belong to the adaptation panel. Both save .playcraft/model-adaptation.json. Original source/ files and exact parent plan are retained. Runtime geometry identities are checked by the same core as the model viewer. Skin, animation, morph targets and instancing are unsupported; merged parts need a real split. Preview controls are temporary.\n',
|
|
66
|
+
...provenanceFiles,
|
|
53
67
|
};
|
|
54
68
|
const adaptationPaths = Object.keys(files).filter(path => path.startsWith('src/') || path.startsWith('scripts/') ||
|
|
55
|
-
['index.html', 'package.json', 'vite.config.mjs', '.playcraft/workspace.json', '.playcraft/model-adaptation.json', '.playcraft/model-adaptation-lineage.json'].includes(path));
|
|
69
|
+
['index.html', 'package.json', 'vite.config.mjs', '.playcraft/workspace.json', '.playcraft/model-adaptation.json', '.playcraft/model-adaptation-lineage.json', ...Object.keys(provenanceFiles)].includes(path));
|
|
56
70
|
if (derivedSource.files.length + adaptationPaths.length > 256 || adaptationPaths.length > 64)
|
|
57
71
|
throw new Error('model_adaptation_build_input_bound');
|
|
58
72
|
files['.playcraft/model-build-inputs.json'] = json({ schemaVersion: 1, models: [{ source: derivedSource, adaptationFiles: adaptationPaths.map(path => ({ path })) }] });
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { WORKSPACE_REMIX_REFERENCES_DIRECTORY } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
2
|
import { gitRead } from './git.js';
|
|
2
3
|
const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
3
4
|
const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
|
|
4
5
|
const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
|
|
5
6
|
function contentPath(path) {
|
|
7
|
+
if (path.startsWith(`${WORKSPACE_REMIX_REFERENCES_DIRECTORY}/`))
|
|
8
|
+
return false;
|
|
6
9
|
const parts = path.split('/');
|
|
7
10
|
if (parts.some(part => part.startsWith('.') || excludedDirectories.has(part)))
|
|
8
11
|
return false;
|
|
@@ -10,6 +10,7 @@ import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from
|
|
|
10
10
|
import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
|
|
11
11
|
import { publishSourceCandidate } from './source-publish.js';
|
|
12
12
|
import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
|
|
13
|
+
import { adoptPublicationBinding, publicationBinding, publicationGitOptions } from './publication.js';
|
|
13
14
|
async function usagePercent(path) {
|
|
14
15
|
const disk = await statfs(path);
|
|
15
16
|
if (disk.blocks <= 0)
|
|
@@ -32,9 +33,12 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
32
33
|
if (!parsed.success)
|
|
33
34
|
throw new Error('invalid_batch_request');
|
|
34
35
|
const request = parsed.data;
|
|
36
|
+
const leaseFence = state.acceptedLeaseFence;
|
|
35
37
|
const frozen = await readSnapshot(state, request.batchId);
|
|
36
38
|
if (frozen) {
|
|
37
39
|
assertSourceIdentity(state, request, frozen, await readSourceCandidate(state, request.batchId));
|
|
40
|
+
if (request.publication)
|
|
41
|
+
await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
|
|
38
42
|
await clearDirectoryFreeze(state, request.batchId);
|
|
39
43
|
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
|
|
40
44
|
}
|
|
@@ -47,6 +51,8 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
47
51
|
const existing = await readSnapshot(state, request.batchId);
|
|
48
52
|
if (existing) {
|
|
49
53
|
assertSourceIdentity(state, request, existing, pending);
|
|
54
|
+
if (request.publication)
|
|
55
|
+
await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
|
|
50
56
|
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
|
|
51
57
|
}
|
|
52
58
|
if (!pending && 'resumeOnly' in request && request.resumeOnly === true) {
|
|
@@ -55,6 +61,12 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
55
61
|
if (pending)
|
|
56
62
|
assertSourceIdentity(state, request, pending.snapshot, pending);
|
|
57
63
|
source = await workspaceRoot(state, request.workspaceId, request.sourceDirectoryName);
|
|
64
|
+
const binding = publicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication);
|
|
65
|
+
if (request.publication) {
|
|
66
|
+
if (request.persist !== 'remote_ref')
|
|
67
|
+
throw new Error('batch_source_identity_mismatch');
|
|
68
|
+
publicationGitOptions(state, request.publication);
|
|
69
|
+
}
|
|
58
70
|
if (pending)
|
|
59
71
|
candidate = pending;
|
|
60
72
|
else {
|
|
@@ -67,7 +79,6 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
67
79
|
const captured = await captureIndependentSource(source, cache);
|
|
68
80
|
// private
|
|
69
81
|
await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
|
|
70
|
-
const binding = state.journal.getWorkspace(request.workspaceId);
|
|
71
82
|
const snapshot = {
|
|
72
83
|
batchId: request.batchId, commitHash: captured.commitHash, digest: captured.digest,
|
|
73
84
|
sourceWorkspaceId: request.workspaceId, sourceDirectoryName: request.sourceDirectoryName,
|
|
@@ -95,7 +106,9 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
95
106
|
outcome: code === 'batch_source_identity_mismatch' || code === 'batch_source_snapshot_mismatch' ? 'unknown' : 'not_published',
|
|
96
107
|
}, error);
|
|
97
108
|
}
|
|
98
|
-
await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state));
|
|
109
|
+
await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state), request.publication);
|
|
110
|
+
if (request.publication)
|
|
111
|
+
await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
|
|
99
112
|
try {
|
|
100
113
|
await writeSnapshot(state, candidate.snapshot);
|
|
101
114
|
}
|
|
@@ -100,7 +100,10 @@ export async function generateBatchMember(state, body) {
|
|
|
100
100
|
const policy = memberExecutionPolicy(root, tempDir);
|
|
101
101
|
await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
|
|
102
102
|
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
103
|
-
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {})
|
|
103
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}),
|
|
104
|
+
...(command.remixReferences?.length ? { context: { files: [], skills: [], remixReferences: command.remixReferences.map(reference => ({
|
|
105
|
+
...reference, directoryName: command.directoryName,
|
|
106
|
+
})) } } : {}) };
|
|
104
107
|
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
105
108
|
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
106
109
|
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
@@ -7,17 +7,18 @@ export async function gitRead(root, args) {
|
|
|
7
7
|
return runGit(root, ['-c', 'credential.helper=', ...args]);
|
|
8
8
|
}
|
|
9
9
|
/** Push and fetch. Local credential helpers copied onto this repo stay in effect. */
|
|
10
|
-
export async function gitWrite(root, args) {
|
|
11
|
-
return runGit(root, args);
|
|
10
|
+
export async function gitWrite(root, args, options) {
|
|
11
|
+
return runGit(root, args, options);
|
|
12
12
|
}
|
|
13
|
-
async function runGit(root, args) {
|
|
13
|
+
async function runGit(root, args, options) {
|
|
14
14
|
const { stdout } = await execute('git', [
|
|
15
15
|
'-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', '-c', 'core.fsmonitor=false', '-C', root, ...args,
|
|
16
16
|
], {
|
|
17
|
-
timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
|
|
17
|
+
timeout: options?.timeout ?? 90_000, maxBuffer: 64 * 1024 * 1024,
|
|
18
18
|
env: {
|
|
19
19
|
PATH: process.env.PATH, HOME: dirname(root), GIT_OPTIONAL_LOCKS: '0',
|
|
20
20
|
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0',
|
|
21
|
+
...options?.env,
|
|
21
22
|
},
|
|
22
23
|
});
|
|
23
24
|
return stdout;
|
|
@@ -38,3 +39,14 @@ export async function copyCredentialHelpers(sourceRoot, destinationRoot) {
|
|
|
38
39
|
await gitWrite(destinationRoot, ['config', '--local', key, value]);
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
/** Remove helpers and URL rewrites copied by an older attempt before using trusted publication credentials. */
|
|
43
|
+
export async function clearCredentialHelpers(root) {
|
|
44
|
+
const listed = await gitRead(root, ['config', '--local', '--name-only', '--get-regexp', '^(credential\\.|http\\.|url\\.)'])
|
|
45
|
+
.catch((error) => { if (error.code === 1)
|
|
46
|
+
return ''; throw error; });
|
|
47
|
+
for (const key of new Set(listed.split('\n').filter(Boolean))) {
|
|
48
|
+
if (!/^(credential\.|http\.|url\.)/i.test(key) || /[\r\n\0]/.test(key))
|
|
49
|
+
throw new Error('batch_source_git_config_invalid');
|
|
50
|
+
await gitWrite(root, ['config', '--local', '--unset-all', key]);
|
|
51
|
+
}
|
|
52
|
+
}
|