@penpair/core 0.4.0 → 0.4.1
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 +129 -15
- 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/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
package/dist/adapters.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface Adapter {
|
|
|
51
51
|
/** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
|
|
52
52
|
export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
|
|
53
53
|
/** The commands of the core itself; an adapter cannot take one of these names. */
|
|
54
|
-
export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
|
|
54
|
+
export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "wake", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
|
|
55
55
|
export interface LoadedAdapters {
|
|
56
56
|
adapters: Adapter[];
|
|
57
57
|
problems: string[];
|
package/dist/adapters.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface BrowserTargetOptions {
|
|
2
|
+
origin: string;
|
|
3
|
+
fileId: string;
|
|
4
|
+
port: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
allowLoopbackAlias?: boolean;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export interface BrowserWakeResult {
|
|
11
|
+
activated: true;
|
|
12
|
+
targetId: string;
|
|
13
|
+
keepAlive: "none";
|
|
14
|
+
}
|
|
15
|
+
export interface BrowserInspectionResult {
|
|
16
|
+
targetId: string;
|
|
17
|
+
keepAlive: "unknown";
|
|
18
|
+
}
|
|
19
|
+
/** Activate only the existing page for the pinned file; never reload or evaluate page code. */
|
|
20
|
+
export declare function wakeBrowserTarget(options: BrowserTargetOptions): Promise<BrowserWakeResult>;
|
|
21
|
+
/** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
|
|
22
|
+
export declare function inspectBrowserTarget(options: BrowserTargetOptions): Promise<BrowserInspectionResult>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const maxTargetBytes = 1024 * 1024;
|
|
2
|
+
const maxTargetCount = 1000;
|
|
3
|
+
/** Activate only the existing page for the pinned file; never reload or evaluate page code. */
|
|
4
|
+
export async function wakeBrowserTarget(options) {
|
|
5
|
+
return withBrowserDeadline(options, async (request) => {
|
|
6
|
+
const targetId = await findBrowserTarget(request);
|
|
7
|
+
const response = await browserRequest(request, `/json/activate/${encodeURIComponent(targetId)}`);
|
|
8
|
+
void response.body?.cancel().catch(() => undefined);
|
|
9
|
+
request.signal.throwIfAborted();
|
|
10
|
+
return {
|
|
11
|
+
activated: true,
|
|
12
|
+
targetId,
|
|
13
|
+
keepAlive: "none",
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
|
|
18
|
+
export async function inspectBrowserTarget(options) {
|
|
19
|
+
return withBrowserDeadline(options, async (request) => ({
|
|
20
|
+
targetId: await findBrowserTarget(request),
|
|
21
|
+
keepAlive: "unknown",
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
async function withBrowserDeadline(options, work) {
|
|
25
|
+
const origin = validateBrowserOptions(options);
|
|
26
|
+
if (options.signal?.aborted) {
|
|
27
|
+
throw new Error("Browser debugging cancelled");
|
|
28
|
+
}
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
31
|
+
let timedOut = false;
|
|
32
|
+
const externalAbort = () => controller.abort();
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
timedOut = true;
|
|
35
|
+
controller.abort();
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
options.signal?.addEventListener("abort", externalAbort, {
|
|
38
|
+
once: true,
|
|
39
|
+
});
|
|
40
|
+
let abort = () => undefined;
|
|
41
|
+
const interrupted = new Promise((_resolve, reject) => {
|
|
42
|
+
abort = () => reject(new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled"));
|
|
43
|
+
controller.signal.addEventListener("abort", abort, {
|
|
44
|
+
once: true,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
if (options.signal?.aborted) {
|
|
49
|
+
controller.abort();
|
|
50
|
+
}
|
|
51
|
+
controller.signal.throwIfAborted();
|
|
52
|
+
return await Promise.race([
|
|
53
|
+
interrupted,
|
|
54
|
+
work({
|
|
55
|
+
origin,
|
|
56
|
+
fileId: options.fileId,
|
|
57
|
+
allowLoopbackAlias: options.allowLoopbackAlias === true,
|
|
58
|
+
endpoint: `http://127.0.0.1:${options.port}`,
|
|
59
|
+
fetch: options.fetch ?? fetch,
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (controller.signal.aborted) {
|
|
66
|
+
throw new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled");
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
options.signal?.removeEventListener("abort", externalAbort);
|
|
73
|
+
controller.signal.removeEventListener("abort", abort);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function validateBrowserOptions(options) {
|
|
77
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) {
|
|
78
|
+
throw new Error("Browser debugging port must be an integer from 1 to 65535");
|
|
79
|
+
}
|
|
80
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
81
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
|
|
82
|
+
throw new Error("Browser debugging timeout must be an integer from 1000 to 30000 ms");
|
|
83
|
+
}
|
|
84
|
+
if (typeof options.fileId !== "string" ||
|
|
85
|
+
!options.fileId.trim() ||
|
|
86
|
+
options.fileId.length > 256 ||
|
|
87
|
+
/[\u0000-\u0020\u007f]/.test(options.fileId)) {
|
|
88
|
+
throw new Error("Browser activation requires a pinned file ID");
|
|
89
|
+
}
|
|
90
|
+
let origin;
|
|
91
|
+
try {
|
|
92
|
+
origin = new URL(options.origin);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error("Browser activation requires a valid Penpot origin");
|
|
96
|
+
}
|
|
97
|
+
if (![
|
|
98
|
+
"http:",
|
|
99
|
+
"https:",
|
|
100
|
+
].includes(origin.protocol) ||
|
|
101
|
+
origin.username ||
|
|
102
|
+
origin.password ||
|
|
103
|
+
origin.pathname !== "/" ||
|
|
104
|
+
origin.search ||
|
|
105
|
+
origin.hash) {
|
|
106
|
+
throw new Error("Browser activation requires a Penpot origin without credentials, path or query");
|
|
107
|
+
}
|
|
108
|
+
if (options.allowLoopbackAlias && !isHttpLoopback(origin)) {
|
|
109
|
+
throw new Error("Browser loopback aliases require an HTTP localhost or 127.0.0.1 origin");
|
|
110
|
+
}
|
|
111
|
+
return origin.origin;
|
|
112
|
+
}
|
|
113
|
+
async function browserRequest(request, path) {
|
|
114
|
+
request.signal.throwIfAborted();
|
|
115
|
+
let response;
|
|
116
|
+
try {
|
|
117
|
+
response = await request.fetch(`${request.endpoint}${path}`, {
|
|
118
|
+
method: "GET",
|
|
119
|
+
redirect: "error",
|
|
120
|
+
signal: request.signal,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error("Cannot reach the local browser debugging endpoint; start a dedicated browser with a loopback debugging port");
|
|
125
|
+
}
|
|
126
|
+
request.signal.throwIfAborted();
|
|
127
|
+
if (!response.ok || response.redirected) {
|
|
128
|
+
void response.body?.cancel().catch(() => undefined);
|
|
129
|
+
throw new Error("The local browser debugging endpoint refused the request");
|
|
130
|
+
}
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
async function findBrowserTarget(request) {
|
|
134
|
+
const response = await browserRequest(request, "/json/list");
|
|
135
|
+
const text = await readTargetList(response);
|
|
136
|
+
request.signal.throwIfAborted();
|
|
137
|
+
let targets;
|
|
138
|
+
try {
|
|
139
|
+
targets = JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error("Invalid browser debugging target list");
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(targets) ||
|
|
145
|
+
targets.length > maxTargetCount ||
|
|
146
|
+
!targets.every(isBrowserTarget)) {
|
|
147
|
+
throw new Error("Invalid browser debugging target list");
|
|
148
|
+
}
|
|
149
|
+
const matching = targets.filter((target) => target.type === "page" && matchesFile(target.url, request));
|
|
150
|
+
if (matching.length !== 1) {
|
|
151
|
+
throw new Error(matching.length === 0
|
|
152
|
+
? "No browser page matches the project's Penpot origin and pinned file ID"
|
|
153
|
+
: "Multiple browser pages match the pinned file; close duplicate tabs before waking");
|
|
154
|
+
}
|
|
155
|
+
const targetId = matching[0].id;
|
|
156
|
+
if (!/^[A-Za-z0-9_-]{1,256}$/.test(targetId)) {
|
|
157
|
+
throw new Error("Invalid browser debugging target ID");
|
|
158
|
+
}
|
|
159
|
+
return targetId;
|
|
160
|
+
}
|
|
161
|
+
function isBrowserTarget(value) {
|
|
162
|
+
if (!value || typeof value !== "object") {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const target = value;
|
|
166
|
+
return (typeof target.id === "string" &&
|
|
167
|
+
typeof target.type === "string" &&
|
|
168
|
+
typeof target.url === "string");
|
|
169
|
+
}
|
|
170
|
+
function isHttpLoopback(url) {
|
|
171
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
172
|
+
}
|
|
173
|
+
function matchesOrigin(target, request) {
|
|
174
|
+
if (target.origin === request.origin) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (!request.allowLoopbackAlias || !isHttpLoopback(target)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const expected = new URL(request.origin);
|
|
181
|
+
return isHttpLoopback(expected) && target.port === expected.port;
|
|
182
|
+
}
|
|
183
|
+
function matchesFile(targetUrl, request) {
|
|
184
|
+
let url;
|
|
185
|
+
try {
|
|
186
|
+
url = new URL(targetUrl);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!matchesOrigin(url, request) || url.username || url.password) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const hashRoute = url.hash.slice(1);
|
|
195
|
+
const hashQuery = hashRoute.indexOf("?");
|
|
196
|
+
const parameters = hashQuery >= 0 && hashRoute.slice(0, hashQuery) === "/workspace"
|
|
197
|
+
? new URLSearchParams(hashRoute.slice(hashQuery + 1))
|
|
198
|
+
: url.pathname === "/workspace" && !url.hash
|
|
199
|
+
? url.searchParams
|
|
200
|
+
: undefined;
|
|
201
|
+
const fileIds = parameters?.getAll("file-id");
|
|
202
|
+
return fileIds?.length === 1 && fileIds[0] === request.fileId;
|
|
203
|
+
}
|
|
204
|
+
async function readTargetList(response) {
|
|
205
|
+
const declaredBytes = Number(response.headers.get("content-length"));
|
|
206
|
+
if (declaredBytes > maxTargetBytes) {
|
|
207
|
+
void response.body?.cancel().catch(() => undefined);
|
|
208
|
+
throw new Error("Browser debugging target list exceeds the size limit");
|
|
209
|
+
}
|
|
210
|
+
if (!response.body) {
|
|
211
|
+
throw new Error("Invalid browser debugging target list");
|
|
212
|
+
}
|
|
213
|
+
const reader = response.body.getReader();
|
|
214
|
+
const chunks = [];
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
try {
|
|
217
|
+
while (true) {
|
|
218
|
+
const next = await reader.read();
|
|
219
|
+
if (next.done) {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
bytes += next.value.byteLength;
|
|
223
|
+
if (bytes > maxTargetBytes) {
|
|
224
|
+
throw new Error("Browser debugging target list exceeds the size limit");
|
|
225
|
+
}
|
|
226
|
+
chunks.push(next.value);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
void reader.cancel().catch(() => undefined);
|
|
231
|
+
if (error instanceof Error &&
|
|
232
|
+
error.message === "Browser debugging target list exceeds the size limit") {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
throw new Error("Cannot read browser debugging target list");
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
reader.releaseLock();
|
|
239
|
+
}
|
|
240
|
+
return Buffer.concat(chunks, bytes).toString("utf8");
|
|
241
|
+
}
|
package/dist/cli-main.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { auditDesignRules } from "./audit-rules.js";
|
|
2
2
|
import { projectStatus, projectFileUrl, openProject } from "./project-status.js";
|
|
3
|
+
import { wakeBrowserTarget } from "./browser-wake.js";
|
|
4
|
+
import { pluginHealthFromError } from "./plugin-health.js";
|
|
3
5
|
import { artifactSchemas, migrateProjectConfig } from "./formats.js";
|
|
4
6
|
import { importDtcg, exportDtcg } from "./portable-tokens.js";
|
|
5
7
|
import { captureStructuralSnapshot, writeStructuralSnapshot, readStructuralSnapshot, diffStructuralSnapshots, renderStructuralDiff, } from "./structure-snapshot.js";
|
|
@@ -44,7 +46,7 @@ import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
|
|
|
44
46
|
import { validateSkillAgent } from "./skill-hosts.js";
|
|
45
47
|
import { loadAdapters, readRawConfig, reservedCommands, } from "./adapters.js";
|
|
46
48
|
import { requireLocalProfile } from "./local-profile.js";
|
|
47
|
-
import { assertEndpointIsInstance, doctorExternal, LifecycleError, } from "./instance/connection.js";
|
|
49
|
+
import { assertEndpointIsInstance, doctorExternal, doctorEditor, frontendUrl, LifecycleError, } from "./instance/connection.js";
|
|
48
50
|
import { nodeRunner } from "./instance/runner.js";
|
|
49
51
|
import { version } from "./version.js";
|
|
50
52
|
import { guardFile } from "./file-guard.js";
|
|
@@ -71,6 +73,7 @@ Connect / diagnose:
|
|
|
71
73
|
penpair config check [--json] validate configuration offline
|
|
72
74
|
penpair config schema print its JSON Schema
|
|
73
75
|
penpair doctor [--json] [--timeout-ms 5000] origin, transport, fresh editor read and file pin (1000–30000 ms)
|
|
76
|
+
penpair wake --debug-port 9222 [--json] activate the pinned browser tab, then verify the editor; no reload or replay
|
|
74
77
|
penpair status [--adapters] observed project health and optional companion readiness
|
|
75
78
|
penpair open [--print] open the pinned public file URL, or print it
|
|
76
79
|
penpair probe [--integrity] observed APIs and limits
|
|
@@ -84,7 +87,7 @@ Build:
|
|
|
84
87
|
penpair snippets / snippets check list or validate configured trusted scripts
|
|
85
88
|
penpair tokens add-set|set|remove|promote inspect token edits; --apply writes once
|
|
86
89
|
penpair archive --page PAGE --name Archive --keep PREFIX [--apply]
|
|
87
|
-
(run-file/export/shots accept --layout auto|native|geometry
|
|
90
|
+
(run-file/run/archive/token edits/export/shots accept --layout auto|native|geometry, repeatable --layout-root ID, --layout-max-nodes 1..100000)
|
|
88
91
|
(journaled writes accept --resume RUN_ID --apply, --checkpoint and --trace)
|
|
89
92
|
Inspect / spec:
|
|
90
93
|
penpair pages file identity, revision and pages
|
|
@@ -235,9 +238,19 @@ async function main(argv, cli) {
|
|
|
235
238
|
"timeout-ms": {
|
|
236
239
|
type: "string",
|
|
237
240
|
},
|
|
241
|
+
"debug-port": {
|
|
242
|
+
type: "string",
|
|
243
|
+
},
|
|
238
244
|
layout: {
|
|
239
245
|
type: "string",
|
|
240
246
|
},
|
|
247
|
+
"layout-root": {
|
|
248
|
+
type: "string",
|
|
249
|
+
multiple: true,
|
|
250
|
+
},
|
|
251
|
+
"layout-max-nodes": {
|
|
252
|
+
type: "string",
|
|
253
|
+
},
|
|
241
254
|
page: {
|
|
242
255
|
type: "string",
|
|
243
256
|
},
|
|
@@ -432,6 +445,11 @@ async function main(argv, cli) {
|
|
|
432
445
|
"max-total-bytes",
|
|
433
446
|
"capture-timeout-ms",
|
|
434
447
|
];
|
|
448
|
+
const layoutFlags = [
|
|
449
|
+
"layout",
|
|
450
|
+
"layout-root",
|
|
451
|
+
"layout-max-nodes",
|
|
452
|
+
];
|
|
435
453
|
const flags = {
|
|
436
454
|
formats: [
|
|
437
455
|
"out",
|
|
@@ -486,6 +504,7 @@ async function main(argv, cli) {
|
|
|
486
504
|
"theme",
|
|
487
505
|
]
|
|
488
506
|
: [
|
|
507
|
+
...layoutFlags,
|
|
489
508
|
"type",
|
|
490
509
|
"value",
|
|
491
510
|
"description",
|
|
@@ -499,16 +518,17 @@ async function main(argv, cli) {
|
|
|
499
518
|
"page",
|
|
500
519
|
"scale",
|
|
501
520
|
"name",
|
|
502
|
-
|
|
521
|
+
...layoutFlags,
|
|
503
522
|
],
|
|
504
523
|
shots: [
|
|
505
524
|
"page",
|
|
506
525
|
"prefix",
|
|
507
526
|
"out",
|
|
508
527
|
"scale",
|
|
509
|
-
|
|
528
|
+
...layoutFlags,
|
|
510
529
|
],
|
|
511
530
|
archive: [
|
|
531
|
+
...layoutFlags,
|
|
512
532
|
"page",
|
|
513
533
|
"name",
|
|
514
534
|
"keep",
|
|
@@ -527,7 +547,7 @@ async function main(argv, cli) {
|
|
|
527
547
|
"include",
|
|
528
548
|
],
|
|
529
549
|
"run-file": [
|
|
530
|
-
|
|
550
|
+
...layoutFlags,
|
|
531
551
|
"include",
|
|
532
552
|
"inspect",
|
|
533
553
|
"readback",
|
|
@@ -540,6 +560,7 @@ async function main(argv, cli) {
|
|
|
540
560
|
"trace",
|
|
541
561
|
],
|
|
542
562
|
run: [
|
|
563
|
+
...layoutFlags,
|
|
543
564
|
"builtins-only",
|
|
544
565
|
"param",
|
|
545
566
|
"apply",
|
|
@@ -594,6 +615,10 @@ async function main(argv, cli) {
|
|
|
594
615
|
doctor: [
|
|
595
616
|
"timeout-ms",
|
|
596
617
|
],
|
|
618
|
+
wake: [
|
|
619
|
+
"debug-port",
|
|
620
|
+
"timeout-ms",
|
|
621
|
+
],
|
|
597
622
|
up: [],
|
|
598
623
|
down: [],
|
|
599
624
|
open: [
|
|
@@ -710,12 +735,31 @@ async function main(argv, cli) {
|
|
|
710
735
|
].includes(values.layout)) {
|
|
711
736
|
throw new CommandArgumentError("--layout must be auto, native or geometry");
|
|
712
737
|
}
|
|
713
|
-
if (
|
|
738
|
+
if (values["layout-max-nodes"] !== undefined &&
|
|
739
|
+
(!/^\d+$/.test(values["layout-max-nodes"]) ||
|
|
740
|
+
Number(values["layout-max-nodes"]) < 1 ||
|
|
741
|
+
Number(values["layout-max-nodes"]) > 100000)) {
|
|
742
|
+
throw new CommandArgumentError("--layout-max-nodes must be an integer from 1 to 100000");
|
|
743
|
+
}
|
|
744
|
+
if (values["layout-root"] &&
|
|
745
|
+
(values["layout-root"].some((id) => !id.trim()) ||
|
|
746
|
+
new Set(values["layout-root"]).size !== values["layout-root"].length)) {
|
|
747
|
+
throw new CommandArgumentError("--layout-root must name distinct nonempty shape IDs");
|
|
748
|
+
}
|
|
749
|
+
if ((command === "doctor" || command === "wake") &&
|
|
714
750
|
values["timeout-ms"] !== undefined &&
|
|
715
751
|
(!/^\d+$/.test(values["timeout-ms"]) ||
|
|
716
752
|
Number(values["timeout-ms"]) < 1000 ||
|
|
717
753
|
Number(values["timeout-ms"]) > 30000)) {
|
|
718
|
-
throw new CommandArgumentError(
|
|
754
|
+
throw new CommandArgumentError(`${command} --timeout-ms must be an integer from 1000 to 30000`);
|
|
755
|
+
}
|
|
756
|
+
if (command === "wake" &&
|
|
757
|
+
(rest.length ||
|
|
758
|
+
!values["debug-port"] ||
|
|
759
|
+
!/^\d+$/.test(values["debug-port"]) ||
|
|
760
|
+
Number(values["debug-port"]) < 1 ||
|
|
761
|
+
Number(values["debug-port"]) > 65535)) {
|
|
762
|
+
throw new CommandArgumentError("wake requires --debug-port with an integer from 1 to 65535");
|
|
719
763
|
}
|
|
720
764
|
let tokenEdit;
|
|
721
765
|
if (command === "tokens" &&
|
|
@@ -1358,7 +1402,11 @@ async function main(argv, cli) {
|
|
|
1358
1402
|
{
|
|
1359
1403
|
check: "editor and plugin",
|
|
1360
1404
|
ok: false,
|
|
1361
|
-
detail: "No resolved project credential/connection; editor identity was not observed",
|
|
1405
|
+
detail: "No resolved project credential/connection; editor identity was not observed; heartbeat age unavailable; keep-alive unknown",
|
|
1406
|
+
evidence: {
|
|
1407
|
+
heartbeat: "unknown",
|
|
1408
|
+
pluginHealth: pluginHealthFromError(undefined),
|
|
1409
|
+
},
|
|
1362
1410
|
remedy: "Configure the private credential source, connect the intended editor, then rerun doctor.",
|
|
1363
1411
|
},
|
|
1364
1412
|
]
|
|
@@ -1392,6 +1440,57 @@ async function main(argv, cli) {
|
|
|
1392
1440
|
}
|
|
1393
1441
|
return ok ? 0 : 1;
|
|
1394
1442
|
}
|
|
1443
|
+
if (command === "wake") {
|
|
1444
|
+
const config = await loadCommandConfig(root);
|
|
1445
|
+
if (!config.file?.id) {
|
|
1446
|
+
throw new CommandArgumentError("Pin the owner-confirmed file before waking its browser tab");
|
|
1447
|
+
}
|
|
1448
|
+
const mcp = await mcpProbeFor();
|
|
1449
|
+
if (!mcp) {
|
|
1450
|
+
throw new Error("Configure the project's MCP connection before waking and verifying its tab");
|
|
1451
|
+
}
|
|
1452
|
+
assertEndpointIsInstance({
|
|
1453
|
+
origin: mcp.origin,
|
|
1454
|
+
source: "project connection",
|
|
1455
|
+
}, config);
|
|
1456
|
+
const timeoutMs = Number(values["timeout-ms"] ?? 5000);
|
|
1457
|
+
const started = Date.now();
|
|
1458
|
+
const activation = await wakeBrowserTarget({
|
|
1459
|
+
origin: isExternal(config) ? config.profile.origin : frontendUrl(config),
|
|
1460
|
+
allowLoopbackAlias: !isExternal(config),
|
|
1461
|
+
fileId: config.file.id,
|
|
1462
|
+
port: Number(values["debug-port"]),
|
|
1463
|
+
timeoutMs,
|
|
1464
|
+
signal: commandSignal(),
|
|
1465
|
+
});
|
|
1466
|
+
const remaining = timeoutMs - (Date.now() - started);
|
|
1467
|
+
const checks = remaining > 0
|
|
1468
|
+
? await doctorEditor(config, {
|
|
1469
|
+
mcp,
|
|
1470
|
+
timeoutMs: remaining,
|
|
1471
|
+
})
|
|
1472
|
+
: [
|
|
1473
|
+
{
|
|
1474
|
+
check: "editor and plugin",
|
|
1475
|
+
ok: false,
|
|
1476
|
+
detail: "Tab activation exhausted the verification deadline",
|
|
1477
|
+
},
|
|
1478
|
+
];
|
|
1479
|
+
const ready = checks.length > 0 && checks.every((check) => check.ok);
|
|
1480
|
+
output.result({
|
|
1481
|
+
...activation,
|
|
1482
|
+
ready,
|
|
1483
|
+
checks,
|
|
1484
|
+
});
|
|
1485
|
+
if (!values.json) {
|
|
1486
|
+
output.log("Browser activation acknowledged; sustained keep-alive: none. No write was replayed.");
|
|
1487
|
+
for (const check of checks) {
|
|
1488
|
+
output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
|
|
1489
|
+
}
|
|
1490
|
+
output.log("Reconcile any uncertain run independently before explicitly resuming it.");
|
|
1491
|
+
}
|
|
1492
|
+
return ready ? 0 : 1;
|
|
1493
|
+
}
|
|
1395
1494
|
if (command === "open") {
|
|
1396
1495
|
if (rest.length) {
|
|
1397
1496
|
throw new CommandArgumentError("open accepts only --print for a URL preview");
|
|
@@ -1683,10 +1782,24 @@ async function main(argv, cli) {
|
|
|
1683
1782
|
const client = await guarded();
|
|
1684
1783
|
commandRedactor(endpointRedactor(endpoint));
|
|
1685
1784
|
const options = {
|
|
1686
|
-
...(values.layout
|
|
1785
|
+
...(values.layout || values["layout-root"] || values["layout-max-nodes"]
|
|
1687
1786
|
? {
|
|
1688
1787
|
layout: {
|
|
1689
|
-
|
|
1788
|
+
...(values.layout
|
|
1789
|
+
? {
|
|
1790
|
+
strategy: values.layout,
|
|
1791
|
+
}
|
|
1792
|
+
: {}),
|
|
1793
|
+
...(values["layout-root"]
|
|
1794
|
+
? {
|
|
1795
|
+
shapeIds: values["layout-root"],
|
|
1796
|
+
}
|
|
1797
|
+
: {}),
|
|
1798
|
+
...(values["layout-max-nodes"]
|
|
1799
|
+
? {
|
|
1800
|
+
maxNodes: Number(values["layout-max-nodes"]),
|
|
1801
|
+
}
|
|
1802
|
+
: {}),
|
|
1690
1803
|
},
|
|
1691
1804
|
}
|
|
1692
1805
|
: {}),
|
|
@@ -2391,15 +2504,16 @@ async function runCliBound(argv, options = {}) {
|
|
|
2391
2504
|
...argv,
|
|
2392
2505
|
];
|
|
2393
2506
|
const timeoutAt = args.indexOf("--timeout-ms");
|
|
2507
|
+
const boundedDiagnosis = args[0] === "doctor" || args[0] === "wake";
|
|
2394
2508
|
let invalidTimeout = false;
|
|
2395
2509
|
if (timeoutAt !== -1) {
|
|
2396
2510
|
const milliseconds = Number(args[timeoutAt + 1]);
|
|
2397
2511
|
invalidTimeout =
|
|
2398
2512
|
!Number.isInteger(milliseconds) ||
|
|
2399
|
-
milliseconds < (
|
|
2400
|
-
milliseconds > (
|
|
2513
|
+
milliseconds < (boundedDiagnosis ? 1000 : 1) ||
|
|
2514
|
+
milliseconds > (boundedDiagnosis ? 30000 : 1_800_000) ||
|
|
2401
2515
|
args.indexOf("--timeout-ms", timeoutAt + 1) !== -1;
|
|
2402
|
-
if (
|
|
2516
|
+
if (!boundedDiagnosis) {
|
|
2403
2517
|
args.splice(timeoutAt, 2);
|
|
2404
2518
|
}
|
|
2405
2519
|
if (!invalidTimeout) {
|
|
@@ -2426,8 +2540,8 @@ async function runCliBound(argv, options = {}) {
|
|
|
2426
2540
|
try {
|
|
2427
2541
|
if (invalidTimeout) {
|
|
2428
2542
|
const result = await collectCommand(args[0] ?? "help", async () => {
|
|
2429
|
-
throw new Error(
|
|
2430
|
-
?
|
|
2543
|
+
throw new Error(boundedDiagnosis
|
|
2544
|
+
? `${args[0]} --timeout-ms must be one integer from 1000 to 30000`
|
|
2431
2545
|
: "--timeout-ms must be one integer from 1 to 1800000");
|
|
2432
2546
|
});
|
|
2433
2547
|
if (json) {
|
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()};`;
|
package/dist/layout-settling.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
|
|
2
2
|
export async function settleLayout(observe, options) {
|
|
3
|
-
const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
|
|
4
|
-
if (!
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const { timeoutMs, intervalMs, strategy = "auto", maxNodes = 10000, sleep } = options;
|
|
4
|
+
if (!Number.isInteger(maxNodes) ||
|
|
5
|
+
maxNodes < 1 ||
|
|
6
|
+
maxNodes > 100000 ||
|
|
7
|
+
![
|
|
8
|
+
"auto",
|
|
9
|
+
"native",
|
|
10
|
+
"geometry",
|
|
11
|
+
].includes(strategy) ||
|
|
9
12
|
!Number.isFinite(timeoutMs) ||
|
|
10
13
|
timeoutMs < 1 ||
|
|
11
14
|
timeoutMs > 30000 ||
|
|
@@ -17,6 +20,9 @@ export async function settleLayout(observe, options) {
|
|
|
17
20
|
const started = Date.now();
|
|
18
21
|
const deadline = started + timeoutMs;
|
|
19
22
|
let samples = 0;
|
|
23
|
+
let nodeCount = 0;
|
|
24
|
+
let rootIds = [];
|
|
25
|
+
let truncated = false;
|
|
20
26
|
let nativeOutcome = "skipped";
|
|
21
27
|
let fallbackReason;
|
|
22
28
|
let targetIdentity;
|
|
@@ -24,8 +30,12 @@ export async function settleLayout(observe, options) {
|
|
|
24
30
|
let matchingSamples = 0;
|
|
25
31
|
const createResult = (stable, method, reason) => ({
|
|
26
32
|
stable,
|
|
27
|
-
method,
|
|
33
|
+
method: truncated ? "partial" : method,
|
|
28
34
|
samples,
|
|
35
|
+
rootIds,
|
|
36
|
+
nodeCount,
|
|
37
|
+
maxNodes,
|
|
38
|
+
truncated,
|
|
29
39
|
strategy,
|
|
30
40
|
nativeOutcome,
|
|
31
41
|
...(fallbackReason
|
|
@@ -34,9 +44,10 @@ export async function settleLayout(observe, options) {
|
|
|
34
44
|
}
|
|
35
45
|
: {}),
|
|
36
46
|
elapsedMs: Date.now() - started,
|
|
37
|
-
...(reason
|
|
47
|
+
...(reason || truncated
|
|
38
48
|
? {
|
|
39
|
-
reason
|
|
49
|
+
reason: reason ??
|
|
50
|
+
"Only the bounded layout sample was observed; independent readback is required",
|
|
40
51
|
}
|
|
41
52
|
: {}),
|
|
42
53
|
});
|
|
@@ -74,20 +85,34 @@ export async function settleLayout(observe, options) {
|
|
|
74
85
|
const nextTargetIdentity = JSON.stringify([
|
|
75
86
|
observation.fileId,
|
|
76
87
|
observation.pageId,
|
|
77
|
-
observation.
|
|
88
|
+
observation.rootIds ?? [
|
|
89
|
+
observation.geometry?.[0]?.[0],
|
|
90
|
+
],
|
|
78
91
|
]);
|
|
79
92
|
if (targetIdentity && targetIdentity !== nextTargetIdentity) {
|
|
80
93
|
throw new Error("Layout target changed between samples");
|
|
81
94
|
}
|
|
82
95
|
targetIdentity = nextTargetIdentity;
|
|
83
|
-
if (observation.
|
|
84
|
-
!Array.isArray(observation.geometry) ||
|
|
96
|
+
if (!Array.isArray(observation.geometry) ||
|
|
85
97
|
!observation.geometry.length ||
|
|
86
|
-
observation.geometry.length >
|
|
98
|
+
observation.geometry.length > maxNodes) {
|
|
87
99
|
throw new Error("Incomplete layout geometry evidence");
|
|
88
100
|
}
|
|
101
|
+
const observedRoots = observation.rootIds ?? [
|
|
102
|
+
observation.geometry[0][0],
|
|
103
|
+
];
|
|
104
|
+
if (!Array.isArray(observedRoots) ||
|
|
105
|
+
!observedRoots.length ||
|
|
106
|
+
observedRoots.some((id) => typeof id !== "string" || !id) ||
|
|
107
|
+
new Set(observedRoots).size !== observedRoots.length) {
|
|
108
|
+
throw new Error("Malformed layout root evidence");
|
|
109
|
+
}
|
|
110
|
+
rootIds = observedRoots;
|
|
111
|
+
nodeCount = observation.geometry.length;
|
|
112
|
+
truncated = observation.truncated;
|
|
89
113
|
const observedShapeIds = new Set();
|
|
90
114
|
const siblingCountByParent = new Map();
|
|
115
|
+
let rootIndex = 0;
|
|
91
116
|
for (const geometryRow of observation.geometry) {
|
|
92
117
|
if (!Array.isArray(geometryRow) ||
|
|
93
118
|
geometryRow.length !== 7 ||
|
|
@@ -102,9 +127,12 @@ export async function settleLayout(observe, options) {
|
|
|
102
127
|
geometryRow[6] < 0) {
|
|
103
128
|
throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
|
|
104
129
|
}
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
if (geometryRow[5] === null) {
|
|
131
|
+
if (geometryRow[0] !== rootIds[rootIndex++] || geometryRow[6] !== 0) {
|
|
132
|
+
throw new Error("Malformed layout root hierarchy");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (typeof geometryRow[5] !== "string") {
|
|
108
136
|
throw new Error("Malformed layout root hierarchy");
|
|
109
137
|
}
|
|
110
138
|
if (geometryRow[5] !== null) {
|
|
@@ -116,6 +144,9 @@ export async function settleLayout(observe, options) {
|
|
|
116
144
|
}
|
|
117
145
|
observedShapeIds.add(geometryRow[0]);
|
|
118
146
|
}
|
|
147
|
+
if (!truncated && rootIndex !== rootIds.length) {
|
|
148
|
+
throw new Error("Incomplete layout root evidence");
|
|
149
|
+
}
|
|
119
150
|
if (sampleIndex === 0) {
|
|
120
151
|
nativeOutcome =
|
|
121
152
|
observation.nativeOutcome ||
|
|
@@ -143,7 +174,10 @@ export async function settleLayout(observe, options) {
|
|
|
143
174
|
if (Date.now() >= deadline) {
|
|
144
175
|
return createResult(false, "geometry", "Layout observation exceeded its deadline");
|
|
145
176
|
}
|
|
146
|
-
const currentGeometry = JSON.stringify(
|
|
177
|
+
const currentGeometry = JSON.stringify([
|
|
178
|
+
observation.geometry,
|
|
179
|
+
observation.truncated,
|
|
180
|
+
]);
|
|
147
181
|
matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
|
|
148
182
|
if (matchingSamples >= 3) {
|
|
149
183
|
return createResult(true, "geometry");
|
package/dist/layout.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { PenpotToolClient } from "./client.js";
|
|
|
2
2
|
export type LayoutStrategy = "auto" | "native" | "geometry";
|
|
3
3
|
export interface LayoutOptions {
|
|
4
4
|
shapeId?: string;
|
|
5
|
+
shapeIds?: string[];
|
|
6
|
+
maxNodes?: number;
|
|
5
7
|
strategy?: LayoutStrategy;
|
|
6
8
|
timeoutMs?: number;
|
|
7
9
|
intervalMs?: number;
|
|
@@ -9,7 +11,11 @@ export interface LayoutOptions {
|
|
|
9
11
|
}
|
|
10
12
|
export interface LayoutResult {
|
|
11
13
|
stable: boolean;
|
|
12
|
-
method: "native" | "geometry" | "unavailable";
|
|
14
|
+
method: "native" | "geometry" | "partial" | "unavailable";
|
|
15
|
+
rootIds?: string[];
|
|
16
|
+
nodeCount?: number;
|
|
17
|
+
maxNodes?: number;
|
|
18
|
+
truncated?: boolean;
|
|
13
19
|
samples: number;
|
|
14
20
|
reason?: string;
|
|
15
21
|
strategy?: LayoutStrategy;
|
package/dist/layout.js
CHANGED
|
@@ -5,10 +5,25 @@ import { layoutPolicySource } from "./layout-policy.js";
|
|
|
5
5
|
import { settleLayout } from "./layout-settling.js";
|
|
6
6
|
/** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
|
|
7
7
|
export async function waitForStableLayout(client, options = {}) {
|
|
8
|
-
const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
|
|
8
|
+
const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, shapeIds, maxNodes = 10000, } = options;
|
|
9
|
+
const roots = shapeIds ??
|
|
10
|
+
(shapeId
|
|
11
|
+
? [
|
|
12
|
+
shapeId,
|
|
13
|
+
]
|
|
14
|
+
: []);
|
|
15
|
+
if (!Array.isArray(roots) ||
|
|
16
|
+
roots.some((id) => typeof id !== "string" || !id.trim()) ||
|
|
17
|
+
new Set(roots).size !== roots.length) {
|
|
18
|
+
throw new Error("Layout roots must be distinct nonempty shape IDs");
|
|
19
|
+
}
|
|
20
|
+
if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 100000) {
|
|
21
|
+
throw new Error("Layout node budget must be an integer from 1 to 100000");
|
|
22
|
+
}
|
|
9
23
|
try {
|
|
10
|
-
return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst
|
|
11
|
-
|
|
24
|
+
return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst roots = CFG.shapeIds.length ? CFG.shapeIds.map(id => penpot.currentPage.getShapeById(id)) : [penpot.currentPage.root];\nreturn await observeLayout(penpot, roots, CFG);`, {
|
|
25
|
+
shapeIds: roots,
|
|
26
|
+
maxNodes,
|
|
12
27
|
nativeMs,
|
|
13
28
|
}, "read"), {
|
|
14
29
|
retries: 0,
|
|
@@ -17,6 +32,7 @@ export async function waitForStableLayout(client, options = {}) {
|
|
|
17
32
|
timeoutMs,
|
|
18
33
|
intervalMs,
|
|
19
34
|
strategy,
|
|
35
|
+
maxNodes,
|
|
20
36
|
sleep,
|
|
21
37
|
});
|
|
22
38
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface PluginHealth {
|
|
2
|
+
state: "responsive" | "suspended" | "unknown";
|
|
3
|
+
heartbeatAgeMs: number | null;
|
|
4
|
+
heartbeatSource: "upstream-error" | "unavailable";
|
|
5
|
+
keepAlive: {
|
|
6
|
+
method: "unknown";
|
|
7
|
+
verified: false;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
|
|
11
|
+
export declare function responsivePluginHealth(): PluginHealth;
|
|
12
|
+
/** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
|
|
13
|
+
export declare function pluginHealthFromError(error: unknown): PluginHealth;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function health(state, heartbeatAgeMs) {
|
|
2
|
+
return {
|
|
3
|
+
state,
|
|
4
|
+
heartbeatAgeMs,
|
|
5
|
+
heartbeatSource: heartbeatAgeMs === null ? "unavailable" : "upstream-error",
|
|
6
|
+
keepAlive: {
|
|
7
|
+
method: "unknown",
|
|
8
|
+
verified: false,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
|
|
13
|
+
export function responsivePluginHealth() {
|
|
14
|
+
return health("responsive", null);
|
|
15
|
+
}
|
|
16
|
+
/** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
|
|
17
|
+
export function pluginHealthFromError(error) {
|
|
18
|
+
const message = error instanceof Error
|
|
19
|
+
? error.message.slice(0, 8192)
|
|
20
|
+
: typeof error === "string"
|
|
21
|
+
? error.slice(0, 8192)
|
|
22
|
+
: "";
|
|
23
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
24
|
+
const suspended = code === "plugin-suspended" || /\bsuspended\b|\bno heartbeat\b/i.test(message);
|
|
25
|
+
if (!suspended) {
|
|
26
|
+
return health("unknown", null);
|
|
27
|
+
}
|
|
28
|
+
const duration = /\bno heartbeat\s+for\s+([^\s,)]+?)\s*(milliseconds?|ms|seconds?|s)\b/i.exec(message);
|
|
29
|
+
if (!duration || !/^\d+(?:\.\d+)?$/.test(duration[1])) {
|
|
30
|
+
return health("suspended", null);
|
|
31
|
+
}
|
|
32
|
+
const value = Number(duration[1]);
|
|
33
|
+
const heartbeatAgeMs = value * (/^(?:ms|milliseconds?)$/i.test(duration[2]) ? 1 : 1000);
|
|
34
|
+
return health("suspended", Number.isFinite(heartbeatAgeMs) &&
|
|
35
|
+
heartbeatAgeMs >= 0 &&
|
|
36
|
+
heartbeatAgeMs <= Number.MAX_SAFE_INTEGER
|
|
37
|
+
? heartbeatAgeMs
|
|
38
|
+
: null);
|
|
39
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export declare const version = "0.4.
|
|
2
|
+
export declare const version = "0.4.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export const version = "0.4.
|
|
2
|
+
export const version = "0.4.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|