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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.13.4",
3
+ "version": "1.13.5",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
168
- ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
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