@penpair/core 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +59 -32
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
package/dist/client.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { type Endpoint } from "./endpoint.js";
|
|
2
|
-
import { type PenpairErrorCode } from "./errors.js";
|
|
2
|
+
import { PenpairError, type PenpairErrorCode } from "./errors.js";
|
|
3
|
+
export interface ExecutionMetrics {
|
|
4
|
+
requestSnippetBytes: number;
|
|
5
|
+
responsePayloadBytes: number;
|
|
6
|
+
}
|
|
3
7
|
export interface ToolOutcome {
|
|
8
|
+
metrics?: ExecutionMetrics;
|
|
4
9
|
text: string;
|
|
10
|
+
structuredContent?: unknown;
|
|
5
11
|
isError: boolean;
|
|
6
12
|
}
|
|
7
13
|
/** The only Penpot surface penpair needs: one tool call at a time on an already connected editor. */
|
|
14
|
+
export interface ToolCallOptions {
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
8
17
|
export interface PenpotToolClient {
|
|
9
|
-
callTool(name: string, args: Record<string, unknown
|
|
18
|
+
callTool(name: string, args: Record<string, unknown>, options?: ToolCallOptions): Promise<ToolOutcome>;
|
|
10
19
|
/** The names of the tools the server offers. A hosted Penpot may offer fewer than a local one. */
|
|
11
20
|
listTools?(): Promise<string[]>;
|
|
12
21
|
close(): Promise<void>;
|
|
@@ -17,6 +26,10 @@ export declare function connect(endpoint: Endpoint): Promise<PenpotToolClient>;
|
|
|
17
26
|
/** Maps Penpot MCP failure text to a distinct code so each failure gets its own remedy. */
|
|
18
27
|
export declare function classifyFailure(text: string): PenpairErrorCode;
|
|
19
28
|
export interface ExecuteOptions {
|
|
29
|
+
/** Per-request budget; adapters/guards must forward it. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/** Only known read-only executions may be retried. Unclassified source is treated as a possible write. */
|
|
32
|
+
effect?: "read" | "write" | "unknown";
|
|
20
33
|
/** How many times to wait and retry a suspended tab. */
|
|
21
34
|
retries?: number;
|
|
22
35
|
sleep?: (milliseconds: number) => Promise<void>;
|
|
@@ -25,13 +38,21 @@ export interface ExecuteOptions {
|
|
|
25
38
|
redact?: (text: string) => string;
|
|
26
39
|
}
|
|
27
40
|
export interface ExecuteResult {
|
|
41
|
+
metrics?: ExecutionMetrics;
|
|
42
|
+
runId: string;
|
|
43
|
+
outcome: "ran";
|
|
28
44
|
/** What the snippet returned, parsed when it was JSON. */
|
|
29
45
|
result: unknown;
|
|
30
46
|
/** What the snippet printed. */
|
|
31
47
|
log: string;
|
|
32
48
|
}
|
|
49
|
+
export declare function executionEnvelope(outcome: ToolOutcome): Pick<ExecuteResult, "result" | "log"> | undefined;
|
|
33
50
|
/**
|
|
34
|
-
*
|
|
35
|
-
* already have run; the caller reads the page before repeating a write.
|
|
51
|
+
* Retries only declared reads. A lost response to any possible write leaves an uncertain outcome requiring independent inspection.
|
|
36
52
|
*/
|
|
37
|
-
export declare function executeCode(client: PenpotToolClient, code: string, { retries, sleep, onWait, redact }?: ExecuteOptions): Promise<ExecuteResult>;
|
|
53
|
+
export declare function executeCode(client: PenpotToolClient, code: string, { timeoutMs, effect, retries: requestedRetries, sleep, onWait, redact }?: ExecuteOptions): Promise<ExecuteResult>;
|
|
54
|
+
export declare class UncertainExecutionError extends PenpairError {
|
|
55
|
+
readonly runId: string;
|
|
56
|
+
readonly outcome = "uncertain";
|
|
57
|
+
constructor(runId: string, message: string);
|
|
58
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
1
|
+
import { commandSignal, commandSleep } from "./command-result.js";
|
|
1
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
5
|
import { redactEndpoint } from "./endpoint.js";
|
|
4
6
|
import { PenpairError } from "./errors.js";
|
|
5
7
|
/** Penpot's proxy refuses request bodies of about 110 KB; snippets stay under this. */
|
|
6
8
|
export const maxSnippetBytes = 90 * 1024;
|
|
7
9
|
export async function connect(endpoint) {
|
|
10
|
+
commandSignal()?.throwIfAborted();
|
|
8
11
|
const client = new Client({ name: "penpair", version: "0" });
|
|
9
12
|
const transport = new StreamableHTTPClientTransport(new URL(endpoint.url));
|
|
10
13
|
try {
|
|
11
|
-
await client.connect(transport);
|
|
14
|
+
await client.connect(transport, { signal: commandSignal(), timeout: 120_000 });
|
|
12
15
|
}
|
|
13
16
|
catch (error) {
|
|
14
17
|
throw transportFailure(error, endpoint);
|
|
15
18
|
}
|
|
16
19
|
return {
|
|
17
|
-
async callTool(name, args) {
|
|
20
|
+
async callTool(name, args, options) {
|
|
18
21
|
try {
|
|
19
|
-
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 120_000 });
|
|
22
|
+
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: options?.timeoutMs ?? 120_000, signal: commandSignal() });
|
|
20
23
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
21
24
|
const text = content
|
|
22
25
|
.flatMap((item) => typeof item === "object" && item !== null && "type" in item && item.type === "text" && "text" in item
|
|
23
26
|
? [String(item.text)]
|
|
24
27
|
: [])
|
|
25
28
|
.join("\n");
|
|
26
|
-
return { text, isError: result.isError === true };
|
|
29
|
+
return { text, metrics: { requestSnippetBytes: typeof args.code === "string" ? Buffer.byteLength(args.code, "utf8") : 0, responsePayloadBytes: Buffer.byteLength(text, "utf8") + (result.structuredContent === undefined ? 0 : Buffer.byteLength(JSON.stringify(result.structuredContent), "utf8")) }, ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}), isError: result.isError === true };
|
|
27
30
|
}
|
|
28
31
|
catch (error) {
|
|
29
32
|
throw transportFailure(error, endpoint);
|
|
@@ -31,7 +34,7 @@ export async function connect(endpoint) {
|
|
|
31
34
|
},
|
|
32
35
|
async listTools() {
|
|
33
36
|
try {
|
|
34
|
-
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
37
|
+
return (await client.listTools(undefined, { signal: commandSignal(), timeout: 120_000 })).tools.map((tool) => tool.name);
|
|
35
38
|
}
|
|
36
39
|
catch (error) {
|
|
37
40
|
throw transportFailure(error, endpoint);
|
|
@@ -43,6 +46,8 @@ export async function connect(endpoint) {
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
function transportFailure(error, endpoint) {
|
|
49
|
+
if (commandSignal()?.aborted)
|
|
50
|
+
return commandSignal().reason;
|
|
46
51
|
const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
47
52
|
const message = redactEndpoint(raw, endpoint);
|
|
48
53
|
if (error instanceof StreamableHTTPError && (error.code === 401 || error.code === 403))
|
|
@@ -62,16 +67,18 @@ export function classifyFailure(text) {
|
|
|
62
67
|
return "mcp-unavailable";
|
|
63
68
|
return "tool-failed";
|
|
64
69
|
}
|
|
65
|
-
const defaultSleep =
|
|
66
|
-
function
|
|
67
|
-
let envelope;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const defaultSleep = commandSleep;
|
|
71
|
+
export function executionEnvelope(outcome) {
|
|
72
|
+
let envelope = outcome.structuredContent;
|
|
73
|
+
if (envelope === undefined) {
|
|
74
|
+
try {
|
|
75
|
+
envelope = JSON.parse(outcome.text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
73
80
|
}
|
|
74
|
-
if (typeof envelope !== "object" || envelope === null || !("result" in envelope))
|
|
81
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope) || !("result" in envelope) || "log" in envelope && typeof envelope.log !== "string")
|
|
75
82
|
return undefined;
|
|
76
83
|
const log = "log" in envelope && typeof envelope.log === "string" ? envelope.log : "";
|
|
77
84
|
const raw = envelope.result;
|
|
@@ -85,16 +92,23 @@ function unpack(text) {
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
/**
|
|
88
|
-
*
|
|
89
|
-
* already have run; the caller reads the page before repeating a write.
|
|
95
|
+
* Retries only declared reads. A lost response to any possible write leaves an uncertain outcome requiring independent inspection.
|
|
90
96
|
*/
|
|
91
|
-
export async function executeCode(client, code, { retries = 6, sleep = defaultSleep, onWait, redact = (text) => text } = {}) {
|
|
97
|
+
export async function executeCode(client, code, { timeoutMs = 120_000, effect = "unknown", retries: requestedRetries = 6, sleep = defaultSleep, onWait, redact = (text) => text } = {}) {
|
|
98
|
+
commandSignal()?.throwIfAborted();
|
|
92
99
|
if (Buffer.byteLength(code, "utf8") > maxSnippetBytes)
|
|
93
100
|
throw new PenpairError("snippet-too-large", `The snippet is ${Buffer.byteLength(code, "utf8")} bytes`);
|
|
101
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
|
|
102
|
+
throw new Error("Execution timeout must be from 1 to 120000 ms");
|
|
103
|
+
const retries = effect === "read" ? requestedRetries : 0;
|
|
104
|
+
const runId = randomUUID();
|
|
105
|
+
const uncertain = (message) => new UncertainExecutionError(runId, redact(message));
|
|
94
106
|
for (let attempt = 0;; attempt++) {
|
|
95
107
|
let outcome;
|
|
96
108
|
try {
|
|
97
|
-
|
|
109
|
+
commandSignal()?.throwIfAborted();
|
|
110
|
+
outcome = await client.callTool("execute_code", { code }, { timeoutMs });
|
|
111
|
+
commandSignal()?.throwIfAborted();
|
|
98
112
|
}
|
|
99
113
|
catch (error) {
|
|
100
114
|
if (error instanceof PenpairError && error.code === "plugin-suspended" && attempt < retries) {
|
|
@@ -102,18 +116,30 @@ export async function executeCode(client, code, { retries = 6, sleep = defaultSl
|
|
|
102
116
|
await sleep(5_000 * (attempt + 1));
|
|
103
117
|
continue;
|
|
104
118
|
}
|
|
119
|
+
if (effect !== "read" && (!(error instanceof PenpairError) || !["file-mismatch", "snippet-too-large", "auth-rejected"].includes(error.code)))
|
|
120
|
+
throw uncertain(error instanceof Error ? error.message : String(error));
|
|
105
121
|
throw error;
|
|
106
122
|
}
|
|
107
|
-
const unpacked =
|
|
123
|
+
const unpacked = executionEnvelope(outcome);
|
|
108
124
|
const failed = outcome.isError || (!unpacked && /^Tool execution failed:/i.test(outcome.text.trim()));
|
|
109
125
|
if (!failed && unpacked)
|
|
110
|
-
return unpacked;
|
|
126
|
+
return { ...unpacked, runId, outcome: "ran", metrics: outcome.metrics ?? { requestSnippetBytes: Buffer.byteLength(code, "utf8"), responsePayloadBytes: Buffer.byteLength(outcome.text, "utf8") + (outcome.structuredContent === undefined ? 0 : Buffer.byteLength(JSON.stringify(outcome.structuredContent), "utf8")) } };
|
|
111
127
|
const code_ = classifyFailure(outcome.text);
|
|
112
128
|
if (code_ === "plugin-suspended" && attempt < retries) {
|
|
113
129
|
onWait?.(`Penpot tab is suspended; bring it to the foreground (retry ${attempt + 1}/${retries})`);
|
|
114
130
|
await sleep(5_000 * (attempt + 1));
|
|
115
131
|
continue;
|
|
116
132
|
}
|
|
133
|
+
if (effect !== "read" && code_ !== "auth-rejected")
|
|
134
|
+
throw uncertain(outcome.text.slice(0, 500));
|
|
117
135
|
throw new PenpairError(code_, redact(outcome.text).slice(0, 500));
|
|
118
136
|
}
|
|
119
137
|
}
|
|
138
|
+
export class UncertainExecutionError extends PenpairError {
|
|
139
|
+
runId;
|
|
140
|
+
outcome = "uncertain";
|
|
141
|
+
constructor(runId, message) {
|
|
142
|
+
super("write-uncertain", `Run ${runId} has an uncertain outcome: ${message}`);
|
|
143
|
+
this.runId = runId;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
export interface CommandFailure {
|
|
2
|
+
code: string;
|
|
3
|
+
message: string;
|
|
4
|
+
remedy: string;
|
|
5
|
+
runId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandResult {
|
|
8
|
+
schemaVersion: 1;
|
|
9
|
+
redacted?: boolean;
|
|
10
|
+
command: string;
|
|
11
|
+
outcome: "success" | "drift" | "failed" | "incomplete" | "uncertain" | "cancelled" | "deadline";
|
|
12
|
+
phase: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
target: {
|
|
15
|
+
fileId?: string;
|
|
16
|
+
pageId?: string;
|
|
17
|
+
} | null;
|
|
18
|
+
runId: string | null;
|
|
19
|
+
data: unknown;
|
|
20
|
+
messages: string[];
|
|
21
|
+
warnings: string[];
|
|
22
|
+
errors: CommandFailure[];
|
|
23
|
+
completeness: {
|
|
24
|
+
complete: boolean;
|
|
25
|
+
reason?: string;
|
|
26
|
+
};
|
|
27
|
+
timings: {
|
|
28
|
+
startedAt: string;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface CommandOutput {
|
|
33
|
+
log(...values: unknown[]): void;
|
|
34
|
+
error(...values: unknown[]): void;
|
|
35
|
+
result(value: unknown): void;
|
|
36
|
+
}
|
|
37
|
+
export declare const commandOutput: CommandOutput;
|
|
38
|
+
export declare function commandRedactor(redact: (value: string) => string): void;
|
|
39
|
+
export declare function commandFailure(error: unknown): void;
|
|
40
|
+
export declare function withCommandSignal<T>(signal: AbortSignal, work: () => Promise<T>): Promise<T>;
|
|
41
|
+
/** Bounded explicit restoration work may finish after the main command is cancelled. */
|
|
42
|
+
export declare function withCleanupDeadline<T>(work: () => Promise<T>, milliseconds?: number): Promise<T>;
|
|
43
|
+
export declare function commandSignal(): AbortSignal | undefined;
|
|
44
|
+
export declare function commandSleep(milliseconds: number): Promise<void>;
|
|
45
|
+
export declare class CommandInterruptedError extends Error {
|
|
46
|
+
readonly code: "cancelled" | "deadline";
|
|
47
|
+
constructor(code: "cancelled" | "deadline");
|
|
48
|
+
}
|
|
49
|
+
/** Shared service boundary for CLI and future tool surfaces; no stdout or global console interception. */
|
|
50
|
+
export declare function collectCommand(command: string, work: () => Promise<number>, options?: {
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): Promise<CommandResult>;
|
|
53
|
+
/** Outer transport contract. Command-specific data remains owned by the command service. */
|
|
54
|
+
export declare const commandResultSchema: {
|
|
55
|
+
readonly type: "object";
|
|
56
|
+
readonly additionalProperties: false;
|
|
57
|
+
readonly required: readonly ["schemaVersion", "command", "outcome", "phase", "exitCode", "target", "runId", "data", "messages", "warnings", "errors", "completeness", "timings"];
|
|
58
|
+
readonly properties: {
|
|
59
|
+
readonly schemaVersion: {
|
|
60
|
+
readonly const: 1;
|
|
61
|
+
};
|
|
62
|
+
readonly redacted: {
|
|
63
|
+
readonly type: "boolean";
|
|
64
|
+
};
|
|
65
|
+
readonly command: {
|
|
66
|
+
readonly type: "string";
|
|
67
|
+
};
|
|
68
|
+
readonly outcome: {
|
|
69
|
+
readonly enum: readonly ["success", "drift", "failed", "incomplete", "uncertain", "cancelled", "deadline"];
|
|
70
|
+
};
|
|
71
|
+
readonly phase: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
};
|
|
74
|
+
readonly exitCode: {
|
|
75
|
+
readonly enum: readonly [0, 1, 2, 3, 4, 124, 130];
|
|
76
|
+
};
|
|
77
|
+
readonly target: {
|
|
78
|
+
readonly anyOf: readonly [{
|
|
79
|
+
readonly type: "null";
|
|
80
|
+
}, {
|
|
81
|
+
readonly type: "object";
|
|
82
|
+
readonly properties: {
|
|
83
|
+
readonly fileId: {
|
|
84
|
+
readonly type: "string";
|
|
85
|
+
};
|
|
86
|
+
readonly pageId: {
|
|
87
|
+
readonly type: "string";
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
readonly additionalProperties: false;
|
|
91
|
+
}];
|
|
92
|
+
};
|
|
93
|
+
readonly runId: {
|
|
94
|
+
readonly type: readonly ["string", "null"];
|
|
95
|
+
};
|
|
96
|
+
readonly data: {};
|
|
97
|
+
readonly messages: {
|
|
98
|
+
readonly type: "array";
|
|
99
|
+
readonly items: {
|
|
100
|
+
readonly type: "string";
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
readonly warnings: {
|
|
104
|
+
readonly type: "array";
|
|
105
|
+
readonly items: {
|
|
106
|
+
readonly type: "string";
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
readonly errors: {
|
|
110
|
+
readonly type: "array";
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "object";
|
|
113
|
+
readonly required: readonly ["code", "message", "remedy"];
|
|
114
|
+
readonly properties: {
|
|
115
|
+
readonly code: {
|
|
116
|
+
readonly type: "string";
|
|
117
|
+
};
|
|
118
|
+
readonly message: {
|
|
119
|
+
readonly type: "string";
|
|
120
|
+
};
|
|
121
|
+
readonly remedy: {
|
|
122
|
+
readonly type: "string";
|
|
123
|
+
};
|
|
124
|
+
readonly runId: {
|
|
125
|
+
readonly type: "string";
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
readonly additionalProperties: false;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
readonly completeness: {
|
|
132
|
+
readonly type: "object";
|
|
133
|
+
readonly required: readonly ["complete"];
|
|
134
|
+
readonly properties: {
|
|
135
|
+
readonly complete: {
|
|
136
|
+
readonly type: "boolean";
|
|
137
|
+
};
|
|
138
|
+
readonly reason: {
|
|
139
|
+
readonly type: "string";
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
readonly additionalProperties: false;
|
|
143
|
+
};
|
|
144
|
+
readonly timings: {
|
|
145
|
+
readonly type: "object";
|
|
146
|
+
readonly required: readonly ["startedAt", "durationMs"];
|
|
147
|
+
readonly properties: {
|
|
148
|
+
readonly startedAt: {
|
|
149
|
+
readonly type: "string";
|
|
150
|
+
};
|
|
151
|
+
readonly durationMs: {
|
|
152
|
+
readonly type: "number";
|
|
153
|
+
readonly minimum: 0;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
readonly additionalProperties: false;
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { redactEndpoint } from "./endpoint.js";
|
|
4
|
+
const scope = new AsyncLocalStorage();
|
|
5
|
+
const line = (values) => values.map((value) => typeof value === "string" ? value : JSON.stringify(value)).join(" ");
|
|
6
|
+
export const commandOutput = {
|
|
7
|
+
log(...values) {
|
|
8
|
+
const current = scope.getStore();
|
|
9
|
+
if (!current) {
|
|
10
|
+
console.log(...values.map(value => renderHuman(value)));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const message = line(values);
|
|
14
|
+
try {
|
|
15
|
+
current.data.push(JSON.parse(message));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
current.messages.push(message);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
error(...values) {
|
|
22
|
+
const current = scope.getStore();
|
|
23
|
+
if (current) {
|
|
24
|
+
const message = current.redactors.reduce((text, redact) => redact(text), redactEndpoint(line(values)));
|
|
25
|
+
if (message !== line(values))
|
|
26
|
+
current.redacted = true;
|
|
27
|
+
current.warnings.push(message);
|
|
28
|
+
console.error(message);
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
console.error(...values.map(value => renderHuman(value)));
|
|
32
|
+
},
|
|
33
|
+
result(value) { const current = scope.getStore(); if (current)
|
|
34
|
+
current.authoritative = value; },
|
|
35
|
+
};
|
|
36
|
+
export function commandRedactor(redact) { (scope.getStore()?.redactors ?? signals.getStore()?.redactors)?.push(redact); }
|
|
37
|
+
export function commandFailure(error) {
|
|
38
|
+
const current = scope.getStore();
|
|
39
|
+
if (!current)
|
|
40
|
+
return;
|
|
41
|
+
const value = error;
|
|
42
|
+
if (current.signal?.aborted && value?.code !== "write-uncertain" && value?.outcome !== "uncertain") {
|
|
43
|
+
const reason = current.signal.reason;
|
|
44
|
+
current.errors.push({ code: reason?.code === "deadline" ? "deadline" : "cancelled", message: reason?.code === "deadline" ? "Command deadline exceeded" : "Command cancelled", remedy: "Inspect any recorded run and current state before starting again." });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
current.errors.push({ code: typeof value?.code === "string" ? value.code : typeof value?.outcome === "string" ? value.outcome : "command-failed",
|
|
48
|
+
message: error instanceof Error ? error.message : String(error), remedy: typeof value?.remedy === "string" ? value.remedy : "Inspect the command result and current project state before trying again.",
|
|
49
|
+
...(typeof value?.runId === "string" ? { runId: value.runId } : {}) });
|
|
50
|
+
}
|
|
51
|
+
const signals = new AsyncLocalStorage();
|
|
52
|
+
function redactHuman(text) { return (signals.getStore()?.redactors ?? []).reduce((value, redact) => redact(value), redactEndpoint(text)); }
|
|
53
|
+
function renderHuman(value) {
|
|
54
|
+
const clean = (entry) => typeof entry === "string" ? redactHuman(entry) : Array.isArray(entry) ? entry.map(clean) : entry && typeof entry === "object" ? Object.fromEntries(Object.entries(entry).map(([key, child]) => [redactHuman(key), clean(child)])) : entry;
|
|
55
|
+
if (typeof value !== "string")
|
|
56
|
+
return clean(value);
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(clean(JSON.parse(value)), null, 2);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return redactHuman(value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function withCommandSignal(signal, work) { return signals.run({ signal, redactors: [] }, work); }
|
|
65
|
+
const cleanupSignals = new AsyncLocalStorage();
|
|
66
|
+
/** Bounded explicit restoration work may finish after the main command is cancelled. */
|
|
67
|
+
export async function withCleanupDeadline(work, milliseconds = 10000) {
|
|
68
|
+
if (!Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > 30000)
|
|
69
|
+
throw new Error("Cleanup deadline must be from 1 to 30000 ms");
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(new Error("Restoration deadline exceeded")), milliseconds);
|
|
72
|
+
try {
|
|
73
|
+
return await cleanupSignals.run(controller.signal, work);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function commandSignal() { return cleanupSignals.getStore() ?? scope.getStore()?.signal ?? signals.getStore()?.signal; }
|
|
80
|
+
export async function commandSleep(milliseconds) {
|
|
81
|
+
const signal = commandSignal();
|
|
82
|
+
try {
|
|
83
|
+
await wait(milliseconds, undefined, { signal });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
throw signal.reason;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export class CommandInterruptedError extends Error {
|
|
92
|
+
code;
|
|
93
|
+
constructor(code) {
|
|
94
|
+
super(code === "deadline" ? "Command deadline exceeded" : "Command cancelled");
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Shared service boundary for CLI and future tool surfaces; no stdout or global console interception. */
|
|
99
|
+
export async function collectCommand(command, work, options = {}) {
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
const capture = { data: [], messages: [], warnings: [], errors: [], redactors: [], signal: options.signal };
|
|
102
|
+
let exitCode;
|
|
103
|
+
try {
|
|
104
|
+
exitCode = await scope.run(capture, async () => { options.signal?.throwIfAborted(); const code = await work(); options.signal?.throwIfAborted(); return code; });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await scope.run(capture, async () => commandFailure(error));
|
|
108
|
+
exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
if (![0, 1, 2, 3, 4, 124, 130].includes(exitCode)) {
|
|
111
|
+
capture.warnings.push("Unsupported extension exit code was normalized to failure");
|
|
112
|
+
exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
const data = capture.authoritative !== undefined ? capture.authoritative : capture.data.length === 1 ? capture.data[0] : capture.data.length ? capture.data : null;
|
|
115
|
+
const record = data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
116
|
+
const errorCode = capture.errors[0]?.code;
|
|
117
|
+
const incomplete = record.complete === false || record.truncated === true || errorCode === "incomplete-output";
|
|
118
|
+
let outcome = exitCode === 0 ? "success" : exitCode === 2 ? "drift" : exitCode === 3 ? "incomplete" : exitCode === 4 ? "uncertain" : exitCode === 130 ? "cancelled" : exitCode === 124 ? "deadline" : "failed";
|
|
119
|
+
if (errorCode === "write-uncertain" || errorCode === "uncertain") {
|
|
120
|
+
outcome = "uncertain";
|
|
121
|
+
exitCode = 4;
|
|
122
|
+
}
|
|
123
|
+
else if (errorCode === "cancelled" || errorCode === "deadline") {
|
|
124
|
+
outcome = errorCode;
|
|
125
|
+
exitCode = errorCode === "cancelled" ? 130 : 124;
|
|
126
|
+
}
|
|
127
|
+
else if (incomplete) {
|
|
128
|
+
outcome = "incomplete";
|
|
129
|
+
exitCode = 3;
|
|
130
|
+
}
|
|
131
|
+
if (exitCode !== 0 && !capture.errors.length && !["drift", "incomplete"].includes(outcome))
|
|
132
|
+
capture.errors.push({ code: "command-failed", message: "Command checks failed; inspect data and diagnostics.", remedy: "Resolve the reported checks before proceeding." });
|
|
133
|
+
const identity = (record.capture ?? record.target ?? record);
|
|
134
|
+
const target = typeof identity.fileId === "string" ? { fileId: identity.fileId, ...(typeof identity.pageId === "string" ? { pageId: identity.pageId } : {}) } : null;
|
|
135
|
+
const journal = record.journal;
|
|
136
|
+
const sanitize = (value) => {
|
|
137
|
+
if (value === undefined || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint" || typeof value === "number" && !Number.isFinite(value))
|
|
138
|
+
return null;
|
|
139
|
+
if (typeof value === "string")
|
|
140
|
+
return capture.redactors.reduce((text, redact) => redact(text), redactEndpoint(value));
|
|
141
|
+
if (Array.isArray(value))
|
|
142
|
+
return value.map(sanitize);
|
|
143
|
+
if (value && typeof value === "object")
|
|
144
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [String(sanitize(key)), sanitize(entry)]));
|
|
145
|
+
return value;
|
|
146
|
+
};
|
|
147
|
+
const raw = { schemaVersion: 1, command, outcome, phase: typeof record.phase === "string" ? record.phase : outcome === "uncertain" || outcome === "cancelled" || outcome === "deadline" ? "interrupted" : "finished", exitCode, target,
|
|
148
|
+
runId: capture.errors.find((error) => error.runId)?.runId ?? (typeof journal?.runId === "string" ? journal.runId : null), data, messages: capture.messages, warnings: capture.warnings, errors: capture.errors,
|
|
149
|
+
completeness: { complete: outcome === "success" || outcome === "drift", ...(incomplete ? { reason: "Output is truncated or required observations are missing" } : {}) }, timings: { startedAt: new Date(started).toISOString(), durationMs: Math.max(0, Date.now() - started) } };
|
|
150
|
+
const result = sanitize(raw);
|
|
151
|
+
if (record.redacted === true || capture.redacted || JSON.stringify(result) !== JSON.stringify(raw)) {
|
|
152
|
+
result.redacted = true;
|
|
153
|
+
result.warnings.push("Sensitive or non-JSON output was redacted; hashes describe original evidence.");
|
|
154
|
+
if (JSON.stringify(result.data) !== JSON.stringify(data)) {
|
|
155
|
+
result.completeness = { complete: false, reason: "Displayed data required redaction and is not lossless source evidence" };
|
|
156
|
+
if (result.outcome === "success" || result.outcome === "drift") {
|
|
157
|
+
result.outcome = "incomplete";
|
|
158
|
+
result.exitCode = 3;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
/** Outer transport contract. Command-specific data remains owned by the command service. */
|
|
165
|
+
export const commandResultSchema = {
|
|
166
|
+
type: "object", additionalProperties: false,
|
|
167
|
+
required: ["schemaVersion", "command", "outcome", "phase", "exitCode", "target", "runId", "data", "messages", "warnings", "errors", "completeness", "timings"],
|
|
168
|
+
properties: {
|
|
169
|
+
schemaVersion: { const: 1 }, redacted: { type: "boolean" }, command: { type: "string" },
|
|
170
|
+
outcome: { enum: ["success", "drift", "failed", "incomplete", "uncertain", "cancelled", "deadline"] },
|
|
171
|
+
phase: { type: "string" }, exitCode: { enum: [0, 1, 2, 3, 4, 124, 130] },
|
|
172
|
+
target: { anyOf: [{ type: "null" }, { type: "object", properties: { fileId: { type: "string" }, pageId: { type: "string" } }, additionalProperties: false }] },
|
|
173
|
+
runId: { type: ["string", "null"] }, data: {},
|
|
174
|
+
messages: { type: "array", items: { type: "string" } }, warnings: { type: "array", items: { type: "string" } },
|
|
175
|
+
errors: { type: "array", items: { type: "object", required: ["code", "message", "remedy"], properties: { code: { type: "string" }, message: { type: "string" }, remedy: { type: "string" }, runId: { type: "string" } }, additionalProperties: false } },
|
|
176
|
+
completeness: { type: "object", required: ["complete"], properties: { complete: { type: "boolean" }, reason: { type: "string" } }, additionalProperties: false },
|
|
177
|
+
timings: { type: "object", required: ["startedAt", "durationMs"], properties: { startedAt: { type: "string" }, durationMs: { type: "number", minimum: 0 } }, additionalProperties: false },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "./client.js";
|
|
2
|
+
import type { JournalTarget } from "./journal.js";
|
|
3
|
+
export interface WriteLease {
|
|
4
|
+
check(): Promise<void>;
|
|
5
|
+
release(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface CoordinationOptions {
|
|
8
|
+
root: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
forceLock?: boolean;
|
|
11
|
+
warn?(message: string): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function writeCoordinator(client: PenpotToolClient, options: CoordinationOptions): (runId: string, target: JournalTarget) => Promise<WriteLease>;
|
|
14
|
+
/** Revision precondition is checked inside the delivered mutation, after all guard calls. It is not a server CAS. */
|
|
15
|
+
export declare function guardRevision(client: PenpotToolClient, target: JournalTarget): PenpotToolClient;
|