opencode-workflow-guard 1.8.3 → 1.8.4
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.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"workflow"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node test/test.
|
|
37
|
+
"test": "node test/run-test.mjs",
|
|
38
38
|
"test:install": "node test/test-e2e.mts",
|
|
39
39
|
"test:all": "npm run typecheck && npm test && npm run test:install",
|
|
40
40
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { execFile, spawnSync } from "node:child_process";
|
|
5
5
|
import { Database, type SqliteDatabase } from "./sqlite.ts";
|
|
@@ -7,13 +7,27 @@ import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, Projec
|
|
|
7
7
|
|
|
8
8
|
const KINDS = new Set<ProjectMemoryKind>(["fact", "decision", "constraint", "lesson"]);
|
|
9
9
|
const SOURCES = new Set<ProjectMemorySource>(["user", "file", "git", "tool", "agent", "portable"]);
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const OBSOLETE_RETENTION_MS = 90 * DAY_MS;
|
|
12
|
+
const MAINTENANCE_INTERVAL_MS = DAY_MS;
|
|
13
|
+
const STORAGE_LIMIT_BYTES = 100 * 1024 * 1024;
|
|
14
|
+
const STORAGE_TARGET_BYTES = 80 * 1024 * 1024;
|
|
10
15
|
|
|
11
16
|
export interface ProjectMemoryStore {
|
|
12
17
|
db: SqliteDatabase;
|
|
13
18
|
projectId: string;
|
|
19
|
+
path: string;
|
|
20
|
+
activeMarker?: string;
|
|
14
21
|
close(): void;
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
interface MaintenanceOptions {
|
|
25
|
+
force?: boolean;
|
|
26
|
+
now?: number;
|
|
27
|
+
storageLimitBytes?: number;
|
|
28
|
+
storageTargetBytes?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export function getProjectMemoryDir(): string {
|
|
18
32
|
const base = process.env.XDG_DATA_HOME || join(process.env.HOME || process.cwd(), ".local", "share");
|
|
19
33
|
return join(base, "opencode", "workflow-guard", "project-memory");
|
|
@@ -66,14 +80,20 @@ export async function isProjectMemoryFreshAsync(memory: ProjectMemoryRecord, roo
|
|
|
66
80
|
export function openProjectMemory(projectId: string, directory = getProjectMemoryDir()): ProjectMemoryStore {
|
|
67
81
|
if (!projectId || projectId.length > 200) throw new Error("Invalid project memory identity.");
|
|
68
82
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
|
-
const
|
|
70
|
-
|
|
83
|
+
const path = join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`);
|
|
84
|
+
const activeMarker = `${path}.active.${process.pid}.${randomUUID()}`;
|
|
85
|
+
const releaseLock = acquireDatabaseLock(directory);
|
|
86
|
+
let db: SqliteDatabase | undefined;
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(activeMarker, "", { mode: 0o600 });
|
|
89
|
+
db = new Database(path);
|
|
90
|
+
db.exec(`
|
|
71
91
|
PRAGMA journal_mode = WAL;
|
|
72
92
|
PRAGMA busy_timeout = 2000;
|
|
73
93
|
CREATE TABLE IF NOT EXISTS memories (
|
|
74
94
|
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
|
|
75
95
|
source TEXT NOT NULL, created_at INTEGER NOT NULL, session_id TEXT, commit_sha TEXT,
|
|
76
|
-
paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current'
|
|
96
|
+
paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current', superseded_at INTEGER
|
|
77
97
|
);
|
|
78
98
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content);
|
|
79
99
|
CREATE TABLE IF NOT EXISTS review_followups (
|
|
@@ -81,8 +101,105 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
|
|
|
81
101
|
reviewer TEXT NOT NULL, created_at INTEGER NOT NULL, resolved_at INTEGER, session_id TEXT,
|
|
82
102
|
commit_sha TEXT, paths TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open'
|
|
83
103
|
);
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
`);
|
|
105
|
+
const memoryColumns = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
|
|
106
|
+
if (!memoryColumns.some((column) => column.name === "superseded_at")) {
|
|
107
|
+
db.exec("ALTER TABLE memories ADD COLUMN superseded_at INTEGER");
|
|
108
|
+
db.exec("UPDATE memories SET superseded_at = created_at WHERE status = 'superseded'");
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
try { db?.close(); } finally { rmSync(activeMarker, { force: true }); }
|
|
112
|
+
throw error;
|
|
113
|
+
} finally {
|
|
114
|
+
releaseLock();
|
|
115
|
+
}
|
|
116
|
+
if (!db) throw new Error("Project memory database failed to initialize.");
|
|
117
|
+
const store: ProjectMemoryStore = { db, projectId, path, activeMarker, close: () => { try { db.close(); } finally { rmSync(activeMarker, { force: true }); } } };
|
|
118
|
+
try { maintainProjectMemoryStorage(store, directory); } catch {}
|
|
119
|
+
return store;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function acquireDatabaseLock(directory: string): () => void {
|
|
123
|
+
const lock = new Database(join(directory, ".coordination"));
|
|
124
|
+
try {
|
|
125
|
+
lock.exec("PRAGMA busy_timeout = 2000; BEGIN IMMEDIATE");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
lock.close();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
return () => { try { lock.exec("COMMIT"); } finally { lock.close(); } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function databaseDiskUsage(path: string): number {
|
|
134
|
+
let size = 0;
|
|
135
|
+
for (const suffix of ["", "-wal", "-shm"]) try { size += statSync(path + suffix).size; } catch {}
|
|
136
|
+
return size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function databaseIsActive(path: string, directory: string): boolean {
|
|
140
|
+
const prefix = `${path.slice(directory.length + 1)}.active.`;
|
|
141
|
+
let active = false;
|
|
142
|
+
for (const name of readdirSync(directory).filter((entry) => entry.startsWith(prefix))) {
|
|
143
|
+
const pid = Number(name.slice(prefix.length).split(".", 1)[0]);
|
|
144
|
+
if (!Number.isInteger(pid) || pid <= 0) { active = true; continue; }
|
|
145
|
+
try { process.kill(pid, 0); active = true; } catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(join(directory, name), { force: true });
|
|
147
|
+
else active = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return active;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function maintainProjectMemoryStorage(store: ProjectMemoryStore, directory = getProjectMemoryDir(), options: MaintenanceOptions = {}): void {
|
|
154
|
+
const now = options.now ?? Date.now();
|
|
155
|
+
const maintenanceMarker = `${store.path}.maintenance`;
|
|
156
|
+
const releaseMaintenanceLock = acquireDatabaseLock(directory);
|
|
157
|
+
try {
|
|
158
|
+
let lastMaintained = 0;
|
|
159
|
+
try { lastMaintained = statSync(maintenanceMarker).mtimeMs; } catch {}
|
|
160
|
+
if (!options.force && now - lastMaintained < MAINTENANCE_INTERVAL_MS) return;
|
|
161
|
+
|
|
162
|
+
const cutoff = now - OBSOLETE_RETENTION_MS;
|
|
163
|
+
store.db.exec("BEGIN IMMEDIATE");
|
|
164
|
+
try {
|
|
165
|
+
store.db.prepare("DELETE FROM memories_fts WHERE id IN (SELECT id FROM memories WHERE status = 'superseded' AND superseded_at < ?)").run(cutoff);
|
|
166
|
+
store.db.prepare("DELETE FROM memories WHERE status = 'superseded' AND superseded_at < ?").run(cutoff);
|
|
167
|
+
store.db.prepare("DELETE FROM review_followups WHERE status = 'resolved' AND resolved_at < ?").run(cutoff);
|
|
168
|
+
store.db.exec("COMMIT");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
store.db.exec("ROLLBACK");
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
store.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
174
|
+
store.db.exec("PRAGMA optimize");
|
|
175
|
+
store.db.exec("VACUUM");
|
|
176
|
+
try { utimesSync(store.path, now / 1000, now / 1000); } catch {}
|
|
177
|
+
writeFileSync(maintenanceMarker, "", { mode: 0o600 });
|
|
178
|
+
try { utimesSync(maintenanceMarker, now / 1000, now / 1000); } catch {}
|
|
179
|
+
} finally {
|
|
180
|
+
releaseMaintenanceLock();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const limit = options.storageLimitBytes ?? STORAGE_LIMIT_BYTES;
|
|
184
|
+
const target = Math.min(limit, options.storageTargetBytes ?? STORAGE_TARGET_BYTES);
|
|
185
|
+
const candidates = readdirSync(directory)
|
|
186
|
+
.filter((name) => name.endsWith(".sqlite"))
|
|
187
|
+
.map((name) => join(directory, name))
|
|
188
|
+
.filter((path) => path !== store.path)
|
|
189
|
+
.flatMap((path) => { try { const stat = statSync(path); return [{ path, size: databaseDiskUsage(path), mtimeMs: stat.mtimeMs }]; } catch { return []; } });
|
|
190
|
+
let total = candidates.reduce((sum, item) => sum + item.size, databaseDiskUsage(store.path));
|
|
191
|
+
if (total <= limit) return;
|
|
192
|
+
for (const candidate of candidates.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
|
|
193
|
+
const releaseCandidateLock = acquireDatabaseLock(directory);
|
|
194
|
+
try {
|
|
195
|
+
if (databaseIsActive(candidate.path, directory)) continue;
|
|
196
|
+
for (const suffix of ["", "-wal", "-shm", ".maintenance"]) rmSync(candidate.path + suffix, { force: true });
|
|
197
|
+
} finally {
|
|
198
|
+
releaseCandidateLock();
|
|
199
|
+
}
|
|
200
|
+
total -= candidate.size;
|
|
201
|
+
if (total <= target) break;
|
|
202
|
+
}
|
|
86
203
|
}
|
|
87
204
|
|
|
88
205
|
function followupRowToRecord(row: Record<string, unknown>): ReviewFollowup {
|
|
@@ -147,8 +264,8 @@ export function recordProjectMemory(store: ProjectMemoryStore, input: ProjectMem
|
|
|
147
264
|
const createdAt = Date.now();
|
|
148
265
|
store.db.exec("BEGIN IMMEDIATE");
|
|
149
266
|
try {
|
|
150
|
-
if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded' WHERE id = ? AND project_id = ?").run(input.supersedes, store.projectId);
|
|
151
|
-
store.db.prepare("INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current')").run(
|
|
267
|
+
if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded', superseded_at = ? WHERE id = ? AND project_id = ?").run(createdAt, input.supersedes, store.projectId);
|
|
268
|
+
store.db.prepare("INSERT INTO memories (id, project_id, kind, content, source, created_at, session_id, commit_sha, paths, supersedes, status, superseded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', NULL)").run(
|
|
152
269
|
id, store.projectId, input.kind, input.content.trim(), input.source, createdAt,
|
|
153
270
|
input.sessionID ?? null, input.commit ?? null, JSON.stringify(input.paths ?? []), input.supersedes ?? null,
|
|
154
271
|
);
|
|
@@ -129,7 +129,7 @@ export function escapeAppleScriptString(str: string): string {
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
|
|
132
|
-
*
|
|
132
|
+
* for events that explicitly require the user's attention.
|
|
133
133
|
* Non-blocking, fails gracefully.
|
|
134
134
|
*/
|
|
135
135
|
export function sendDesktopNotification(title: string, message: string): void {
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -31,9 +31,9 @@ function text(props: Record<string, unknown>, children: Child[]) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const BADGE_ACTIVE = "Workflow Guard 🛡️";
|
|
34
|
+
const BLOCKED_BADGE_DURATION_MS = 5_000;
|
|
34
35
|
let lastBlockedReason: string | undefined;
|
|
35
36
|
const sessionBlockedReasons = new Map<string, string>();
|
|
36
|
-
const badgeNodes = new Map<string, BaseRenderable>();
|
|
37
37
|
|
|
38
38
|
export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
|
|
39
39
|
const path = projectConfigPath(root);
|
|
@@ -86,12 +86,15 @@ export function formatBadge(sessionID?: string): { text: string; isBlocked: bool
|
|
|
86
86
|
if (!reason) {
|
|
87
87
|
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
88
88
|
}
|
|
89
|
-
const cleanReason = reason.replace(/^\[workflow-guard\]\s
|
|
89
|
+
const cleanReason = reason.replace(/^\[workflow-guard\]\s*/i, "").replace(/^Blocked(?::|\s+)\s*/i, "");
|
|
90
90
|
const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
|
|
91
91
|
return { text: `[Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
95
|
+
const badgeNodes = new Map<string, BaseRenderable>();
|
|
96
|
+
const blockedBadgeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
97
|
+
|
|
95
98
|
api.keymap.registerLayer({
|
|
96
99
|
commands: [{
|
|
97
100
|
name: "workflow-guard.project-options",
|
|
@@ -125,18 +128,34 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
125
128
|
bindings: [],
|
|
126
129
|
});
|
|
127
130
|
|
|
131
|
+
function scheduleBadgeReset(sessionID?: string): void {
|
|
132
|
+
const key = sessionID ?? "home";
|
|
133
|
+
const existing = blockedBadgeTimers.get(key);
|
|
134
|
+
if (existing) clearTimeout(existing);
|
|
135
|
+
blockedBadgeTimers.set(key, setTimeout(() => {
|
|
136
|
+
blockedBadgeTimers.delete(key);
|
|
137
|
+
if (sessionID) sessionBlockedReasons.delete(sessionID);
|
|
138
|
+
else lastBlockedReason = undefined;
|
|
139
|
+
const node = badgeNodes.get(key);
|
|
140
|
+
if (node) {
|
|
141
|
+
setProp(node, "content", BADGE_ACTIVE);
|
|
142
|
+
setProp(node, "fg", api.theme.current.success);
|
|
143
|
+
}
|
|
144
|
+
}, BLOCKED_BADGE_DURATION_MS));
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
try {
|
|
129
148
|
api.event.on("tui.toast.show", (e) => {
|
|
130
149
|
const title = e.properties.title;
|
|
131
150
|
const msg = e.properties.message;
|
|
132
151
|
if (title === "Workflow Guard Blocked" && typeof msg === "string") {
|
|
133
|
-
lastBlockedReason = msg;
|
|
134
152
|
const route = api.route.current;
|
|
135
153
|
const sessionID =
|
|
136
154
|
route.name === "session" && typeof route.params?.sessionID === "string"
|
|
137
155
|
? route.params.sessionID
|
|
138
156
|
: undefined;
|
|
139
157
|
if (sessionID) sessionBlockedReasons.set(sessionID, msg);
|
|
158
|
+
else lastBlockedReason = msg;
|
|
140
159
|
const key = sessionID ?? "home";
|
|
141
160
|
const node = badgeNodes.get(key);
|
|
142
161
|
if (node) {
|
|
@@ -144,6 +163,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
144
163
|
setProp(node, "content", badge.text);
|
|
145
164
|
setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
|
|
146
165
|
}
|
|
166
|
+
scheduleBadgeReset(sessionID);
|
|
147
167
|
}
|
|
148
168
|
});
|
|
149
169
|
} catch {}
|
package/src/workflow-guard.ts
CHANGED
|
@@ -122,6 +122,7 @@ export {
|
|
|
122
122
|
ensureProjectMemoryExcluded,
|
|
123
123
|
isProjectMemoryFresh,
|
|
124
124
|
openProjectMemory,
|
|
125
|
+
maintainProjectMemoryStorage,
|
|
125
126
|
recordProjectMemory,
|
|
126
127
|
searchProjectMemory,
|
|
127
128
|
getRecentProjectMemory,
|
|
@@ -270,7 +271,6 @@ export { branchHasDocumentationChange };
|
|
|
270
271
|
import {
|
|
271
272
|
checkInteractiveTtyCommand,
|
|
272
273
|
checkPackageHygiene,
|
|
273
|
-
sendDesktopNotification,
|
|
274
274
|
escapeAppleScriptString,
|
|
275
275
|
} from "./policies/shell-safety.ts";
|
|
276
276
|
|
|
@@ -278,7 +278,8 @@ import { checkCompletionClaims } from "./policies/completion.ts";
|
|
|
278
278
|
|
|
279
279
|
export { checkCompletionClaims };
|
|
280
280
|
|
|
281
|
-
export {
|
|
281
|
+
export { sendDesktopNotification } from "./policies/shell-safety.ts";
|
|
282
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
|
|
282
283
|
|
|
283
284
|
function logObservation(client: unknown, message: string): void {
|
|
284
285
|
try {
|
|
@@ -322,9 +323,6 @@ export async function guardToolCall(
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
async function emitBlockFeedback(message: string): Promise<void> {
|
|
325
|
-
try {
|
|
326
|
-
sendDesktopNotification("Workflow Guard Blocked", message);
|
|
327
|
-
} catch {}
|
|
328
326
|
await showBlockToast(message);
|
|
329
327
|
}
|
|
330
328
|
|