patchwork-os 0.2.0-beta.2 → 0.2.0-beta.3

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.
Files changed (135) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +156 -12
  3. package/dist/activityLog.d.ts +6 -0
  4. package/dist/activityLog.js +8 -0
  5. package/dist/activityLog.js.map +1 -1
  6. package/dist/analyticsPrefs.d.ts +35 -2
  7. package/dist/analyticsPrefs.js +120 -21
  8. package/dist/analyticsPrefs.js.map +1 -1
  9. package/dist/analyticsSend.js +5 -1
  10. package/dist/analyticsSend.js.map +1 -1
  11. package/dist/bridge.d.ts +2 -0
  12. package/dist/bridge.js +111 -7
  13. package/dist/bridge.js.map +1 -1
  14. package/dist/bridgeLockDiscovery.d.ts +27 -1
  15. package/dist/bridgeLockDiscovery.js +37 -11
  16. package/dist/bridgeLockDiscovery.js.map +1 -1
  17. package/dist/commands/patchworkInit.d.ts +5 -0
  18. package/dist/commands/patchworkInit.js +86 -7
  19. package/dist/commands/patchworkInit.js.map +1 -1
  20. package/dist/commands/recipe.d.ts +51 -0
  21. package/dist/commands/recipe.js +353 -2
  22. package/dist/commands/recipe.js.map +1 -1
  23. package/dist/commands/recipeInstall.js +6 -3
  24. package/dist/commands/recipeInstall.js.map +1 -1
  25. package/dist/commands/task.js +2 -2
  26. package/dist/commands/task.js.map +1 -1
  27. package/dist/config.d.ts +9 -2
  28. package/dist/config.js +35 -17
  29. package/dist/config.js.map +1 -1
  30. package/dist/connectors/tokenStorage.js +46 -10
  31. package/dist/connectors/tokenStorage.js.map +1 -1
  32. package/dist/featureFlags.d.ts +76 -0
  33. package/dist/featureFlags.js +166 -2
  34. package/dist/featureFlags.js.map +1 -1
  35. package/dist/index.js +765 -69
  36. package/dist/index.js.map +1 -1
  37. package/dist/lockfile.js +4 -1
  38. package/dist/lockfile.js.map +1 -1
  39. package/dist/patchworkConfig.js +5 -0
  40. package/dist/patchworkConfig.js.map +1 -1
  41. package/dist/recipeOrchestration.js +35 -1
  42. package/dist/recipeOrchestration.js.map +1 -1
  43. package/dist/recipeRoutes.d.ts +36 -0
  44. package/dist/recipeRoutes.js +231 -32
  45. package/dist/recipeRoutes.js.map +1 -1
  46. package/dist/recipes/agentExecutor.d.ts +25 -5
  47. package/dist/recipes/agentExecutor.js.map +1 -1
  48. package/dist/recipes/chainedRunner.js +16 -2
  49. package/dist/recipes/chainedRunner.js.map +1 -1
  50. package/dist/recipes/connectorPreflight.d.ts +53 -0
  51. package/dist/recipes/connectorPreflight.js +79 -0
  52. package/dist/recipes/connectorPreflight.js.map +1 -0
  53. package/dist/recipes/githubInstallSource.d.ts +62 -0
  54. package/dist/recipes/githubInstallSource.js +125 -0
  55. package/dist/recipes/githubInstallSource.js.map +1 -0
  56. package/dist/recipes/haltCategory.d.ts +80 -0
  57. package/dist/recipes/haltCategory.js +125 -0
  58. package/dist/recipes/haltCategory.js.map +1 -0
  59. package/dist/recipes/idempotencyKey.d.ts +126 -0
  60. package/dist/recipes/idempotencyKey.js +298 -0
  61. package/dist/recipes/idempotencyKey.js.map +1 -0
  62. package/dist/recipes/judgeSummary.d.ts +50 -0
  63. package/dist/recipes/judgeSummary.js +47 -0
  64. package/dist/recipes/judgeSummary.js.map +1 -0
  65. package/dist/recipes/judgeVerdict.d.ts +48 -0
  66. package/dist/recipes/judgeVerdict.js +174 -0
  67. package/dist/recipes/judgeVerdict.js.map +1 -0
  68. package/dist/recipes/migrations/index.d.ts +9 -0
  69. package/dist/recipes/migrations/index.js +133 -0
  70. package/dist/recipes/migrations/index.js.map +1 -1
  71. package/dist/recipes/runBudget.d.ts +70 -0
  72. package/dist/recipes/runBudget.js +109 -0
  73. package/dist/recipes/runBudget.js.map +1 -0
  74. package/dist/recipes/scheduler.js +1 -1
  75. package/dist/recipes/scheduler.js.map +1 -1
  76. package/dist/recipes/schema.d.ts +30 -0
  77. package/dist/recipes/toolRegistry.js +19 -0
  78. package/dist/recipes/toolRegistry.js.map +1 -1
  79. package/dist/recipes/tools/http.d.ts +10 -0
  80. package/dist/recipes/tools/http.js +176 -0
  81. package/dist/recipes/tools/http.js.map +1 -0
  82. package/dist/recipes/tools/index.d.ts +1 -0
  83. package/dist/recipes/tools/index.js +1 -0
  84. package/dist/recipes/tools/index.js.map +1 -1
  85. package/dist/recipes/validation.js +1 -1
  86. package/dist/recipes/validation.js.map +1 -1
  87. package/dist/recipes/yamlRunner.d.ts +71 -7
  88. package/dist/recipes/yamlRunner.js +156 -22
  89. package/dist/recipes/yamlRunner.js.map +1 -1
  90. package/dist/runLog.d.ts +28 -0
  91. package/dist/runLog.js +5 -0
  92. package/dist/runLog.js.map +1 -1
  93. package/dist/server.d.ts +65 -0
  94. package/dist/server.js +302 -3
  95. package/dist/server.js.map +1 -1
  96. package/dist/streamableHttp.js +17 -6
  97. package/dist/streamableHttp.js.map +1 -1
  98. package/dist/tools/bridgeDoctor.js +6 -2
  99. package/dist/tools/bridgeDoctor.js.map +1 -1
  100. package/dist/tools/ccRoutines.d.ts +221 -0
  101. package/dist/tools/ccRoutines.js +264 -0
  102. package/dist/tools/ccRoutines.js.map +1 -0
  103. package/dist/tools/getCodeCoverage.js +7 -3
  104. package/dist/tools/getCodeCoverage.js.map +1 -1
  105. package/dist/tools/index.js +6 -0
  106. package/dist/tools/index.js.map +1 -1
  107. package/dist/tools/recentTracesDigest.js +56 -11
  108. package/dist/tools/recentTracesDigest.js.map +1 -1
  109. package/dist/tools/testRunners/vitestJest.js +3 -1
  110. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  111. package/dist/tools/utils.js +6 -3
  112. package/dist/tools/utils.js.map +1 -1
  113. package/package.json +17 -6
  114. package/scripts/postinstall.mjs +27 -0
  115. package/scripts/smoke/run-all.mjs +162 -0
  116. package/scripts/start-all.mjs +513 -0
  117. package/scripts/start-all.ps1 +209 -0
  118. package/scripts/start-all.sh +73 -17
  119. package/scripts/start-orchestrator.ps1 +158 -0
  120. package/scripts/start-remote.mjs +122 -0
  121. package/templates/automation-policies/recipe-authoring.json +1 -1
  122. package/templates/automation-policies/security-first.json +1 -1
  123. package/templates/automation-policies/strict-lint.json +1 -1
  124. package/templates/automation-policies/test-driven.json +1 -1
  125. package/templates/automation-policy.example.json +1 -1
  126. package/templates/co.patchwork-os.bridge.plist +1 -1
  127. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  128. package/templates/recipes/ctx-loop-test.yaml +1 -1
  129. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  130. package/dist/commands/marketplace.d.ts +0 -16
  131. package/dist/commands/marketplace.js +0 -32
  132. package/dist/commands/marketplace.js.map +0 -1
  133. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  134. package/dist/recipes/legacyRecipeCompat.js +0 -131
  135. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Parses + allowlists the `github:<owner>/<repo>/(recipes|bundles)/<name>`
3
+ * source format used by `POST /recipes/install`.
4
+ *
5
+ * Before this module existed, the install handler hard-coded
6
+ * `github:patchworkos/recipes/...` everywhere — every URL, every
7
+ * prefix match. Third-party orgs / forks / private mirrors could not
8
+ * host recipe catalogs even though the rest of the install pipeline
9
+ * (SSRF guard, parser, scheduler) is org-agnostic.
10
+ *
11
+ * Allowlist policy:
12
+ * - Always includes `patchworkos/recipes` (backward compat).
13
+ * - Operator opts in additional `<owner>/<repo>` entries via the
14
+ * `PATCHWORK_RECIPE_REPO_ALLOWLIST` env var (comma-separated).
15
+ * - Allowlist matching is case-insensitive (GitHub itself is).
16
+ * - Both owner and repo segments must match the strict regex
17
+ * `[a-z0-9_.-]{1,100}` AFTER lowercasing — guards against
18
+ * traversal segments smuggled into the source string.
19
+ *
20
+ * The default-only behaviour matches the audit recommendation: real
21
+ * multi-org support is opt-in, so existing single-org deployments
22
+ * don't see a behaviour change.
23
+ */
24
+ export type GithubInstallKind = "recipe" | "bundle";
25
+ export interface ParsedGithubInstallSource {
26
+ kind: GithubInstallKind;
27
+ owner: string;
28
+ repo: string;
29
+ /** Recipe name (single basename) or bundle name. */
30
+ name: string;
31
+ }
32
+ export type GithubInstallParseResult = {
33
+ ok: true;
34
+ parsed: ParsedGithubInstallSource;
35
+ } | {
36
+ ok: false;
37
+ code: "bad_shape" | "bad_segment" | "not_allowlisted";
38
+ error: string;
39
+ };
40
+ /**
41
+ * Read the runtime allowlist. Combines the always-on default with
42
+ * whatever the operator has set in PATCHWORK_RECIPE_REPO_ALLOWLIST.
43
+ * Entries are lowercased + de-duplicated; trailing whitespace, empty
44
+ * fragments, and shapes that don't look like `owner/repo` are
45
+ * silently dropped (logging here is the install handler's job, not
46
+ * this pure helper's).
47
+ */
48
+ export declare function loadAllowlist(env?: NodeJS.ProcessEnv): string[];
49
+ /**
50
+ * Parse a `github:owner/repo/(recipes|bundles)/name` source string
51
+ * against the active allowlist. Pure — does NOT fetch anything; the
52
+ * install handler is responsible for the network leg and the SSRF
53
+ * guard. Returns a discriminated union the caller can map to a 400
54
+ * (bad_shape / bad_segment) or 403 (not_allowlisted) response.
55
+ */
56
+ export declare function parseGithubInstallSource(source: string, allowlist?: ReadonlyArray<string>): GithubInstallParseResult;
57
+ /**
58
+ * Build the raw.githubusercontent URL for a parsed install source.
59
+ * Always pulls `main` branch HEAD — version pinning is on the
60
+ * deferred audit backlog.
61
+ */
62
+ export declare function buildGithubRawUrl(parsed: ParsedGithubInstallSource): string;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Parses + allowlists the `github:<owner>/<repo>/(recipes|bundles)/<name>`
3
+ * source format used by `POST /recipes/install`.
4
+ *
5
+ * Before this module existed, the install handler hard-coded
6
+ * `github:patchworkos/recipes/...` everywhere — every URL, every
7
+ * prefix match. Third-party orgs / forks / private mirrors could not
8
+ * host recipe catalogs even though the rest of the install pipeline
9
+ * (SSRF guard, parser, scheduler) is org-agnostic.
10
+ *
11
+ * Allowlist policy:
12
+ * - Always includes `patchworkos/recipes` (backward compat).
13
+ * - Operator opts in additional `<owner>/<repo>` entries via the
14
+ * `PATCHWORK_RECIPE_REPO_ALLOWLIST` env var (comma-separated).
15
+ * - Allowlist matching is case-insensitive (GitHub itself is).
16
+ * - Both owner and repo segments must match the strict regex
17
+ * `[a-z0-9_.-]{1,100}` AFTER lowercasing — guards against
18
+ * traversal segments smuggled into the source string.
19
+ *
20
+ * The default-only behaviour matches the audit recommendation: real
21
+ * multi-org support is opt-in, so existing single-org deployments
22
+ * don't see a behaviour change.
23
+ */
24
+ const DEFAULT_ALLOWLIST = ["patchworkos/recipes"];
25
+ const SEGMENT_RE = /^[a-z0-9_.-]{1,100}$/;
26
+ /**
27
+ * Read the runtime allowlist. Combines the always-on default with
28
+ * whatever the operator has set in PATCHWORK_RECIPE_REPO_ALLOWLIST.
29
+ * Entries are lowercased + de-duplicated; trailing whitespace, empty
30
+ * fragments, and shapes that don't look like `owner/repo` are
31
+ * silently dropped (logging here is the install handler's job, not
32
+ * this pure helper's).
33
+ */
34
+ export function loadAllowlist(env = process.env) {
35
+ const fromEnv = (env.PATCHWORK_RECIPE_REPO_ALLOWLIST ?? "")
36
+ .split(",")
37
+ .map((s) => s.trim().toLowerCase())
38
+ .filter((s) => s.length > 0 && s.includes("/"));
39
+ return Array.from(new Set([...DEFAULT_ALLOWLIST, ...fromEnv]));
40
+ }
41
+ /**
42
+ * Parse a `github:owner/repo/(recipes|bundles)/name` source string
43
+ * against the active allowlist. Pure — does NOT fetch anything; the
44
+ * install handler is responsible for the network leg and the SSRF
45
+ * guard. Returns a discriminated union the caller can map to a 400
46
+ * (bad_shape / bad_segment) or 403 (not_allowlisted) response.
47
+ */
48
+ export function parseGithubInstallSource(source, allowlist = loadAllowlist()) {
49
+ if (!source.startsWith("github:")) {
50
+ return {
51
+ ok: false,
52
+ code: "bad_shape",
53
+ error: "source must start with 'github:'",
54
+ };
55
+ }
56
+ // After the `github:` prefix we expect <owner>/<repo>/<kind>/<name>.
57
+ // We split into exactly 4 segments — extra trailing slashes or
58
+ // missing components are rejected with `bad_shape` so the response
59
+ // is actionable.
60
+ const tail = source.slice("github:".length);
61
+ const segments = tail.split("/");
62
+ if (segments.length !== 4) {
63
+ return {
64
+ ok: false,
65
+ code: "bad_shape",
66
+ error: "source must match 'github:<owner>/<repo>/(recipes|bundles)/<name>'",
67
+ };
68
+ }
69
+ const [ownerRaw, repoRaw, kindRaw, nameRaw] = segments;
70
+ const owner = ownerRaw.toLowerCase();
71
+ const repo = repoRaw.toLowerCase();
72
+ if (!SEGMENT_RE.test(owner) || !SEGMENT_RE.test(repo)) {
73
+ return {
74
+ ok: false,
75
+ code: "bad_segment",
76
+ error: "owner and repo must match [a-z0-9_.-]{1,100}",
77
+ };
78
+ }
79
+ if (kindRaw !== "recipes" && kindRaw !== "bundles") {
80
+ return {
81
+ ok: false,
82
+ code: "bad_shape",
83
+ error: "third path segment must be 'recipes' or 'bundles'",
84
+ };
85
+ }
86
+ // Reuse the strict basename predicate inline rather than importing
87
+ // recipeInstall.ts here (circular deps), but match its rules:
88
+ // single segment, no `..`, no slashes, conservative charset, ≤100.
89
+ if (!SEGMENT_RE.test(nameRaw.toLowerCase())) {
90
+ return {
91
+ ok: false,
92
+ code: "bad_segment",
93
+ error: "name must match [a-z0-9_.-]{1,100}",
94
+ };
95
+ }
96
+ const allowSet = new Set(allowlist.map((s) => s.toLowerCase()));
97
+ if (!allowSet.has(`${owner}/${repo}`)) {
98
+ return {
99
+ ok: false,
100
+ code: "not_allowlisted",
101
+ error: `'${owner}/${repo}' is not in the recipe-repo allowlist. Set PATCHWORK_RECIPE_REPO_ALLOWLIST=${owner}/${repo} to opt in.`,
102
+ };
103
+ }
104
+ return {
105
+ ok: true,
106
+ parsed: {
107
+ kind: kindRaw === "recipes" ? "recipe" : "bundle",
108
+ owner,
109
+ repo,
110
+ name: nameRaw,
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Build the raw.githubusercontent URL for a parsed install source.
116
+ * Always pulls `main` branch HEAD — version pinning is on the
117
+ * deferred audit backlog.
118
+ */
119
+ export function buildGithubRawUrl(parsed) {
120
+ if (parsed.kind === "recipe") {
121
+ return `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/main/recipes/${parsed.name}/${parsed.name}.yaml`;
122
+ }
123
+ return `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/main/bundles/${parsed.name}/patchwork-bundle.json`;
124
+ }
125
+ //# sourceMappingURL=githubInstallSource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"githubInstallSource.js","sourceRoot":"","sources":["../../src/recipes/githubInstallSource.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Halt-category derivation.
3
+ *
4
+ * PR1c of the Val-inspired plan. PR1 attached a `haltReason` sentence to
5
+ * every error-status StepResult; this module categorises those sentences
6
+ * into a small bounded enum so the dashboard / metrics layer can count
7
+ * them over time. Foundation for "is the haltReason work actually
8
+ * surfacing useful signal, or is everything landing in `unknown`?"
9
+ *
10
+ * The mapping is intentionally pattern-based against the 5 phrases
11
+ * emitted by yamlRunner.ts. Keep this file and those phrases in sync.
12
+ * When a new error site is added, add a category here AND a test.
13
+ */
14
+ export type HaltCategory = "agent_silent_fail" | "agent_narration_only" | "agent_threw" | "tool_threw" | "tool_error"
15
+ /** Write blocked by the global kill-switch (#422). Distinct from a real tool failure. */
16
+ | "kill_switch"
17
+ /** Recipe's `tokensMax` budget breached (PR2b). */
18
+ | "budget_exceeded"
19
+ /** Whole-recipe failure (e.g. circular dependencies) — has no step row. */
20
+ | "run_level" | "unknown";
21
+ export declare function categoriseHaltReason(reason: string | undefined): HaltCategory;
22
+ export interface HaltSummary {
23
+ /** Total error-status step results scanned. */
24
+ total: number;
25
+ /** Per-category counts; categories with zero hits are omitted. */
26
+ byCategory: Partial<Record<HaltCategory, number>>;
27
+ /** Most recent 5 halt reasons (verbatim) for surfacing in the UI. */
28
+ recent: Array<{
29
+ reason: string;
30
+ category: HaltCategory;
31
+ runSeq: number;
32
+ }>;
33
+ }
34
+ interface HaltSummaryInputRun {
35
+ seq: number;
36
+ /** Top-level run status — `run_level` halts are runs with status === "error" but no error stepResults (e.g. circular-dep failure before any step ran). */
37
+ status?: "running" | "done" | "error" | "cancelled" | "interrupted";
38
+ /** Top-level errorMessage — surfaced as a `run_level` halt when no per-step halts cover it. */
39
+ errorMessage?: string;
40
+ stepResults?: Array<{
41
+ status: "ok" | "skipped" | "error";
42
+ haltReason?: string;
43
+ }>;
44
+ }
45
+ /**
46
+ * Aggregate halt categories across a set of runs. Runs are expected to be
47
+ * sorted newest-first so `recent` reflects the most recent halts.
48
+ *
49
+ * A run contributes:
50
+ * - one entry per error-status stepResult that has a `haltReason`
51
+ * - plus one `run_level` entry if `status === "error"` and there were no
52
+ * per-step halts that already explained it (avoids double-counting).
53
+ */
54
+ export declare function summariseHalts(runs: HaltSummaryInputRun[]): HaltSummary;
55
+ /**
56
+ * Format a `HaltSummary` as Prometheus text-exposition lines for the
57
+ * `bridge_recipe_halts{category="..."} N` gauge. Returns an empty array
58
+ * when the summary is empty (no HELP/TYPE block emitted in that case so
59
+ * Prom scrapers don't see an orphan declaration).
60
+ *
61
+ * Surfaced via `/metrics` so users with their own observability stack
62
+ * can dashboard halts without using Patchwork's UI.
63
+ */
64
+ export declare function haltSummaryToPrometheus(summary: HaltSummary): string[];
65
+ /**
66
+ * Derive a one-sentence haltReason from a step's error-status + raw error
67
+ * string. Used by `chainedRunner` to mirror the convention emitted by
68
+ * `yamlRunner`. Returns `undefined` for non-error rows or missing error.
69
+ *
70
+ * Pattern-matches the same phrases `categoriseHaltReason` knows about,
71
+ * so chained-run haltReasons categorise into the same buckets.
72
+ */
73
+ export declare function deriveHaltReasonFromError(opts: {
74
+ stepId: string;
75
+ toolName?: string;
76
+ isAgent?: boolean;
77
+ status: "ok" | "skipped" | "error";
78
+ error?: string;
79
+ }): string | undefined;
80
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Halt-category derivation.
3
+ *
4
+ * PR1c of the Val-inspired plan. PR1 attached a `haltReason` sentence to
5
+ * every error-status StepResult; this module categorises those sentences
6
+ * into a small bounded enum so the dashboard / metrics layer can count
7
+ * them over time. Foundation for "is the haltReason work actually
8
+ * surfacing useful signal, or is everything landing in `unknown`?"
9
+ *
10
+ * The mapping is intentionally pattern-based against the 5 phrases
11
+ * emitted by yamlRunner.ts. Keep this file and those phrases in sync.
12
+ * When a new error site is added, add a category here AND a test.
13
+ */
14
+ export function categoriseHaltReason(reason) {
15
+ if (!reason)
16
+ return "unknown";
17
+ // Order matters: more specific phrases (silent-fail, narration, kill
18
+ // switch) must match before the general "Agent step ... threw" /
19
+ // "Tool ... threw" patterns. The phrases below mirror
20
+ // yamlRunner.ts:558-606,677-684,693-708 and
21
+ // featureFlags.ts:assertWriteAllowed.
22
+ if (/silent-fail/i.test(reason))
23
+ return "agent_silent_fail";
24
+ if (/narration|whitespace|no content/i.test(reason))
25
+ return "agent_narration_only";
26
+ if (/kill[- _]?switch/i.test(reason))
27
+ return "kill_switch";
28
+ if (/budget[_ ]?exceeded|exceeded its token budget/i.test(reason))
29
+ return "budget_exceeded";
30
+ if (/^Agent step .* threw/i.test(reason))
31
+ return "agent_threw";
32
+ if (/^Tool .* threw/i.test(reason))
33
+ return "tool_threw";
34
+ if (/^Tool .* reported an error/i.test(reason))
35
+ return "tool_error";
36
+ return "unknown";
37
+ }
38
+ /**
39
+ * Aggregate halt categories across a set of runs. Runs are expected to be
40
+ * sorted newest-first so `recent` reflects the most recent halts.
41
+ *
42
+ * A run contributes:
43
+ * - one entry per error-status stepResult that has a `haltReason`
44
+ * - plus one `run_level` entry if `status === "error"` and there were no
45
+ * per-step halts that already explained it (avoids double-counting).
46
+ */
47
+ export function summariseHalts(runs) {
48
+ const byCategory = {};
49
+ const recent = [];
50
+ let total = 0;
51
+ for (const run of runs) {
52
+ let stepHaltsForRun = 0;
53
+ for (const step of run.stepResults ?? []) {
54
+ if (step.status !== "error" || !step.haltReason)
55
+ continue;
56
+ stepHaltsForRun++;
57
+ total++;
58
+ const cat = categoriseHaltReason(step.haltReason);
59
+ byCategory[cat] = (byCategory[cat] ?? 0) + 1;
60
+ if (recent.length < 5) {
61
+ recent.push({
62
+ reason: step.haltReason,
63
+ category: cat,
64
+ runSeq: run.seq,
65
+ });
66
+ }
67
+ }
68
+ if (stepHaltsForRun === 0 && run.status === "error" && run.errorMessage) {
69
+ total++;
70
+ byCategory.run_level = (byCategory.run_level ?? 0) + 1;
71
+ if (recent.length < 5) {
72
+ recent.push({
73
+ reason: run.errorMessage,
74
+ category: "run_level",
75
+ runSeq: run.seq,
76
+ });
77
+ }
78
+ }
79
+ }
80
+ return { total, byCategory, recent };
81
+ }
82
+ /**
83
+ * Format a `HaltSummary` as Prometheus text-exposition lines for the
84
+ * `bridge_recipe_halts{category="..."} N` gauge. Returns an empty array
85
+ * when the summary is empty (no HELP/TYPE block emitted in that case so
86
+ * Prom scrapers don't see an orphan declaration).
87
+ *
88
+ * Surfaced via `/metrics` so users with their own observability stack
89
+ * can dashboard halts without using Patchwork's UI.
90
+ */
91
+ export function haltSummaryToPrometheus(summary) {
92
+ if (summary.total === 0)
93
+ return [];
94
+ const lines = [
95
+ "# HELP bridge_recipe_halts Recipe halts in the in-memory run-log window, by category",
96
+ "# TYPE bridge_recipe_halts gauge",
97
+ ];
98
+ for (const [category, count] of Object.entries(summary.byCategory)) {
99
+ lines.push(`bridge_recipe_halts{category="${category}"} ${count}`);
100
+ }
101
+ return lines;
102
+ }
103
+ /**
104
+ * Derive a one-sentence haltReason from a step's error-status + raw error
105
+ * string. Used by `chainedRunner` to mirror the convention emitted by
106
+ * `yamlRunner`. Returns `undefined` for non-error rows or missing error.
107
+ *
108
+ * Pattern-matches the same phrases `categoriseHaltReason` knows about,
109
+ * so chained-run haltReasons categorise into the same buckets.
110
+ */
111
+ export function deriveHaltReasonFromError(opts) {
112
+ if (opts.status !== "error" || !opts.error)
113
+ return undefined;
114
+ if (/silent-fail/i.test(opts.error)) {
115
+ return `Step "${opts.stepId}" returned no usable output (silent-fail).`;
116
+ }
117
+ if (/narration|whitespace|no content/i.test(opts.error)) {
118
+ return `Step "${opts.stepId}" returned only narration or whitespace — no content.`;
119
+ }
120
+ if (opts.isAgent) {
121
+ return `Agent step "${opts.stepId}" threw before completing: ${opts.error}`;
122
+ }
123
+ return `Tool "${opts.toolName ?? "?"}" in step "${opts.stepId}" reported an error: ${opts.error}`;
124
+ }
125
+ //# sourceMappingURL=haltCategory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"haltCategory.js","sourceRoot":"","sources":["../../src/recipes/haltCategory.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Idempotency keys for write-tool calls.
3
+ *
4
+ * PR5a of the Val-inspired plan. Foundation for safe retry + safe resume.
5
+ *
6
+ * Two pieces:
7
+ *
8
+ * `deriveIdempotencyKey(toolId, params)`
9
+ * A stable, deterministic hash over `(toolId, canonicalised params)`.
10
+ * Canonicalisation = JSON.stringify with sorted keys, recursive — so
11
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` hash identically. Returns a
12
+ * hex SHA-256 prefix (first 16 chars; collisions vanishingly small
13
+ * within a single run scope).
14
+ *
15
+ * `WriteEffectLedger`
16
+ * Per-run in-memory map of key → cached output. The runner constructs
17
+ * one per recipe run and threads it through `StepDeps` / `ToolContext`.
18
+ * `toolRegistry.executeTool` checks the ledger before invoking write
19
+ * tools; if the key is present, returns the cached output instead of
20
+ * re-executing — preventing duplicate side effects when two parallel
21
+ * branches of a chained recipe both call the same write tool with the
22
+ * same params.
23
+ *
24
+ * Scope of this PR (deliberately narrow):
25
+ * - In-run dedup only (Map lives for one recipe run, discarded after).
26
+ * - Records only on successful execution; errors don't pollute the
27
+ * ledger, so retry-after-failure still re-executes (correct: if the
28
+ * tool errored, we can't assume the side effect happened).
29
+ * - No cross-run persistence — that's PR5b (disk-backed effect ledger).
30
+ * - No retry-time idempotency on partial-failure cases (Slack posted
31
+ * but HTTP timed out); that needs tool-side support and is a future
32
+ * PR.
33
+ *
34
+ * The protection this DOES provide today: a `parallel:` block (or a
35
+ * recipe that calls a write tool from two different chained steps with
36
+ * identical params) cannot duplicate the side effect. Concretely, this
37
+ * was a footgun that pre-dated PR5a: `chainedRunner.ts` schedules steps
38
+ * with dependency-graph parallelism; if two branches happen to call
39
+ * `slack.postMessage` with the same payload, the message went twice.
40
+ */
41
+ import type { Logger } from "../logger.js";
42
+ /**
43
+ * Derive a stable idempotency key for a write-tool invocation. 16 hex
44
+ * chars is 64 bits of entropy — far more than enough for in-run dedup
45
+ * (a single recipe with even 10⁵ steps has ~5×10⁻¹⁰ collision risk).
46
+ */
47
+ export declare function deriveIdempotencyKey(toolId: string, params: Record<string, unknown>): string;
48
+ /**
49
+ * Compose a collision-safe scope key from `(recipeName, manualRunId)`.
50
+ *
51
+ * Naive `${recipeName}:${manualRunId}` is ambiguous: recipe `a:b` +
52
+ * attempt `c` and recipe `a` + attempt `b:c` both produce `a:b:c` and
53
+ * would share a ledger scope, letting one attempt read another's
54
+ * cached write-tool outputs. We hash both fields separately as a JSON
55
+ * array so the encoding is unambiguous regardless of either field's
56
+ * contents.
57
+ *
58
+ * Returned as a 32-hex-char SHA-256 prefix — long enough that
59
+ * collisions across a realistic ledger are effectively impossible
60
+ * (~2^128 birthday bound), short enough to scan in a JSONL row.
61
+ */
62
+ export declare function deriveScopeKey(recipeName: string, manualRunId: string): string;
63
+ export declare function assertValidManualRunId(id: string): string;
64
+ /**
65
+ * In-memory per-run ledger of executed write-tool calls. Maps idempotency
66
+ * keys to the cached output the tool returned, so a duplicate call can
67
+ * be short-circuited to the same result the first call produced.
68
+ *
69
+ * The ledger is single-threaded by design — runners are single-process
70
+ * and a per-run ledger has no cross-thread access. Concurrency safety
71
+ * within a run is provided by the dependency graph (parallel-only steps
72
+ * with no shared params hash by construction); the ledger catches
73
+ * accidental same-params calls.
74
+ */
75
+ /**
76
+ * Optional disk-backed persistence for the ledger.
77
+ *
78
+ * PR5b — extends in-memory dedup so a *retry* of the same logical
79
+ * `(recipeName, manualRunId)` attempt won't replay side effects. The
80
+ * ledger stays per-attempt; cron/webhook runs and recipes without a
81
+ * manualRunId stay purely in memory (no scope key = nothing to write).
82
+ *
83
+ * File layout: a single JSONL at `${dir}/effect_ledger.jsonl`. Each row
84
+ * is `{scopeKey, idemKey, output, recordedAt}`. On construction, the
85
+ * ledger streams the file and rehydrates entries whose `scopeKey`
86
+ * matches the configured scope; everything else is left alone for the
87
+ * other attempts' ledgers to pick up.
88
+ *
89
+ * Failure mode: any IO error falls back to in-memory operation and logs
90
+ * a warning. A partially-replayed attempt with an unreadable ledger
91
+ * degrades to "re-execute side effects" — louder than "silently dedup
92
+ * something we can't audit".
93
+ */
94
+ export interface DiskLedgerOptions {
95
+ /** Directory holding `effect_ledger.jsonl`. Created if missing. */
96
+ dir: string;
97
+ /** `${recipeName}:${manualRunId}` — composed by the caller. */
98
+ scopeKey: string;
99
+ logger?: Logger;
100
+ }
101
+ export declare class WriteEffectLedger {
102
+ private readonly cache;
103
+ private readonly disk;
104
+ private readonly file;
105
+ constructor(disk?: DiskLedgerOptions);
106
+ has(key: string): boolean;
107
+ /**
108
+ * Return the previously-cached output for `key`, or `undefined` if not
109
+ * recorded. `null` is a legitimate cached value (= the tool returned
110
+ * `null` originally), so callers must use `has()` to distinguish "not
111
+ * present" from "present and null".
112
+ */
113
+ get(key: string): string | null | undefined;
114
+ record(key: string, output: string | null): void;
115
+ /** Test-only inspection of the current key set. */
116
+ keys(): string[];
117
+ size(): number;
118
+ private loadExisting;
119
+ private append;
120
+ /**
121
+ * Trim `effect_ledger.jsonl` to the most recent MAX_PERSIST_LINES.
122
+ * Best-effort — failure logs and the next append proceeds against the
123
+ * un-rotated file. Same pattern as RecipeRunLog / DecisionTraceLog.
124
+ */
125
+ private rotate;
126
+ }