@mcrescenzo/opencode-workflows 0.1.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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_SECRET_GLOBS } from "./path-policy.js";
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_DIR = path.resolve(import.meta.dirname, "..");
|
|
8
|
+
|
|
9
|
+
// Detect the in-monorepo copy STRUCTURALLY: it lives at <root>/plugins/opencode-workflows/. Standalone
|
|
10
|
+
// it lives under <project>/node_modules/@mcrescenzo/opencode-workflows, whose parent dir is the npm
|
|
11
|
+
// scope ("@mcrescenzo"), never "plugins" — so this can't false-match a user project that merely has
|
|
12
|
+
// opencode.json + a workflows/ dir (the [Major] false-positive the advisor flagged).
|
|
13
|
+
export function detectLegacyConfigDir(startDir = PLUGIN_DIR) {
|
|
14
|
+
const pluginsDir = path.dirname(path.resolve(startDir)); // candidate <root>/plugins
|
|
15
|
+
if (path.basename(pluginsDir) !== "plugins") return null;
|
|
16
|
+
const root = path.dirname(pluginsDir); // candidate <root>
|
|
17
|
+
if (existsSync(path.join(root, "opencode.json")) && existsSync(path.join(root, "workflows"))) return root;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// opencode's config dir (where opencode.json lives). Order: explicit OPENCODE_CONFIG_DIR →
|
|
22
|
+
// $XDG_CONFIG_HOME/opencode → <home>/.config/opencode. PluginInput exposes no config dir, and
|
|
23
|
+
// (verified on the opencode 1.17.11 runtime) no OPENCODE_CONFIG* env var is set, so we derive it.
|
|
24
|
+
// Cross-platform via os.homedir()/path.join; the env override is the escape hatch for any platform
|
|
25
|
+
// whose opencode config dir is not <home>/.config/opencode (e.g. Windows %APPDATA%).
|
|
26
|
+
export function resolveOpencodeConfigDir(env = process.env, home = os.homedir()) {
|
|
27
|
+
if (env.OPENCODE_CONFIG_DIR) return path.resolve(env.OPENCODE_CONFIG_DIR);
|
|
28
|
+
const configHome = env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(home, ".config");
|
|
29
|
+
return path.join(configHome, "opencode");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Global/shared workflow dir (user-authored global workflows, runs, materialized roles/templates).
|
|
33
|
+
// Order: explicit env → legacy monorepo (marker-gated) → opencode config dir.
|
|
34
|
+
// User-authored content lives in the CONFIG dir (~/.config/opencode/workflows), not XDG state.
|
|
35
|
+
// ⚠️ To force the config-dir branch even inside the monorepo, move its return above the legacy block.
|
|
36
|
+
export function resolveGlobalWorkflowDir(env = process.env, startDir = PLUGIN_DIR, home = os.homedir()) {
|
|
37
|
+
if (env.OPENCODE_WORKFLOWS_DIR) return path.resolve(env.OPENCODE_WORKFLOWS_DIR);
|
|
38
|
+
const legacy = detectLegacyConfigDir(startDir);
|
|
39
|
+
if (legacy) return path.join(legacy, "workflows");
|
|
40
|
+
return path.join(resolveOpencodeConfigDir(env, home), "workflows");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const GLOBAL_WORKFLOW_DIR = resolveGlobalWorkflowDir();
|
|
44
|
+
export const BUNDLED_WORKFLOW_DIR = path.join(PLUGIN_DIR, "workflows");
|
|
45
|
+
export const BUNDLED_COMMAND_DIR = path.join(PLUGIN_DIR, "commands");
|
|
46
|
+
export const BUNDLED_SKILL_DIR = path.join(PLUGIN_DIR, "skills");
|
|
47
|
+
export const ROLE_DIR = path.join(GLOBAL_WORKFLOW_DIR, "roles");
|
|
48
|
+
export const TEMPLATE_DIR = path.join(GLOBAL_WORKFLOW_DIR, "templates");
|
|
49
|
+
export const MAX_SOURCE_BYTES = 512 * 1024;
|
|
50
|
+
export const MAX_RESULT_BYTES = 256 * 1024;
|
|
51
|
+
export const MAX_INLINE_RESULT_BYTES = 32 * 1024;
|
|
52
|
+
export const MAX_RESULT_READBACK_BYTES = MAX_RESULT_BYTES;
|
|
53
|
+
export const MAX_RESULT_READ_FILE_BYTES = 16 * 1024 * 1024;
|
|
54
|
+
export const OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV = "OPENCODE_WORKFLOWS_DEBUG_CAPTURE";
|
|
55
|
+
export const MAX_DEBUG_CAPTURE_FILE_BYTES = MAX_RESULT_READ_FILE_BYTES;
|
|
56
|
+
export const MAX_EVENT_MESSAGE_CHARS = 4000;
|
|
57
|
+
export const MAX_STATUS_STRING_CHARS = 600;
|
|
58
|
+
export const DEFAULT_WORKFLOW_EVENTS_LIMIT = 100;
|
|
59
|
+
export const MAX_WORKFLOW_EVENTS_LIMIT = 500;
|
|
60
|
+
export const MAX_ARGS_PREVIEW_CHARS = 2000;
|
|
61
|
+
export const MAX_HOST_CALLS = 10000;
|
|
62
|
+
export const MAX_PENDING_JOB_DRAIN_ITERATIONS = 10000;
|
|
63
|
+
export const HOST_CALLS_PER_MAX_AGENT = 1;
|
|
64
|
+
export const HOST_CALL_MARGIN = 1000;
|
|
65
|
+
export const MAX_EVENTS = 200000;
|
|
66
|
+
export const MAX_JOURNAL_RECORDS = 200000;
|
|
67
|
+
export const DEFAULT_MAX_AGENTS = 64;
|
|
68
|
+
export const DEFAULT_HARD_CONCURRENCY_LIMIT = 64;
|
|
69
|
+
export const MAX_CONFIGURABLE_CONCURRENCY_LIMIT = 100000;
|
|
70
|
+
export const HARD_CONCURRENCY_LIMIT_ENV = "OPENCODE_WORKFLOWS_HARD_CONCURRENCY_LIMIT";
|
|
71
|
+
|
|
72
|
+
export function normalizeHardConcurrencyLimit(value, fallback = DEFAULT_HARD_CONCURRENCY_LIMIT) {
|
|
73
|
+
const parsed = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
|
74
|
+
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_CONFIGURABLE_CONCURRENCY_LIMIT) return parsed;
|
|
75
|
+
const fallbackParsed = typeof fallback === "string" && fallback.trim() !== "" ? Number(fallback) : fallback;
|
|
76
|
+
if (Number.isInteger(fallbackParsed) && fallbackParsed >= 1 && fallbackParsed <= MAX_CONFIGURABLE_CONCURRENCY_LIMIT) return fallbackParsed;
|
|
77
|
+
return DEFAULT_HARD_CONCURRENCY_LIMIT;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resolveHardConcurrencyLimit(env = process.env, fallback = DEFAULT_HARD_CONCURRENCY_LIMIT) {
|
|
81
|
+
return normalizeHardConcurrencyLimit(env?.[HARD_CONCURRENCY_LIMIT_ENV], fallback);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Conservative default. Concurrent blocking child prompts (session.prompt) have been
|
|
85
|
+
// observed stalling as an entire wave at high fan-out (12 lanes all timed out at the lane
|
|
86
|
+
// limit with 0 tokens, while single lanes completed). 4 remains the default pending live
|
|
87
|
+
// evidence. The ceiling is a separate operator policy knob: set
|
|
88
|
+
// OPENCODE_WORKFLOWS_HARD_CONCURRENCY_LIMIT or plugin option `hardConcurrencyLimit` to
|
|
89
|
+
// raise/lower the schema/runtime clamp. Larger bursts can amplify provider rate limits or
|
|
90
|
+
// reproduce the 2026-06-22 stall, so use workflow_live_gates({probeConcurrencyCapacity})
|
|
91
|
+
// to characterize a runtime before treating higher values as safe production headroom.
|
|
92
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
93
|
+
export const HARD_CONCURRENCY_LIMIT = resolveHardConcurrencyLimit();
|
|
94
|
+
export const DEFAULT_CONCURRENCY_PROBE_LIMIT = 16;
|
|
95
|
+
export const DEFAULT_RETRY_COUNT = 1;
|
|
96
|
+
export const DEFAULT_CORRECTIVE_RETRY_COUNT = 1;
|
|
97
|
+
export const MAX_CORRECTIVE_RETRY_COUNT = 2;
|
|
98
|
+
export const DEFAULT_CHILD_CREATE_TIMEOUT_MS = 30_000;
|
|
99
|
+
export const DEFAULT_CHILD_PROMPT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
100
|
+
export const MAX_CHILD_PROMPT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
101
|
+
export const DEFAULT_LIVE_PROBE_TIMEOUT_MS = 60_000;
|
|
102
|
+
export const DEFAULT_SUBPROCESS_TIMEOUT_MS = 2 * 60 * 1000;
|
|
103
|
+
export const DEFAULT_SUBPROCESS_MAX_BUFFER = 10 * 1024 * 1024;
|
|
104
|
+
export const DEFAULT_KEEP_RUNS = 30;
|
|
105
|
+
export const DEFAULT_GUEST_DEADLINE_MS = 5_000;
|
|
106
|
+
export const WORKFLOW_TOAST_DURATION_MS = 90_000;
|
|
107
|
+
export const WORKFLOW_PROGRESS_TOAST_INTERVAL_MS = 45_000;
|
|
108
|
+
export const WORKFLOW_PROGRESS_TOAST_FORCE_MS = 75_000;
|
|
109
|
+
export const RUN_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
110
|
+
|
|
111
|
+
export const ACTIVE_STATUSES = new Set(["running", "cancelling", "pausing", "apply-running", "active-unknown", "stale-active"]);
|
|
112
|
+
export const AMBIGUOUS_EDIT_STATUSES = new Set(["awaiting-diff-approval", "apply-running", "review-required", "failed-with-diff-plan"]);
|
|
113
|
+
export const LANE_OUTCOMES = ["success", "failure", "cancelled", "timeout", "budget_stopped"];
|
|
114
|
+
export const DURABLE_STATE_VERSION = 2;
|
|
115
|
+
export const DURABLE_LEDGER_FILES = ["integration-ledger.jsonl", "validation-ledger.jsonl", "domain-ledger.jsonl", "apply-ledger.jsonl"];
|
|
116
|
+
export const SENSITIVE_KEY_RE = /(api[_-]?key|authorization|credential|password|secret|token)/i;
|
|
117
|
+
export const SECRET_GLOBS = DEFAULT_SECRET_GLOBS;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { redactFreeTextSecrets } from "./free-text-redactor.js";
|
|
6
|
+
|
|
7
|
+
const SCHEMA = "opencode.plugin.diagnostic.v1";
|
|
8
|
+
const PLUGIN = "opencode-workflows";
|
|
9
|
+
const LEVELS = new Set(["debug", "info", "warn", "error"]);
|
|
10
|
+
const MAX_STRING = 4_000;
|
|
11
|
+
const MAX_RECORD = 16_000;
|
|
12
|
+
const MAX_DEPTH = 6;
|
|
13
|
+
const MAX_ENTRIES = 100;
|
|
14
|
+
const SECRET_KEY_RE = /(^|_|-|\.)(authorization|cookie|password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|refresh[_-]?token|serverPassword)($|_|-|\.)/i;
|
|
15
|
+
|
|
16
|
+
const EVENT_MAP = new Map([
|
|
17
|
+
["run.started", { level: "info", event: "workflow_run_started", message: "Workflow run started", outcome: "started" }],
|
|
18
|
+
["run.completed", { level: "info", event: "workflow_run_completed", message: "Workflow run completed", outcome: "success" }],
|
|
19
|
+
["run.awaiting_diff_approval", { level: "warn", event: "workflow_run_awaiting_diff_approval", message: "Workflow run awaits diff approval", outcome: "blocked" }],
|
|
20
|
+
["run.failed_with_diff_plan", { level: "warn", event: "workflow_run_failed_with_diff_plan", message: "Workflow run failed with a diff plan for review", outcome: "failure" }],
|
|
21
|
+
["run.apply_failed", { level: "error", event: "workflow_apply_failed", message: "Workflow auto-apply failed", outcome: "failure" }],
|
|
22
|
+
["run.failed", { level: "error", event: "workflow_run_failed", message: "Workflow run failed", outcome: "failure" }],
|
|
23
|
+
["run.cancelled", { level: "warn", event: "workflow_run_cancelled", message: "Workflow run cancelled", outcome: "cancelled" }],
|
|
24
|
+
["run.paused", { level: "warn", event: "workflow_run_paused", message: "Workflow run paused", outcome: "paused" }],
|
|
25
|
+
["agent.failure", { level: "error", event: "workflow_lane_failed", message: "Workflow child lane failed", outcome: "failure" }],
|
|
26
|
+
["agent.timeout", { level: "error", event: "workflow_lane_timed_out", message: "Workflow child lane timed out", outcome: "timeout" }],
|
|
27
|
+
["agent.cancelled", { level: "warn", event: "workflow_lane_cancelled", message: "Workflow child lane cancelled", outcome: "cancelled" }],
|
|
28
|
+
["agent.budget_stopped", { level: "warn", event: "workflow_lane_budget_stopped", message: "Workflow child lane stopped by budget", outcome: "budget_stopped" }],
|
|
29
|
+
["agent.salvageable_dirty_failure", { level: "warn", event: "workflow_lane_salvageable_dirty_failure", message: "Workflow lane failed with dirty salvageable worktree", outcome: "failure" }],
|
|
30
|
+
["cache.checkpoint_write_failed", { level: "warn", event: "workflow_checkpoint_write_failed", message: "Workflow checkpoint write failed", outcome: "degraded" }],
|
|
31
|
+
["fanout.lane_dropped", { level: "warn", event: "workflow_fanout_lane_dropped", message: "Workflow fanout lane was dropped", outcome: "failure" }],
|
|
32
|
+
["fanout.sequential", { level: "info", event: "workflow_fanout_sequential", message: "Workflow fanout intentionally ran sequentially", outcome: "success" }],
|
|
33
|
+
["integration.review_required", { level: "warn", event: "workflow_integration_review_required", message: "Workflow integration requires review", outcome: "blocked" }],
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function redactText(value) {
|
|
37
|
+
if (value === undefined || value === null) return "";
|
|
38
|
+
const text = redactFreeTextSecrets(String(value));
|
|
39
|
+
if (text.length <= MAX_STRING) return text;
|
|
40
|
+
return `${text.slice(0, MAX_STRING)}\n[truncated ${text.length - MAX_STRING} chars]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redactValue(value, seen = new WeakSet(), depth = 0) {
|
|
44
|
+
if (value === null || value === undefined) return value;
|
|
45
|
+
if (typeof value === "string") return redactText(value);
|
|
46
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
47
|
+
if (typeof value === "bigint") return String(value);
|
|
48
|
+
if (typeof value === "function") return "[function]";
|
|
49
|
+
if (typeof value !== "object") return redactText(String(value));
|
|
50
|
+
if (seen.has(value)) return "[circular]";
|
|
51
|
+
if (depth >= MAX_DEPTH) return "[max-depth]";
|
|
52
|
+
seen.add(value);
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
const items = value.slice(0, MAX_ENTRIES).map((item) => redactValue(item, seen, depth + 1));
|
|
55
|
+
if (value.length > MAX_ENTRIES) items.push(`[${value.length - MAX_ENTRIES} more items]`);
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
const out = {};
|
|
59
|
+
let count = 0;
|
|
60
|
+
for (const [key, item] of Object.entries(value)) {
|
|
61
|
+
if (count >= MAX_ENTRIES) {
|
|
62
|
+
out.__truncated_entries = Object.keys(value).length - MAX_ENTRIES;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
out[key] = SECRET_KEY_RE.test(String(key)) ? "[redacted]" : redactValue(item, seen, depth + 1);
|
|
66
|
+
count += 1;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function diagnosticsRoot() {
|
|
72
|
+
if (process.env.OPENCODE_PLUGIN_DIAGNOSTICS_DIR) return path.resolve(process.env.OPENCODE_PLUGIN_DIAGNOSTICS_DIR);
|
|
73
|
+
const base = process.env.XDG_STATE_HOME ? path.resolve(process.env.XDG_STATE_HOME) : path.join(os.homedir(), ".local", "state");
|
|
74
|
+
return path.join(base, "opencode", "plugin-diagnostics");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeName(value, fallback = "project") {
|
|
78
|
+
return (String(value || fallback).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40) || fallback);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function projectFromRunDir(runDir) {
|
|
82
|
+
const marker = `${path.sep}.opencode${path.sep}workflows${path.sep}runs${path.sep}`;
|
|
83
|
+
const index = String(runDir || "").indexOf(marker);
|
|
84
|
+
return index > 0 ? String(runDir).slice(0, index) : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function projectKey(directory) {
|
|
88
|
+
const resolved = path.resolve(directory || process.cwd());
|
|
89
|
+
let canonical = resolved;
|
|
90
|
+
try { canonical = await realpath(resolved); } catch {}
|
|
91
|
+
return `${safeName(path.basename(canonical || resolved))}-${createHash("sha256").update(canonical).digest("hex").slice(0, 16)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function jsonLine(record) {
|
|
95
|
+
let text = JSON.stringify(record);
|
|
96
|
+
if (text.length <= MAX_RECORD) return `${text}\n`;
|
|
97
|
+
text = JSON.stringify({ ...record, data: record.data === undefined ? undefined : "[omitted: record too large]" });
|
|
98
|
+
return `${text}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function summaryData(event) {
|
|
102
|
+
return redactValue({
|
|
103
|
+
type: event.type,
|
|
104
|
+
callId: event.callId,
|
|
105
|
+
childID: event.childID,
|
|
106
|
+
reason: event.reason,
|
|
107
|
+
culpritLane: event.culpritLane,
|
|
108
|
+
conflictCount: event.conflictCount,
|
|
109
|
+
changedFileCount: event.changedFileCount,
|
|
110
|
+
drainStatus: event.drainStatus,
|
|
111
|
+
diffPlanHash: event.diffPlanHash,
|
|
112
|
+
error: event.error,
|
|
113
|
+
kind: event.kind,
|
|
114
|
+
// Lane failure taxonomy (transient / transient_exhausted / terminal) so the diagnostic
|
|
115
|
+
// sink distinguishes a rate-limit/overload lane that exhausted its retries from a terminal
|
|
116
|
+
// one (bad model id, auth, schema). Set on agent.* failure/retry events (jbs3.2).
|
|
117
|
+
failureClass: event.failureClass,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function emitWorkflowDiagnostic(run, event) {
|
|
122
|
+
if (process.env.OPENCODE_PLUGIN_DIAGNOSTICS_DISABLED === "1") return;
|
|
123
|
+
const mapped = EVENT_MAP.get(event?.type);
|
|
124
|
+
if (!mapped) return;
|
|
125
|
+
try {
|
|
126
|
+
const directory = run?.projectDirectory || projectFromRunDir(run?.dir) || process.cwd();
|
|
127
|
+
const dir = path.join(diagnosticsRoot(), await projectKey(directory), PLUGIN);
|
|
128
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
129
|
+
await chmod(dir, 0o700).catch(() => {});
|
|
130
|
+
const record = redactValue({
|
|
131
|
+
schema: SCHEMA,
|
|
132
|
+
ts: new Date().toISOString(),
|
|
133
|
+
plugin: PLUGIN,
|
|
134
|
+
level: LEVELS.has(mapped.level) ? mapped.level : "info",
|
|
135
|
+
event: mapped.event,
|
|
136
|
+
message: mapped.message,
|
|
137
|
+
runID: run?.id,
|
|
138
|
+
childID: event.childID,
|
|
139
|
+
operation: event.type,
|
|
140
|
+
outcome: mapped.outcome,
|
|
141
|
+
error: event.error ? { message: event.error } : undefined,
|
|
142
|
+
data: summaryData(event),
|
|
143
|
+
});
|
|
144
|
+
const file = path.join(dir, `${PLUGIN}-${new Date().toISOString().slice(0, 10)}-${process.pid}.jsonl`);
|
|
145
|
+
await appendFile(file, jsonLine(record), { mode: 0o600 });
|
|
146
|
+
await chmod(file, 0o600).catch(() => {});
|
|
147
|
+
} catch {
|
|
148
|
+
// Diagnostics are best effort and must never affect workflow execution.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const __test = { redactText, redactValue, projectFromRunDir, EVENT_MAP };
|