@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,91 @@
|
|
|
1
|
+
// free-text-redactor.js — content-based (value) secret masking for user-visible text.
|
|
2
|
+
//
|
|
3
|
+
// The kernel's redactValue (text-json.js) combines key-based redaction for sensitive object
|
|
4
|
+
// keys with this value-based scanner for prose strings. Domain workflows that can discover
|
|
5
|
+
// secrets still run their own in-guest masking before returning report envelopes, so raw
|
|
6
|
+
// values are removed before both durable persistence and operator display.
|
|
7
|
+
//
|
|
8
|
+
// This module is the SHARED, pure, idempotent free-text masker applied at the user-visible
|
|
9
|
+
// DISPLAY boundaries (salvage preview snippet, lane taskSummary/title/errorSummary
|
|
10
|
+
// derivation, compact status text, and notification toast text) BEFORE truncation. It masks
|
|
11
|
+
// common credential-like VALUES embedded in prose so a model that pasted a raw token into
|
|
12
|
+
// assistant text, a lane label, or an error summary cannot leak through a preview/status/toast.
|
|
13
|
+
//
|
|
14
|
+
// Scope note: raw transcripts remain local-sensitive by design; durable controller artifacts
|
|
15
|
+
// and display projections use this scanner through redactValue/redactDurableValue. Approval,
|
|
16
|
+
// source, and diff-plan HASHES are computed from raw approved material and are never fed masked
|
|
17
|
+
// text, so this masking cannot perturb a hash.
|
|
18
|
+
//
|
|
19
|
+
// All regexes are linear (no nested/overlapping quantifiers) to avoid catastrophic
|
|
20
|
+
// backtracking. The fixed placeholder [REDACTED:secret] is deliberately composed of characters
|
|
21
|
+
// ([ ] : and the word "secret"/"redacted") that are NOT present in any secret-value character
|
|
22
|
+
// class below, so a second pass finds nothing to match — the function is idempotent by
|
|
23
|
+
// construction (redact(redact(x)) === redact(x)).
|
|
24
|
+
|
|
25
|
+
// Fixed, non-reversible replacement. The brackets/colons keep it out of every value char class.
|
|
26
|
+
const PLACEHOLDER = "[REDACTED:secret]";
|
|
27
|
+
|
|
28
|
+
// Well-known credential SHAPES (whole-token match → mask the entire token).
|
|
29
|
+
//
|
|
30
|
+
// AWS access key id: AKIA + 16+ uppercase alphanumerics (real keys are exactly 20 chars).
|
|
31
|
+
const AWS_ACCESS_KEY_RE = /\bAKIA[0-9A-Z]{16,}\b/g;
|
|
32
|
+
// Provider tokens with a known prefix, allowing internal hyphen-separated segments so the
|
|
33
|
+
// OpenAI `sk-proj-<long>` shape is matched (the prior in-guest regex missed embedded hyphens).
|
|
34
|
+
// Segment form `(?:[A-Za-z0-9]+-)*[A-Za-z0-9]{8,}` is unambiguous (hyphen is the sole
|
|
35
|
+
// delimiter and is not in the segment class) → linear, no catastrophic backtracking.
|
|
36
|
+
const PROVIDER_TOKEN_RE = /\b(sk|pk|ghp|gho|github_pat|xox[baprs])[-_](?:[A-Za-z0-9]+[-_])*[A-Za-z0-9]{8,}/g;
|
|
37
|
+
// PEM private key blocks (lazy up to the matching END marker, or EOF for truncated blocks).
|
|
38
|
+
const PEM_KEY_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
|
|
39
|
+
|
|
40
|
+
// `Bearer <token>` / `Authorization: Bearer <token>` — mask only the token portion, keeping the
|
|
41
|
+
// keyword for readability. Capture group 1 is the "bearer " prefix; the trailing value run is the
|
|
42
|
+
// token. Value class excludes whitespace/brackets so it cannot run past the token.
|
|
43
|
+
const BEARER_RE = /(\bbearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
44
|
+
// HTTP Basic credentials are base64-ish and commonly appear in diagnostics/log errors.
|
|
45
|
+
const BASIC_RE = /(\bbasic\s+)[A-Za-z0-9+/=]{8,}/gi;
|
|
46
|
+
|
|
47
|
+
// Generic key/value ASSIGNMENTS in env-ish, colon-ish, and JSON-ish contexts:
|
|
48
|
+
// secret=value api_key: value "token":"value" password = "value"
|
|
49
|
+
// Anchored on the key NAME (so common prose words are not redacted unless they appear as an
|
|
50
|
+
// assignment key), with an optional quote between key and separator to absorb JSON's `"key":`.
|
|
51
|
+
// Only the VALUE (group 2) is masked; the key + separator are preserved so the surrounding text
|
|
52
|
+
// stays readable. The value minimum (8 chars) avoids masking short common words.
|
|
53
|
+
const ASSIGN_RE = /\b(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|secret[_-]?key|secret|password|passwd|auth[_-]?token|authorization|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?access[_-]?key[_-]?id|client[_-]?secret|private[_-]?key|token)\b["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
54
|
+
|
|
55
|
+
function maskWhole() {
|
|
56
|
+
return PLACEHOLDER;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function maskValueOnly(match, key, value) {
|
|
60
|
+
// `key` is capture group 1 (the key name); `value` is capture group 2 (the secret).
|
|
61
|
+
// Preserve everything before the value (key + separator + optional quotes); mask only the
|
|
62
|
+
// value run so the surrounding assignment stays readable.
|
|
63
|
+
return match.slice(0, match.length - value.length) + PLACEHOLDER;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function maskBearer(match, prefix) {
|
|
67
|
+
return prefix + PLACEHOLDER;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Mask common credential-like values embedded in free text. Pure and idempotent.
|
|
71
|
+
//
|
|
72
|
+
// @param {string} text - the source text to mask.
|
|
73
|
+
// @param {object} [options] - reserved for future use (accepted but currently ignored; the
|
|
74
|
+
// placeholder is fixed so the idempotency guarantee always holds).
|
|
75
|
+
// @returns {string} the text with detected secrets replaced by [REDACTED:secret].
|
|
76
|
+
export function redactFreeTextSecrets(text, options) {
|
|
77
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
78
|
+
// `options` is intentionally accepted but not consumed: the placeholder is fixed to preserve
|
|
79
|
+
// the idempotency guarantee, so no knob may change it. Kept on the signature per contract.
|
|
80
|
+
void options;
|
|
81
|
+
let out = text;
|
|
82
|
+
out = out.replace(PROVIDER_TOKEN_RE, maskWhole);
|
|
83
|
+
out = out.replace(AWS_ACCESS_KEY_RE, maskWhole);
|
|
84
|
+
out = out.replace(PEM_KEY_RE, maskWhole);
|
|
85
|
+
out = out.replace(BEARER_RE, maskBearer);
|
|
86
|
+
out = out.replace(BASIC_RE, maskBearer);
|
|
87
|
+
out = out.replace(ASSIGN_RE, maskValueOnly);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { PLACEHOLDER as REDACTED_PLACEHOLDER };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Live-gate result shape constructors. These pure helpers build the canonical
|
|
2
|
+
// `{ state, verified, evidence, evidenceStrength? }` gate objects consumed by the
|
|
3
|
+
// CapabilityAdapter / liveGateReport orchestrator and the live-gate probes. Kept in a
|
|
4
|
+
// focused leaf module (no probe or adapter dependencies) so both the adapter and the
|
|
5
|
+
// probe functions can share them without an import cycle.
|
|
6
|
+
import { MAX_STATUS_STRING_CHARS } from "./constants.js";
|
|
7
|
+
import { extractTextFromError, truncateText } from "./text-json.js";
|
|
8
|
+
import {
|
|
9
|
+
WorkflowCancelledError,
|
|
10
|
+
WorkflowProbeStructuralError,
|
|
11
|
+
WorkflowTimeoutError,
|
|
12
|
+
} from "./errors.js";
|
|
13
|
+
|
|
14
|
+
function gateBlocked(evidence) {
|
|
15
|
+
return { state: "blocked", verified: false, evidence };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gateAvailableUnverified(evidence) {
|
|
19
|
+
return { state: "available-unverified", verified: false, evidence };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Evidence contract for verified gates: `evidenceStrength` distinguishes directly-observed
|
|
23
|
+
// target behavior ("observed", the default) from a compatibility fallback that verifies only
|
|
24
|
+
// via retained deny rules plus an absent tool attempt ("no-attempt-fallback"). The latter is
|
|
25
|
+
// not equivalent to an observed denial and must be explicitly accepted before release-
|
|
26
|
+
// readiness messaging treats it as enforcement proof.
|
|
27
|
+
function gateVerified(evidence, evidenceStrength = "observed") {
|
|
28
|
+
return { state: "verified", verified: true, evidence, evidenceStrength };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function gateFailed(evidence) {
|
|
32
|
+
return { state: "failed-with-evidence", verified: false, evidence };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function forcedGate(value) {
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
const verified = value.verified === true;
|
|
38
|
+
const result = {
|
|
39
|
+
state: value.state || (verified ? "verified" : "available-unverified"),
|
|
40
|
+
verified,
|
|
41
|
+
evidence: value.evidence || "forced test input",
|
|
42
|
+
};
|
|
43
|
+
if (verified) result.evidenceStrength = value.evidenceStrength ?? "observed";
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
if (value === "verified" || value === "passed") return gateVerified("forced test input");
|
|
47
|
+
if (typeof value === "string") return { state: value, verified: false, evidence: "forced test input" };
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function shapeGate(forcedValue, available, evidence) {
|
|
52
|
+
return forcedGate(forcedValue) ?? (available ? gateAvailableUnverified(evidence) : gateBlocked(evidence));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A transport/structural probe failure is NOT denial evidence: the probe could not
|
|
56
|
+
// be run, so it must never verify the gate. We discriminate these by typed error
|
|
57
|
+
// (code/instanceof) BEFORE the denial-text regex, because the regex also matches
|
|
58
|
+
// the probe's own label text (e.g. a "denied-bash probe ... timed out" message),
|
|
59
|
+
// which would otherwise silently escalate authority on latency / API-shape anomalies.
|
|
60
|
+
function transportFailureGate(error, label) {
|
|
61
|
+
const evidence = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
|
|
62
|
+
if (error instanceof WorkflowTimeoutError || error?.code === "WORKFLOW_TIMEOUT") {
|
|
63
|
+
return gateFailed(`${label} could not be verified: probe timed out before observing enforcement: ${evidence}`);
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof WorkflowCancelledError || error?.code === "WORKFLOW_CANCELLED") {
|
|
66
|
+
return gateBlocked(`${label} could not be verified: probe was cancelled before observing enforcement: ${evidence}`);
|
|
67
|
+
}
|
|
68
|
+
if (error instanceof WorkflowProbeStructuralError || error?.code === "WORKFLOW_PROBE_STRUCTURAL") {
|
|
69
|
+
return gateBlocked(`${label} could not be verified: ${evidence}`);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
gateBlocked,
|
|
76
|
+
gateAvailableUnverified,
|
|
77
|
+
gateVerified,
|
|
78
|
+
gateFailed,
|
|
79
|
+
forcedGate,
|
|
80
|
+
shapeGate,
|
|
81
|
+
transportFailureGate,
|
|
82
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { DEFAULT_SUBPROCESS_MAX_BUFFER, DEFAULT_SUBPROCESS_TIMEOUT_MS } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_GIT_TIMEOUT_MS = DEFAULT_SUBPROCESS_TIMEOUT_MS;
|
|
8
|
+
export const DEFAULT_GIT_MAX_BUFFER = DEFAULT_SUBPROCESS_MAX_BUFFER;
|
|
9
|
+
|
|
10
|
+
async function execGit(directory, args, options = {}) {
|
|
11
|
+
return await execFileAsync("git", args, {
|
|
12
|
+
cwd: directory,
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
timeout: DEFAULT_GIT_TIMEOUT_MS,
|
|
15
|
+
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
|
|
16
|
+
...options,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function git(directory, args, options = {}) {
|
|
21
|
+
try {
|
|
22
|
+
return await execGit(directory, args, options);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const detail = error.stderr || error.stdout || error.message || String(error);
|
|
25
|
+
throw new Error(`git ${args.join(" ")} failed in ${directory}: ${detail.trim()}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function gitSucceeds(directory, args, options = {}) {
|
|
30
|
+
try {
|
|
31
|
+
await execGit(directory, args, options);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function gitCapture(directory, args, options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await execGit(directory, args, options);
|
|
41
|
+
return { ok: true, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { ok: false, stdout: error.stdout ?? "", stderr: error.stderr ?? "", message: error.message ?? String(error) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Kernel barrel. Real modules own their symbols; the workflow-plugin orchestrator
|
|
2
|
+
// exposes only its core (sandbox/run/child/apply/git/lane) symbols.
|
|
3
|
+
import WorkflowPlugin from "./workflow-plugin.js";
|
|
4
|
+
import * as kernel from "./index.js";
|
|
5
|
+
export { default } from "./workflow-plugin.js";
|
|
6
|
+
export {
|
|
7
|
+
acquireAgentSlot,
|
|
8
|
+
addEditPlanFromResult,
|
|
9
|
+
applyWorkflow,
|
|
10
|
+
approvalSummary,
|
|
11
|
+
assertGitCleanAtBase,
|
|
12
|
+
cleanupWorktrees,
|
|
13
|
+
configureWorkflowEntrypoints,
|
|
14
|
+
createEditWorktree,
|
|
15
|
+
executeSandbox,
|
|
16
|
+
gitHead,
|
|
17
|
+
gitOutput,
|
|
18
|
+
gitPathTracked,
|
|
19
|
+
isRunAborted,
|
|
20
|
+
normalizePatches,
|
|
21
|
+
planWorkflowEnvelope,
|
|
22
|
+
releaseAgentSlot,
|
|
23
|
+
rollbackPatches,
|
|
24
|
+
runChildAgent,
|
|
25
|
+
runNestedWorkflow,
|
|
26
|
+
runWorkflowExecution,
|
|
27
|
+
salvageRun,
|
|
28
|
+
startWorkflow,
|
|
29
|
+
throwIfAborted,
|
|
30
|
+
validatePatchTargets,
|
|
31
|
+
} from "./workflow-plugin.js";
|
|
32
|
+
export * from "./approval-hashing.js";
|
|
33
|
+
export * from "./async-util.js";
|
|
34
|
+
export * from "./audited-shell-policy.js";
|
|
35
|
+
export * from "./authority-policy.js";
|
|
36
|
+
export * from "./budget-accounting.js";
|
|
37
|
+
export * from "./capability-adapter.js";
|
|
38
|
+
export * from "./child-agent-runner.js";
|
|
39
|
+
export * from "./constants.js";
|
|
40
|
+
export * from "./drain-runtime.js";
|
|
41
|
+
export * from "./errors.js";
|
|
42
|
+
export * from "./event-journal.js";
|
|
43
|
+
export * from "./extension-registry.js";
|
|
44
|
+
export * from "./git-util.js";
|
|
45
|
+
export * from "./integration-mode.js";
|
|
46
|
+
export * from "./lifecycle-control.js";
|
|
47
|
+
export * from "./lane-effort-policy.js";
|
|
48
|
+
export * from "./notification-toast.js";
|
|
49
|
+
export * from "./notification-toast-cards.js";
|
|
50
|
+
export * from "./notification-toast-policy.js";
|
|
51
|
+
export * from "./notification-toast-scope.js";
|
|
52
|
+
export * from "./path-policy.js";
|
|
53
|
+
export * from "./role-template-loading.js";
|
|
54
|
+
export * from "./result-readback.js";
|
|
55
|
+
export * from "./run-context.js";
|
|
56
|
+
export * from "./run-observability.js";
|
|
57
|
+
export * from "./run-store-status.js";
|
|
58
|
+
export * from "./sandbox-executor.js";
|
|
59
|
+
export * from "./session-access.js";
|
|
60
|
+
export * from "./structured-output.js";
|
|
61
|
+
export * from "./test-fix-drain-adapter.js";
|
|
62
|
+
export * from "./text-json.js";
|
|
63
|
+
export * from "./workflow-source.js";
|
|
64
|
+
export * from "./worktree-adapter.js";
|
|
65
|
+
|
|
66
|
+
// Test-only surface. The orchestrator's WorkflowPlugin.__test carries only its core
|
|
67
|
+
// (sandbox/run/child/apply/git/lane) symbols; the extracted modules own everything else.
|
|
68
|
+
// Aggregate the kernel barrel (this module's own namespace) onto __test so test suites
|
|
69
|
+
// reach module internals via this barrel's default export without importing the entry
|
|
70
|
+
// (opencode-workflows.js). The orchestrator's own __test wins on conflict. Not part of the runtime
|
|
71
|
+
// plugin contract.
|
|
72
|
+
WorkflowPlugin.__test = { ...kernel, ...WorkflowPlugin.__test };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { git } from "./git-util.js";
|
|
4
|
+
import { protectedPathReason } from "./path-policy.js";
|
|
5
|
+
|
|
6
|
+
function normalizeRelativePath(filePath) {
|
|
7
|
+
const normalized = String(filePath ?? "").replace(/\\/g, "/");
|
|
8
|
+
if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
9
|
+
throw new Error(`Unsafe integration path: ${filePath}`);
|
|
10
|
+
}
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function changedPathsSinceBase(directory, baseCommit, options = {}) {
|
|
15
|
+
const { stdout } = await git(directory, ["diff", "--name-status", baseCommit, "HEAD", "--"], { signal: options.signal });
|
|
16
|
+
return stdout.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
17
|
+
const [status, ...parts] = line.split(/\t/);
|
|
18
|
+
const filePath = normalizeRelativePath(parts.at(-1));
|
|
19
|
+
return { status, path: filePath, supported: /^[AMTUXB]/.test(status) };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function detectPathConflicts(laneChanges) {
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
const conflicts = [];
|
|
26
|
+
for (const lane of laneChanges) {
|
|
27
|
+
for (const change of lane.paths ?? []) {
|
|
28
|
+
const filePath = normalizeRelativePath(typeof change === "string" ? change : change.path);
|
|
29
|
+
const previous = seen.get(filePath);
|
|
30
|
+
if (previous && previous.callId !== lane.callId) {
|
|
31
|
+
conflicts.push({ path: filePath, lanes: [previous.callId, lane.callId] });
|
|
32
|
+
} else {
|
|
33
|
+
seen.set(filePath, { callId: lane.callId });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return conflicts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function buildPatchesFromIntegration({ integrationPath, baseCommit, paths, secretGlobs, signal }) {
|
|
41
|
+
const changes = paths?.length ? paths : await changedPathsSinceBase(integrationPath, baseCommit, { signal });
|
|
42
|
+
const patches = [];
|
|
43
|
+
const unsupported = [];
|
|
44
|
+
for (const change of changes) {
|
|
45
|
+
const filePath = normalizeRelativePath(typeof change === "string" ? change : change.path);
|
|
46
|
+
const status = typeof change === "string" ? "M" : change.status;
|
|
47
|
+
if (status && !/^[AMTUXB]/.test(status)) {
|
|
48
|
+
unsupported.push({ path: filePath, status, reason: "unsupported-change-kind" });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const protectedReason = protectedPathReason(filePath, { secretGlobs });
|
|
52
|
+
if (protectedReason) {
|
|
53
|
+
unsupported.push({ path: filePath, status, reason: protectedReason });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const absolute = path.join(integrationPath, filePath);
|
|
57
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
58
|
+
if (!stat || !stat.isFile()) {
|
|
59
|
+
unsupported.push({ path: filePath, status, reason: stat?.isSymbolicLink() ? "symlink" : "not-regular-file" });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let content;
|
|
63
|
+
try {
|
|
64
|
+
content = await fs.readFile(absolute, "utf8");
|
|
65
|
+
} catch {
|
|
66
|
+
unsupported.push({ path: filePath, status, reason: "non-utf8-or-unreadable" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
patches.push({ path: filePath, content, mode: "replace" });
|
|
70
|
+
}
|
|
71
|
+
return { patches, unsupported };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeIntegrationValidationResult(result) {
|
|
75
|
+
if (result === true) return { accepted: true, status: "passed" };
|
|
76
|
+
if (result === false) return { accepted: false, status: "failed", reason: "integration validation rejected" };
|
|
77
|
+
if (!result || typeof result !== "object") return { accepted: false, status: "failed", reason: "integration validation returned no explicit result" };
|
|
78
|
+
const status = typeof result.status === "string" ? result.status : undefined;
|
|
79
|
+
const accepted = result.accepted === true
|
|
80
|
+
|| result.ok === true
|
|
81
|
+
|| ["ok", "pass", "passed", "success", "validated"].includes(status);
|
|
82
|
+
return {
|
|
83
|
+
...result,
|
|
84
|
+
accepted,
|
|
85
|
+
status: status ?? (accepted ? "passed" : "failed"),
|
|
86
|
+
reason: accepted ? result.reason : (result.reason ?? result.error ?? "integration validation rejected"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function integrateLaneCommits({ adapter, runId, baseCommit, lanes, secretGlobs, signal }) {
|
|
91
|
+
const laneChanges = [];
|
|
92
|
+
for (const lane of lanes) {
|
|
93
|
+
laneChanges.push({ ...lane, paths: lane.paths?.length ? lane.paths : await changedPathsSinceBase(lane.path, baseCommit, { signal }) });
|
|
94
|
+
}
|
|
95
|
+
const unsupportedLane = laneChanges.find((lane) => lane.paths.some((change) => {
|
|
96
|
+
const filePath = typeof change === "string" ? change : change.path;
|
|
97
|
+
return change.supported === false || protectedPathReason(filePath, { secretGlobs });
|
|
98
|
+
}));
|
|
99
|
+
if (unsupportedLane) {
|
|
100
|
+
return { status: "review-required", culpritLane: unsupportedLane.callId, reason: "unsupported-lane-change", lanes: laneChanges };
|
|
101
|
+
}
|
|
102
|
+
const conflicts = detectPathConflicts(laneChanges);
|
|
103
|
+
if (conflicts.length > 0) {
|
|
104
|
+
return { status: "review-required", conflicts, reason: "path-conflict", lanes: laneChanges };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const integration = await adapter.createIntegrationWorktree({ runId, baseRef: baseCommit });
|
|
108
|
+
const mergedLanes = [];
|
|
109
|
+
try {
|
|
110
|
+
for (const lane of laneChanges.sort((a, b) => String(a.callId).localeCompare(String(b.callId)))) {
|
|
111
|
+
try {
|
|
112
|
+
await adapter.merge({ directory: integration.path, ref: lane.branch, message: `merge ${lane.callId}` });
|
|
113
|
+
mergedLanes.push(lane.callId);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { status: "review-required", integrationWorktree: integration, mergedLanes, culpritLane: lane.callId, reason: "merge-failed", error: error.message, lanes: laneChanges };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let validation;
|
|
119
|
+
if (typeof adapter.validateIntegrationWorktree === "function") {
|
|
120
|
+
try {
|
|
121
|
+
validation = normalizeIntegrationValidationResult(await adapter.validateIntegrationWorktree({
|
|
122
|
+
runId,
|
|
123
|
+
directory: integration.path,
|
|
124
|
+
path: integration.path,
|
|
125
|
+
baseCommit,
|
|
126
|
+
lanes: laneChanges,
|
|
127
|
+
mergedLanes,
|
|
128
|
+
signal,
|
|
129
|
+
}));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
validation = { accepted: false, status: "failed", reason: "integration validation threw", error: error.message };
|
|
132
|
+
}
|
|
133
|
+
if (validation.accepted !== true) {
|
|
134
|
+
return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "integration-validation-failed", validation, lanes: laneChanges };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const allChanges = await changedPathsSinceBase(integration.path, baseCommit, { signal });
|
|
138
|
+
const built = await buildPatchesFromIntegration({ integrationPath: integration.path, baseCommit, paths: allChanges, secretGlobs, signal });
|
|
139
|
+
if (built.unsupported.length > 0) {
|
|
140
|
+
return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "unsupported-integration-change", unsupported: built.unsupported, lanes: laneChanges };
|
|
141
|
+
}
|
|
142
|
+
return { status: "awaiting-diff-approval", integrationWorktree: integration, mergedLanes, validation, lanes: laneChanges, patches: built.patches };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "integration-failed", error: error.message, lanes: laneChanges };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
buildPatchesFromIntegration,
|
|
150
|
+
changedPathsSinceBase,
|
|
151
|
+
detectPathConflicts,
|
|
152
|
+
integrateLaneCommits,
|
|
153
|
+
normalizeIntegrationValidationResult,
|
|
154
|
+
normalizeRelativePath,
|
|
155
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { WorkflowAuthorityError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export const LANE_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high"]);
|
|
4
|
+
export const LANE_EFFORT_PROVIDER_OPTIONS = Object.freeze({
|
|
5
|
+
openai: Object.freeze({
|
|
6
|
+
providerOptionsKey: "openai",
|
|
7
|
+
optionKey: "reasoningEffort",
|
|
8
|
+
values: LANE_EFFORT_VALUES,
|
|
9
|
+
}),
|
|
10
|
+
});
|
|
11
|
+
export const LANE_EFFORT_POLICY_MAX = 1000;
|
|
12
|
+
|
|
13
|
+
class BoundedLaneEffortMap extends Map {
|
|
14
|
+
constructor(max = LANE_EFFORT_POLICY_MAX) {
|
|
15
|
+
super();
|
|
16
|
+
this.max = max;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get(key) {
|
|
20
|
+
const value = super.get(key);
|
|
21
|
+
if (value !== undefined && super.delete(key)) super.set(key, value);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
set(key, value) {
|
|
26
|
+
if (super.has(key)) super.delete(key);
|
|
27
|
+
super.set(key, value);
|
|
28
|
+
while (this.size > this.max) {
|
|
29
|
+
const oldest = super.keys().next().value;
|
|
30
|
+
if (oldest === undefined) break;
|
|
31
|
+
super.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const laneEffortPolicies = new BoundedLaneEffortMap();
|
|
38
|
+
|
|
39
|
+
function normalizedID(value) {
|
|
40
|
+
if (typeof value !== "string") return undefined;
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (!trimmed) return undefined;
|
|
43
|
+
return trimmed.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function providerID(value) {
|
|
47
|
+
if (typeof value === "string") return normalizedID(value.split("/")[0]);
|
|
48
|
+
if (!value || typeof value !== "object") return undefined;
|
|
49
|
+
return normalizedID(value.providerID)
|
|
50
|
+
?? normalizedID(value.providerId)
|
|
51
|
+
?? normalizedID(value.id)
|
|
52
|
+
?? normalizedID(value.name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function modelProviderID(model) {
|
|
56
|
+
if (typeof model === "string") return providerID(model);
|
|
57
|
+
if (!model || typeof model !== "object") return undefined;
|
|
58
|
+
return normalizedID(model.providerID)
|
|
59
|
+
?? normalizedID(model.providerId)
|
|
60
|
+
?? providerID(model.provider);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeLaneEffort(value) {
|
|
64
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
65
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
66
|
+
throw new WorkflowAuthorityError(`agent() option effort must be one of ${LANE_EFFORT_VALUES.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
const effort = value.trim().toLowerCase();
|
|
69
|
+
if (!LANE_EFFORT_VALUES.includes(effort)) {
|
|
70
|
+
throw new WorkflowAuthorityError(`Invalid agent() option effort: ${value}. Expected one of ${LANE_EFFORT_VALUES.join(", ")}.`);
|
|
71
|
+
}
|
|
72
|
+
return effort;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function laneEffortPolicyForModel(effort, model) {
|
|
76
|
+
const normalized = normalizeLaneEffort(effort);
|
|
77
|
+
if (!normalized) return undefined;
|
|
78
|
+
const modelProvider = modelProviderID(model);
|
|
79
|
+
const mapping = LANE_EFFORT_PROVIDER_OPTIONS[modelProvider];
|
|
80
|
+
if (!mapping) {
|
|
81
|
+
throw new WorkflowAuthorityError(
|
|
82
|
+
`agent() option effort is currently supported only for OpenAI providers via chat.params; ` +
|
|
83
|
+
`model provider ${modelProvider ? `"${modelProvider}"` : "could not be determined"}.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!mapping.values.includes(normalized)) {
|
|
87
|
+
throw new WorkflowAuthorityError(`Provider ${modelProvider} does not support effort "${normalized}"`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
effort: normalized,
|
|
91
|
+
providerID: modelProvider,
|
|
92
|
+
providerOptionsKey: mapping.providerOptionsKey,
|
|
93
|
+
optionKey: mapping.optionKey,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerLaneEffort(childID, policy) {
|
|
98
|
+
if (!childID || !policy) return;
|
|
99
|
+
laneEffortPolicies.set(String(childID), { ...policy });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearLaneEffort(childID) {
|
|
103
|
+
if (!childID) return false;
|
|
104
|
+
return laneEffortPolicies.delete(String(childID));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function laneEffortPolicyForChild(childID) {
|
|
108
|
+
return childID ? laneEffortPolicies.get(String(childID)) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function applyLaneEffortParams(input = {}, output = {}) {
|
|
112
|
+
const policy = laneEffortPolicyForChild(input.sessionID);
|
|
113
|
+
if (!policy) return false;
|
|
114
|
+
|
|
115
|
+
const existingOptions = output.options && typeof output.options === "object" ? output.options : {};
|
|
116
|
+
const existingProviderOptions = existingOptions.providerOptions && typeof existingOptions.providerOptions === "object"
|
|
117
|
+
? existingOptions.providerOptions
|
|
118
|
+
: {};
|
|
119
|
+
const existingProvider = existingProviderOptions[policy.providerOptionsKey] && typeof existingProviderOptions[policy.providerOptionsKey] === "object"
|
|
120
|
+
? existingProviderOptions[policy.providerOptionsKey]
|
|
121
|
+
: {};
|
|
122
|
+
|
|
123
|
+
output.options = {
|
|
124
|
+
...existingOptions,
|
|
125
|
+
providerOptions: {
|
|
126
|
+
...existingProviderOptions,
|
|
127
|
+
[policy.providerOptionsKey]: {
|
|
128
|
+
...existingProvider,
|
|
129
|
+
[policy.optionKey]: policy.effort,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return true;
|
|
134
|
+
}
|