@penpair/core 0.4.0 → 0.5.0
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/adapters.d.ts +1 -1
- package/dist/adapters.js +1 -0
- package/dist/browser-wake.d.ts +22 -0
- package/dist/browser-wake.js +241 -0
- package/dist/cli-main.js +140 -15
- package/dist/clipboard-payload.d.ts +2 -0
- package/dist/clipboard-payload.js +89 -0
- package/dist/design-commands/context.d.ts +5 -0
- package/dist/design-commands/copy-command.d.ts +3 -0
- package/dist/design-commands/copy-command.js +101 -0
- package/dist/design-commands/release-command.js +6 -1
- package/dist/design-releases/copy-evidence.d.ts +33 -0
- package/dist/design-releases/copy-evidence.js +278 -0
- package/dist/design-releases/copy-plan.d.ts +49 -0
- package/dist/design-releases/copy-plan.js +55 -0
- package/dist/design-releases/copy-snippets.d.ts +11 -0
- package/dist/design-releases/copy-snippets.js +293 -0
- package/dist/design-releases/copy.d.ts +28 -0
- package/dist/design-releases/copy.js +466 -0
- package/dist/design-releases/index.d.ts +1 -0
- package/dist/design-releases/index.js +1 -0
- package/dist/errors.js +1 -1
- package/dist/instance/connection.d.ts +2 -0
- package/dist/instance/connection.js +15 -3
- package/dist/kit-source.js +1 -1
- package/dist/layout-policy.js +21 -11
- package/dist/layout-settling.d.ts +1 -0
- package/dist/layout-settling.js +51 -17
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +19 -3
- package/dist/mcp-server.js +99 -3
- package/dist/native-editor-clipboard.d.ts +17 -0
- package/dist/native-editor-clipboard.js +470 -0
- package/dist/plugin-health.d.ts +13 -0
- package/dist/plugin-health.js +39 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { commandSleep } from "../command-result.js";
|
|
4
|
+
import { writeCoordinator } from "../coordination.js";
|
|
5
|
+
import { guardFile } from "../file-guard.js";
|
|
6
|
+
import { runJournaledSteps, readJournalTarget, readJournal, listJournals, observationHash, JournalRunError, } from "../journal.js";
|
|
7
|
+
import { withPageSession, selectPage, rememberPage } from "../page-session.js";
|
|
8
|
+
import { runSnippet } from "../snippet.js";
|
|
9
|
+
import { ensureGitignoreEntry } from "../instance/gitignore.js";
|
|
10
|
+
import { atomicWriteFile } from "../atomic-output.js";
|
|
11
|
+
import { readProjectBytes } from "../bounded-file.js";
|
|
12
|
+
import { artifactPath, saveArtifact } from "./artifact-paths.js";
|
|
13
|
+
import { captureReleaseScreen } from "./live.js";
|
|
14
|
+
import { captureCopyEvidence, matchCopiedFrames, compareCopyEvidence, sourceEvidenceHash, } from "./copy-evidence.js";
|
|
15
|
+
import { nativeCopyInspect, nativeCopyCreatePage, nativeCopySelect, nativeCopyObserve, nativeCopyArrange, } from "./copy-snippets.js";
|
|
16
|
+
import { validateCopyRequest, readCopyPreview, copyRequestHash, } from "./copy-plan.js";
|
|
17
|
+
const operation = "design-release-copy";
|
|
18
|
+
async function observedDestination(client, fileId, pageId) {
|
|
19
|
+
await selectPage(client, {
|
|
20
|
+
id: pageId,
|
|
21
|
+
});
|
|
22
|
+
const result = await runSnippet(client, nativeCopyObserve(fileId, pageId), {
|
|
23
|
+
effect: "read",
|
|
24
|
+
retries: 0,
|
|
25
|
+
});
|
|
26
|
+
if (result.ok !== true ||
|
|
27
|
+
result.fileId !== fileId ||
|
|
28
|
+
result.pageId !== pageId ||
|
|
29
|
+
!Array.isArray(result.rootIds) ||
|
|
30
|
+
!Array.isArray(result.selectionIds) ||
|
|
31
|
+
!Array.isArray(result.rootFrames) ||
|
|
32
|
+
result.rootIds.some((id) => typeof id !== "string")) {
|
|
33
|
+
throw new Error("Destination inventory or selection was not independently observed");
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/** Copy native boards through an explicitly supplied editor transport; preview never touches clipboard or selection. */
|
|
38
|
+
export async function copyReleaseBoards(client, root, request, options = {}) {
|
|
39
|
+
validateCopyRequest(request);
|
|
40
|
+
if (options.resume && (!options.apply || !options.runId)) {
|
|
41
|
+
throw new Error("Copy resume requires --apply and the original run ID");
|
|
42
|
+
}
|
|
43
|
+
client = guardFile(client, {
|
|
44
|
+
id: request.fileId,
|
|
45
|
+
}, {
|
|
46
|
+
mode: "strict",
|
|
47
|
+
});
|
|
48
|
+
return withPageSession(client, async () => {
|
|
49
|
+
const inspect = async (ownerKey) => readCopyPreview(await runSnippet(client, nativeCopyInspect(request.fileId, request.sourcePage, request.frameIds, request.releaseVersion, ownerKey), {
|
|
50
|
+
effect: "read",
|
|
51
|
+
retries: 0,
|
|
52
|
+
}), request);
|
|
53
|
+
const current = await inspect();
|
|
54
|
+
if (!options.apply) {
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
kind: "native-release-copy",
|
|
58
|
+
status: "preview",
|
|
59
|
+
preview: current,
|
|
60
|
+
problems: [
|
|
61
|
+
"Native browser copy/paste is experimental; structural and stable-render verification follow an explicit apply.",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!options.clipboard) {
|
|
66
|
+
throw new Error("Native copy requires an explicitly configured browser clipboard transport");
|
|
67
|
+
}
|
|
68
|
+
const runId = options.runId ?? randomUUID();
|
|
69
|
+
if (!/^[A-Za-z0-9-]{1,100}$/.test(runId)) {
|
|
70
|
+
throw new Error("Invalid native copy run ID");
|
|
71
|
+
}
|
|
72
|
+
const assertNoConflictingRuns = async () => {
|
|
73
|
+
const records = await listJournals(root);
|
|
74
|
+
for (const previous of records.filter((record) => record.operation === operation && record.target.fileId === request.fileId)) {
|
|
75
|
+
if (previous.runId === runId) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const record = await readJournal(root, previous.runId);
|
|
79
|
+
const payload = record.payload;
|
|
80
|
+
if (previous.state !== "verified" ||
|
|
81
|
+
(payload?.kind === "native-release-copy-plan" &&
|
|
82
|
+
copyRequestHash(payload.request) === copyRequestHash(request))) {
|
|
83
|
+
throw new Error(`Copy run ${previous.runId} already covers this transfer or requires reconciliation; use its explicit --resume before another paste`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
await assertNoConflictingRuns();
|
|
88
|
+
await ensureGitignoreEntry(root, ".penpair", {
|
|
89
|
+
directory: true,
|
|
90
|
+
comment: "penpair: private operation evidence",
|
|
91
|
+
});
|
|
92
|
+
const prefix = `.penpair/native-copy/${runId}`;
|
|
93
|
+
const fencePath = await artifactPath(root, `${prefix}/paste-attempt.json`, true);
|
|
94
|
+
let payload;
|
|
95
|
+
let attempted = false;
|
|
96
|
+
let pageAttempted = false;
|
|
97
|
+
if (options.resume) {
|
|
98
|
+
const previous = await readJournal(root, runId);
|
|
99
|
+
pageAttempted = previous.steps.some((step) => step.key === "page" && step.state !== "pending");
|
|
100
|
+
payload = previous.payload;
|
|
101
|
+
if (!payload ||
|
|
102
|
+
payload.kind !== "native-release-copy-plan" ||
|
|
103
|
+
copyRequestHash(payload.request) !== copyRequestHash(request)) {
|
|
104
|
+
throw new Error("Copy resume inputs changed; no clipboard operation was dispatched");
|
|
105
|
+
}
|
|
106
|
+
// The durable fence is written before the only paste call. A started
|
|
107
|
+
// journal step may have stopped earlier during page/selection preparation.
|
|
108
|
+
try {
|
|
109
|
+
await lstat(fencePath);
|
|
110
|
+
attempted = true;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== "ENOENT") {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const sources = await captureCopyEvidence(client, root, request.fileId, current.sourcePageId, request.frameIds, `${prefix}/source`, options.redact);
|
|
120
|
+
payload = {
|
|
121
|
+
kind: "native-release-copy-plan",
|
|
122
|
+
request,
|
|
123
|
+
preview: current,
|
|
124
|
+
sources,
|
|
125
|
+
};
|
|
126
|
+
await saveArtifact(root, `${prefix}/plan.json`, `${JSON.stringify(payload)}\n`, options.redact);
|
|
127
|
+
}
|
|
128
|
+
if (Buffer.byteLength(JSON.stringify(payload)) > 16 * 1024 * 1024) {
|
|
129
|
+
throw new Error("Native transfer plan exceeds 16 MiB");
|
|
130
|
+
}
|
|
131
|
+
const baseline = payload.preview;
|
|
132
|
+
let pinnedDestinationId = baseline.destinationPageId;
|
|
133
|
+
const readSource = async () => {
|
|
134
|
+
for (const source of payload.sources) {
|
|
135
|
+
const observed = await captureReleaseScreen(client, request.fileId, baseline.sourcePageId, source.frameId);
|
|
136
|
+
if (observationHash(observed.nodes) !== observationHash(source.inspection.nodes)) {
|
|
137
|
+
throw new Error("Source board content changed; retain the run and make a new reviewed plan");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const targetPreview = async () => {
|
|
142
|
+
const value = await inspect(runId);
|
|
143
|
+
if (!baseline.destinationPageId &&
|
|
144
|
+
value.destinationPageId &&
|
|
145
|
+
value.createdPageOwner !== runId) {
|
|
146
|
+
throw new Error("A different page appeared at the planned destination");
|
|
147
|
+
}
|
|
148
|
+
if (pinnedDestinationId && value.destinationPageId !== pinnedDestinationId) {
|
|
149
|
+
throw new Error("Pinned destination page was replaced or renamed");
|
|
150
|
+
}
|
|
151
|
+
if (value.destinationPageId) {
|
|
152
|
+
pinnedDestinationId = value.destinationPageId;
|
|
153
|
+
}
|
|
154
|
+
if (value.sourcePageId !== baseline.sourcePageId ||
|
|
155
|
+
(baseline.destinationPageId && value.destinationPageId !== baseline.destinationPageId)) {
|
|
156
|
+
throw new Error("Pinned copy page identity changed");
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
};
|
|
160
|
+
const addedRoots = (value) => value.destinationRootIds.filter((id) => !baseline.destinationRootIds.includes(id));
|
|
161
|
+
const inventoryComplete = (value) => addedRoots(value).length === request.frameIds.length &&
|
|
162
|
+
baseline.destinationRootIds.every((id) => value.destinationRootIds.includes(id));
|
|
163
|
+
const pasteComplete = (value) => attempted &&
|
|
164
|
+
inventoryComplete(value) &&
|
|
165
|
+
pairs.length === request.frameIds.length &&
|
|
166
|
+
pairs.every((pair) => addedRoots(value).includes(pair.targetFrameId));
|
|
167
|
+
let pairs = [];
|
|
168
|
+
let placements = [];
|
|
169
|
+
const mappingPath = `${prefix}/mapping.json`;
|
|
170
|
+
try {
|
|
171
|
+
await artifactPath(root, mappingPath);
|
|
172
|
+
const saved = JSON.parse((await readProjectBytes(root, mappingPath, 1024 * 1024)).toString("utf8"));
|
|
173
|
+
if (saved.requestHash !== copyRequestHash(request) ||
|
|
174
|
+
!Array.isArray(saved.pairs) ||
|
|
175
|
+
saved.pairs.length !== request.frameIds.length ||
|
|
176
|
+
saved.pairs.some((pair, index) => !pair ||
|
|
177
|
+
pair.sourceFrameId !== request.frameIds[index] ||
|
|
178
|
+
typeof pair.targetFrameId !== "string") ||
|
|
179
|
+
new Set(saved.pairs.map((pair) => pair.targetFrameId)).size !==
|
|
180
|
+
saved.pairs.length) {
|
|
181
|
+
throw new Error("Saved native copy mapping is invalid");
|
|
182
|
+
}
|
|
183
|
+
pairs = saved.pairs;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error.code !== "ENOENT") {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const calculatePlacements = () => {
|
|
191
|
+
let x = 0;
|
|
192
|
+
const y = Math.max(1000, baseline.destinationBounds.bottom + 160);
|
|
193
|
+
return pairs.map((pair) => {
|
|
194
|
+
const source = baseline.sourceFrames.find((frame) => frame.id === pair.sourceFrameId);
|
|
195
|
+
const placement = {
|
|
196
|
+
id: pair.targetFrameId,
|
|
197
|
+
x,
|
|
198
|
+
y,
|
|
199
|
+
};
|
|
200
|
+
x += source.width + 160;
|
|
201
|
+
return placement;
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
const identifyPasted = async () => {
|
|
205
|
+
const value = await targetPreview();
|
|
206
|
+
if (!attempted || !inventoryComplete(value) || !value.destinationPageId) {
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
if (!pairs.length) {
|
|
210
|
+
const observed = await observedDestination(client, request.fileId, value.destinationPageId);
|
|
211
|
+
const ids = addedRoots(value);
|
|
212
|
+
if (observationHash([
|
|
213
|
+
...observed.selectionIds,
|
|
214
|
+
].sort()) !==
|
|
215
|
+
observationHash([
|
|
216
|
+
...ids,
|
|
217
|
+
].sort())) {
|
|
218
|
+
throw new Error("Pasted selection does not uniquely identify the new boards; inspect without replay");
|
|
219
|
+
}
|
|
220
|
+
const targets = await captureCopyEvidence(client, root, request.fileId, value.destinationPageId, ids, `${prefix}/pasted-${randomUUID()}`, options.redact);
|
|
221
|
+
pairs = matchCopiedFrames(payload.sources, targets);
|
|
222
|
+
await saveArtifact(root, mappingPath, JSON.stringify({
|
|
223
|
+
requestHash: copyRequestHash(request),
|
|
224
|
+
pairs,
|
|
225
|
+
}), options.redact);
|
|
226
|
+
}
|
|
227
|
+
placements = calculatePlacements();
|
|
228
|
+
return value;
|
|
229
|
+
};
|
|
230
|
+
const arrangementState = async () => {
|
|
231
|
+
const value = await identifyPasted();
|
|
232
|
+
if (!value.destinationPageId || !pasteComplete(value)) {
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
const observed = await observedDestination(client, request.fileId, value.destinationPageId);
|
|
236
|
+
value.arranged = placements.every((placement) => observed.rootFrames.some((frame) => frame.id === placement.id &&
|
|
237
|
+
frame.x === placement.x &&
|
|
238
|
+
frame.y === placement.y &&
|
|
239
|
+
frame.copyRunId === runId));
|
|
240
|
+
return value;
|
|
241
|
+
};
|
|
242
|
+
const pageStep = {
|
|
243
|
+
key: "page",
|
|
244
|
+
label: "Prepare the release destination page",
|
|
245
|
+
signature: nativeCopyCreatePage(request.fileId, request.releaseVersion, runId).code,
|
|
246
|
+
inspect: targetPreview,
|
|
247
|
+
apply: async () => {
|
|
248
|
+
await readSource();
|
|
249
|
+
pageAttempted = true;
|
|
250
|
+
const result = await runSnippet(client, nativeCopyCreatePage(request.fileId, request.releaseVersion, runId), {
|
|
251
|
+
effect: "write",
|
|
252
|
+
retries: 0,
|
|
253
|
+
});
|
|
254
|
+
if (typeof result.pageId !== "string") {
|
|
255
|
+
throw new Error("Created release page identity was not acknowledged");
|
|
256
|
+
}
|
|
257
|
+
rememberPage(client, {
|
|
258
|
+
fileId: request.fileId,
|
|
259
|
+
pageId: result.pageId,
|
|
260
|
+
});
|
|
261
|
+
return result;
|
|
262
|
+
},
|
|
263
|
+
verify: (value) => Boolean(value.destinationPageId) && value.destinationNamed !== false,
|
|
264
|
+
reconcile: (_before, value) => value.destinationPageId && value.destinationNamed !== false
|
|
265
|
+
? "complete"
|
|
266
|
+
: pageAttempted && !value.destinationPageId
|
|
267
|
+
? "conflict"
|
|
268
|
+
: "pending",
|
|
269
|
+
};
|
|
270
|
+
const pasteStep = {
|
|
271
|
+
key: "paste",
|
|
272
|
+
label: "Copy selected native boards and paste once",
|
|
273
|
+
signature: "native-clipboard-batch-v1-no-replay",
|
|
274
|
+
inspect: identifyPasted,
|
|
275
|
+
apply: async (value) => {
|
|
276
|
+
if (attempted ||
|
|
277
|
+
!value.destinationPageId ||
|
|
278
|
+
addedRoots(value).length ||
|
|
279
|
+
!baseline.destinationRootIds.every((id) => value.destinationRootIds.includes(id))) {
|
|
280
|
+
throw new Error("Paste is fenced or destination changed; inspect without replaying");
|
|
281
|
+
}
|
|
282
|
+
await readSource();
|
|
283
|
+
const clipboard = await options.clipboard();
|
|
284
|
+
try {
|
|
285
|
+
await selectPage(client, {
|
|
286
|
+
id: baseline.sourcePageId,
|
|
287
|
+
});
|
|
288
|
+
await runSnippet(client, nativeCopySelect(request.fileId, baseline.sourcePageId, request.frameIds), {
|
|
289
|
+
effect: "write",
|
|
290
|
+
retries: 0,
|
|
291
|
+
});
|
|
292
|
+
const selection = await observedDestination(client, request.fileId, baseline.sourcePageId);
|
|
293
|
+
if (observationHash([
|
|
294
|
+
...selection.selectionIds,
|
|
295
|
+
].sort()) !==
|
|
296
|
+
observationHash([
|
|
297
|
+
...request.frameIds,
|
|
298
|
+
].sort())) {
|
|
299
|
+
throw new Error("Source board selection changed before copy");
|
|
300
|
+
}
|
|
301
|
+
await clipboard.copy(baseline.sourcePageId, request.frameIds);
|
|
302
|
+
await selectPage(client, {
|
|
303
|
+
id: value.destinationPageId,
|
|
304
|
+
});
|
|
305
|
+
await runSnippet(client, nativeCopySelect(request.fileId, value.destinationPageId, []), {
|
|
306
|
+
effect: "write",
|
|
307
|
+
retries: 0,
|
|
308
|
+
});
|
|
309
|
+
const destination = await targetPreview();
|
|
310
|
+
if (observationHash(destination.destinationRootIds) !==
|
|
311
|
+
observationHash(baseline.destinationRootIds)) {
|
|
312
|
+
throw new Error("Destination changed before paste");
|
|
313
|
+
}
|
|
314
|
+
const empty = await observedDestination(client, request.fileId, value.destinationPageId);
|
|
315
|
+
if (empty.selectionIds.length) {
|
|
316
|
+
throw new Error("Destination selection changed before paste");
|
|
317
|
+
}
|
|
318
|
+
await atomicWriteFile(fencePath, JSON.stringify({
|
|
319
|
+
schemaVersion: 1,
|
|
320
|
+
runId,
|
|
321
|
+
fileId: request.fileId,
|
|
322
|
+
pageId: value.destinationPageId,
|
|
323
|
+
requestHash: copyRequestHash(request),
|
|
324
|
+
}));
|
|
325
|
+
attempted = true;
|
|
326
|
+
await clipboard.paste(value.destinationPageId);
|
|
327
|
+
for (let poll = 0; poll < 25; poll++) {
|
|
328
|
+
if (inventoryComplete(await targetPreview())) {
|
|
329
|
+
return {
|
|
330
|
+
dispatched: true,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
await (options.sleep ?? commandSleep)(200);
|
|
334
|
+
}
|
|
335
|
+
throw new Error("Paste acknowledgment did not establish new boards; reconcile without replay");
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await clipboard.close();
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
verify: pasteComplete,
|
|
342
|
+
reconcile: (_before, value) => pasteComplete(value)
|
|
343
|
+
? "complete"
|
|
344
|
+
: attempted || addedRoots(value).length
|
|
345
|
+
? "conflict"
|
|
346
|
+
: "pending",
|
|
347
|
+
};
|
|
348
|
+
const arrangeStep = {
|
|
349
|
+
key: "arrange",
|
|
350
|
+
label: "Identify and arrange only the new native copies",
|
|
351
|
+
signature: "native-copy-arrange-v1",
|
|
352
|
+
inspect: arrangementState,
|
|
353
|
+
apply: async (value) => {
|
|
354
|
+
if (!value.destinationPageId || !pasteComplete(value)) {
|
|
355
|
+
throw new Error("Copied root inventory is incomplete");
|
|
356
|
+
}
|
|
357
|
+
placements = calculatePlacements();
|
|
358
|
+
await readSource();
|
|
359
|
+
await selectPage(client, {
|
|
360
|
+
id: value.destinationPageId,
|
|
361
|
+
});
|
|
362
|
+
return runSnippet(client, nativeCopyArrange(request.fileId, value.destinationPageId, placements, runId), {
|
|
363
|
+
effect: "write",
|
|
364
|
+
retries: 0,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
verify: (value) => pasteComplete(value) && value.arranged === true,
|
|
368
|
+
reconcile: (_before, value) => pasteComplete(value) ? (value.arranged === true ? "complete" : "pending") : "conflict",
|
|
369
|
+
};
|
|
370
|
+
let finalResult;
|
|
371
|
+
let receiptPersisted = false;
|
|
372
|
+
try {
|
|
373
|
+
await runJournaledSteps([
|
|
374
|
+
pageStep,
|
|
375
|
+
pasteStep,
|
|
376
|
+
arrangeStep,
|
|
377
|
+
], {
|
|
378
|
+
root,
|
|
379
|
+
operation,
|
|
380
|
+
input: request,
|
|
381
|
+
payload,
|
|
382
|
+
runId,
|
|
383
|
+
resume: options.resume,
|
|
384
|
+
target: () => readJournalTarget(client),
|
|
385
|
+
coordinate: async (identity, target) => {
|
|
386
|
+
const lease = await writeCoordinator(client, {
|
|
387
|
+
root,
|
|
388
|
+
operation,
|
|
389
|
+
warn: options.onStep,
|
|
390
|
+
})(identity, target);
|
|
391
|
+
try {
|
|
392
|
+
await assertNoConflictingRuns();
|
|
393
|
+
return lease;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
await lease.release();
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
redact: options.redact,
|
|
401
|
+
onStep: options.onStep,
|
|
402
|
+
finalVerify: async () => {
|
|
403
|
+
const destination = await arrangementState();
|
|
404
|
+
if (!destination.destinationPageId ||
|
|
405
|
+
!pasteComplete(destination) ||
|
|
406
|
+
!destination.arranged) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
const targets = await captureCopyEvidence(client, root, request.fileId, destination.destinationPageId, pairs.map((pair) => pair.targetFrameId), `${prefix}/target-${randomUUID()}`, options.redact);
|
|
410
|
+
const verifiedPairs = matchCopiedFrames(payload.sources, targets);
|
|
411
|
+
if (observationHash(verifiedPairs) !== observationHash(pairs)) {
|
|
412
|
+
throw new Error("Copied identities changed before final readback");
|
|
413
|
+
}
|
|
414
|
+
const verification = compareCopyEvidence(payload.sources, targets, pairs);
|
|
415
|
+
const originals = await captureCopyEvidence(client, root, request.fileId, baseline.sourcePageId, request.frameIds, `${prefix}/source-readback-${randomUUID()}`, options.redact);
|
|
416
|
+
if (sourceEvidenceHash(originals) !== sourceEvidenceHash(payload.sources)) {
|
|
417
|
+
throw new Error("Original boards changed during transfer; independent reconciliation is required");
|
|
418
|
+
}
|
|
419
|
+
await selectPage(client, {
|
|
420
|
+
id: destination.destinationPageId,
|
|
421
|
+
});
|
|
422
|
+
finalResult = {
|
|
423
|
+
schemaVersion: 1,
|
|
424
|
+
kind: "native-release-copy",
|
|
425
|
+
status: verification.nativeVerified && verification.pixelsMatch ? "copied" : "incomplete",
|
|
426
|
+
preview: destination,
|
|
427
|
+
runId,
|
|
428
|
+
receiptPath: `${prefix}/receipt.json`,
|
|
429
|
+
pairs,
|
|
430
|
+
nativeVerified: verification.nativeVerified,
|
|
431
|
+
pixelsMatch: verification.pixelsMatch,
|
|
432
|
+
problems: verification.problems,
|
|
433
|
+
};
|
|
434
|
+
const receipt = JSON.stringify({
|
|
435
|
+
...finalResult,
|
|
436
|
+
nodeMap: verification.nodeMap,
|
|
437
|
+
sourceEvidence: payload.sources.map(({ inspection: _inspection, ...source }) => source),
|
|
438
|
+
targetEvidence: targets.map(({ inspection: _inspection, ...target }) => target),
|
|
439
|
+
}, null, 2);
|
|
440
|
+
const receiptFile = await artifactPath(root, finalResult.receiptPath, true);
|
|
441
|
+
if (options.redact && options.redact(receipt) !== receipt) {
|
|
442
|
+
throw new Error("Copy receipt requires credential redaction");
|
|
443
|
+
}
|
|
444
|
+
await atomicWriteFile(receiptFile, `${receipt}\n`, {
|
|
445
|
+
replace: true,
|
|
446
|
+
});
|
|
447
|
+
receiptPersisted = true;
|
|
448
|
+
return verification.nativeVerified && verification.pixelsMatch;
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (finalResult?.status === "incomplete" &&
|
|
454
|
+
receiptPersisted &&
|
|
455
|
+
error instanceof JournalRunError &&
|
|
456
|
+
error.outcome === "failed") {
|
|
457
|
+
return finalResult;
|
|
458
|
+
}
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
if (!finalResult) {
|
|
462
|
+
throw new Error("Native copy final verification did not run");
|
|
463
|
+
}
|
|
464
|
+
return finalResult;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
@@ -6,3 +6,4 @@ export { finalizeRelease } from "./finalize.js";
|
|
|
6
6
|
export { checkRelease } from "./check.js";
|
|
7
7
|
export { releaseContext } from "./context.js";
|
|
8
8
|
export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
|
|
9
|
+
export * from "./copy.js";
|
|
@@ -5,3 +5,4 @@ export { finalizeRelease } from "./finalize.js";
|
|
|
5
5
|
export { checkRelease } from "./check.js";
|
|
6
6
|
export { releaseContext } from "./context.js";
|
|
7
7
|
export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
|
|
8
|
+
export * from "./copy.js";
|
package/dist/errors.js
CHANGED
|
@@ -9,7 +9,7 @@ export const errorRemedies = Object.freeze({
|
|
|
9
9
|
"mcp-unavailable": "Start the Penpot instance and check the MCP URL. Run `penpair probe` to see what answers.",
|
|
10
10
|
"auth-rejected": "Penpot rejected the MCP key. Use the key from Account, Integrations, MCP Server.",
|
|
11
11
|
"plugin-disconnected": "In the open Penpot file choose File, MCP Server, Connect, and keep that tab visible, then run the command again.",
|
|
12
|
-
"plugin-suspended": "The browser put the Penpot tab to sleep. Bring it to the foreground
|
|
12
|
+
"plugin-suspended": "The browser put the Penpot tab to sleep. Bring it to the foreground, or use penpair wake --debug-port PORT with a dedicated debugging browser, then run penpair doctor. Waking is temporary recovery. A suspended-tab error does not mean nothing was written: read the page and reconcile the run before explicitly replaying a write.",
|
|
13
13
|
"tool-failed": "Penpot's tool reported a failure; the message above says what.",
|
|
14
14
|
"snippet-too-large": "Split the snippet. Penpot's proxy refuses request bodies of about 110 KB, so snippets stay under 90 KB.",
|
|
15
15
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PluginHealth } from "../plugin-health.js";
|
|
1
2
|
import { type ExternalConfig, type InstanceConfig, type ProjectConfig } from "./config.js";
|
|
2
3
|
/** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
|
|
3
4
|
export declare class LifecycleError extends Error {
|
|
@@ -18,6 +19,7 @@ export interface DoctorCheck extends Check {
|
|
|
18
19
|
pageId?: string;
|
|
19
20
|
apiVersion?: string;
|
|
20
21
|
heartbeat?: "unknown";
|
|
22
|
+
pluginHealth?: PluginHealth;
|
|
21
23
|
};
|
|
22
24
|
remedy?: string;
|
|
23
25
|
file?: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { commandSignal, withCommandSignal } from "../command-result.js";
|
|
3
3
|
import { redactEndpoint } from "../endpoint.js";
|
|
4
|
+
import { pluginHealthFromError, responsivePluginHealth, } from "../plugin-health.js";
|
|
4
5
|
import { isExternal, } from "./config.js";
|
|
5
6
|
/** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
|
|
6
7
|
export class LifecycleError extends Error {
|
|
@@ -105,7 +106,7 @@ export async function doctorEditor(config, options) {
|
|
|
105
106
|
check: "editor and plugin",
|
|
106
107
|
layer: "execution",
|
|
107
108
|
ok: true,
|
|
108
|
-
detail: `Fresh read from file "${actual.file}"`,
|
|
109
|
+
detail: `Fresh read from file "${actual.file}"; heartbeat age unavailable; keep-alive unknown`,
|
|
109
110
|
observedAt: new Date().toISOString(),
|
|
110
111
|
durationMs: Date.now() - started,
|
|
111
112
|
evidence: {
|
|
@@ -117,11 +118,16 @@ export async function doctorEditor(config, options) {
|
|
|
117
118
|
}
|
|
118
119
|
: {}),
|
|
119
120
|
heartbeat: "unknown",
|
|
121
|
+
pluginHealth: responsivePluginHealth(),
|
|
120
122
|
},
|
|
121
123
|
});
|
|
122
124
|
out.push(fileIdentityCheck(config, actual));
|
|
123
125
|
}
|
|
124
126
|
catch (error) {
|
|
127
|
+
const pluginHealth = pluginHealthFromError(error);
|
|
128
|
+
const heartbeatDetail = pluginHealth.heartbeatAgeMs === null
|
|
129
|
+
? "heartbeat age unavailable"
|
|
130
|
+
: `last heartbeat ${pluginHealth.heartbeatAgeMs / 1000}s ago (reported by upstream)`;
|
|
125
131
|
if (!connected) {
|
|
126
132
|
out.push({
|
|
127
133
|
check: "MCP transport",
|
|
@@ -136,10 +142,16 @@ export async function doctorEditor(config, options) {
|
|
|
136
142
|
check: "editor and plugin",
|
|
137
143
|
layer: "execution",
|
|
138
144
|
ok: false,
|
|
139
|
-
detail: redactEndpoint(error instanceof Error ? error.message : String(error))
|
|
145
|
+
detail: `${redactEndpoint(error instanceof Error ? error.message : String(error))}; ${heartbeatDetail}; keep-alive unknown`,
|
|
140
146
|
observedAt: new Date().toISOString(),
|
|
141
147
|
durationMs: Date.now() - started,
|
|
142
|
-
|
|
148
|
+
evidence: {
|
|
149
|
+
heartbeat: "unknown",
|
|
150
|
+
pluginHealth,
|
|
151
|
+
},
|
|
152
|
+
remedy: pluginHealth.state === "suspended"
|
|
153
|
+
? "Bring the intended Penpot tab to the foreground, then rerun doctor for a fresh read. Reconcile any uncertain run before replaying a write."
|
|
154
|
+
: "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
|
|
143
155
|
});
|
|
144
156
|
}
|
|
145
157
|
finished = true;
|
package/dist/kit-source.js
CHANGED
|
@@ -225,7 +225,7 @@ const pp = (() => {
|
|
|
225
225
|
try { return await settleLayout(async nativeMs => {
|
|
226
226
|
if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed between samples");
|
|
227
227
|
return observeLayout(penpot, root, { nativeMs, maxNodes: options.maxNodes });
|
|
228
|
-
}, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, sleep: wait }); }
|
|
228
|
+
}, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, maxNodes: options.maxNodes, sleep: wait }); }
|
|
229
229
|
catch (error) { return { stable: false, method: "unavailable", samples: 0, reason: String(error && error.message || error) }; }
|
|
230
230
|
};
|
|
231
231
|
const flush = async (options = {}) => {
|
package/dist/layout-policy.js
CHANGED
|
@@ -2,21 +2,26 @@ import { settleLayout } from "./layout-settling.js";
|
|
|
2
2
|
/** Trusted, dependency-free observation source for the Penpot sandbox. */
|
|
3
3
|
const layoutObservationSource = String.raw `
|
|
4
4
|
const observeLayout = async (penpot, root, options) => {
|
|
5
|
-
if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes >
|
|
5
|
+
if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 100000))) throw new Error("Invalid settling node budget");
|
|
6
|
+
const roots = Array.isArray(root) ? root : [root];
|
|
7
|
+
if (!roots.length || roots.some(shape => !shape)) throw new Error("layout target does not exist");
|
|
6
8
|
const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
|
|
7
9
|
const validTarget = () => {
|
|
8
10
|
if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed while waiting");
|
|
9
|
-
|
|
11
|
+
for (const shape of roots) {
|
|
12
|
+
if (shape.id !== penpot.currentPage.root.id && typeof penpot.currentPage.getShapeById === "function" && !penpot.currentPage.getShapeById(shape.id)) throw new Error("Layout target no longer exists");
|
|
13
|
+
}
|
|
10
14
|
};
|
|
11
|
-
|
|
15
|
+
|
|
12
16
|
let nativeOutcome = "skipped", nativeError;
|
|
13
|
-
|
|
17
|
+
validTarget();
|
|
18
|
+
const nativeTargets = roots.every(shape => typeof shape.waitForLayoutUpdate === "function") ? roots : [penpot];
|
|
14
19
|
if (options.nativeMs > 0) {
|
|
15
20
|
nativeOutcome = "unsupported";
|
|
16
|
-
if (typeof target.waitForLayoutUpdate === "function") {
|
|
21
|
+
if (nativeTargets.every(target => typeof target.waitForLayoutUpdate === "function")) {
|
|
17
22
|
let timer;
|
|
18
23
|
try {
|
|
19
|
-
await Promise.race([target.waitForLayoutUpdate(options.nativeMs), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
|
|
24
|
+
await Promise.race([Promise.all(nativeTargets.map(target => target.waitForLayoutUpdate(options.nativeMs))), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
|
|
20
25
|
nativeOutcome = "completed";
|
|
21
26
|
} catch (error) { nativeOutcome = "failed"; nativeError = String(error && error.message || error); }
|
|
22
27
|
finally { clearTimeout(timer); }
|
|
@@ -24,20 +29,25 @@ const observeLayout = async (penpot, root, options) => {
|
|
|
24
29
|
}
|
|
25
30
|
validTarget();
|
|
26
31
|
// Keep hierarchy and sibling order as well as geometry; repeated IDs include cycles.
|
|
27
|
-
const geometry = [], stack = [
|
|
32
|
+
const geometry = [], stack = roots.map(shape => [shape, null, 0]).reverse(), seen = new Set();
|
|
33
|
+
let truncated = false;
|
|
28
34
|
while (stack.length) {
|
|
29
|
-
if (geometry.length >= (options.maxNodes || 10000))
|
|
35
|
+
if (geometry.length >= (options.maxNodes || 10000)) { truncated = true; break; }
|
|
30
36
|
const [shape, parent, index] = stack.pop();
|
|
31
37
|
if (!shape || typeof shape.id !== "string" || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite)) throw new Error("Malformed layout geometry or duplicate IDs");
|
|
32
38
|
seen.add(shape.id);
|
|
33
39
|
geometry.push([shape.id, shape.x, shape.y, shape.width, shape.height, parent, index]);
|
|
34
40
|
const children = shape.children || [];
|
|
35
41
|
if (!Array.isArray(children)) throw new Error("Malformed layout hierarchy");
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
const available = (options.maxNodes || 10000) - geometry.length;
|
|
43
|
+
if (stack.length + children.length > available) truncated = true;
|
|
44
|
+
// Only retain the bounded depth-first prefix; avoid allocating a page-sized traversal stack.
|
|
45
|
+
if (children.length >= available) stack.length = 0;
|
|
46
|
+
else if (stack.length > available - children.length) stack.splice(0, stack.length - (available - children.length));
|
|
47
|
+
for (let index = Math.min(children.length, available) - 1; index >= 0; index--) stack.push([children[index], shape.id, index]);
|
|
38
48
|
}
|
|
39
49
|
validTarget();
|
|
40
|
-
return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry,
|
|
50
|
+
return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, rootIds: roots.map(shape => shape.id), truncated };
|
|
41
51
|
};
|
|
42
52
|
`;
|
|
43
53
|
export const layoutPolicySource = `${layoutObservationSource}\nconst settleLayout = ${settleLayout.toString()};`;
|