@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/journal.js
CHANGED
|
@@ -7,41 +7,71 @@ import { version } from "./version.js";
|
|
|
7
7
|
import { runSnippet, snippet } from "./snippet.js";
|
|
8
8
|
export async function readJournalTarget(client) {
|
|
9
9
|
const result = await runSnippet(client, snippet("journal-target", "return { ok: true, fileId: penpot.currentFile.id, revision: penpot.currentFile.revn };", {}, "read"));
|
|
10
|
-
if (result.ok !== true ||
|
|
10
|
+
if (result.ok !== true ||
|
|
11
|
+
typeof result.fileId !== "string" ||
|
|
12
|
+
!result.fileId ||
|
|
13
|
+
!Number.isInteger(result.revision)) {
|
|
11
14
|
throw new Error("Journal target identity/revision was not observed");
|
|
12
|
-
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
fileId: result.fileId,
|
|
18
|
+
revision: Number(result.revision),
|
|
19
|
+
};
|
|
13
20
|
}
|
|
14
21
|
/** Stable JSON hashing; observation comparisons may explicitly ignore transport execution metadata. */
|
|
15
22
|
export function observationHash(value, ignoreExecution = false) {
|
|
16
23
|
const canonical = (entry) => {
|
|
17
|
-
if (Array.isArray(entry))
|
|
24
|
+
if (Array.isArray(entry)) {
|
|
18
25
|
return entry.map(canonical);
|
|
19
|
-
|
|
26
|
+
}
|
|
27
|
+
if (typeof entry !== "object" || entry === null) {
|
|
20
28
|
return entry;
|
|
21
|
-
|
|
29
|
+
}
|
|
30
|
+
return Object.fromEntries(Object.entries(entry)
|
|
31
|
+
.filter(([key]) => !ignoreExecution || key !== "execution")
|
|
32
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
33
|
+
.map(([key, child]) => [
|
|
34
|
+
key,
|
|
35
|
+
canonical(child),
|
|
36
|
+
]));
|
|
22
37
|
};
|
|
23
|
-
return createHash("sha256")
|
|
38
|
+
return createHash("sha256")
|
|
39
|
+
.update(JSON.stringify(canonical(value)) ?? "null")
|
|
40
|
+
.digest("hex");
|
|
24
41
|
}
|
|
25
42
|
function validateRunId(runId) {
|
|
26
|
-
if (!/^[a-zA-Z0-9-]{1,100}$/.test(runId))
|
|
43
|
+
if (!/^[a-zA-Z0-9-]{1,100}$/.test(runId)) {
|
|
27
44
|
throw new Error("Run ID must contain only letters, digits and hyphens (at most 100)");
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
47
|
async function folder(root, create) {
|
|
30
|
-
if (create)
|
|
31
|
-
await ensureGitignoreEntry(root, ".penpair", {
|
|
48
|
+
if (create) {
|
|
49
|
+
await ensureGitignoreEntry(root, ".penpair", {
|
|
50
|
+
directory: true,
|
|
51
|
+
comment: "penpair: private local operations and recovery evidence",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
32
54
|
let current = root;
|
|
33
|
-
for (const segment of [
|
|
55
|
+
for (const segment of [
|
|
56
|
+
".penpair",
|
|
57
|
+
"runs",
|
|
58
|
+
]) {
|
|
34
59
|
current = path.join(current, segment);
|
|
35
60
|
try {
|
|
36
61
|
const info = await lstat(current);
|
|
37
|
-
if (!info.isDirectory() || info.isSymbolicLink())
|
|
62
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
38
63
|
throw new Error("Operation journal directories must be real directories inside this project");
|
|
64
|
+
}
|
|
39
65
|
}
|
|
40
66
|
catch (error) {
|
|
41
|
-
if (error.code !== "ENOENT")
|
|
67
|
+
if (error.code !== "ENOENT") {
|
|
42
68
|
throw error;
|
|
43
|
-
|
|
44
|
-
|
|
69
|
+
}
|
|
70
|
+
if (create) {
|
|
71
|
+
await mkdir(current, {
|
|
72
|
+
mode: 0o700,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
45
75
|
}
|
|
46
76
|
}
|
|
47
77
|
return current;
|
|
@@ -50,11 +80,18 @@ export async function readJournal(root, runId) {
|
|
|
50
80
|
validateRunId(runId);
|
|
51
81
|
const file = path.join(await folder(root, false), `${runId}.json`);
|
|
52
82
|
const info = await lstat(file);
|
|
53
|
-
if (!info.isFile() || info.isSymbolicLink())
|
|
83
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
54
84
|
throw new Error("Operation journal must be a regular local file");
|
|
85
|
+
}
|
|
55
86
|
const record = JSON.parse(await readFile(file, "utf8"));
|
|
56
|
-
if (record.schemaVersion !== 1 ||
|
|
87
|
+
if (record.schemaVersion !== 1 ||
|
|
88
|
+
record.runId !== runId ||
|
|
89
|
+
typeof record.operation !== "string" ||
|
|
90
|
+
!Array.isArray(record.steps) ||
|
|
91
|
+
!record.target ||
|
|
92
|
+
typeof record.target.fileId !== "string") {
|
|
57
93
|
throw new Error("Operation journal is invalid or uses an unsupported schema");
|
|
94
|
+
}
|
|
58
95
|
return record;
|
|
59
96
|
}
|
|
60
97
|
export async function listJournals(root) {
|
|
@@ -63,12 +100,23 @@ export async function listJournals(root) {
|
|
|
63
100
|
files = await readdir(await folder(root, false));
|
|
64
101
|
}
|
|
65
102
|
catch (error) {
|
|
66
|
-
if (error.code === "ENOENT")
|
|
103
|
+
if (error.code === "ENOENT") {
|
|
67
104
|
return [];
|
|
105
|
+
}
|
|
68
106
|
throw error;
|
|
69
107
|
}
|
|
70
|
-
const records = await Promise.all(files
|
|
71
|
-
|
|
108
|
+
const records = await Promise.all(files
|
|
109
|
+
.filter((name) => /^[a-zA-Z0-9-]+\.json$/.test(name))
|
|
110
|
+
.map((name) => readJournal(root, name.slice(0, -5))));
|
|
111
|
+
return records
|
|
112
|
+
.map(({ runId, operation, state, target, updatedAt }) => ({
|
|
113
|
+
runId,
|
|
114
|
+
operation,
|
|
115
|
+
state,
|
|
116
|
+
target,
|
|
117
|
+
updatedAt,
|
|
118
|
+
}))
|
|
119
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
72
120
|
}
|
|
73
121
|
export class JournalRunError extends Error {
|
|
74
122
|
runId;
|
|
@@ -81,86 +129,183 @@ export class JournalRunError extends Error {
|
|
|
81
129
|
}
|
|
82
130
|
/** Each started phase is persisted before its write. Resume must inspect and reconcile actual state, never replay blindly. */
|
|
83
131
|
export async function runJournaledSteps(steps, options) {
|
|
84
|
-
if (options.requireCheckpoint && !options.checkpoint)
|
|
132
|
+
if (options.requireCheckpoint && !options.checkpoint) {
|
|
85
133
|
throw new Error("A required checkpoint needs a checkpoint provider");
|
|
134
|
+
}
|
|
86
135
|
const runId = options.runId ?? randomUUID();
|
|
87
136
|
validateRunId(runId);
|
|
88
137
|
const redact = (text) => redactEndpoint(options.redact?.(text) ?? text);
|
|
89
138
|
const directory = await folder(options.root, true);
|
|
90
139
|
const destination = path.join(directory, `${runId}.json`);
|
|
91
140
|
const target = await options.target();
|
|
92
|
-
if (!target || typeof target.fileId !== "string" || !target.fileId)
|
|
141
|
+
if (!target || typeof target.fileId !== "string" || !target.fileId) {
|
|
93
142
|
throw new Error("Operation journal requires an observed file ID");
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
143
|
+
}
|
|
144
|
+
const inputHash = observationHash({
|
|
145
|
+
input: options.input,
|
|
146
|
+
requireCheckpoint: options.requireCheckpoint === true,
|
|
147
|
+
checkpointRequested: Boolean(options.checkpoint),
|
|
148
|
+
});
|
|
149
|
+
const planHash = observationHash({
|
|
150
|
+
steps: steps.map(({ key, signature }) => ({
|
|
151
|
+
key,
|
|
152
|
+
signature,
|
|
153
|
+
})),
|
|
154
|
+
finalVerifier: options.finalVerify?.toString(),
|
|
155
|
+
});
|
|
156
|
+
if (new Set(steps.map((step) => step.key)).size !== steps.length) {
|
|
97
157
|
throw new Error("Journal step keys must be unique");
|
|
158
|
+
}
|
|
98
159
|
let record;
|
|
99
160
|
if (options.resume) {
|
|
100
|
-
if (!options.runId)
|
|
161
|
+
if (!options.runId) {
|
|
101
162
|
throw new Error("Resume requires the original run ID");
|
|
163
|
+
}
|
|
102
164
|
record = await readJournal(options.root, runId);
|
|
103
|
-
if (record.redacted)
|
|
165
|
+
if (record.redacted) {
|
|
104
166
|
throw new Error("This journal required redaction and cannot be automatically resumed; inspect and make a fresh plan");
|
|
105
|
-
|
|
167
|
+
}
|
|
168
|
+
if (record.engineVersion !== version ||
|
|
169
|
+
record.operation !== options.operation ||
|
|
170
|
+
record.inputHash !== inputHash ||
|
|
171
|
+
record.planHash !== planHash ||
|
|
172
|
+
record.target.fileId !== target.fileId ||
|
|
173
|
+
record.steps.length !== steps.length) {
|
|
106
174
|
throw new Error("Resume target, inputs, step implementation or engine version changed; no write was replayed");
|
|
175
|
+
}
|
|
107
176
|
}
|
|
108
177
|
else {
|
|
109
178
|
const before = [];
|
|
110
|
-
for (const step of steps)
|
|
179
|
+
for (const step of steps) {
|
|
111
180
|
before.push(step.initial === undefined ? await step.inspect() : step.initial);
|
|
181
|
+
}
|
|
112
182
|
const observed = await options.target();
|
|
113
|
-
if (observed.fileId !== target.fileId ||
|
|
183
|
+
if (observed.fileId !== target.fileId ||
|
|
184
|
+
(target.revision !== undefined && observed.revision !== target.revision)) {
|
|
114
185
|
throw new Error("File changed while planning; inspect again before starting a run");
|
|
186
|
+
}
|
|
115
187
|
const now = new Date().toISOString();
|
|
116
|
-
record = {
|
|
188
|
+
record = {
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
engineVersion: version,
|
|
191
|
+
runId,
|
|
192
|
+
operation: options.operation,
|
|
193
|
+
inputHash,
|
|
194
|
+
planHash,
|
|
195
|
+
payload: options.payload,
|
|
196
|
+
target,
|
|
197
|
+
state: "planned",
|
|
198
|
+
createdAt: now,
|
|
199
|
+
updatedAt: now,
|
|
200
|
+
redacted: false,
|
|
201
|
+
steps: steps.map((step, index) => ({
|
|
202
|
+
key: step.key,
|
|
203
|
+
label: step.label,
|
|
204
|
+
state: "pending",
|
|
205
|
+
before: before[index],
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
117
208
|
}
|
|
118
209
|
const persist = async (create = false) => {
|
|
119
210
|
record.updatedAt = new Date().toISOString();
|
|
120
211
|
const sanitize = (value) => {
|
|
121
|
-
if (typeof value === "string")
|
|
212
|
+
if (typeof value === "string") {
|
|
122
213
|
return redact(value);
|
|
123
|
-
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(value)) {
|
|
124
216
|
return value.map(sanitize);
|
|
125
|
-
|
|
126
|
-
|
|
217
|
+
}
|
|
218
|
+
if (value && typeof value === "object") {
|
|
219
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
220
|
+
redact(key),
|
|
221
|
+
sanitize(entry),
|
|
222
|
+
]));
|
|
223
|
+
}
|
|
127
224
|
return value;
|
|
128
225
|
};
|
|
129
226
|
const serialize = () => JSON.stringify(sanitize(record), null, 2);
|
|
130
|
-
if (serialize() !== JSON.stringify(record, null, 2))
|
|
227
|
+
if (serialize() !== JSON.stringify(record, null, 2)) {
|
|
131
228
|
record.redacted = true;
|
|
229
|
+
}
|
|
132
230
|
const temporary = path.join(directory, `.${runId}-${randomUUID()}.tmp`);
|
|
133
|
-
await writeFile(temporary, `${serialize()}\n`, {
|
|
231
|
+
await writeFile(temporary, `${serialize()}\n`, {
|
|
232
|
+
flag: "wx",
|
|
233
|
+
mode: 0o600,
|
|
234
|
+
});
|
|
134
235
|
try {
|
|
135
|
-
if (create)
|
|
236
|
+
if (create) {
|
|
136
237
|
await link(temporary, destination);
|
|
137
|
-
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
138
240
|
await rename(temporary, destination);
|
|
241
|
+
}
|
|
139
242
|
}
|
|
140
243
|
finally {
|
|
141
|
-
await unlink(temporary).catch((error) => {
|
|
142
|
-
|
|
244
|
+
await unlink(temporary).catch((error) => {
|
|
245
|
+
if (error.code !== "ENOENT") {
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
143
249
|
}
|
|
144
250
|
};
|
|
145
|
-
if (!options.resume)
|
|
251
|
+
if (!options.resume) {
|
|
146
252
|
await persist(true);
|
|
253
|
+
}
|
|
147
254
|
options.onStep?.(`operation run ${runId}`);
|
|
255
|
+
const traceStarted = Date.now();
|
|
256
|
+
const trace = (event, step, target) => {
|
|
257
|
+
if (!options.trace && !options.onTrace) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const entry = {
|
|
261
|
+
event,
|
|
262
|
+
at: new Date().toISOString(),
|
|
263
|
+
elapsedMs: Date.now() - traceStarted,
|
|
264
|
+
...(step === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: {
|
|
267
|
+
step,
|
|
268
|
+
}),
|
|
269
|
+
...(target
|
|
270
|
+
? {
|
|
271
|
+
target: {
|
|
272
|
+
fileId: redact(target.fileId),
|
|
273
|
+
revision: target.revision,
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
: {}),
|
|
277
|
+
};
|
|
278
|
+
if (options.trace) {
|
|
279
|
+
record.trace ??= [];
|
|
280
|
+
if (record.trace.length < 10000) {
|
|
281
|
+
record.trace.push(entry);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
options.onTrace?.(structuredClone(entry));
|
|
285
|
+
};
|
|
148
286
|
const assertTarget = async () => {
|
|
149
287
|
const actual = await options.target();
|
|
150
|
-
if (actual.fileId !== record.target.fileId)
|
|
288
|
+
if (actual.fileId !== record.target.fileId) {
|
|
151
289
|
throw new Error("The operation target file changed");
|
|
290
|
+
}
|
|
291
|
+
trace("revision-observed", undefined, actual);
|
|
152
292
|
return actual;
|
|
153
293
|
};
|
|
154
294
|
let lease;
|
|
155
295
|
try {
|
|
296
|
+
trace("lease-acquire-start", undefined, target);
|
|
156
297
|
lease = await options.coordinate?.(runId, target);
|
|
157
|
-
|
|
298
|
+
trace("lease-acquired", undefined, options.trace || options.onTrace ? await assertTarget() : undefined);
|
|
299
|
+
if (options.checkpoint &&
|
|
300
|
+
record.steps.every((step) => step.state === "pending") &&
|
|
301
|
+
record.checkpoint === undefined) {
|
|
158
302
|
record.state = "running";
|
|
159
303
|
await persist();
|
|
160
304
|
await assertTarget();
|
|
161
305
|
const checkpoint = await options.checkpoint(runId);
|
|
162
|
-
if (checkpoint.supported !== false && checkpoint.report?.verified !== true)
|
|
306
|
+
if (checkpoint.supported !== false && checkpoint.report?.verified !== true) {
|
|
163
307
|
throw new Error("Checkpoint did not provide verified recovery evidence");
|
|
308
|
+
}
|
|
164
309
|
if (!checkpoint.supported && options.requireCheckpoint) {
|
|
165
310
|
record.state = "failed";
|
|
166
311
|
throw new Error("Required checkpoint API is unavailable; no operation step was applied");
|
|
@@ -172,8 +317,11 @@ export async function runJournaledSteps(steps, options) {
|
|
|
172
317
|
const step = steps[index];
|
|
173
318
|
const saved = record.steps[index];
|
|
174
319
|
await lease?.check();
|
|
320
|
+
trace("lease-check", index);
|
|
175
321
|
const beforeInspection = await assertTarget();
|
|
322
|
+
trace("inspection-start", index, beforeInspection);
|
|
176
323
|
const current = await step.inspect();
|
|
324
|
+
trace("inspection-end", index);
|
|
177
325
|
const afterInspection = await assertTarget();
|
|
178
326
|
if (beforeInspection.revision !== afterInspection.revision) {
|
|
179
327
|
record.state = "conflict";
|
|
@@ -234,7 +382,7 @@ export async function runJournaledSteps(steps, options) {
|
|
|
234
382
|
throw error;
|
|
235
383
|
}
|
|
236
384
|
}
|
|
237
|
-
if (options.finalVerify && !await options.finalVerify()) {
|
|
385
|
+
if (options.finalVerify && !(await options.finalVerify())) {
|
|
238
386
|
record.state = "failed";
|
|
239
387
|
throw new Error("Final operation verification failed");
|
|
240
388
|
}
|
|
@@ -245,16 +393,31 @@ export async function runJournaledSteps(steps, options) {
|
|
|
245
393
|
}
|
|
246
394
|
catch (error) {
|
|
247
395
|
record.error = redact(error instanceof Error ? error.message : String(error));
|
|
248
|
-
if (record.state === "planned" || record.state === "running")
|
|
396
|
+
if (record.state === "planned" || record.state === "running") {
|
|
249
397
|
record.state = "uncertain";
|
|
398
|
+
}
|
|
250
399
|
try {
|
|
251
400
|
await persist();
|
|
252
401
|
}
|
|
253
|
-
catch {
|
|
402
|
+
catch {
|
|
403
|
+
/* The pre-write started marker remains the recovery authority when a later local save fails. */
|
|
404
|
+
}
|
|
254
405
|
throw new JournalRunError(runId, record.state, redact(error instanceof Error ? error.message : String(error)));
|
|
255
406
|
}
|
|
256
407
|
finally {
|
|
257
|
-
if (lease)
|
|
258
|
-
|
|
408
|
+
if (lease) {
|
|
409
|
+
trace("lease-release-start");
|
|
410
|
+
try {
|
|
411
|
+
await lease.release();
|
|
412
|
+
trace("lease-released");
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
trace("lease-release-failed");
|
|
416
|
+
options.onStep?.(`Writer marker release needs inspection: ${redact(error instanceof Error ? error.message : String(error))}`);
|
|
417
|
+
}
|
|
418
|
+
if (options.trace) {
|
|
419
|
+
await persist().catch(() => undefined);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
259
422
|
}
|
|
260
423
|
}
|
package/dist/kit-smoke.js
CHANGED
|
@@ -16,8 +16,9 @@ const state=()=>{
|
|
|
16
16
|
const result={fileId:f.id,pageId:p.id,structure,tokens,themes};if(JSON.stringify(result).length>30000)throw new Error("Kit probe needs a smaller scratch page/token context");return result;
|
|
17
17
|
};`;
|
|
18
18
|
export const kitSmokeOperation = {
|
|
19
|
-
name: "kit-smoke",
|
|
20
|
-
|
|
19
|
+
name: "kit-smoke",
|
|
20
|
+
page: () => undefined,
|
|
21
|
+
inspect: (params) => snippet("kit-smoke-inspect", `${snapshotBody}
|
|
21
22
|
const current=state(),effective=new Map();for(const set of penpot.library.local.tokens.sets)if(set.active)for(const token of set.tokens)effective.set(token.name,token);
|
|
22
23
|
const pick=type=>{const names=[...effective.values()].filter(token=>token.type===type).map(token=>token.name).sort();if(!names.length)throw new Error("Kit probe needs an active "+type+" token on its scratch file");return names[0];};
|
|
23
24
|
if(penpot.currentPage.root.children.some(shape=>shape.name===CFG.tag))throw new Error("A prior kit-probe root needs inspection before another run");
|
|
@@ -38,46 +39,107 @@ try{
|
|
|
38
39
|
result={ok:true,built:true,actual,geometry:{width:card.width,height:card.height},previewBase64:btoa(binary),previewLength:second.length,capture:{fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,frameId:root.id,revision:penpot.currentFile.revn,penpotApiVersion:typeof penpot.version==="string"?penpot.version:null},layout:settled.layout};
|
|
39
40
|
}catch(error){result.error=String(error&&error.message||error);}
|
|
40
41
|
finally{if(root)try{root.remove();}catch(error){result.cleanupError=String(error&&error.message||error);}}
|
|
41
|
-
return result;`, {
|
|
42
|
+
return result;`, {
|
|
43
|
+
...params,
|
|
44
|
+
expected: inspected?.state ?? null,
|
|
45
|
+
selected: inspected?.selected ?? null,
|
|
46
|
+
}, "write"),
|
|
42
47
|
readback: () => snippet("kit-smoke-readback", `${snapshotBody}\nreturn {ok:true,readOnly:true,state:state()};`, {}, "read"),
|
|
43
48
|
verify({ inspect, apply, readback }) {
|
|
44
49
|
const selected = inspect.selected;
|
|
45
50
|
const problems = [];
|
|
46
|
-
if (!selected || apply.built !== true || apply.error || apply.cleanupError)
|
|
51
|
+
if (!selected || apply.built !== true || apply.error || apply.cleanupError) {
|
|
47
52
|
problems.push(`Kit example failed: ${apply.error ?? apply.cleanupError ?? "no observed build"}`);
|
|
48
|
-
|
|
53
|
+
}
|
|
54
|
+
if (!selected ||
|
|
55
|
+
observationHash(apply.actual) !==
|
|
56
|
+
observationHash({
|
|
57
|
+
text: "Hello, card",
|
|
58
|
+
typography: selected.typo,
|
|
59
|
+
color: selected.color,
|
|
60
|
+
space: selected.space,
|
|
61
|
+
radius: selected.radius,
|
|
62
|
+
})) {
|
|
49
63
|
problems.push("Kit example lacks matching independent text/binding observations");
|
|
64
|
+
}
|
|
50
65
|
const geometry = apply.geometry;
|
|
51
|
-
if (!geometry ||
|
|
66
|
+
if (!geometry ||
|
|
67
|
+
typeof geometry.width !== "number" ||
|
|
68
|
+
!Number.isFinite(geometry.width) ||
|
|
69
|
+
geometry.width <= 0 ||
|
|
70
|
+
typeof geometry.height !== "number" ||
|
|
71
|
+
!Number.isFinite(geometry.height) ||
|
|
72
|
+
geometry.height <= 0) {
|
|
52
73
|
problems.push("Kit example has no finite positive geometry");
|
|
53
|
-
|
|
74
|
+
}
|
|
75
|
+
if (!inspect.state ||
|
|
76
|
+
!readback.state ||
|
|
77
|
+
observationHash(inspect.state) !== observationHash(readback.state)) {
|
|
54
78
|
problems.push("Kit cleanup did not independently preserve the original page and token selection");
|
|
55
|
-
|
|
79
|
+
}
|
|
80
|
+
if (typeof apply.previewBase64 !== "string" ||
|
|
81
|
+
!Number.isInteger(apply.previewLength) ||
|
|
82
|
+
Number(apply.previewLength) < 1) {
|
|
56
83
|
problems.push("Kit example has no stable preview evidence");
|
|
84
|
+
}
|
|
57
85
|
return problems;
|
|
58
86
|
},
|
|
59
87
|
};
|
|
60
88
|
/** Opt-in scratch operation; uses existing active typed tokens and removes only its directly owned temporary root. */
|
|
61
89
|
export async function runKitSmoke(client, root, options = {}) {
|
|
62
90
|
const runId = options.journal?.runId ?? randomUUID();
|
|
63
|
-
const report = await runOperation(client, kitSmokeOperation, {
|
|
91
|
+
const report = await runOperation(client, kitSmokeOperation, {
|
|
92
|
+
tag: `_penpair-kit-${runId}`,
|
|
93
|
+
}, {
|
|
94
|
+
...options,
|
|
95
|
+
journal: options.apply
|
|
96
|
+
? {
|
|
97
|
+
...options.journal,
|
|
98
|
+
root,
|
|
99
|
+
runId,
|
|
100
|
+
}
|
|
101
|
+
: undefined,
|
|
102
|
+
});
|
|
64
103
|
const encoded = report.apply?.previewBase64;
|
|
65
|
-
if (report.apply)
|
|
104
|
+
if (report.apply) {
|
|
66
105
|
delete report.apply.previewBase64;
|
|
67
|
-
|
|
106
|
+
}
|
|
107
|
+
if (!report.verified || !report.apply) {
|
|
68
108
|
return report;
|
|
69
|
-
|
|
109
|
+
}
|
|
110
|
+
if (typeof encoded !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
|
|
70
111
|
throw new Error("Kit preview encoding is invalid");
|
|
112
|
+
}
|
|
71
113
|
const bytes = Buffer.from(encoded, "base64");
|
|
72
|
-
if (bytes.length !== report.apply.previewLength || bytes.length > 2097152)
|
|
114
|
+
if (bytes.length !== report.apply.previewLength || bytes.length > 2097152) {
|
|
73
115
|
throw new Error("Kit preview byte evidence is invalid");
|
|
116
|
+
}
|
|
74
117
|
const destination = await resolveProjectOutput(root, `.penpair/probes/kit-${runId}.png`);
|
|
75
|
-
await mkdir(path.dirname(destination), {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
118
|
+
await mkdir(path.dirname(destination), {
|
|
119
|
+
recursive: true,
|
|
120
|
+
mode: 0o700,
|
|
121
|
+
});
|
|
122
|
+
const existing = await readFile(destination).catch((error) => {
|
|
123
|
+
if (error.code !== "ENOENT") {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
});
|
|
128
|
+
if (existing && !existing.equals(bytes)) {
|
|
79
129
|
throw new Error("Existing kit preview differs; previous evidence was preserved");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
130
|
+
}
|
|
131
|
+
if (!existing) {
|
|
132
|
+
await atomicWriteFile(destination, bytes, {
|
|
133
|
+
mode: 0o600,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
...report,
|
|
138
|
+
preview: {
|
|
139
|
+
path: destination,
|
|
140
|
+
bytes: bytes.length,
|
|
141
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
142
|
+
visualApproval: false,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
83
145
|
}
|
package/dist/kit-source.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { layoutPolicySource } from "./layout-policy.js";
|
|
1
2
|
/** Plain plugin-sandbox JavaScript. Inclusion defines helpers without touching the document. */
|
|
2
3
|
export const coreKitSource = String.raw `// penpair: read
|
|
3
4
|
const pp = (() => {
|
|
@@ -218,45 +219,14 @@ const pp = (() => {
|
|
|
218
219
|
const fillV = shape => { if (!shape.layoutChild)
|
|
219
220
|
throw new Error("Layout child unavailable"); shape.layoutChild.verticalSizing = "fill"; return shape; };
|
|
220
221
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
222
|
+
${layoutPolicySource}
|
|
221
223
|
const settle = async (root = penpot.currentPage.root, options = {}) => {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
throw new Error("
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const shape = stack.pop();
|
|
229
|
-
if (!shape || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite))
|
|
230
|
-
throw new Error("Malformed settling geometry");
|
|
231
|
-
seen.add(shape.id);
|
|
232
|
-
rows.push([shape.id, shape.x, shape.y, shape.width, shape.height]);
|
|
233
|
-
for (const child of shape.children || [])
|
|
234
|
-
stack.push(child);
|
|
235
|
-
} return JSON.stringify(rows); };
|
|
236
|
-
try {
|
|
237
|
-
const target = root && typeof root.waitForLayoutUpdate === "function" ? root : penpot;
|
|
238
|
-
if (typeof target.waitForLayoutUpdate === "function") {
|
|
239
|
-
await target.waitForLayoutUpdate(timeout);
|
|
240
|
-
sample();
|
|
241
|
-
if (Date.now() - started >= timeout)
|
|
242
|
-
return { stable: false, method: "native", samples: 1, reason: "Native layout exceeded deadline" };
|
|
243
|
-
return { stable: true, method: "native", samples: 1 };
|
|
244
|
-
}
|
|
245
|
-
let previous = sample();
|
|
246
|
-
for (let samples = 2; samples <= 1000 && Date.now() - started < timeout; samples++) {
|
|
247
|
-
await wait(Math.min(interval, Math.max(1, timeout - (Date.now() - started))));
|
|
248
|
-
const current = sample();
|
|
249
|
-
if (Date.now() - started >= timeout)
|
|
250
|
-
break;
|
|
251
|
-
if (current === previous)
|
|
252
|
-
return { stable: true, method: "geometry", samples };
|
|
253
|
-
previous = current;
|
|
254
|
-
}
|
|
255
|
-
return { stable: false, method: "geometry", reason: "Layout did not settle within budget" };
|
|
256
|
-
}
|
|
257
|
-
catch (error) {
|
|
258
|
-
return { stable: false, method: "unavailable", reason: String(error && error.message || error) };
|
|
259
|
-
}
|
|
224
|
+
const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
|
|
225
|
+
try { return await settleLayout(async nativeMs => {
|
|
226
|
+
if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed between samples");
|
|
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 }); }
|
|
229
|
+
catch (error) { return { stable: false, method: "unavailable", samples: 0, reason: String(error && error.message || error) }; }
|
|
260
230
|
};
|
|
261
231
|
const flush = async (options = {}) => {
|
|
262
232
|
if (flushing)
|
|
@@ -269,7 +239,7 @@ const pp = (() => {
|
|
|
269
239
|
try {
|
|
270
240
|
if (queuedContext !== null && context() !== queuedContext)
|
|
271
241
|
throw new Error("Active token context changed before flush");
|
|
272
|
-
let layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
|
|
242
|
+
let layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining(), strategy: options.strategy });
|
|
273
243
|
if (!layout.stable)
|
|
274
244
|
throw new Error(layout.reason || "Layout did not settle");
|
|
275
245
|
if (queuedContext !== null && context() !== queuedContext)
|
|
@@ -288,7 +258,7 @@ const pp = (() => {
|
|
|
288
258
|
bindings.shift();
|
|
289
259
|
applied++;
|
|
290
260
|
}
|
|
291
|
-
layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
|
|
261
|
+
layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining(), strategy: options.strategy });
|
|
292
262
|
if (!layout.stable)
|
|
293
263
|
throw new Error(layout.reason || "Layout did not settle");
|
|
294
264
|
queuedContext = null;
|