opencode-workflow-guard 1.13.4 → 1.13.5
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/package.json +1 -1
- package/src/workflow-guard-ui.ts +119 -5
- package/test/test.mts +27 -1
package/package.json
CHANGED
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -8,15 +8,106 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
11
|
-
import { Plugin } from "@opencode/plugin/tui";
|
|
11
|
+
import { Plugin, usePlugin } from "@opencode/plugin/tui";
|
|
12
12
|
import type { JSX } from "@opentui/solid";
|
|
13
|
-
import { createElement, insert, setProp } from "@opentui/solid";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createElement, insert, setProp, RendererContext } from "@opentui/solid";
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { getOwner, useContext } from "solid-js";
|
|
16
18
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
|
|
17
19
|
import { projectConfigPath } from "./lib/project-config.ts";
|
|
18
20
|
import { canonicalPath } from "./policies/file-claims.ts";
|
|
19
21
|
|
|
22
|
+
// ── V2 render diagnostics (bounded; never throws) ────────────────────────────
|
|
23
|
+
// Slot renders run inside the TUI provider tree, but recurring failures have
|
|
24
|
+
// been observed in long-running sessions after plugin versions were swapped
|
|
25
|
+
// (opencode 2.0.10, "No renderer found" from @opentui/solid createElement).
|
|
26
|
+
// Instead of rethrowing — which makes the host error boundary toast on every
|
|
27
|
+
// retry for a cosmetic badge — a failing slot renders null, and failures are
|
|
28
|
+
// logged with owner/renderer context to a state file, bounded to the first
|
|
29
|
+
// DIAG_MAX_FAILURES_PER_SLOT failures per slot between successes. The next
|
|
30
|
+
// render attempt retries naturally, and a later success is logged again.
|
|
31
|
+
const DIAG_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode");
|
|
32
|
+
const DIAG_LOG = join(DIAG_DIR, "workflow-guard-ui.log");
|
|
33
|
+
const DIAG_MAX_FAILURES_PER_SLOT = 3;
|
|
34
|
+
const DIAG_MAX_LOG_BYTES = 2_000_000;
|
|
35
|
+
|
|
36
|
+
function diag(line: string): void {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(DIAG_DIR, { recursive: true });
|
|
39
|
+
if (existsSync(DIAG_LOG) && statSync(DIAG_LOG).size > DIAG_MAX_LOG_BYTES) {
|
|
40
|
+
writeFileSync(DIAG_LOG, "", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
appendFileSync(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
43
|
+
} catch {
|
|
44
|
+
// diagnostics must never crash the plugin
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
diag(`module load: ${import.meta.url}; @opentui/solid -> ${import.meta.resolve("@opentui/solid")}; solid-js -> ${import.meta.resolve("solid-js")}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
diag(`module load: ${import.meta.url}; resolve probe failed: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const diagOkSlots = new Set<string>();
|
|
55
|
+
const diagFailureCount = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
function diagRender(slot: string): void {
|
|
58
|
+
try {
|
|
59
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
60
|
+
const contextKeys = owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "no-owner";
|
|
61
|
+
const renderer = useContext(RendererContext);
|
|
62
|
+
let plugin = "ok";
|
|
63
|
+
try {
|
|
64
|
+
usePlugin();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
plugin = `FAILED: ${error}`;
|
|
67
|
+
}
|
|
68
|
+
if (!diagOkSlots.has(slot)) {
|
|
69
|
+
diagOkSlots.add(slot);
|
|
70
|
+
diag(`render ${slot} ok; owner=${owner ? "present" : "null"}; contextKeys=[${contextKeys}]; rendererContext=${renderer ? "found" : "MISSING"}; usePlugin=${plugin}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
diag(`render ${slot} probe FAILED: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function diagFailure(slot: string, error: unknown): void {
|
|
78
|
+
const count = (diagFailureCount.get(slot) ?? 0) + 1;
|
|
79
|
+
diagFailureCount.set(slot, count);
|
|
80
|
+
if (count > DIAG_MAX_FAILURES_PER_SLOT) {
|
|
81
|
+
if (count === DIAG_MAX_FAILURES_PER_SLOT + 1) {
|
|
82
|
+
diag(`render ${slot} still failing after ${DIAG_MAX_FAILURES_PER_SLOT} logged failures; further failures are suppressed until a render succeeds`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const lines = [
|
|
87
|
+
`render ${slot} FAILED (${count}/${DIAG_MAX_FAILURES_PER_SLOT}): ${error instanceof Error ? error.message : String(error)}`,
|
|
88
|
+
` stack: ${error instanceof Error ? error.stack : "(none)"}`,
|
|
89
|
+
];
|
|
90
|
+
try {
|
|
91
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
92
|
+
lines.push(` owner=${owner ? "present" : "null"}; contextKeys=[${owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "none"}]`);
|
|
93
|
+
} catch (probeError) {
|
|
94
|
+
lines.push(` owner probe failed: ${probeError}`);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
lines.push(` useContext(RendererContext): ${useContext(RendererContext) ? "found" : "MISSING"}`);
|
|
98
|
+
} catch (probeError) {
|
|
99
|
+
lines.push(` renderer probe failed: ${probeError}`);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
usePlugin();
|
|
103
|
+
lines.push(` usePlugin: ok`);
|
|
104
|
+
} catch (probeError) {
|
|
105
|
+
lines.push(` usePlugin: FAILED ${probeError}`);
|
|
106
|
+
}
|
|
107
|
+
diag(lines.join("\n"));
|
|
108
|
+
}
|
|
109
|
+
// ── end render diagnostics ───────────────────────────────────────────────────
|
|
110
|
+
|
|
20
111
|
type Child = JSX.Element | string | number | null | undefined | false;
|
|
21
112
|
|
|
22
113
|
function element(
|
|
@@ -98,6 +189,8 @@ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: st
|
|
|
98
189
|
];
|
|
99
190
|
|
|
100
191
|
export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
192
|
+
diag(`setup: app=${ctx.app?.version ?? "?"}; location=${ctx.location?.directory ?? "?"}`);
|
|
193
|
+
|
|
101
194
|
const badge = () => {
|
|
102
195
|
const formatted = formatBadge();
|
|
103
196
|
return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
|
|
@@ -164,8 +257,29 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
164
257
|
},
|
|
165
258
|
});
|
|
166
259
|
|
|
167
|
-
|
|
168
|
-
|
|
260
|
+
const registerBadgeSlot = (slot: NonNullable<Parameters<TuiContext["ui"]["slot"]>[0]["append"]>) => {
|
|
261
|
+
ctx.ui.slot({
|
|
262
|
+
append: slot,
|
|
263
|
+
render: () => {
|
|
264
|
+
try {
|
|
265
|
+
const element = badge();
|
|
266
|
+
if (!diagOkSlots.has(slot)) {
|
|
267
|
+
diagOkSlots.add(slot);
|
|
268
|
+
diagFailureCount.delete(slot);
|
|
269
|
+
diag(`render ${slot} ok`);
|
|
270
|
+
}
|
|
271
|
+
return element;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
diagOkSlots.delete(slot);
|
|
274
|
+
diagFailure(slot, error);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
registerBadgeSlot("home.footer.status");
|
|
281
|
+
registerBadgeSlot("prompt.footer.status");
|
|
282
|
+
diagRender("probe-after-setup");
|
|
169
283
|
|
|
170
284
|
return () => {
|
|
171
285
|
// Slot claims and keymap layers are disposed automatically on unload.
|
package/test/test.mts
CHANGED
|
@@ -114,7 +114,7 @@ import { audit } from "../src/lib/audit.ts";
|
|
|
114
114
|
import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
115
115
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
116
116
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
117
|
-
import { WorkflowGuardTui, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
117
|
+
import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
118
118
|
import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
|
|
119
119
|
import { effectiveTodosWithOwner } from "../src/policies/todo.ts";
|
|
120
120
|
|
|
@@ -1345,6 +1345,32 @@ check("recovery checkpoint project option refuses symlink escape", tuiSymlinkRej
|
|
|
1345
1345
|
rmSync(outsideTuiOptions, { recursive: true, force: true });
|
|
1346
1346
|
rmSync(tuiOptionsDir, { recursive: true, force: true });
|
|
1347
1347
|
|
|
1348
|
+
// V2 TUI companion degrades gracefully when slot renders fail (bounded diagnostics)
|
|
1349
|
+
const v2SlotClaims: Array<{ append: string; render: () => unknown }> = [];
|
|
1350
|
+
const fakeV2TuiCtx = {
|
|
1351
|
+
app: { version: "9.9.9-test", channel: "test" },
|
|
1352
|
+
location: { directory: tuiCommandOptionsDir },
|
|
1353
|
+
theme: { text: { feedback: { success: { base: "#00ff00" } } } },
|
|
1354
|
+
ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
|
|
1355
|
+
};
|
|
1356
|
+
await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
|
|
1357
|
+
check("v2 tui companion registers home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
|
|
1358
|
+
const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
|
|
1359
|
+
const diagLogText = () => readFileSync(diagLogPath, "utf8");
|
|
1360
|
+
check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
|
|
1361
|
+
const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
|
|
1362
|
+
// In a plain Node process there is no OpenTUI renderer, so badge() throws the
|
|
1363
|
+
// same "No renderer found" the long-running-session bug produced in the TUI.
|
|
1364
|
+
check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
|
|
1365
|
+
check("v2 slot failure log records probes and stack", diagLogText().includes("render home.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
|
|
1366
|
+
for (let i = 0; i < 6; i++) v2HomeRender();
|
|
1367
|
+
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
|
|
1368
|
+
check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
|
|
1369
|
+
check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
|
|
1370
|
+
const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1371
|
+
check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
|
|
1372
|
+
|
|
1373
|
+
|
|
1348
1374
|
// ── Adversarial tests & hardened invariants ──
|
|
1349
1375
|
console.log("- Adversarial tests & hardened invariants -");
|
|
1350
1376
|
|