@playcraft/cli 0.1.57-beta → 0.1.58-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/workspace.js +43 -0
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +11 -2
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { listMembers, readSnapshot } from '../batch/records.js';
|
|
5
|
+
const context = new AsyncLocalStorage();
|
|
6
|
+
const gates = new WeakMap();
|
|
7
|
+
const instances = new WeakMap();
|
|
8
|
+
const executing = new WeakMap();
|
|
9
|
+
const freezing = new WeakMap();
|
|
10
|
+
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'workspace.save', 'directory.create',
|
|
12
|
+
'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
|
|
13
|
+
'checkpoint.create', 'checkpoint.restore']);
|
|
14
|
+
export function managedDirectoryScope(state, value) {
|
|
15
|
+
if (state.config.workspaceMode !== 'multi')
|
|
16
|
+
return { directoryName: 'basic' };
|
|
17
|
+
const [workspaceId, directoryName = 'basic'] = value.split(':');
|
|
18
|
+
return { workspaceId, directoryName };
|
|
19
|
+
}
|
|
20
|
+
function overlaps(a, b) {
|
|
21
|
+
return (a.workspaceId ?? '') === (b.workspaceId ?? '') &&
|
|
22
|
+
((a.directoryName ?? 'basic') === (b.directoryName ?? 'basic') || a.directoryName === 'root' || b.directoryName === 'root');
|
|
23
|
+
}
|
|
24
|
+
function covers(parent, child) {
|
|
25
|
+
return (parent.workspaceId ?? '') === (child.workspaceId ?? '') &&
|
|
26
|
+
(parent.directoryName === 'root' || (parent.directoryName ?? 'basic') === (child.directoryName ?? 'basic'));
|
|
27
|
+
}
|
|
28
|
+
export function operationDirectoryMode(operation) {
|
|
29
|
+
return operation.directoryAccessMode ?? (EXCLUSIVE_OPERATIONS.has(operation.kind) || operation.kind.startsWith('git.') ? 'exclusive' : 'shared');
|
|
30
|
+
}
|
|
31
|
+
function activeSessionOwner(state, gate, scope, sessionId, except) {
|
|
32
|
+
if (!sessionId)
|
|
33
|
+
return undefined;
|
|
34
|
+
const operation = state.journal.listOperations().map(item => item.operation).find(item => item.sessionId === sessionId &&
|
|
35
|
+
item.operationId !== except && ['session.turn', 'session.review', 'session.compact'].includes(item.kind) &&
|
|
36
|
+
['dispatched', 'accepted', 'uncertain'].includes(item.status) && covers(item, scope) && gate.has(`operation:${item.operationId}`));
|
|
37
|
+
return operation ? `operation:${operation.operationId}` : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A directory has many ordinary writers, or one build, freeze, maintenance job or member Run. */
|
|
40
|
+
export class DirectoryAccessGate {
|
|
41
|
+
normalize;
|
|
42
|
+
held = new Map();
|
|
43
|
+
pending = [];
|
|
44
|
+
constructor(normalize = claim => claim) {
|
|
45
|
+
this.normalize = normalize;
|
|
46
|
+
}
|
|
47
|
+
claim(input) {
|
|
48
|
+
const claim = this.normalize(input);
|
|
49
|
+
const held = this.held.get(claim.owner);
|
|
50
|
+
if (held) {
|
|
51
|
+
if (!covers(held, claim) || held.mode === 'shared' && claim.mode === 'exclusive')
|
|
52
|
+
return Promise.reject(new Error('directory_access_upgrade_forbidden'));
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
const existing = this.pending.find(item => item.owner === claim.owner);
|
|
56
|
+
if (existing) {
|
|
57
|
+
if (!covers(existing, claim) || existing.mode === 'shared' && claim.mode === 'exclusive')
|
|
58
|
+
return Promise.reject(new Error('directory_access_upgrade_forbidden'));
|
|
59
|
+
if (existing.paused && !claim.paused) {
|
|
60
|
+
existing.paused = false;
|
|
61
|
+
this.drain();
|
|
62
|
+
}
|
|
63
|
+
return existing.ready;
|
|
64
|
+
}
|
|
65
|
+
let resolve;
|
|
66
|
+
let reject;
|
|
67
|
+
const ready = new Promise((done, fail) => { resolve = done; reject = fail; });
|
|
68
|
+
this.pending.push({ ...claim, ready, resolve, reject });
|
|
69
|
+
this.drain();
|
|
70
|
+
return ready;
|
|
71
|
+
}
|
|
72
|
+
restore(claim) { this.held.set(claim.owner, this.normalize(claim)); }
|
|
73
|
+
has(owner) { return this.held.has(owner); }
|
|
74
|
+
permits(owner, scope, mode) {
|
|
75
|
+
const held = this.held.get(owner);
|
|
76
|
+
return Boolean(held && covers(held, this.normalize({ ...scope, owner, mode })) && (held.mode === 'exclusive' || mode === 'shared'));
|
|
77
|
+
}
|
|
78
|
+
tryClaim(claim) {
|
|
79
|
+
const ready = this.claim(claim);
|
|
80
|
+
if (this.has(claim.owner))
|
|
81
|
+
return true;
|
|
82
|
+
void ready.catch(() => undefined);
|
|
83
|
+
this.release(claim.owner);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
adopt(owner, parentOwner) {
|
|
87
|
+
const child = this.pending.find(item => item.owner === owner);
|
|
88
|
+
const parent = this.held.get(parentOwner);
|
|
89
|
+
if (!child || !parent || parent.mode !== 'exclusive' || !covers(parent, child))
|
|
90
|
+
return;
|
|
91
|
+
child.parentOwner = parentOwner;
|
|
92
|
+
this.drain();
|
|
93
|
+
}
|
|
94
|
+
cancelPending(owner) {
|
|
95
|
+
if (this.held.has(owner) || !this.pending.some(item => item.owner === owner))
|
|
96
|
+
return false;
|
|
97
|
+
this.release(owner);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
release(owner) {
|
|
101
|
+
this.held.delete(owner);
|
|
102
|
+
const pending = this.pending.find(item => item.owner === owner);
|
|
103
|
+
if (pending) {
|
|
104
|
+
this.pending.splice(this.pending.indexOf(pending), 1);
|
|
105
|
+
pending.reject(new Error('directory_access_cancelled'));
|
|
106
|
+
}
|
|
107
|
+
this.drain();
|
|
108
|
+
}
|
|
109
|
+
owners() { return [...this.held.keys(), ...this.pending.map(item => item.owner)]; }
|
|
110
|
+
drain() {
|
|
111
|
+
const earlier = [];
|
|
112
|
+
for (const item of [...this.pending]) {
|
|
113
|
+
if (item.paused) {
|
|
114
|
+
earlier.push(item);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const parent = item.parentOwner ? this.held.get(item.parentOwner) : undefined;
|
|
118
|
+
const family = parent && covers(parent, item) && (parent.mode === 'exclusive' || item.mode === 'shared');
|
|
119
|
+
const conflict = [...this.held.values()].some(held => !(family && (held.owner === parent.owner || held.parentOwner === parent.owner)) && overlaps(item, held) &&
|
|
120
|
+
(item.mode === 'exclusive' || held.mode === 'exclusive' || Boolean(item.sessionId && held.sessionId === item.sessionId)));
|
|
121
|
+
const queuedWriter = !family && earlier.some(prior => overlaps(item, prior) && (item.mode === 'exclusive' || prior.mode === 'exclusive'));
|
|
122
|
+
if (conflict || queuedWriter) {
|
|
123
|
+
earlier.push(item);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
this.pending.splice(this.pending.indexOf(item), 1);
|
|
127
|
+
this.held.set(item.owner, item);
|
|
128
|
+
item.resolve();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function gateFor(state) {
|
|
133
|
+
let pending = gates.get(state);
|
|
134
|
+
if (!pending) {
|
|
135
|
+
pending = (async () => {
|
|
136
|
+
const gate = new DirectoryAccessGate(claim => state.config.workspaceMode === 'multi'
|
|
137
|
+
? claim : { ...claim, workspaceId: undefined, directoryName: 'basic' });
|
|
138
|
+
instances.set(state, gate);
|
|
139
|
+
const members = await listMembers(state);
|
|
140
|
+
for (const member of members)
|
|
141
|
+
if (member.holding)
|
|
142
|
+
gate.restore({ ...member, owner: `run:${member.runId}`, mode: 'exclusive' });
|
|
143
|
+
for (const { operation } of state.journal.listOperations()) {
|
|
144
|
+
if (!takesWorkspaceDirectory(operation.kind) || isTurnControl(operation.kind) ||
|
|
145
|
+
!['dispatched', 'accepted', 'uncertain'].includes(operation.status) ||
|
|
146
|
+
members.some(member => member.holding && (member.operationId === operation.operationId || Boolean(member.sessionId && member.sessionId === operation.sessionId))))
|
|
147
|
+
continue;
|
|
148
|
+
gate.restore({ ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation) });
|
|
149
|
+
}
|
|
150
|
+
for (const terminal of state.journal.listTerminals())
|
|
151
|
+
if (terminal.status !== 'destroyed')
|
|
152
|
+
gate.restore({ ...terminal, owner: `terminal:${terminal.terminalId}`, mode: 'shared' });
|
|
153
|
+
for (const writer of state.journal.listManagedWrites())
|
|
154
|
+
if (!writer.stoppedAt) {
|
|
155
|
+
gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: 'shared' });
|
|
156
|
+
}
|
|
157
|
+
const waiting = [];
|
|
158
|
+
for (const barrier of state.journal.directoryBarriers()) {
|
|
159
|
+
const snapshot = barrier.owner.startsWith('freeze:')
|
|
160
|
+
? await readSnapshot(state, barrier.owner.slice('freeze:'.length)).catch(() => undefined) : undefined;
|
|
161
|
+
if (snapshot?.sourceWorkspaceId === barrier.workspaceId && snapshot.sourceDirectoryName === barrier.directoryName) {
|
|
162
|
+
await state.journal.deleteDirectoryBarrier(barrier.owner);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (barrier.phase !== 'pending') {
|
|
166
|
+
gate.restore({ ...barrier, mode: 'exclusive' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (barrier.deadline && Date.parse(barrier.deadline) <= Date.now()) {
|
|
170
|
+
await state.journal.deleteDirectoryBarrier(barrier.owner);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
waiting.push({ claim: { ...barrier, mode: 'exclusive', paused: true }, queuedAt: barrier.queuedAt ?? '' });
|
|
174
|
+
const timeout = setTimeout(() => {
|
|
175
|
+
if (gate.cancelPending(barrier.owner))
|
|
176
|
+
void state.journal.deleteDirectoryBarrier(barrier.owner).catch(() => undefined);
|
|
177
|
+
}, Math.max(0, Date.parse(barrier.deadline ?? new Date(Date.now() + 60_000).toISOString()) - Date.now()));
|
|
178
|
+
timeout.unref?.();
|
|
179
|
+
}
|
|
180
|
+
for (const pending of state.journal.deferredDirectoryOperations()) {
|
|
181
|
+
const operation = state.journal.getOperationById(pending.operationId);
|
|
182
|
+
if (operation?.status !== 'queued')
|
|
183
|
+
continue;
|
|
184
|
+
waiting.push({ claim: { ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation),
|
|
185
|
+
...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}) }, queuedAt: pending.queuedAt ?? operation.createdAt });
|
|
186
|
+
}
|
|
187
|
+
for (const item of waiting.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)))
|
|
188
|
+
void gate.claim(item.claim).catch(() => undefined);
|
|
189
|
+
state.journal.onChange(() => reconcileDirectoryAccess(state));
|
|
190
|
+
return gate;
|
|
191
|
+
})();
|
|
192
|
+
gates.set(state, pending);
|
|
193
|
+
}
|
|
194
|
+
return pending;
|
|
195
|
+
}
|
|
196
|
+
/** The journal is the long-lived execution truth; only confirmed terminal writers release a lease. */
|
|
197
|
+
export function reconcileDirectoryAccess(state) {
|
|
198
|
+
const gate = instances.get(state);
|
|
199
|
+
if (!gate)
|
|
200
|
+
return;
|
|
201
|
+
for (const owner of gate.owners()) {
|
|
202
|
+
if (owner.startsWith('operation:')) {
|
|
203
|
+
const operation = state.journal.getOperationById(owner.slice('operation:'.length));
|
|
204
|
+
if (operation && TERMINAL.has(operation.status))
|
|
205
|
+
gate.release(owner);
|
|
206
|
+
}
|
|
207
|
+
else if (owner.startsWith('terminal:')) {
|
|
208
|
+
if (state.journal.getTerminal(owner.slice('terminal:'.length))?.status === 'destroyed')
|
|
209
|
+
gate.release(owner);
|
|
210
|
+
}
|
|
211
|
+
else if (owner.startsWith('managed:')) {
|
|
212
|
+
if (state.journal.listManagedWrites().find(writer => writer.writeId === owner.slice('managed:'.length))?.stoppedAt)
|
|
213
|
+
gate.release(owner);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function holdDirectoryAccess(state, scope, owner, mode = 'shared') {
|
|
218
|
+
const gate = await gateFor(state);
|
|
219
|
+
const inherited = context.getStore();
|
|
220
|
+
await gate.claim({ ...scope, owner, mode,
|
|
221
|
+
...(inherited?.state === state && gate.permits(inherited.claim.owner, scope, mode) ? { parentOwner: inherited.claim.owner } : {}) });
|
|
222
|
+
return () => gate.release(owner);
|
|
223
|
+
}
|
|
224
|
+
/** A build may join its known installer that was queued behind the build's own exclusive lease. */
|
|
225
|
+
export async function adoptDirectoryAccess(state, owner) {
|
|
226
|
+
const inherited = context.getStore();
|
|
227
|
+
if (inherited?.state !== state || inherited.claim.mode !== 'exclusive')
|
|
228
|
+
return;
|
|
229
|
+
(await gateFor(state)).adopt(owner, inherited.claim.owner);
|
|
230
|
+
}
|
|
231
|
+
/** Cancelling a not-yet-spawned writer cannot release a process that is already running. */
|
|
232
|
+
export async function cancelPendingDirectoryAccess(state, owner) {
|
|
233
|
+
return (await gateFor(state)).cancelPending(owner);
|
|
234
|
+
}
|
|
235
|
+
export async function withDirectoryOwner(state, scope, owner, action) {
|
|
236
|
+
if (!(await gateFor(state)).permits(owner, scope, 'exclusive'))
|
|
237
|
+
throw new Error('directory_owner_not_held');
|
|
238
|
+
return context.run({ state, claim: { ...scope, owner, mode: 'exclusive' } }, action);
|
|
239
|
+
}
|
|
240
|
+
export async function withDirectoryWrite(state, scope, action) {
|
|
241
|
+
const inherited = context.getStore();
|
|
242
|
+
const gate = await gateFor(state);
|
|
243
|
+
if (inherited?.state === state && gate.permits(inherited.claim.owner, scope, scope.mode ?? 'shared'))
|
|
244
|
+
return action();
|
|
245
|
+
if (inherited?.state === state && scope.mode === 'exclusive' && gate.permits(inherited.claim.owner, scope, 'shared'))
|
|
246
|
+
throw new Error('directory_busy');
|
|
247
|
+
const parentOwner = scope.mode !== 'exclusive' ? activeSessionOwner(state, gate, scope, scope.sessionId) : undefined;
|
|
248
|
+
const claim = { ...scope, sessionId: undefined, owner: scope.owner ?? `write:${randomUUID()}`, mode: scope.mode ?? 'shared',
|
|
249
|
+
...(parentOwner ? { parentOwner } : {}) };
|
|
250
|
+
if (scope.wait === false && !gate.tryClaim(claim))
|
|
251
|
+
throw new Error('directory_busy');
|
|
252
|
+
if (scope.wait !== false)
|
|
253
|
+
await gate.claim(claim);
|
|
254
|
+
const release = () => gate.release(claim.owner);
|
|
255
|
+
try {
|
|
256
|
+
return await context.run({ state, claim }, action);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
release();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function withOperationDirectoryAccess(state, operation, action, deferred) {
|
|
263
|
+
if (!takesWorkspaceDirectory(operation.kind) || isTurnControl(operation.kind) || operation.kind === 'runtime.destroy')
|
|
264
|
+
return action();
|
|
265
|
+
const inherited = context.getStore();
|
|
266
|
+
const mode = operationDirectoryMode(operation);
|
|
267
|
+
const gate = await gateFor(state);
|
|
268
|
+
if (inherited?.state === state && gate.permits(inherited.claim.owner, operation, mode))
|
|
269
|
+
return action();
|
|
270
|
+
const active = executing.get(state) ?? new Set();
|
|
271
|
+
executing.set(state, active);
|
|
272
|
+
if (active.has(operation.operationId))
|
|
273
|
+
return state.journal.getOperationById(operation.operationId) ?? operation;
|
|
274
|
+
active.add(operation.operationId);
|
|
275
|
+
const parentOwner = !takesExecutionSlot(operation.kind) && mode === 'shared'
|
|
276
|
+
? activeSessionOwner(state, gate, operation, operation.sessionId, operation.operationId) : undefined;
|
|
277
|
+
const claim = { ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation),
|
|
278
|
+
...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}),
|
|
279
|
+
...(parentOwner ? { parentOwner } : {}) };
|
|
280
|
+
if (inherited?.state === state && mode === 'shared' && gate.permits(inherited.claim.owner, operation, mode))
|
|
281
|
+
claim.parentOwner = inherited.claim.owner;
|
|
282
|
+
const ready = gate.claim(claim);
|
|
283
|
+
const run = async () => {
|
|
284
|
+
try {
|
|
285
|
+
await ready;
|
|
286
|
+
const current = state.journal.getOperationById(operation.operationId);
|
|
287
|
+
if (current && TERMINAL.has(current.status))
|
|
288
|
+
return current;
|
|
289
|
+
return await context.run({ state, claim }, action);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
active.delete(operation.operationId);
|
|
293
|
+
const current = state.journal.getOperationById(operation.operationId);
|
|
294
|
+
if (!current || current.status === 'queued' || TERMINAL.has(current.status))
|
|
295
|
+
gate.release(claim.owner);
|
|
296
|
+
if (deferred && current?.status !== 'queued')
|
|
297
|
+
await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
if (gate.has(claim.owner))
|
|
301
|
+
return run();
|
|
302
|
+
if (!deferred)
|
|
303
|
+
return run();
|
|
304
|
+
try {
|
|
305
|
+
await state.journal.deferDirectoryOperation(deferred);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
active.delete(operation.operationId);
|
|
309
|
+
void ready.catch(() => undefined);
|
|
310
|
+
gate.release(claim.owner);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
void run().catch(async (error) => {
|
|
314
|
+
const current = state.journal.getOperationById(operation.operationId);
|
|
315
|
+
if (!current || TERMINAL.has(current.status))
|
|
316
|
+
return;
|
|
317
|
+
await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, 'uncertain', {
|
|
318
|
+
error: error instanceof Error && error.message === 'directory_access_cancelled' ? 'cancelled' : 'native_receipt_lost',
|
|
319
|
+
}));
|
|
320
|
+
}).catch(() => undefined);
|
|
321
|
+
return state.journal.getOperationById(operation.operationId) ?? operation;
|
|
322
|
+
}
|
|
323
|
+
/** A queued command is replayed only if it never started; accepted or uncertain work is only observed. */
|
|
324
|
+
export async function restoreDeferredDirectoryOperations(state, execute) {
|
|
325
|
+
for (const pending of state.journal.deferredDirectoryOperations()) {
|
|
326
|
+
const operation = state.journal.getOperationById(pending.operationId);
|
|
327
|
+
if (operation?.status === 'queued') {
|
|
328
|
+
if (operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch) {
|
|
329
|
+
await state.journal.putOperation(pending.scopeKey, transitionOperation(operation, 'failed', { error: 'generation_mismatch' }));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
void execute(pending.scopeKey, operation, pending.payload, pending.recovery).catch(() => undefined);
|
|
333
|
+
}
|
|
334
|
+
else
|
|
335
|
+
await state.journal.deleteDeferredDirectoryOperation(pending.operationId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
export async function claimBatchDirectory(state, member) {
|
|
339
|
+
await holdDirectoryAccess(state, member, `run:${member.runId}`, 'exclusive');
|
|
340
|
+
}
|
|
341
|
+
export function releaseBatchDirectory(state, member) {
|
|
342
|
+
if (!member.holding && member.writersStopped)
|
|
343
|
+
instances.get(state)?.release(`run:${member.runId}`);
|
|
344
|
+
}
|
|
345
|
+
export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
346
|
+
const pending = freezing.get(state) ?? new Map();
|
|
347
|
+
freezing.set(state, pending);
|
|
348
|
+
const existing = pending.get(scope.batchId);
|
|
349
|
+
if (existing) {
|
|
350
|
+
if (existing.scope.workspaceId !== scope.workspaceId || (existing.scope.directoryName ?? 'basic') !== (scope.directoryName ?? 'basic'))
|
|
351
|
+
throw new Error('batch_source_identity_mismatch');
|
|
352
|
+
return existing.task;
|
|
353
|
+
}
|
|
354
|
+
const task = performDirectoryFreeze(state, scope, action, quiesce, options);
|
|
355
|
+
pending.set(scope.batchId, { scope, task });
|
|
356
|
+
try {
|
|
357
|
+
return await task;
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
if (pending.get(scope.batchId)?.task === task)
|
|
361
|
+
pending.delete(scope.batchId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function performDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
365
|
+
const deadline = options?.deadline ?? new Date(Date.now() + 60_000).toISOString();
|
|
366
|
+
const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(Math.max(0, Date.parse(deadline) - Date.now()))]);
|
|
367
|
+
if (signal.aborted)
|
|
368
|
+
throw new Error('directory_access_cancelled');
|
|
369
|
+
const owner = `freeze:${scope.batchId}`;
|
|
370
|
+
const gate = await gateFor(state);
|
|
371
|
+
const previous = state.journal.directoryBarriers().find(barrier => barrier.owner === owner);
|
|
372
|
+
if (previous && (previous.workspaceId !== scope.workspaceId || previous.directoryName !== (scope.directoryName ?? 'basic')))
|
|
373
|
+
throw new Error('batch_source_identity_mismatch');
|
|
374
|
+
// A restored capture may still have writers. Retrying its publication is not a new admission,
|
|
375
|
+
// and a missing candidate or a lost request cannot provide the old capture's stop proof.
|
|
376
|
+
const restoredCapture = Boolean(previous && previous.phase !== 'pending');
|
|
377
|
+
const claimed = gate.claim({ ...scope, owner, mode: 'exclusive' });
|
|
378
|
+
void claimed.catch(() => undefined);
|
|
379
|
+
let started = restoredCapture;
|
|
380
|
+
const cancel = () => {
|
|
381
|
+
if (started)
|
|
382
|
+
return;
|
|
383
|
+
gate.release(owner);
|
|
384
|
+
void state.journal.deleteDirectoryBarrier(owner).catch(() => undefined);
|
|
385
|
+
};
|
|
386
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
387
|
+
try {
|
|
388
|
+
if (!restoredCapture)
|
|
389
|
+
await state.journal.putDirectoryBarrier({ owner, workspaceId: scope.workspaceId,
|
|
390
|
+
directoryName: scope.directoryName ?? 'basic', runtimeEpoch: state.runtimeEpoch, phase: 'pending', deadline });
|
|
391
|
+
await quiesce?.();
|
|
392
|
+
await claimed;
|
|
393
|
+
if (signal.aborted)
|
|
394
|
+
throw new Error('directory_access_cancelled');
|
|
395
|
+
started = true;
|
|
396
|
+
if (!restoredCapture)
|
|
397
|
+
await state.journal.putDirectoryBarrier({ owner, workspaceId: scope.workspaceId,
|
|
398
|
+
directoryName: scope.directoryName ?? 'basic', runtimeEpoch: state.runtimeEpoch, phase: 'capturing', deadline });
|
|
399
|
+
return await withDirectoryOwner(state, scope, owner, action);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
signal.removeEventListener('abort', cancel);
|
|
403
|
+
const snapshot = restoredCapture ? await readSnapshot(state, scope.batchId).catch(() => undefined) : undefined;
|
|
404
|
+
if (!restoredCapture || (snapshot?.sourceWorkspaceId === scope.workspaceId && snapshot.sourceDirectoryName === (scope.directoryName ?? 'basic'))) {
|
|
405
|
+
await state.journal.deleteDirectoryBarrier(owner);
|
|
406
|
+
gate.release(owner);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
export async function clearDirectoryFreeze(state, batchId) {
|
|
411
|
+
const owner = `freeze:${batchId}`;
|
|
412
|
+
await state.journal.deleteDirectoryBarrier(owner);
|
|
413
|
+
instances.get(state)?.release(owner);
|
|
414
|
+
}
|
|
@@ -2,6 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
|
|
4
4
|
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
5
|
+
import { holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -11,6 +12,7 @@ import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
|
11
12
|
import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
|
|
12
13
|
import { importWorkspaceRemixReference, RemixReferenceError } from '../workspaces/remix-references.js';
|
|
13
14
|
import { ComposerContextError, resolveComposerContext } from '../workspaces/composer-context.js';
|
|
15
|
+
import { ensureParameterInitializationSkill } from '../workspaces/parameter-initialization-skill.js';
|
|
14
16
|
import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
|
|
15
17
|
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from '../workspaces/skill-artifact-loader.js';
|
|
16
18
|
import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
@@ -28,7 +30,7 @@ import { ThemeAssetIndexError } from '../workspaces/theme-asset-index.js';
|
|
|
28
30
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
29
31
|
import { prepareParameterAgentTask, finishParameterAgentTask, verifyParameterAgentTask, } from '../workspaces/parameter-agent.js';
|
|
30
32
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
31
|
-
import {
|
|
33
|
+
import { startManagedBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
32
34
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
33
35
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
34
36
|
import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
|
|
@@ -90,6 +92,8 @@ export async function recoverInterruptedParameterVerifications(journal) {
|
|
|
90
92
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
91
93
|
if (operation.kind !== 'parameter.verify' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
92
94
|
continue;
|
|
95
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
96
|
+
continue;
|
|
93
97
|
await journal.putOperation(scopeKey, transitionOperation(operation, 'interrupted', { error: 'parameter_verification_interrupted' }));
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -105,12 +109,21 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
105
109
|
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed()) {
|
|
106
110
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
|
|
107
111
|
}
|
|
108
|
-
|
|
112
|
+
if (operation.kind === 'session.turn' && !recovery && operation.sessionId &&
|
|
113
|
+
state.journal.getSession(operation.sessionId)?.recoverability === 'history_only')
|
|
114
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'native_not_recoverable' }));
|
|
115
|
+
return withOperationDirectoryAccess(state, operation, () => {
|
|
116
|
+
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
|
|
117
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
|
|
118
|
+
return executeScopedOperation(state, scopeKey, operation, payload, recovery);
|
|
119
|
+
}, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
|
|
109
120
|
};
|
|
110
121
|
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
|
|
111
122
|
return run();
|
|
112
123
|
}
|
|
113
|
-
return state.journal.exclusive(
|
|
124
|
+
return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
|
|
125
|
+
? `session-execution:${operation.sessionId}`
|
|
126
|
+
: `workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
|
|
114
127
|
}
|
|
115
128
|
async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
|
|
116
129
|
let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
|
|
@@ -381,12 +394,12 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
381
394
|
}
|
|
382
395
|
found = rebound;
|
|
383
396
|
}
|
|
384
|
-
const binding = found;
|
|
385
397
|
// Also covers a new thread whose first turn failed before starting: the next turn still carries it.
|
|
386
|
-
const context = recovery &&
|
|
387
|
-
if (!
|
|
398
|
+
const context = recovery && found?.recoveryContextPending ? recovery.context : '';
|
|
399
|
+
if (!found) {
|
|
388
400
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
389
401
|
}
|
|
402
|
+
let binding = found;
|
|
390
403
|
if ((binding.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic')) {
|
|
391
404
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'context_directory_mismatch' }));
|
|
392
405
|
}
|
|
@@ -398,17 +411,30 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
398
411
|
}
|
|
399
412
|
const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
400
413
|
let composer;
|
|
414
|
+
let initializationSkill;
|
|
401
415
|
try {
|
|
416
|
+
let turnContext = selection.data.context;
|
|
417
|
+
if (parameterTask.data?.action === 'initialize') {
|
|
418
|
+
const selectedSkill = await ensureParameterInitializationSkill(root);
|
|
419
|
+
initializationSkill = { ...selectedSkill,
|
|
420
|
+
path: join(root, '.playcraft/skills', selectedSkill.lockHash, selectedSkill.entryKey, 'SKILL.md') };
|
|
421
|
+
turnContext = { ...turnContext, files: turnContext?.files ?? [], skills: [
|
|
422
|
+
...(turnContext?.skills ?? []).filter(skill => skill.entryKey !== selectedSkill.entryKey), selectedSkill,
|
|
423
|
+
] };
|
|
424
|
+
}
|
|
402
425
|
composer = await resolveComposerContext({
|
|
403
426
|
root,
|
|
404
|
-
directoryName: binding.directoryName ?? 'basic', context:
|
|
427
|
+
directoryName: binding.directoryName ?? 'basic', context: turnContext,
|
|
405
428
|
skillCache: join(state.config.stateDir, 'skill-artifacts'),
|
|
406
429
|
loadSkillArtifact: entry => loadWorkspaceSkillArtifact(state, operation.workspaceId ?? 'workspace', entry),
|
|
407
430
|
});
|
|
408
431
|
}
|
|
409
432
|
catch (error) {
|
|
410
433
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', {
|
|
411
|
-
error: error instanceof ComposerContextError ? error.message : '
|
|
434
|
+
error: error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize'
|
|
435
|
+
? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
|
|
436
|
+
? error.message : parameterAgentError(error)
|
|
437
|
+
: 'invalid_composer_context',
|
|
412
438
|
}));
|
|
413
439
|
}
|
|
414
440
|
let attachments;
|
|
@@ -446,7 +472,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
446
472
|
let parameterInstructions = '';
|
|
447
473
|
if (parameterTask.data) {
|
|
448
474
|
try {
|
|
449
|
-
parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root)).instructions;
|
|
475
|
+
parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root, { initializationSkill })).instructions;
|
|
450
476
|
}
|
|
451
477
|
catch (error) {
|
|
452
478
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterAgentError(error) }));
|
|
@@ -458,7 +484,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
458
484
|
});
|
|
459
485
|
let result;
|
|
460
486
|
try {
|
|
461
|
-
|
|
487
|
+
const startTurn = () => native(state).startTurn({
|
|
462
488
|
sessionId,
|
|
463
489
|
threadId: binding.threadId,
|
|
464
490
|
text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions].filter(Boolean).join('\n\n')),
|
|
@@ -490,6 +516,18 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
490
516
|
await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
|
|
491
517
|
},
|
|
492
518
|
});
|
|
519
|
+
result = await startTurn();
|
|
520
|
+
// The native runtime cannot persist an empty thread before its first turn. Adding the
|
|
521
|
+
// selected skill can therefore require replacing that empty thread, before any model work.
|
|
522
|
+
if (initializationSkill && result.outcome === 'failed' && result.error === 'workspace_skills_changed'
|
|
523
|
+
&& !(state.journal.getOperation(scopeKey) ?? operation).nativeRequestId) {
|
|
524
|
+
const started = await native(state).startThread({ sessionId, cwd: root,
|
|
525
|
+
modelConfigId: selected.data ?? binding.modelConfigId, reasoningEffort: requestedEffort, permissionMode: requestedMode });
|
|
526
|
+
binding = { ...binding, threadId: started.threadId, modelConfigId: started.modelConfigId ?? binding.modelConfigId,
|
|
527
|
+
reasoningEffort: started.reasoningEffort ?? requestedEffort, permissionMode: started.permissionMode ?? requestedMode };
|
|
528
|
+
await state.journal.putSession(binding);
|
|
529
|
+
result = await startTurn();
|
|
530
|
+
}
|
|
493
531
|
}
|
|
494
532
|
catch (error) {
|
|
495
533
|
if (!parameterTask.data)
|
|
@@ -1113,7 +1151,7 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
|
|
|
1113
1151
|
: 'invalid_workspace_path';
|
|
1114
1152
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error }));
|
|
1115
1153
|
}
|
|
1116
|
-
const workspaceId = operation.workspaceId ?? 'workspace';
|
|
1154
|
+
const workspaceId = revisionScope(state, operation.workspaceId, operation.directoryName) ?? operation.workspaceId ?? 'workspace';
|
|
1117
1155
|
const writer = parsed.data.writer ?? 'manual';
|
|
1118
1156
|
if (parsed.data.release) {
|
|
1119
1157
|
const lease = state.journal.getManagedWrite(parsed.data.writeId);
|
|
@@ -1147,17 +1185,14 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
|
|
|
1147
1185
|
contentHash: written.contentHash,
|
|
1148
1186
|
}, revisionScope(state, operation.workspaceId, operation.directoryName));
|
|
1149
1187
|
if (parsed.data.hold && parsed.data.writeId) {
|
|
1150
|
-
|
|
1188
|
+
const record = { writeId: parsed.data.writeId, workspaceId, source: writer, startedAt: new Date().toISOString() };
|
|
1151
1189
|
if (writer === 'background') {
|
|
1152
|
-
|
|
1190
|
+
await startManagedBackgroundWriter(state, record, await resolveWorkspaceFile(root, parsed.data.path));
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
await holdDirectoryAccess(state, operation, `managed:${parsed.data.writeId}`, 'shared');
|
|
1194
|
+
await state.journal.putManagedWrite(record);
|
|
1153
1195
|
}
|
|
1154
|
-
await state.journal.putManagedWrite({
|
|
1155
|
-
writeId: parsed.data.writeId,
|
|
1156
|
-
workspaceId,
|
|
1157
|
-
source: writer,
|
|
1158
|
-
startedAt: new Date().toISOString(),
|
|
1159
|
-
pid,
|
|
1160
|
-
});
|
|
1161
1196
|
}
|
|
1162
1197
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { expectedRevision: revision }));
|
|
1163
1198
|
if (artifactFormat(parsed.data.path) && operation.workspaceId) {
|
|
@@ -1349,7 +1384,9 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
|
1349
1384
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
1350
1385
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_data_missing' }));
|
|
1351
1386
|
}
|
|
1352
|
-
const dataPath = document.dataPath;
|
|
1387
|
+
const dataPath = 'documentId' in parsed.data ? document.runtimeI18n?.themePath : document.dataPath;
|
|
1388
|
+
if (!dataPath)
|
|
1389
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_i18n_global_invalid' }));
|
|
1353
1390
|
return withObservedFile(state, operation.workspaceId, dataPath, async ({ revision: currentRevision, file }) => {
|
|
1354
1391
|
if (parsed.data.expectedRevision !== currentRevision) {
|
|
1355
1392
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'revision_conflict' }));
|
|
@@ -1386,6 +1423,7 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
|
1386
1423
|
revision,
|
|
1387
1424
|
contentHash: written.contentHash,
|
|
1388
1425
|
size: written.size,
|
|
1426
|
+
...('documentId' in parsed.data ? { documentId: parsed.data.documentId } : {}),
|
|
1389
1427
|
...('changes' in parsed.data
|
|
1390
1428
|
? { fieldPaths: parsed.data.changes.map((change) => change.fieldPath) }
|
|
1391
1429
|
: { fieldPath: parsed.data.fieldPath }),
|
|
@@ -1416,7 +1454,7 @@ async function executePreviewPublish(state, scopeKey, operation) {
|
|
|
1416
1454
|
trackPreviewPublish(state, runPreviewPublish(state, accepted, root).then(async () => {
|
|
1417
1455
|
await persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
1418
1456
|
}, async (error) => {
|
|
1419
|
-
await persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: previewFailureCode(error) }));
|
|
1457
|
+
await persist(state, scopeKey, transitionOperation(accepted, previewFailureCode(error) === 'preview_process_unknown' ? 'uncertain' : 'failed', { error: previewFailureCode(error) }));
|
|
1420
1458
|
}).catch(() => undefined));
|
|
1421
1459
|
return accepted;
|
|
1422
1460
|
}
|
|
@@ -1438,7 +1476,7 @@ async function executeDeliveryBuild(state, scopeKey, operation, payload) {
|
|
|
1438
1476
|
}).then(async (result) => {
|
|
1439
1477
|
const error = result.record.error
|
|
1440
1478
|
?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
|
|
1441
|
-
await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : 'failed', error ? { error } : {}));
|
|
1479
|
+
await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : error === 'delivery_process_unknown' ? 'uncertain' : 'failed', error ? { error } : {}));
|
|
1442
1480
|
}, async (error) => {
|
|
1443
1481
|
await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
|
|
1444
1482
|
error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
|
|
@@ -12,6 +12,7 @@ export async function applyRuntimeEndpointPatch(state, patch) {
|
|
|
12
12
|
throw new Error('endpoint_config_path_missing');
|
|
13
13
|
}
|
|
14
14
|
const temporary = `${state.configPath}.${process.pid}.tmp`;
|
|
15
|
+
// private
|
|
15
16
|
await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
16
17
|
await chmod(temporary, 0o600);
|
|
17
18
|
await rename(temporary, state.configPath);
|