@playcraft/cli 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/tools-batch.js +59 -0
- package/dist/commands/tools.js +2 -0
- package/dist/workspace-runtime/codex/app-server.js +16 -5
- package/dist/workspace-runtime/codex/model-broker.js +5 -5
- package/dist/workspace-runtime/previews/publish.js +7 -4
- package/dist/workspace-runtime/production/contracts.js +17 -0
- package/dist/workspace-runtime/production/controller.js +207 -0
- package/dist/workspace-runtime/production/dependencies.js +60 -0
- package/dist/workspace-runtime/production/native.js +70 -0
- package/dist/workspace-runtime/production/processes.js +86 -0
- package/dist/workspace-runtime/production/save-command.js +20 -0
- package/dist/workspace-runtime/production/stages.js +175 -0
- package/dist/workspace-runtime/production/storage.js +32 -0
- package/dist/workspace-runtime/serve.js +3 -6
- package/dist/workspace-runtime/server/desktop-host.js +1 -1
- package/dist/workspace-runtime/server/desktop-preview.js +5 -5
- package/dist/workspace-runtime/server/dispatch.js +34 -24
- package/dist/workspace-runtime/server/http.js +110 -31
- package/dist/workspace-runtime/server/terminal-host.js +6 -1
- package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
- package/dist/workspace-runtime/workspaces/directories.js +106 -0
- package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
- package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
- package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
- package/dist/workspace-runtime/workspaces/prepare.js +20 -5
- package/dist/workspace-runtime/workspaces/registry.js +63 -12
- package/dist/workspace-runtime/workspaces/revisions.js +5 -5
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
- package/package.json +3 -3
- package/project-templates/batch-locks/phaser.yaml +922 -0
- package/project-templates/batch-locks/threejs.yaml +978 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { productionDirectoryBusy } from '../production/controller.js';
|
|
8
|
+
import { workspaceRoot, workspaceSessions } from './registry.js';
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
11
|
+
timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
12
|
+
})).stdout.trim();
|
|
13
|
+
export async function workspaceDirectoryOccupied(state, workspaceId, directoryName) {
|
|
14
|
+
if (await productionDirectoryBusy(state, workspaceId, directoryName))
|
|
15
|
+
return true;
|
|
16
|
+
if (state.journal.listOperations().some(({ operation }) => operation.workspaceId === workspaceId
|
|
17
|
+
&& (operation.directoryName ?? 'basic') === directoryName && ['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status)))
|
|
18
|
+
return true;
|
|
19
|
+
if (workspaceSessions(state, workspaceId, directoryName).some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
|
|
20
|
+
return true;
|
|
21
|
+
if (state.journal.listTerminals().some(terminal => terminal.workspaceId === workspaceId && (terminal.directoryName ?? 'basic') === directoryName && terminal.status !== 'destroyed'))
|
|
22
|
+
return true;
|
|
23
|
+
return [workspaceId, `${workspaceId}:${directoryName}`].some(scope => state.journal.listManagedWrites(scope).some(write => !write.stoppedAt));
|
|
24
|
+
}
|
|
25
|
+
async function assertCleanAndPushed(state, root, allowedHead) {
|
|
26
|
+
if (await git(state, root, 'status', '--porcelain', '--untracked-files=all'))
|
|
27
|
+
throw new Error('workspace_directory_uncommitted');
|
|
28
|
+
const upstream = await git(state, root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => '');
|
|
29
|
+
if (!upstream || (Number(await git(state, root, 'rev-list', '--count', `${upstream}..HEAD`)) > 0 && await git(state, root, 'rev-parse', 'HEAD') !== allowedHead))
|
|
30
|
+
throw new Error('workspace_directory_unpushed');
|
|
31
|
+
}
|
|
32
|
+
/** Keep remote branches, published previews and all journal identities; only local checkouts are reclaimed. */
|
|
33
|
+
export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames) {
|
|
34
|
+
if (!Array.isArray(directoryNames) || directoryNames.length === 0 || directoryNames.length > 64)
|
|
35
|
+
throw new Error('workspace_directories_invalid');
|
|
36
|
+
const names = [...new Set(directoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))];
|
|
37
|
+
return state.journal.exclusive(`maintenance:${workspaceId}`, async () => {
|
|
38
|
+
const cleaned = [];
|
|
39
|
+
const blocked = [];
|
|
40
|
+
for (const directoryName of names) {
|
|
41
|
+
try {
|
|
42
|
+
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
43
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
|
|
44
|
+
throw new Error('workspace_directory_occupied');
|
|
45
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
46
|
+
await assertCleanAndPushed(state, root);
|
|
47
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
48
|
+
// Persist recoverable identity before removing the checkout; a retry cannot silently adopt another branch.
|
|
49
|
+
const existing = binding.directories?.find(item => item.directoryName === directoryName);
|
|
50
|
+
const directory = existing ?? { workspaceId, directoryName, remixId: binding.remixId, branchName: binding.branchName };
|
|
51
|
+
await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
|
|
52
|
+
await rm(root, { recursive: true });
|
|
53
|
+
cleaned.push(directoryName);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
blocked.push({ directoryName, reason: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'workspace_cleanup_failed' });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { cleaned, blocked };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Conflicts remain isolated to their own engineering directory. Busy directories are skipped without deferred work. */
|
|
64
|
+
export async function syncWorkspaceDirectories(state, workspaceId, commitHash, targetDirectoryNames) {
|
|
65
|
+
if (typeof commitHash !== 'string' || !/^[a-f0-9]{40,64}$/.test(commitHash) || !Array.isArray(targetDirectoryNames) || targetDirectoryNames.length === 0 || targetDirectoryNames.length > 64)
|
|
66
|
+
throw new Error('workspace_sync_invalid');
|
|
67
|
+
const basic = await workspaceRoot(state, workspaceId, 'basic');
|
|
68
|
+
await git(state, basic, 'merge-base', '--is-ancestor', commitHash, 'HEAD');
|
|
69
|
+
const synced = [], skipped = [], conflicts = [];
|
|
70
|
+
for (const directoryName of [...new Set(targetDirectoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))]) {
|
|
71
|
+
if (directoryName === 'basic')
|
|
72
|
+
throw new Error('workspace_sync_target_invalid');
|
|
73
|
+
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
74
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
|
|
75
|
+
skipped.push({ directoryName, reason: 'directory_busy' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
79
|
+
const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
|
|
80
|
+
const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
81
|
+
await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
|
|
82
|
+
if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
|
|
83
|
+
synced.push(directoryName);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
|
|
87
|
+
if (containing) {
|
|
88
|
+
synced.push(directoryName);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Import the exact preserved basic commit, without fetching a moving remote branch.
|
|
92
|
+
await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
93
|
+
const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
|
|
94
|
+
if (!equivalent) {
|
|
95
|
+
try {
|
|
96
|
+
await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
conflicts.push(directoryName);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
|
|
104
|
+
const temporary = `${ledger}.${randomUUID()}.tmp`;
|
|
105
|
+
await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
|
|
106
|
+
await rename(temporary, ledger);
|
|
107
|
+
synced.push(directoryName);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { synced, skipped, conflicts, previewStale: true };
|
|
111
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { workspaceRoot, WorkspaceContextError } from './registry.js';
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
/** Measures registered checkouts only when requested; journal activity never scans private neighboring workspaces. */
|
|
6
|
+
export async function readWorkspaceDirectoryStats(state, workspaceId) {
|
|
7
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
8
|
+
if (!binding)
|
|
9
|
+
throw new WorkspaceContextError('workspace_not_registered');
|
|
10
|
+
const names = [...new Set(['basic', ...(binding.directories ?? []).map(directory => directory.directoryName)])];
|
|
11
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => operation.workspaceId === workspaceId);
|
|
12
|
+
const events = state.journal.listEvents().flatMap(event => {
|
|
13
|
+
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
14
|
+
const session = typeof payload.sessionId === 'string' ? state.journal.getSession(payload.sessionId) : undefined;
|
|
15
|
+
if ((payload.workspaceId ?? session?.workspaceId) !== workspaceId)
|
|
16
|
+
return [];
|
|
17
|
+
return [{ directoryName: String(payload.directoryName ?? session?.directoryName ?? 'basic'), occurredAt: event.occurredAt }];
|
|
18
|
+
});
|
|
19
|
+
const latest = (dates) => {
|
|
20
|
+
const timestamps = dates.map(value => Date.parse(value)).filter(Number.isFinite);
|
|
21
|
+
return timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null;
|
|
22
|
+
};
|
|
23
|
+
const directories = [];
|
|
24
|
+
for (const directoryName of names) {
|
|
25
|
+
const lastUsedAt = latest([...operations.filter(operation => (operation.directoryName ?? 'basic') === directoryName).map(operation => operation.updatedAt),
|
|
26
|
+
...events.filter(event => event.directoryName === directoryName).map(event => event.occurredAt)]);
|
|
27
|
+
if (binding.directories?.some(directory => directory.directoryName === directoryName && directory.status === 'reclaimed')) {
|
|
28
|
+
directories.push({ directoryName, sizeBytes: 0, lastUsedAt, status: 'reclaimed' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let root;
|
|
32
|
+
try {
|
|
33
|
+
root = await workspaceRoot(state, workspaceId, directoryName);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!(error instanceof WorkspaceContextError) || error.message !== 'workspace_not_prepared')
|
|
37
|
+
throw error;
|
|
38
|
+
directories.push({ directoryName, sizeBytes: 0, lastUsedAt, status: 'missing' });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const output = await execute('du', ['-sk', root], { timeout: 30_000, maxBuffer: 4096 });
|
|
42
|
+
const blocks = Number(output.stdout.trim().split(/\s+/)[0]);
|
|
43
|
+
if (!Number.isSafeInteger(blocks) || blocks < 0 || !Number.isSafeInteger(blocks * 1024))
|
|
44
|
+
throw new Error('workspace_directory_stats_invalid');
|
|
45
|
+
directories.push({ directoryName, sizeBytes: blocks * 1024, lastUsedAt, status: 'present' });
|
|
46
|
+
}
|
|
47
|
+
return { workspaceId, sizeBytes: directories.reduce((sum, directory) => sum + directory.sizeBytes, 0),
|
|
48
|
+
lastUsedAt: latest([...operations.map(operation => operation.updatedAt), ...events.map(event => event.occurredAt)]), directories };
|
|
49
|
+
}
|
|
@@ -6,7 +6,7 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
|
-
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
10
10
|
import { WorkspaceGitDirectStore } from './git-direct-store.js';
|
|
11
11
|
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
12
12
|
const exec = promisify(execFile);
|
|
@@ -76,9 +76,17 @@ export class WorkspaceGitCredentialBroker {
|
|
|
76
76
|
await unlink(this.socketPath).catch(() => undefined);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
async configure(binding, preparation) {
|
|
79
|
+
async configure(binding, preparation, directoryName = 'basic') {
|
|
80
|
+
workspaceDirectoryNameSchema.parse(directoryName);
|
|
81
|
+
if (binding.targetKind === 'standalone')
|
|
82
|
+
throw new Error('git_access_denied');
|
|
83
|
+
if (directoryName !== 'basic')
|
|
84
|
+
binding = this.binding(binding.workspaceId, directoryName);
|
|
85
|
+
const { directories: _directories, ...stableBinding } = binding;
|
|
86
|
+
binding = stableBinding;
|
|
87
|
+
const scope = `${binding.workspaceId}:${directoryName}`;
|
|
80
88
|
preparation = workspacePreparationSchema.parse(preparation);
|
|
81
|
-
if (
|
|
89
|
+
if (preparation.mode !== 'clone')
|
|
82
90
|
throw new Error('git_access_denied');
|
|
83
91
|
const transport = preparation.transport;
|
|
84
92
|
const isDirect = isLocalWorkspaceGitAuthMode(preparation.authMode);
|
|
@@ -95,13 +103,13 @@ export class WorkspaceGitCredentialBroker {
|
|
|
95
103
|
}
|
|
96
104
|
// Revocation and switching to gateway invalidate first; direct rotation keeps the old file until rename.
|
|
97
105
|
if (!isDirect)
|
|
98
|
-
await this.direct.remove(binding.workspaceId);
|
|
106
|
+
await this.direct.remove(binding.workspaceId, directoryName);
|
|
99
107
|
else if (preparation.directAccess === null)
|
|
100
|
-
await this.direct.put(binding, null);
|
|
101
|
-
this.cache.delete(
|
|
108
|
+
await this.direct.put(binding, null, undefined, directoryName);
|
|
109
|
+
this.cache.delete(scope);
|
|
102
110
|
const cloneUrl = isDirect ? binding.repositoryUrl : transport.cloneUrl;
|
|
103
|
-
const root = join(this.state.config.workspaceRoot, binding.workspaceId);
|
|
104
|
-
const helper = `!${quote(process.execPath)} ${quote(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url)))} ${quote(this.socketPath)} ${quote(binding.workspaceId)}`;
|
|
111
|
+
const root = join(this.state.config.workspaceRoot, binding.workspaceId, directoryName);
|
|
112
|
+
const helper = `!${quote(process.execPath)} ${quote(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url)))} ${quote(this.socketPath)} ${quote(binding.workspaceId)} ${quote(directoryName)}`;
|
|
105
113
|
await configureGitFile(root, this.state.config.stateDir, [
|
|
106
114
|
...(!isDirect ? [['--replace-all', `url.${cloneUrl}.insteadOf`, binding.repositoryUrl]] : []),
|
|
107
115
|
['--replace-all', `credential.${cloneUrl}.useHttpPath`, 'true'],
|
|
@@ -111,8 +119,8 @@ export class WorkspaceGitCredentialBroker {
|
|
|
111
119
|
['--replace-all', 'playcraft.gitAuthUrl', cloneUrl],
|
|
112
120
|
], binding.repositoryUrl);
|
|
113
121
|
if (isDirect)
|
|
114
|
-
await this.direct.put(binding, preparation.directAccess);
|
|
115
|
-
this.cache.delete(
|
|
122
|
+
await this.direct.put(binding, preparation.directAccess, undefined, directoryName);
|
|
123
|
+
this.cache.delete(scope);
|
|
116
124
|
}
|
|
117
125
|
async credential(input) {
|
|
118
126
|
if (!this.ready || this.closed || this.state.journal.destroyed() || !input || !['get', 'erase'].includes(input.action)
|
|
@@ -120,17 +128,17 @@ export class WorkspaceGitCredentialBroker {
|
|
|
120
128
|
|| !['http', 'https'].includes(input.protocol) || typeof input.host !== 'string'
|
|
121
129
|
|| typeof input.path !== 'string')
|
|
122
130
|
throw new Error('git_access_denied');
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
const directoryName = workspaceDirectoryNameSchema.parse(input.directoryName ?? 'basic');
|
|
132
|
+
const binding = this.binding(input.workspaceId, directoryName);
|
|
133
|
+
const scope = `${input.workspaceId}:${directoryName}`;
|
|
126
134
|
const cloneUrl = `${input.protocol}://${input.host}/${input.path}`;
|
|
127
|
-
const direct = await this.direct.get(binding);
|
|
135
|
+
const direct = await this.direct.get(binding, directoryName);
|
|
128
136
|
if (direct) {
|
|
129
137
|
if (!direct.access || cloneUrl !== binding.repositoryUrl)
|
|
130
138
|
throw new Error('git_access_denied');
|
|
131
139
|
if (input.action === 'erase') {
|
|
132
140
|
if (input.password === direct.access.password)
|
|
133
|
-
await this.direct.put(binding, null, input.password);
|
|
141
|
+
await this.direct.put(binding, null, input.password, directoryName);
|
|
134
142
|
return {};
|
|
135
143
|
}
|
|
136
144
|
return { username: direct.access.username, password: direct.access.password };
|
|
@@ -139,39 +147,57 @@ export class WorkspaceGitCredentialBroker {
|
|
|
139
147
|
if (!matched.ok || matched.requestId !== `v2/${binding.workspaceId}`)
|
|
140
148
|
throw new Error('git_access_denied');
|
|
141
149
|
if (input.action === 'erase') {
|
|
142
|
-
if (this.cache.get(
|
|
143
|
-
this.cache.delete(
|
|
150
|
+
if (this.cache.get(scope)?.preparation.gitAccess?.password === input.password)
|
|
151
|
+
this.cache.delete(scope);
|
|
144
152
|
return {};
|
|
145
153
|
}
|
|
146
154
|
const key = JSON.stringify([binding, this.state.config.generation, this.state.runtimeEpoch]);
|
|
147
|
-
const cached = this.cache.get(
|
|
155
|
+
const cached = this.cache.get(scope);
|
|
148
156
|
let preparation = cached?.key === key && Date.parse(cached.preparation.gitAccess.expiresAt) > Date.now() + 60_000
|
|
149
157
|
? cached.preparation : undefined;
|
|
150
158
|
if (!preparation) {
|
|
151
|
-
const pendingKey = `${
|
|
159
|
+
const pendingKey = `${scope}:${key}`;
|
|
152
160
|
let pending = this.pending.get(pendingKey);
|
|
153
161
|
if (!pending) {
|
|
154
|
-
pending = this.refresh(binding, key).finally(() => this.pending.delete(pendingKey));
|
|
162
|
+
pending = this.refresh(binding, key, directoryName).finally(() => this.pending.delete(pendingKey));
|
|
155
163
|
this.pending.set(pendingKey, pending);
|
|
156
164
|
}
|
|
157
165
|
preparation = await pending;
|
|
158
166
|
}
|
|
159
|
-
if (await this.direct.get(binding) || preparation.transport?.cloneUrl !== cloneUrl || !preparation.gitAccess)
|
|
167
|
+
if (await this.direct.get(binding, directoryName) || preparation.transport?.cloneUrl !== cloneUrl || !preparation.gitAccess)
|
|
160
168
|
throw new Error('git_access_denied');
|
|
161
169
|
return { username: preparation.gitAccess.username, password: preparation.gitAccess.password };
|
|
162
170
|
}
|
|
171
|
+
async prepareDirectory(workspaceId, directoryName = 'basic') {
|
|
172
|
+
const binding = this.binding(workspaceId, directoryName, true);
|
|
173
|
+
return this.refresh(binding, JSON.stringify([binding, this.state.config.generation, this.state.runtimeEpoch]), directoryName);
|
|
174
|
+
}
|
|
175
|
+
binding(workspaceId, directoryName, recovering = false) {
|
|
176
|
+
workspaceDirectoryNameSchema.parse(directoryName);
|
|
177
|
+
const binding = this.state.journal.getWorkspace(workspaceId);
|
|
178
|
+
if (!binding || binding.targetKind === 'standalone')
|
|
179
|
+
throw new Error('git_access_denied');
|
|
180
|
+
const { directories: _directories, ...base } = binding;
|
|
181
|
+
if (directoryName === 'basic')
|
|
182
|
+
return base;
|
|
183
|
+
const directory = binding.directories?.find(item => item.directoryName === directoryName && (recovering || item.status !== 'reclaimed'));
|
|
184
|
+
if (!directory?.remixId)
|
|
185
|
+
throw new Error('git_access_denied');
|
|
186
|
+
return { ...base, targetKind: 'remix', remixId: directory.remixId, branchName: directory.branchName };
|
|
187
|
+
}
|
|
163
188
|
async destroy() {
|
|
164
189
|
await this.close();
|
|
165
190
|
await this.direct.sweep(true);
|
|
166
191
|
}
|
|
167
|
-
async refresh(binding, key) {
|
|
192
|
+
async refresh(binding, key, directoryName) {
|
|
193
|
+
const scope = `${binding.workspaceId}:${directoryName}`;
|
|
168
194
|
const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
|
|
169
195
|
const epoch = this.state.runtimeEpoch;
|
|
170
196
|
const response = await fetch(endpoint, {
|
|
171
197
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
172
198
|
headers: { authorization: `Bearer ${this.state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': this.state.config.environment },
|
|
173
199
|
body: JSON.stringify({ runtimeId: this.state.config.runtimeId, generation: this.state.config.generation,
|
|
174
|
-
runtimeEpoch: epoch, workspaceId: binding.workspaceId }),
|
|
200
|
+
runtimeEpoch: epoch, workspaceId: binding.workspaceId, directoryName }),
|
|
175
201
|
});
|
|
176
202
|
if (!response.ok) {
|
|
177
203
|
await response.body?.cancel();
|
|
@@ -182,14 +208,21 @@ export class WorkspaceGitCredentialBroker {
|
|
|
182
208
|
throw new Error('git_access_denied');
|
|
183
209
|
const matched = preparation.transport && matchWorkspaceGitGatewayCloneUrl(preparation.transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
|
|
184
210
|
if (this.closed || this.state.journal.destroyed() || this.state.runtimeEpoch !== epoch
|
|
185
|
-
|| JSON.stringify(this.
|
|
186
|
-
|| preparation.transport?.kind !== 'git-gateway-v2' || !matched?.ok || matched.requestId !== `v2/${binding.workspaceId}`
|
|
187
|
-
|| !preparation.gitAccess || Date.parse(preparation.gitAccess.issuedAt) > Date.now()
|
|
188
|
-
|| Date.parse(preparation.gitAccess.expiresAt) <= Date.now() + 10_000)
|
|
211
|
+
|| JSON.stringify(this.binding(binding.workspaceId, directoryName, true)) !== JSON.stringify(binding))
|
|
189
212
|
throw new Error('git_access_denied');
|
|
190
|
-
if (
|
|
191
|
-
this.
|
|
192
|
-
|
|
213
|
+
if (isLocalWorkspaceGitAuthMode(preparation.authMode)) {
|
|
214
|
+
if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || !preparation.directAccess || preparation.directAccess.repositoryUrl !== binding.repositoryUrl)
|
|
215
|
+
throw new Error('git_access_denied');
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
if (preparation.transport?.kind !== 'git-gateway-v2' || !matched?.ok || matched.requestId !== `v2/${binding.workspaceId}`
|
|
219
|
+
|| !preparation.gitAccess || Date.parse(preparation.gitAccess.issuedAt) > Date.now()
|
|
220
|
+
|| Date.parse(preparation.gitAccess.expiresAt) <= Date.now() + 10_000)
|
|
221
|
+
throw new Error('git_access_denied');
|
|
222
|
+
if (this.cache.size >= 128)
|
|
223
|
+
this.cache.clear();
|
|
224
|
+
this.cache.set(scope, { key, preparation });
|
|
225
|
+
}
|
|
193
226
|
return preparation;
|
|
194
227
|
}
|
|
195
228
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { request } from 'node:http';
|
|
2
2
|
// Git invokes this executable directly. Keep standard output exclusively for the credential protocol.
|
|
3
|
-
const [socketPath, workspaceId,
|
|
3
|
+
const [socketPath, workspaceId, third, fourth] = process.argv.slice(2);
|
|
4
|
+
const directoryName = fourth === undefined ? 'basic' : third;
|
|
5
|
+
const action = fourth ?? third;
|
|
4
6
|
try {
|
|
5
7
|
if (action === 'get' || action === 'erase') {
|
|
6
8
|
const fields = {};
|
|
@@ -49,7 +51,7 @@ try {
|
|
|
49
51
|
}
|
|
50
52
|
});
|
|
51
53
|
client.once('error', reject);
|
|
52
|
-
client.end(JSON.stringify({ workspaceId, action, protocol: fields.protocol, host: fields.host,
|
|
54
|
+
client.end(JSON.stringify({ workspaceId, directoryName, action, protocol: fields.protocol, host: fields.host,
|
|
53
55
|
path: fields.path, ...(action === 'erase' ? { password: fields.password } : {}) }));
|
|
54
56
|
});
|
|
55
57
|
if (action === 'get') {
|
|
@@ -2,7 +2,7 @@ import { constants } from 'node:fs';
|
|
|
2
2
|
import { lstat, mkdir, open, realpath, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { join, resolve, sep } from 'node:path';
|
|
5
|
-
import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema, workspaceDirectoryNameSchema, sameWorkspaceRegistration } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
/** Control-owned files only. Same-user processes are not an operating-system isolation boundary. */
|
|
7
7
|
export class WorkspaceGitDirectStore {
|
|
8
8
|
state;
|
|
@@ -19,10 +19,11 @@ export class WorkspaceGitDirectStore {
|
|
|
19
19
|
const c = this.state.config;
|
|
20
20
|
return JSON.stringify([c.environment, c.userId, c.runtimeId, c.generation]);
|
|
21
21
|
}
|
|
22
|
-
name(id) {
|
|
22
|
+
name(id, directoryName = 'basic') {
|
|
23
23
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
24
24
|
throw new Error('git_access_denied');
|
|
25
|
-
|
|
25
|
+
workspaceDirectoryNameSchema.parse(directoryName);
|
|
26
|
+
return `${id}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`;
|
|
26
27
|
}
|
|
27
28
|
async directory(create, action) {
|
|
28
29
|
const stateDir = resolve(this.state.config.stateDir);
|
|
@@ -76,12 +77,12 @@ export class WorkspaceGitDirectStore {
|
|
|
76
77
|
await handle.close();
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
|
-
async get(binding) {
|
|
80
|
+
async get(binding, directoryName = 'basic') {
|
|
80
81
|
return this.directory(false, async (path) => {
|
|
81
|
-
const stored = await this.read(join(path, this.name(binding.workspaceId)));
|
|
82
|
+
const stored = await this.read(join(path, this.name(binding.workspaceId, directoryName)));
|
|
82
83
|
if (!stored)
|
|
83
84
|
return undefined;
|
|
84
|
-
if (stored.identity !== this.identity() ||
|
|
85
|
+
if (stored.identity !== this.identity() || !sameWorkspaceRegistration(stored.binding, binding)
|
|
85
86
|
|| !isLocalWorkspaceGitEnvironment(this.state.config.environment))
|
|
86
87
|
throw new Error('git_access_denied');
|
|
87
88
|
if (stored.access !== null) {
|
|
@@ -92,13 +93,13 @@ export class WorkspaceGitDirectStore {
|
|
|
92
93
|
return stored;
|
|
93
94
|
});
|
|
94
95
|
}
|
|
95
|
-
async put(binding, access, rejectedPassword) {
|
|
96
|
+
async put(binding, access, rejectedPassword, directoryName = 'basic') {
|
|
96
97
|
if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || this.state.journal.destroyed())
|
|
97
98
|
throw new Error('git_access_denied');
|
|
98
99
|
if (access && (workspaceGitDirectAccessSchema.parse(access).repositoryUrl !== binding.repositoryUrl))
|
|
99
100
|
throw new Error('git_access_denied');
|
|
100
101
|
await this.exclusive(() => this.directory(true, async (path) => {
|
|
101
|
-
const target = join(path, this.name(binding.workspaceId));
|
|
102
|
+
const target = join(path, this.name(binding.workspaceId, directoryName));
|
|
102
103
|
// Refuse symlink/hardlink/permissive old files even though rename would replace them.
|
|
103
104
|
const old = await this.read(target);
|
|
104
105
|
if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
|
|
@@ -127,9 +128,9 @@ export class WorkspaceGitDirectStore {
|
|
|
127
128
|
}
|
|
128
129
|
}));
|
|
129
130
|
}
|
|
130
|
-
async remove(workspaceId) {
|
|
131
|
+
async remove(workspaceId, directoryName = 'basic') {
|
|
131
132
|
await this.exclusive(() => this.directory(false, async (path) => {
|
|
132
|
-
const target = join(path, this.name(workspaceId));
|
|
133
|
+
const target = join(path, this.name(workspaceId, directoryName));
|
|
133
134
|
await this.read(target);
|
|
134
135
|
await unlink(target).catch(error => { if (error.code !== 'ENOENT')
|
|
135
136
|
throw error; });
|
|
@@ -138,7 +139,7 @@ export class WorkspaceGitDirectStore {
|
|
|
138
139
|
async sweep(destroy = false) {
|
|
139
140
|
await this.exclusive(() => this.directory(false, async (path) => {
|
|
140
141
|
for (const name of await readdir(path)) {
|
|
141
|
-
if (!/^[a-f0-9-]{36}
|
|
142
|
+
if (!/^[a-f0-9-]{36}(?:--remix-[a-z0-9][a-z0-9-]{3,63})?\.(json|tmp)$/.test(name))
|
|
142
143
|
throw new Error('git_credential_file_rejected');
|
|
143
144
|
const target = join(path, name);
|
|
144
145
|
if (name.endsWith('.tmp')) {
|
|
@@ -2,11 +2,11 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, workspaceDirectoryNameSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
7
|
import { scaffoldEmptyWorkspace } from './empty-template.js';
|
|
8
8
|
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
9
|
-
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
|
|
9
|
+
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence, directoryName = 'basic', recovery = false) {
|
|
10
10
|
const preparation = workspacePreparationSchema.safeParse(input);
|
|
11
11
|
if (!preparation.success)
|
|
12
12
|
throw new WorkspaceContextError('invalid_workspace_preparation');
|
|
@@ -20,15 +20,24 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
20
20
|
|| clone.directAccess.repositoryUrl !== binding.repositoryUrl))
|
|
21
21
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
22
22
|
const parent = await realpath(state.config.workspaceRoot);
|
|
23
|
-
const
|
|
23
|
+
const workspace = join(parent, binding.workspaceId);
|
|
24
|
+
workspaceDirectoryNameSchema.parse(directoryName);
|
|
25
|
+
const target = join(workspace, directoryName);
|
|
24
26
|
const receiptDir = join(state.config.stateDir, 'workspace-preparations');
|
|
25
27
|
await mkdir(receiptDir, { recursive: true, mode: 0o700 });
|
|
26
|
-
const receiptPath = join(receiptDir, `${binding.workspaceId}.json`);
|
|
27
|
-
|
|
28
|
+
const receiptPath = join(receiptDir, `${binding.workspaceId}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`);
|
|
29
|
+
let previous = await readReceipt(receiptPath, binding);
|
|
30
|
+
if (recovery && previous?.status === 'published')
|
|
31
|
+
previous = undefined;
|
|
28
32
|
if (previous?.status === 'published')
|
|
29
33
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
30
34
|
if (await exists(target))
|
|
31
35
|
throw new WorkspaceContextError('workspace_checkout_conflict');
|
|
36
|
+
if (await exists(workspace)) {
|
|
37
|
+
const stat = await lstat(workspace);
|
|
38
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
|
|
39
|
+
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
40
|
+
}
|
|
32
41
|
// An unfinished checkout recorded before the fork instructions were known must be cloned again.
|
|
33
42
|
const forkMatches = !previous || samePendingFork(previous.binding, binding);
|
|
34
43
|
if (previous && !forkMatches)
|
|
@@ -133,6 +142,12 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
133
142
|
assertCurrent();
|
|
134
143
|
if (await exists(target))
|
|
135
144
|
throw new WorkspaceContextError('workspace_checkout_conflict');
|
|
145
|
+
if (await exists(workspace)) {
|
|
146
|
+
const stat = await lstat(workspace);
|
|
147
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
|
|
148
|
+
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
149
|
+
}
|
|
150
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
136
151
|
await rename(staging, target);
|
|
137
152
|
receipt.status = 'published';
|
|
138
153
|
await writeReceipt(receiptPath, receipt);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { lstat, mkdir, realpath } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -10,31 +10,39 @@ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
|
10
10
|
import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
|
|
11
11
|
export { WorkspaceContextError } from './context-error.js';
|
|
12
12
|
const exec = promisify(execFile);
|
|
13
|
-
export function revisionScope(state, workspaceId) {
|
|
14
|
-
return state.config.workspaceMode === 'multi' ? workspaceId : undefined;
|
|
13
|
+
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
14
|
+
return state.config.workspaceMode === 'multi' ? `${workspaceId}:${directoryName}` : undefined;
|
|
15
15
|
}
|
|
16
|
-
export function workspaceSessions(state, workspaceId) {
|
|
16
|
+
export function workspaceSessions(state, workspaceId, directoryName = 'basic') {
|
|
17
17
|
return state.journal
|
|
18
18
|
.listSessions()
|
|
19
|
-
.filter((item) => state.config.workspaceMode !== 'multi' || item.workspaceId === workspaceId);
|
|
19
|
+
.filter((item) => state.config.workspaceMode !== 'multi' || (item.workspaceId === workspaceId && (item.directoryName ?? 'basic') === directoryName));
|
|
20
20
|
}
|
|
21
|
-
export async function workspaceRoot(state, workspaceId) {
|
|
21
|
+
export async function workspaceRoot(state, workspaceId, directoryName = 'basic') {
|
|
22
22
|
if (state.config.workspaceMode !== 'multi')
|
|
23
23
|
return state.config.workspaceRoot;
|
|
24
24
|
if (!workspaceId || !state.journal.getWorkspace(workspaceId))
|
|
25
25
|
throw new WorkspaceContextError('workspace_not_registered');
|
|
26
|
-
|
|
26
|
+
if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
|
|
27
|
+
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
28
|
+
if (directoryName !== 'basic' && !state.journal.getWorkspace(workspaceId)?.directories?.some(directory => directory.directoryName === directoryName))
|
|
29
|
+
throw new WorkspaceContextError('workspace_directory_not_registered');
|
|
30
|
+
return checkedDirectory(state, workspaceId, directoryName);
|
|
27
31
|
}
|
|
28
|
-
async function checkedDirectory(state, workspaceId) {
|
|
32
|
+
async function checkedDirectory(state, workspaceId, directoryName = 'basic') {
|
|
29
33
|
if (!/^[0-9a-f-]{36}$/i.test(workspaceId))
|
|
30
34
|
throw new WorkspaceContextError('invalid_workspace');
|
|
31
35
|
const parent = await realpath(state.config.workspaceRoot);
|
|
32
|
-
const
|
|
36
|
+
const container = join(parent, workspaceId);
|
|
37
|
+
const containerStat = await lstat(container).catch(() => undefined);
|
|
38
|
+
if (!containerStat?.isDirectory() || containerStat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
|
|
39
|
+
throw new WorkspaceContextError('workspace_not_prepared');
|
|
40
|
+
const target = join(container, directoryName);
|
|
33
41
|
try {
|
|
34
42
|
const stat = await lstat(target);
|
|
35
43
|
if (!stat.isDirectory() ||
|
|
36
44
|
stat.isSymbolicLink() ||
|
|
37
|
-
relative(
|
|
45
|
+
relative(container, await realpath(target)) !== directoryName) {
|
|
38
46
|
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
39
47
|
}
|
|
40
48
|
const stateDir = await realpath(state.config.stateDir);
|
|
@@ -49,6 +57,37 @@ async function checkedDirectory(state, workspaceId) {
|
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
/** Registers a fixed directory; optional first creation is restricted to its trusted binding. */
|
|
60
|
+
export async function workspaceContainerRoot(state, workspaceId) {
|
|
61
|
+
if (state.config.workspaceMode !== 'multi')
|
|
62
|
+
return state.config.workspaceRoot;
|
|
63
|
+
if (!workspaceId || !state.journal.getWorkspace(workspaceId))
|
|
64
|
+
throw new WorkspaceContextError('workspace_not_registered');
|
|
65
|
+
if (!/^[0-9a-f-]{36}$/i.test(workspaceId))
|
|
66
|
+
throw new WorkspaceContextError('invalid_workspace');
|
|
67
|
+
const parent = await realpath(state.config.workspaceRoot);
|
|
68
|
+
const container = join(parent, workspaceId);
|
|
69
|
+
const stat = await lstat(container).catch(() => undefined);
|
|
70
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
|
|
71
|
+
throw new WorkspaceContextError('workspace_not_prepared');
|
|
72
|
+
const stateDir = await realpath(state.config.stateDir);
|
|
73
|
+
if (stateDir === container || stateDir.startsWith(`${container}/`))
|
|
74
|
+
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
75
|
+
return container;
|
|
76
|
+
}
|
|
77
|
+
export async function sessionWorkspaceRoot(state, workspaceId, directoryName = 'basic') {
|
|
78
|
+
return directoryName === 'root' ? workspaceContainerRoot(state, workspaceId) : workspaceRoot(state, workspaceId, directoryName);
|
|
79
|
+
}
|
|
80
|
+
export async function workspaceContextFromRoot(state, cwd) {
|
|
81
|
+
const parent = await realpath(state.config.workspaceRoot);
|
|
82
|
+
const parts = relative(parent, cwd).split('/');
|
|
83
|
+
if (parts.length !== 1 && parts.length !== 2)
|
|
84
|
+
throw new WorkspaceContextError('workspace_not_registered');
|
|
85
|
+
const workspaceId = parts[0];
|
|
86
|
+
const directoryName = parts.length === 1 ? 'root' : parts[1];
|
|
87
|
+
if (await sessionWorkspaceRoot(state, workspaceId, directoryName) !== cwd)
|
|
88
|
+
throw new WorkspaceContextError('workspace_not_registered');
|
|
89
|
+
return { workspaceId, directoryName };
|
|
90
|
+
}
|
|
52
91
|
export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
53
92
|
if (state.config.workspaceMode !== 'multi')
|
|
54
93
|
throw new WorkspaceContextError('multi_workspace_unavailable');
|
|
@@ -66,6 +105,18 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
66
105
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
67
106
|
if (existing && !sameWorkspaceRegistration(existing, binding))
|
|
68
107
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
108
|
+
const directories = binding.directories ?? existing?.directories;
|
|
109
|
+
if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
|
|
110
|
+
// Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
|
|
111
|
+
const parent = await realpath(state.config.workspaceRoot);
|
|
112
|
+
const container = join(parent, binding.workspaceId);
|
|
113
|
+
await mkdir(container, { mode: 0o2770 }).catch(error => { if (error.code !== 'EEXIST')
|
|
114
|
+
throw error; });
|
|
115
|
+
const registered = { ...binding, directories };
|
|
116
|
+
await state.journal.putWorkspace(registered);
|
|
117
|
+
await workspaceContainerRoot(state, binding.workspaceId);
|
|
118
|
+
return registered;
|
|
119
|
+
}
|
|
69
120
|
let root;
|
|
70
121
|
try {
|
|
71
122
|
root = await checkedDirectory(state, binding.workspaceId);
|
|
@@ -129,13 +180,13 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
129
180
|
catch {
|
|
130
181
|
throw new WorkspaceContextError('workspace_skills_unavailable');
|
|
131
182
|
}
|
|
132
|
-
await state.journal.putWorkspace(binding);
|
|
183
|
+
await state.journal.putWorkspace({ ...binding, directories });
|
|
133
184
|
try {
|
|
134
185
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
135
186
|
}
|
|
136
187
|
catch { /* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */ }
|
|
137
188
|
// Runs in the background; every registration rechecks it, so reopening retries a failed install.
|
|
138
|
-
void scheduleDependencyInstall(state, binding.workspaceId, root);
|
|
189
|
+
void scheduleDependencyInstall(state, revisionScope(state, binding.workspaceId), root);
|
|
139
190
|
return binding;
|
|
140
191
|
});
|
|
141
192
|
}
|