@penpair/core 0.2.0 → 0.3.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.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/operation.js
CHANGED
|
@@ -1,155 +1,365 @@
|
|
|
1
1
|
import { waitForStableLayout } from "./layout.js";
|
|
2
2
|
import { commandSleep } from "./command-result.js";
|
|
3
3
|
import { runSnippet } from "./snippet.js";
|
|
4
|
-
import {
|
|
5
|
-
import { archiveApply, archiveInspect, archiveReadback } from "./snippets/archive.js";
|
|
4
|
+
import { withPageSession, pinPageSnippet, selectPage, rememberPage } from "./page-session.js";
|
|
5
|
+
import { archiveApply, archiveInspect, archiveReadback, } from "./snippets/archive.js";
|
|
6
6
|
import { PenpairError } from "./errors.js";
|
|
7
7
|
import { UncertainExecutionError } from "./client.js";
|
|
8
|
-
import { observationHash, readJournalTarget, runJournaledSteps } from "./journal.js";
|
|
8
|
+
import { observationHash, readJournalTarget, runJournaledSteps, } from "./journal.js";
|
|
9
9
|
import { guardRevision, writeCoordinator } from "./coordination.js";
|
|
10
10
|
import { guardFile } from "./file-guard.js";
|
|
11
11
|
const defaultSleep = commandSleep;
|
|
12
|
-
/** Runs
|
|
13
|
-
export async function runOnPage(client, s, page, { settleMs
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
/** Runs once on the expected page; page transitions require independently observed IDs. */
|
|
13
|
+
export async function runOnPage(client, s, page, { settleMs: _settleMs, sleep = defaultSleep, onStep, ...execute } = {}) {
|
|
14
|
+
return withPageSession(client, async () => {
|
|
15
|
+
const started = Date.now();
|
|
16
|
+
const remaining = () => ({
|
|
17
|
+
...execute,
|
|
18
|
+
...(execute.timeoutMs
|
|
19
|
+
? {
|
|
20
|
+
timeoutMs: Math.max(1, execute.timeoutMs - (Date.now() - started)),
|
|
21
|
+
}
|
|
22
|
+
: {}),
|
|
23
|
+
});
|
|
24
|
+
const first = await runSnippet(client, pinPageSnippet(client, s), remaining());
|
|
25
|
+
if (!first.wrongPage || !page) {
|
|
26
|
+
if (typeof first.fileId === "string" && typeof first.pageId === "string") {
|
|
27
|
+
rememberPage(client, {
|
|
28
|
+
fileId: first.fileId,
|
|
29
|
+
pageId: first.pageId,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return first;
|
|
33
|
+
}
|
|
34
|
+
onStep?.(`opening page ${page}`);
|
|
35
|
+
await selectPage(client, {
|
|
36
|
+
name: page,
|
|
37
|
+
}, {
|
|
38
|
+
...remaining(),
|
|
39
|
+
sleep,
|
|
40
|
+
});
|
|
41
|
+
const second = await runSnippet(client, pinPageSnippet(client, s), remaining());
|
|
42
|
+
if (second.wrongPage) {
|
|
43
|
+
throw new Error(`${s.name}: the editor still shows another page than ${page}`);
|
|
44
|
+
}
|
|
45
|
+
return second;
|
|
46
|
+
});
|
|
24
47
|
}
|
|
25
48
|
export async function runOperation(client, operation, params, options = {}) {
|
|
26
|
-
|
|
49
|
+
return withPageSession(client, () => runOperationInSession(client, operation, params, options));
|
|
50
|
+
}
|
|
51
|
+
async function runOperationInSession(client, operation, params, options) {
|
|
52
|
+
if (options.settleMs !== undefined &&
|
|
53
|
+
(!Number.isFinite(options.settleMs) || options.settleMs < 0 || options.settleMs > 30000)) {
|
|
27
54
|
throw new Error("Minimum settle delay must be from 0 to 30000 ms");
|
|
28
|
-
|
|
55
|
+
}
|
|
56
|
+
if (options.apply && options.journal) {
|
|
29
57
|
return journaledOperation(client, operation, params, options);
|
|
58
|
+
}
|
|
30
59
|
const { settleMs = 0, sleep = defaultSleep, onStep } = options;
|
|
31
60
|
const page = operation.page(params);
|
|
32
61
|
onStep?.(`${operation.name}: inspecting`);
|
|
33
|
-
const inspect = await runOnPage(client, operation.inspect(params), page, {
|
|
34
|
-
|
|
62
|
+
const inspect = await runOnPage(client, operation.inspect(params), page, {
|
|
63
|
+
...options,
|
|
64
|
+
effect: "read",
|
|
65
|
+
});
|
|
66
|
+
if (inspect.ok !== true) {
|
|
35
67
|
throw new PenpairError("tool-failed", `${operation.name}: inspect did not return an explicit ok result; nothing was applied`);
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
}
|
|
69
|
+
if (!options.apply) {
|
|
70
|
+
return {
|
|
71
|
+
operation: operation.name,
|
|
72
|
+
outcome: "inspected",
|
|
73
|
+
phase: "inspected",
|
|
74
|
+
inspect,
|
|
75
|
+
problems: [],
|
|
76
|
+
verified: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
38
79
|
onStep?.(`${operation.name}: writing once`);
|
|
39
|
-
const apply = await runOnPage(client, operation.apply(params, inspect), page, {
|
|
40
|
-
|
|
80
|
+
const apply = await runOnPage(client, operation.apply(params, inspect), page, {
|
|
81
|
+
...options,
|
|
82
|
+
effect: "write",
|
|
83
|
+
});
|
|
84
|
+
if (apply.ok !== true) {
|
|
41
85
|
throw new UncertainExecutionError(String(apply.execution?.runId ?? "unknown"), `${operation.name}: apply returned no explicit acknowledgment`);
|
|
42
|
-
|
|
86
|
+
}
|
|
87
|
+
if (settleMs > 0) {
|
|
43
88
|
await sleep(settleMs);
|
|
89
|
+
}
|
|
44
90
|
let layout;
|
|
45
91
|
let readback;
|
|
46
92
|
let problems;
|
|
47
93
|
try {
|
|
48
94
|
layout = await settleOperation(client, options);
|
|
49
|
-
readback = await runOnPage(client, operation.readback(params), page, {
|
|
50
|
-
|
|
95
|
+
readback = await runOnPage(client, operation.readback(params), page, {
|
|
96
|
+
...options,
|
|
97
|
+
effect: "read",
|
|
98
|
+
});
|
|
99
|
+
problems = verificationProblems(operation, {
|
|
100
|
+
inspect,
|
|
101
|
+
apply,
|
|
102
|
+
readback,
|
|
103
|
+
});
|
|
51
104
|
}
|
|
52
105
|
catch (error) {
|
|
53
|
-
problems = [
|
|
106
|
+
problems = [
|
|
107
|
+
`Independent verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
108
|
+
];
|
|
54
109
|
}
|
|
55
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
layout,
|
|
112
|
+
operation: operation.name,
|
|
113
|
+
phase: "applied",
|
|
114
|
+
outcome: problems.length === 0 ? "verified" : "failed",
|
|
115
|
+
inspect,
|
|
116
|
+
apply,
|
|
117
|
+
readback,
|
|
118
|
+
problems,
|
|
119
|
+
verified: problems.length === 0,
|
|
120
|
+
};
|
|
56
121
|
}
|
|
57
122
|
function verificationProblems(operation, results) {
|
|
58
123
|
const { readback } = results;
|
|
59
|
-
const observations = Object.keys(readback).filter((key) => ![
|
|
60
|
-
|
|
124
|
+
const observations = Object.keys(readback).filter((key) => ![
|
|
125
|
+
"ok",
|
|
126
|
+
"readOnly",
|
|
127
|
+
"execution",
|
|
128
|
+
"problems",
|
|
129
|
+
].includes(key));
|
|
130
|
+
if (Array.isArray(readback.problems) &&
|
|
131
|
+
readback.problems.length > 0 &&
|
|
132
|
+
readback.problems.every((problem) => typeof problem === "string")) {
|
|
61
133
|
return readback.problems;
|
|
62
|
-
|
|
63
|
-
|
|
134
|
+
}
|
|
135
|
+
if (readback.ok !== true || observations.length === 0) {
|
|
136
|
+
return [
|
|
137
|
+
"Readback did not provide explicit observations of the resulting state",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
64
140
|
const result = operation.verify(results);
|
|
65
|
-
return Array.isArray(result) && result.every((problem) => typeof problem === "string")
|
|
141
|
+
return Array.isArray(result) && result.every((problem) => typeof problem === "string")
|
|
142
|
+
? result
|
|
143
|
+
: [
|
|
144
|
+
"Verifier must return a list of problems",
|
|
145
|
+
];
|
|
66
146
|
}
|
|
67
147
|
async function settleOperation(client, options) {
|
|
68
|
-
if (options.layout === false)
|
|
148
|
+
if (options.layout === false) {
|
|
69
149
|
return undefined;
|
|
150
|
+
}
|
|
70
151
|
options.onStep?.("waiting for bounded layout completion");
|
|
71
|
-
const layout = await waitForStableLayout(client, {
|
|
72
|
-
|
|
152
|
+
const layout = await waitForStableLayout(client, {
|
|
153
|
+
sleep: options.sleep,
|
|
154
|
+
...options.layout,
|
|
155
|
+
});
|
|
156
|
+
if (!layout.stable) {
|
|
73
157
|
throw new PenpairError("incomplete-output", `Layout did not settle: ${layout.reason ?? "unknown reason"}`);
|
|
158
|
+
}
|
|
74
159
|
return layout;
|
|
75
160
|
}
|
|
76
161
|
async function journaledOperation(client, operation, params, options) {
|
|
77
162
|
const page = operation.page(params);
|
|
78
|
-
await runOnPage(client, operation.inspect(params), page, {
|
|
163
|
+
await runOnPage(client, operation.inspect(params), page, {
|
|
164
|
+
...options,
|
|
165
|
+
effect: "read",
|
|
166
|
+
});
|
|
79
167
|
await settleOperation(client, options);
|
|
80
168
|
const initialTarget = await readJournalTarget(client);
|
|
81
|
-
const guarded = guardFile(client, {
|
|
169
|
+
const guarded = guardFile(client, {
|
|
170
|
+
id: initialTarget.fileId,
|
|
171
|
+
});
|
|
82
172
|
const observe = async () => {
|
|
83
|
-
const inspection = await runOnPage(guarded, operation.inspect(params), page, {
|
|
173
|
+
const inspection = await runOnPage(guarded, operation.inspect(params), page, {
|
|
174
|
+
...options,
|
|
175
|
+
effect: "read",
|
|
176
|
+
});
|
|
84
177
|
const layout = await settleOperation(guarded, options);
|
|
85
|
-
const readback = await runOnPage(guarded, operation.readback(params), page, {
|
|
86
|
-
|
|
178
|
+
const readback = await runOnPage(guarded, operation.readback(params), page, {
|
|
179
|
+
...options,
|
|
180
|
+
effect: "read",
|
|
181
|
+
});
|
|
182
|
+
if (inspection.ok !== true || readback.ok !== true) {
|
|
87
183
|
throw new Error("Operation inspection/readback did not acknowledge its observations");
|
|
88
|
-
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
inspection,
|
|
187
|
+
readback,
|
|
188
|
+
layout,
|
|
189
|
+
};
|
|
89
190
|
};
|
|
90
|
-
const verify = (current, before, result) => verificationProblems(operation, {
|
|
91
|
-
|
|
92
|
-
|
|
191
|
+
const verify = (current, before, result) => verificationProblems(operation, {
|
|
192
|
+
inspect: before.inspection,
|
|
193
|
+
apply: result ?? {
|
|
194
|
+
ok: true,
|
|
195
|
+
reconciled: true,
|
|
196
|
+
},
|
|
197
|
+
readback: current.readback,
|
|
198
|
+
}).length === 0;
|
|
199
|
+
const record = await runJournaledSteps([
|
|
200
|
+
{
|
|
201
|
+
key: "apply",
|
|
202
|
+
label: operation.name,
|
|
203
|
+
signature: [
|
|
204
|
+
operation.inspect(params).code,
|
|
205
|
+
operation.apply(params).code,
|
|
206
|
+
operation.readback(params).code,
|
|
207
|
+
operation.verify.toString(),
|
|
208
|
+
operation.reconcile?.toString(),
|
|
209
|
+
settleOperation.toString(),
|
|
210
|
+
waitForStableLayout.toString(),
|
|
211
|
+
].join("\n"),
|
|
93
212
|
inspect: observe,
|
|
94
213
|
apply: async (current, target) => {
|
|
95
|
-
const result = await runOnPage(guardRevision(guarded, target), operation.apply(params, current.inspection), page, {
|
|
96
|
-
|
|
214
|
+
const result = await runOnPage(guardRevision(guarded, target), operation.apply(params, current.inspection), page, {
|
|
215
|
+
...options,
|
|
216
|
+
effect: "write",
|
|
217
|
+
});
|
|
218
|
+
if (result.ok !== true) {
|
|
97
219
|
throw new Error("Apply returned no explicit acknowledgment");
|
|
220
|
+
}
|
|
98
221
|
await (options.sleep ?? defaultSleep)(options.settleMs ?? 0);
|
|
99
222
|
return result;
|
|
100
|
-
},
|
|
223
|
+
},
|
|
224
|
+
verify,
|
|
101
225
|
reconcile: (before, current, result) => {
|
|
102
|
-
if (verify(current, before, result))
|
|
226
|
+
if (verify(current, before, result)) {
|
|
103
227
|
return "complete";
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
228
|
+
}
|
|
229
|
+
if (operation.reconcile) {
|
|
230
|
+
return operation.reconcile({
|
|
231
|
+
before: before.inspection,
|
|
232
|
+
current: current.inspection,
|
|
233
|
+
readback: current.readback,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return observationHash(before.inspection, true) ===
|
|
237
|
+
observationHash(current.inspection, true)
|
|
238
|
+
? "pending"
|
|
239
|
+
: "conflict";
|
|
107
240
|
},
|
|
108
|
-
}
|
|
241
|
+
},
|
|
242
|
+
], {
|
|
243
|
+
...options.journal,
|
|
244
|
+
coordinate: writeCoordinator(guarded, {
|
|
245
|
+
root: options.journal.root,
|
|
246
|
+
operation: operation.name,
|
|
247
|
+
forceLock: options.journal.forceLock,
|
|
248
|
+
warn: options.onStep,
|
|
249
|
+
}),
|
|
250
|
+
operation: operation.name,
|
|
251
|
+
input: {
|
|
252
|
+
params,
|
|
253
|
+
layout: options.layout ?? {},
|
|
254
|
+
minimumWaitMs: options.settleMs ?? 0,
|
|
255
|
+
},
|
|
256
|
+
payload: {
|
|
257
|
+
params,
|
|
258
|
+
},
|
|
259
|
+
target: () => readJournalTarget(guarded),
|
|
260
|
+
onStep: options.onStep ?? options.journal.onStep,
|
|
261
|
+
redact: options.redact ?? options.journal.redact,
|
|
262
|
+
});
|
|
109
263
|
const saved = record.steps[0];
|
|
110
|
-
return {
|
|
264
|
+
return {
|
|
265
|
+
layout: saved.after.layout,
|
|
266
|
+
operation: operation.name,
|
|
267
|
+
phase: "applied",
|
|
268
|
+
outcome: record.redacted ? "failed" : "verified",
|
|
269
|
+
...(record.redacted
|
|
270
|
+
? {
|
|
271
|
+
redacted: true,
|
|
272
|
+
complete: false,
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
275
|
+
inspect: saved.before.inspection,
|
|
276
|
+
apply: saved.result,
|
|
277
|
+
readback: saved.after.readback,
|
|
278
|
+
problems: record.redacted
|
|
279
|
+
? [
|
|
280
|
+
"Journal evidence required credential redaction; completeness cannot be confirmed",
|
|
281
|
+
]
|
|
282
|
+
: [],
|
|
283
|
+
verified: !record.redacted,
|
|
284
|
+
journal: {
|
|
285
|
+
runId: record.runId,
|
|
286
|
+
state: record.state,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
111
289
|
}
|
|
112
290
|
/** Nests the frames that are not part of the live work into one archive board. Frames keep their names, ids and component links. */
|
|
113
291
|
export const archiveOperation = {
|
|
114
292
|
name: "archive",
|
|
115
293
|
page: (params) => params.page,
|
|
116
294
|
inspect: archiveInspect,
|
|
117
|
-
apply: (params, inspected) => archiveApply({
|
|
295
|
+
apply: (params, inspected) => archiveApply({
|
|
296
|
+
...params,
|
|
297
|
+
expectedPlan: inspected?.plan,
|
|
298
|
+
expectedArchiveId: inspected?.archiveId,
|
|
299
|
+
}),
|
|
118
300
|
readback: archiveReadback,
|
|
119
301
|
verify({ inspect, apply, readback }) {
|
|
120
302
|
const problems = [];
|
|
121
|
-
if (!Number.isInteger(inspect.willMove) ||
|
|
122
|
-
|
|
303
|
+
if (!Number.isInteger(inspect.willMove) ||
|
|
304
|
+
Number(inspect.willMove) < 0 ||
|
|
305
|
+
!Array.isArray(inspect.plan) ||
|
|
306
|
+
!Array.isArray(readback.insideIds) ||
|
|
307
|
+
!Array.isArray(readback.loose)) {
|
|
308
|
+
return [
|
|
309
|
+
"Archive verification is missing movement counts or the independent loose-frame listing",
|
|
310
|
+
];
|
|
311
|
+
}
|
|
123
312
|
const expected = Number(inspect.willMove ?? 0);
|
|
124
313
|
const archive = readback.archive;
|
|
125
|
-
if (inspect.plan.length !== expected ||
|
|
314
|
+
if (inspect.plan.length !== expected ||
|
|
315
|
+
inspect.plan.some((shape) => !shape.id || !readback.insideIds.includes(shape.id))) {
|
|
126
316
|
problems.push("the inspected frames are not all in the archive");
|
|
127
|
-
|
|
317
|
+
}
|
|
318
|
+
if (expected > 0 && !archive) {
|
|
128
319
|
problems.push("the archive board is not on the page");
|
|
129
|
-
|
|
320
|
+
}
|
|
321
|
+
if (archive && archive.children < expected) {
|
|
130
322
|
problems.push(`the archive holds ${archive.children} frames, expected at least ${expected}`);
|
|
323
|
+
}
|
|
131
324
|
const loose = readback.loose ?? [];
|
|
132
|
-
if (loose.length > 0)
|
|
325
|
+
if (loose.length > 0) {
|
|
133
326
|
problems.push(`${loose.length} frames are still loose on the page: ${loose.slice(0, 3).join(", ")}`);
|
|
327
|
+
}
|
|
134
328
|
return problems;
|
|
135
329
|
},
|
|
136
330
|
reconcile({ before, current, readback }) {
|
|
137
|
-
if (!Array.isArray(before.plan) ||
|
|
331
|
+
if (!Array.isArray(before.plan) ||
|
|
332
|
+
!Array.isArray(current.plan) ||
|
|
333
|
+
!Array.isArray(readback.insideIds)) {
|
|
138
334
|
return "conflict";
|
|
139
|
-
|
|
335
|
+
}
|
|
336
|
+
if (observationHash(before, true) === observationHash(current, true)) {
|
|
140
337
|
return "pending";
|
|
338
|
+
}
|
|
141
339
|
const original = before.plan;
|
|
142
340
|
const remaining = current.plan;
|
|
143
|
-
const intended = new Map(original.map((shape) => [
|
|
144
|
-
|
|
341
|
+
const intended = new Map(original.map((shape) => [
|
|
342
|
+
shape.id,
|
|
343
|
+
shape,
|
|
344
|
+
]));
|
|
345
|
+
if (remaining.some((shape) => !intended.has(shape.id) ||
|
|
346
|
+
observationHash(shape) !== observationHash(intended.get(shape.id)))) {
|
|
145
347
|
return "conflict";
|
|
146
|
-
|
|
147
|
-
|
|
348
|
+
}
|
|
349
|
+
const accounted = new Set([
|
|
350
|
+
...remaining.map((shape) => shape.id),
|
|
351
|
+
...readback.insideIds,
|
|
352
|
+
]);
|
|
353
|
+
if (original.some((shape) => !accounted.has(shape.id))) {
|
|
148
354
|
return "conflict";
|
|
149
|
-
|
|
355
|
+
}
|
|
356
|
+
if (remaining.length > 0 && (!before.archiveId || before.archiveId !== current.archiveId)) {
|
|
150
357
|
return "conflict";
|
|
151
|
-
|
|
358
|
+
}
|
|
359
|
+
if (Array.isArray(before.insideIds) &&
|
|
360
|
+
before.insideIds.some((id) => !readback.insideIds.includes(id))) {
|
|
152
361
|
return "conflict";
|
|
362
|
+
}
|
|
153
363
|
return remaining.length === 0 ? "complete" : "pending";
|
|
154
364
|
},
|
|
155
365
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExecuteOptions, PenpotToolClient } from "./client.js";
|
|
2
|
+
import { type Snippet } from "./snippet.js";
|
|
3
|
+
interface PageIdentity {
|
|
4
|
+
fileId: string;
|
|
5
|
+
pageId: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function inheritPageSession<T extends PenpotToolClient>(derived: T, base: PenpotToolClient): T;
|
|
8
|
+
/** Nested collection steps share one session; independent callers on the same client queue. */
|
|
9
|
+
export declare function withPageSession<T>(client: PenpotToolClient, work: () => Promise<T>): Promise<T>;
|
|
10
|
+
export declare function rememberPage(client: PenpotToolClient, identity: PageIdentity): void;
|
|
11
|
+
export declare function pinPageSnippet(client: PenpotToolClient, source: Snippet): Snippet;
|
|
12
|
+
export declare function observePage(client: PenpotToolClient, options?: ExecuteOptions): Promise<PageIdentity>;
|
|
13
|
+
export interface PageSwitchOptions extends ExecuteOptions {
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
pageSwitchTimeoutMs?: number;
|
|
16
|
+
onStep?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function selectPage(client: PenpotToolClient, target: {
|
|
19
|
+
name: string;
|
|
20
|
+
} | {
|
|
21
|
+
id: string;
|
|
22
|
+
}, options?: PageSwitchOptions): Promise<PageIdentity>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { commandSignal, commandSleep } from "./command-result.js";
|
|
3
|
+
import { runSnippet, snippet } from "./snippet.js";
|
|
4
|
+
const owners = new WeakMap();
|
|
5
|
+
const owner = (client) => owners.get(client) ?? client;
|
|
6
|
+
export function inheritPageSession(derived, base) {
|
|
7
|
+
owners.set(derived, owner(base));
|
|
8
|
+
return derived;
|
|
9
|
+
}
|
|
10
|
+
const active = new AsyncLocalStorage();
|
|
11
|
+
const queues = new WeakMap();
|
|
12
|
+
/** Nested collection steps share one session; independent callers on the same client queue. */
|
|
13
|
+
export async function withPageSession(client, work) {
|
|
14
|
+
client = owner(client);
|
|
15
|
+
if (active.getStore()?.client === client) {
|
|
16
|
+
return work();
|
|
17
|
+
}
|
|
18
|
+
const previous = queues.get(client) ?? Promise.resolve();
|
|
19
|
+
let release;
|
|
20
|
+
const gate = new Promise((resolve) => {
|
|
21
|
+
release = resolve;
|
|
22
|
+
});
|
|
23
|
+
const tail = previous.catch(() => undefined).then(() => gate);
|
|
24
|
+
queues.set(client, tail);
|
|
25
|
+
const signal = commandSignal();
|
|
26
|
+
let onAbort;
|
|
27
|
+
try {
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
onAbort = () => reject(signal?.reason);
|
|
30
|
+
signal?.addEventListener("abort", onAbort, {
|
|
31
|
+
once: true,
|
|
32
|
+
});
|
|
33
|
+
if (signal?.aborted) {
|
|
34
|
+
onAbort();
|
|
35
|
+
}
|
|
36
|
+
previous.then(resolve, reject);
|
|
37
|
+
});
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
return await active.run({
|
|
40
|
+
client,
|
|
41
|
+
}, work);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (onAbort) {
|
|
45
|
+
signal?.removeEventListener("abort", onAbort);
|
|
46
|
+
}
|
|
47
|
+
release();
|
|
48
|
+
// A cancelled waiter still follows its predecessor; removing it early would open a parallel queue.
|
|
49
|
+
void tail.then(() => {
|
|
50
|
+
if (queues.get(client) === tail) {
|
|
51
|
+
queues.delete(client);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function rememberPage(client, identity) {
|
|
57
|
+
const session = active.getStore();
|
|
58
|
+
if (session?.client === owner(client)) {
|
|
59
|
+
session.expected = identity;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function pinPageSnippet(client, source) {
|
|
63
|
+
const session = active.getStore();
|
|
64
|
+
if (session?.client !== owner(client) || !session.expected) {
|
|
65
|
+
return source;
|
|
66
|
+
}
|
|
67
|
+
const expected = JSON.stringify(session.expected);
|
|
68
|
+
return {
|
|
69
|
+
...source,
|
|
70
|
+
code: `{ const expected = ${expected}; if (penpot.currentFile.id !== expected.fileId || penpot.currentPage.id !== expected.pageId) return {ok:false,message:"Unexpected external navigation during page session"}; }\n${source.code}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function observePage(client, options = {}) {
|
|
74
|
+
const result = await runSnippet(client, pinPageSnippet(client, snippet("page-state", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read")), {
|
|
75
|
+
...options,
|
|
76
|
+
effect: "read",
|
|
77
|
+
retries: 0,
|
|
78
|
+
});
|
|
79
|
+
if (typeof result.fileId !== "string" ||
|
|
80
|
+
!result.fileId ||
|
|
81
|
+
typeof result.pageId !== "string" ||
|
|
82
|
+
!result.pageId) {
|
|
83
|
+
throw new Error("Page identity was not observed");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
fileId: result.fileId,
|
|
87
|
+
pageId: result.pageId,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function selectPage(client, target, options = {}) {
|
|
91
|
+
const timeout = Math.min(options.pageSwitchTimeoutMs ?? 5000, options.timeoutMs ?? 5000);
|
|
92
|
+
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 30000) {
|
|
93
|
+
throw new Error("Invalid page switch budget");
|
|
94
|
+
}
|
|
95
|
+
const deadline = Date.now() + timeout;
|
|
96
|
+
const execute = () => ({
|
|
97
|
+
...options,
|
|
98
|
+
retries: 0,
|
|
99
|
+
effect: "read",
|
|
100
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
101
|
+
});
|
|
102
|
+
const resolved = await runSnippet(client, pinPageSnippet(client, snippet("select-page", `
|
|
103
|
+
const matches = penpot.currentFile.pages.filter(p => CFG.id ? p.id === CFG.id : p.name === CFG.name);
|
|
104
|
+
if (matches.length !== 1) return {ok:false,message:"Page selection is missing or ambiguous"};
|
|
105
|
+
const target = matches[0], fileId = penpot.currentFile.id, previousPageId = penpot.currentPage.id;
|
|
106
|
+
if (target.id !== previousPageId) penpot.openPage(target);
|
|
107
|
+
return {ok:true,fileId,pageId:target.id,previousPageId};`, target, "read")), execute());
|
|
108
|
+
if (typeof resolved.fileId !== "string" ||
|
|
109
|
+
typeof resolved.pageId !== "string" ||
|
|
110
|
+
typeof resolved.previousPageId !== "string") {
|
|
111
|
+
throw new Error("Page selection returned no identity evidence");
|
|
112
|
+
}
|
|
113
|
+
const expected = {
|
|
114
|
+
fileId: resolved.fileId,
|
|
115
|
+
pageId: resolved.pageId,
|
|
116
|
+
};
|
|
117
|
+
let observed = resolved.previousPageId;
|
|
118
|
+
for (let count = 0; count <= Math.ceil(timeout / 100); count++) {
|
|
119
|
+
commandSignal()?.throwIfAborted();
|
|
120
|
+
// During a requested transition either the old or selected page is legitimate.
|
|
121
|
+
const current = await runSnippet(client, snippet("page-switch-observation", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read"), execute());
|
|
122
|
+
if (current.fileId !== expected.fileId) {
|
|
123
|
+
throw new Error("File changed during page switch");
|
|
124
|
+
}
|
|
125
|
+
observed = String(current.pageId);
|
|
126
|
+
if (Date.now() >= deadline) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (observed === expected.pageId) {
|
|
130
|
+
rememberPage(client, expected);
|
|
131
|
+
return expected;
|
|
132
|
+
}
|
|
133
|
+
if (observed !== resolved.previousPageId) {
|
|
134
|
+
throw new Error(`Unexpected external navigation during page switch: expected ${expected.pageId}, observed ${observed}`);
|
|
135
|
+
}
|
|
136
|
+
if (Date.now() >= deadline) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
await (options.sleep ?? commandSleep)(Math.min(100, Math.max(0, deadline - Date.now())));
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Page switch timed out: expected ${expected.pageId}, observed ${observed}, stage=observe-page`);
|
|
142
|
+
}
|
package/dist/png.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
/** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
|
|
2
2
|
export function pngSize(buffer) {
|
|
3
|
-
const signature = Buffer.from([
|
|
4
|
-
|
|
3
|
+
const signature = Buffer.from([
|
|
4
|
+
0x89,
|
|
5
|
+
0x50,
|
|
6
|
+
0x4e,
|
|
7
|
+
0x47,
|
|
8
|
+
0x0d,
|
|
9
|
+
0x0a,
|
|
10
|
+
0x1a,
|
|
11
|
+
0x0a,
|
|
12
|
+
]);
|
|
13
|
+
if (buffer.length < 24 ||
|
|
14
|
+
!buffer.subarray(0, 8).equals(signature) ||
|
|
15
|
+
buffer.subarray(12, 16).toString("ascii") !== "IHDR") {
|
|
5
16
|
return undefined;
|
|
6
|
-
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
width: buffer.readUInt32BE(16),
|
|
20
|
+
height: buffer.readUInt32BE(20),
|
|
21
|
+
};
|
|
7
22
|
}
|