@plainconceptsplatform/workflows 0.20.2 → 0.20.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 (46) hide show
  1. package/dist/index.js +0 -0
  2. package/dist/stack-defaults.js +16 -16
  3. package/dist/workflow-catalog.d.ts +1 -1
  4. package/dist/workflow-catalog.js +2 -0
  5. package/loops/actions/add-issue-labels/action.yml +50 -50
  6. package/loops/actions/agent-output.cjs +17 -17
  7. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  8. package/loops/actions/apply-agent-comments/action.yml +42 -42
  9. package/loops/actions/apply-agent-labels/action.yml +55 -55
  10. package/loops/actions/apply-agent-output/action.yml +108 -108
  11. package/loops/actions/classify-route/action.yml +100 -100
  12. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  13. package/loops/actions/close-agent-issues/action.yml +43 -43
  14. package/loops/actions/collect-app-errors/action.yml +297 -0
  15. package/loops/actions/collect-app-errors/group-and-redact.mjs +252 -0
  16. package/loops/actions/collect-app-errors/query-app-errors.sh +104 -0
  17. package/loops/actions/create-agent-issues/action.yml +52 -52
  18. package/loops/actions/create-issue-comment/action.yml +29 -29
  19. package/loops/actions/download-agent-output/action.yml +53 -53
  20. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  21. package/loops/actions/list-open-issues/action.yml +33 -33
  22. package/loops/actions/load-issue-context/action.yml +45 -45
  23. package/loops/actions/merge-agent-pr/action.yml +49 -49
  24. package/loops/actions/push-agent-branch/action.yml +45 -45
  25. package/loops/actions/remove-issue-labels/action.yml +37 -37
  26. package/loops/actions/update-agent-issues/action.yml +58 -58
  27. package/loops/actions/validate-review-output/action.yml +35 -35
  28. package/loops/actions/validate-triage-output/action.yml +36 -36
  29. package/loops/actions/verify-app-errors/action.yml +11 -0
  30. package/loops/actions/verify-app-errors/verify-app-errors.mjs +216 -0
  31. package/loops/actions/verify-composite-actions/action.yml +9 -9
  32. package/loops/actions/verify-refine-output/action.yml +9 -9
  33. package/loops/actions/verify-route-matrix/action.yml +9 -9
  34. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +88 -0
  35. package/loops/scripts/compile-agent-workflows.mjs +331 -331
  36. package/loops/templates/agentics/agentics-app-errors.yml +168 -0
  37. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  38. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
  39. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
  40. package/loops/templates/issues/bug_report.yml +109 -109
  41. package/loops/templates/issues/feature_request.yml +75 -75
  42. package/loops/templates/release/github-release.yml +30 -30
  43. package/loops/workflows/authorize-bot-work.yml +105 -105
  44. package/loops/workflows/shared/opencode-ci.md +206 -206
  45. package/loops/workflows/shared/platform-defaults.md +19 -19
  46. package/package.json +9 -8
@@ -0,0 +1,216 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-app-errors/verify-app-errors.mjs. Update with workflows update --force; consumer edits may be overwritten.
2
+ //
3
+ // Executes the real grouping and redaction against rows shaped the way Application Insights
4
+ // actually returns them.
5
+ //
6
+ // It runs the shipped module rather than grepping it. The workflow-error report's logic lives
7
+ // inside a `script:` string in its action.yml, so nothing can execute it and its tests are
8
+ // greps; that was the reason to put this one in a module, and this is the payoff.
9
+
10
+ import {
11
+ FINDING_FIELDS,
12
+ MAX_FRAMES,
13
+ clip,
14
+ fingerprint,
15
+ leaks,
16
+ ownFrames,
17
+ prepare,
18
+ render,
19
+ scrub,
20
+ toFindings,
21
+ unexpectedFields,
22
+ } from "../collect-app-errors/group-and-redact.mjs";
23
+
24
+ let pass = 0;
25
+ let fail = 0;
26
+
27
+ function check(label, condition, detail = "") {
28
+ if (condition) {
29
+ pass += 1;
30
+ return;
31
+ }
32
+
33
+ fail += 1;
34
+ console.error(`FAIL: ${label}${detail ? `\n ${detail}` : ""}`);
35
+ }
36
+
37
+ function section(title) {
38
+ console.log(`\n── ${title} ──`);
39
+ }
40
+
41
+ /** A row in the shape `az monitor log-analytics query` returns, from a real exception. */
42
+ function row(overrides = {}) {
43
+ return {
44
+ ProblemId: "System.InvalidOperationException at Pliny.Application.Agents.RunExecutor.ExecuteAsync",
45
+ ExceptionType: "System.InvalidOperationException",
46
+ OperationName: "POST Runs/Start",
47
+ AppRoleName: "plinybot-pre-app-01",
48
+ Occurrences: 42,
49
+ Operations: 3,
50
+ FirstSeen: "2026-09-13T01:10:00Z",
51
+ LastSeen: "2026-09-13T06:40:00Z",
52
+ AnyOperationId: "6f1c1b0e9a2f4d55",
53
+ AnyDetails: [
54
+ {
55
+ parsedStack: [
56
+ { method: "Pliny.Application.Agents.RunExecutor.ExecuteAsync", fileName: "/home/vsts/work/1/s/src/RunExecutor.cs", line: 512 },
57
+ { method: "Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke", fileName: "/_/src/Http/Routing.cs", line: 90 },
58
+ ],
59
+ },
60
+ ],
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ const options = { environment: "pre", lookbackHours: "24", ownCodePrefix: "Pliny" };
66
+
67
+ section("Scrubbing");
68
+
69
+ check(
70
+ "a GUID is masked",
71
+ scrub("run 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e failed") === "run {guid} failed",
72
+ scrub("run 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e failed"));
73
+
74
+ check(
75
+ "every GUID is masked, not just the first",
76
+ !/[0-9a-f]{8}-[0-9a-f]{4}/i.test(scrub("a 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e b 7f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e")));
77
+
78
+ check("an email address is masked", scrub("from someone@example.com") === "from {email}");
79
+
80
+ check(
81
+ "a build-machine path is masked",
82
+ clip("at /home/vsts/work/1/s/src/RunExecutor.cs line 5") === "at {path} line 5",
83
+ clip("at /home/vsts/work/1/s/src/RunExecutor.cs line 5"));
84
+
85
+ check(
86
+ "a query string is masked but the address survives",
87
+ clip("GET https://api.example.com/v1/items?token=abc123") === "GET https://api.example.com/v1/items?{query}",
88
+ clip("GET https://api.example.com/v1/items?token=abc123"));
89
+
90
+ check(
91
+ "a pattern split across a newline is still caught",
92
+ !/example\.com/.test(scrub("mail\nto")) && clip("someone@example.com\nnext") === "{email} next",
93
+ clip("someone@example.com\nnext"));
94
+
95
+ section("Frames");
96
+
97
+ check("only our own frames survive", ownFrames(row().AnyDetails, "Pliny").length === 1);
98
+
99
+ check(
100
+ "a frame carries the method and never the path",
101
+ ownFrames(row().AnyDetails, "Pliny")[0] === "Pliny.Application.Agents.RunExecutor.ExecuteAsync");
102
+
103
+ check(
104
+ "an empty prefix publishes no frames at all",
105
+ ownFrames(row().AnyDetails, "").length === 0);
106
+
107
+ check(
108
+ "frames are capped",
109
+ ownFrames(
110
+ [{ parsedStack: Array.from({ length: 40 }, (_, i) => ({ method: `Pliny.Frame${i}` })) }],
111
+ "Pliny",
112
+ ).length === MAX_FRAMES);
113
+
114
+ check("a row with no details does not throw", ownFrames(undefined, "Pliny").length === 0);
115
+
116
+ section("Fingerprints");
117
+
118
+ check(
119
+ "the same problem in the same place is the same fingerprint",
120
+ fingerprint("a", "b", "pre") === fingerprint("a", "b", "pre"));
121
+
122
+ check(
123
+ "the same problem in another environment is a different one",
124
+ fingerprint("a", "b", "pre") !== fingerprint("a", "b", "pro"));
125
+
126
+ check("a fingerprint is short enough to read", fingerprint("a", "b", "pre").length === 12);
127
+
128
+ section("Thresholds");
129
+
130
+ check(
131
+ "a finding below the floor is dropped",
132
+ toFindings([row({ Occurrences: 2 })], { ...options, minOccurrences: 5 }).length === 0);
133
+
134
+ check(
135
+ "a finding on the floor is kept",
136
+ toFindings([row({ Occurrences: 5 })], { ...options, minOccurrences: 5 }).length === 1);
137
+
138
+ check(
139
+ "the loudest comes first",
140
+ toFindings([row({ Occurrences: 5, ProblemId: "quiet" }), row({ Occurrences: 99, ProblemId: "loud" })], options)[0]
141
+ .problemId === "loud");
142
+
143
+ section("The field allowlist");
144
+
145
+ check(
146
+ "a finding carries exactly the agreed fields",
147
+ unexpectedFields(toFindings([row()], options)[0]).length === 0);
148
+
149
+ check(
150
+ "a field nobody agreed to publish is named",
151
+ unexpectedFields({ ...toFindings([row()], options)[0], runId: "6f1c1b0e" }).join() === "runId");
152
+
153
+ check(
154
+ "an ordinary batch is not refused",
155
+ prepare([row()], options).refused === "" && prepare([row()], options).prepared.length === 1);
156
+
157
+ check(
158
+ "run_id is not a reportable field",
159
+ !FINDING_FIELDS.includes("runId") && !FINDING_FIELDS.includes("run_id"));
160
+
161
+ section("Rendering and the scanner");
162
+
163
+ const rendered = render(toFindings([row()], options)[0], options);
164
+
165
+ check("the marker is the first line", rendered.body.startsWith("<!-- pcp-app-error: "));
166
+
167
+ check("the marker carries the fingerprint", rendered.body.includes(toFindings([row()], options)[0].fingerprint));
168
+
169
+ check("the count is said plainly", rendered.body.includes("**42 occurrence(s)**"));
170
+
171
+ check("a clean report trips nothing", leaks(`${rendered.title}\n${rendered.body}`).length === 0,
172
+ leaks(`${rendered.title}\n${rendered.body}`).join("; "));
173
+
174
+ check(
175
+ "no build-machine path reaches the body",
176
+ !rendered.body.includes("/home/vsts"));
177
+
178
+ // Two ways to have no frames, and they ask the reader for different things. Pliny-Bot #191 was
179
+ // filed with OWN_CODE_PREFIX set to "Pliny" and still said the repository had not said which
180
+ // assemblies were its own -- it sent a reader to configure a knob that had been set the day
181
+ // before. Only the unconfigured case was covered here, which is why the wording survived.
182
+ check(
183
+ "no prefix configured says which knob to set",
184
+ render(toFindings([row()], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
185
+ .body.includes("has not said which assemblies are its own"));
186
+
187
+ check(
188
+ "a prefix that matched nothing does not claim the prefix is missing",
189
+ !render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
190
+ .body.includes("has not said which assemblies are its own"));
191
+
192
+ check(
193
+ "a prefix that matched nothing names the prefix it tried",
194
+ render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
195
+ .body.includes("No frame in this exception belongs to `NoSuchAssembly`"));
196
+
197
+ section("Fail-closed, but not fail-dead");
198
+
199
+ const mixed = prepare(
200
+ [row(), row({ ProblemId: "Secret", ExceptionType: "AccountKey=abc123def456;Other" })],
201
+ options);
202
+
203
+ check("the clean report is still prepared", mixed.prepared.length === 1, JSON.stringify(mixed.withheld));
204
+
205
+ check("the tripped report is withheld", mixed.withheld.length === 1);
206
+
207
+ check("the caller is told what matched", (mixed.withheld[0]?.matched ?? []).length > 0);
208
+
209
+ check("a withheld report does not refuse the run", mixed.refused === "");
210
+
211
+ check("nothing is prepared from no rows", prepare([], options).prepared.length === 0);
212
+
213
+ check("a non-array does not throw", prepare(null, options).prepared.length === 0);
214
+
215
+ console.log(`\n${pass} passed, ${fail} failed`);
216
+ process.exit(fail > 0 ? 1 : 0);
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify composite actions
3
- description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Validate composite action manifests
8
- shell: bash
9
- run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify composite actions
3
+ description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Validate composite action manifests
8
+ shell: bash
9
+ run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify refine output validation
3
- description: Exercise deterministic Refine output validation regressions.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Verify refinement outcomes
8
- shell: bash
9
- run: bash "${{ github.action_path }}/verify-refine-output.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify refine output validation
3
+ description: Exercise deterministic Refine output validation regressions.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Verify refinement outcomes
8
+ shell: bash
9
+ run: bash "${{ github.action_path }}/verify-refine-output.sh"
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify route matrix
3
- description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Run route matrix verification
8
- shell: bash
9
- run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify route matrix
3
+ description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Run route matrix verification
8
+ shell: bash
9
+ run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
@@ -2060,6 +2060,94 @@ fi
2060
2060
  if [ "$GATE_RUN_ID_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2061
2061
  fi
2062
2062
 
2063
+ echo "── App error privacy ─────────────────────────────────────────────────────"
2064
+
2065
+ # The application error report files into the private repository rather than out of it, so
2066
+ # its danger is the opposite one: not that a name escapes, but that an identifier joining a
2067
+ # stack trace back to one person's conversation gets written down beside it.
2068
+ #
2069
+ # Four properties hold that line and each is a line an edit could drop with nothing going red.
2070
+ # Asserted the way the error report's are: call sites and set equality, never a bare symbol,
2071
+ # because a grep for a name passes against a guard that has been renamed rather than removed.
2072
+ APP_ERRORS_MJS="${HERE}/../collect-app-errors/group-and-redact.mjs"
2073
+ APP_ERRORS_SH="${HERE}/../collect-app-errors/query-app-errors.sh"
2074
+ APP_ERRORS_YML="${HERE}/../collect-app-errors/action.yml"
2075
+
2076
+ if [ -f "$APP_ERRORS_MJS" ]; then
2077
+ AE_OK=1
2078
+ ae() {
2079
+ grep -qE "$1" "$2" || { AE_OK=0; echo "FAIL: collect-app-errors ${3}" >&2; }
2080
+ }
2081
+
2082
+ # 1. The query reads the two tables the application's own telemetry goes to, and no others.
2083
+ # AppServiceConsoleLogs and ContainerAppConsoleLogs live in the same workspace and carry raw
2084
+ # engine stdout, which the no-content rule does not govern. A union across them is an
2085
+ # incident rather than a wider query.
2086
+ #
2087
+ # AppTraces is deliberate and was added after the first dry run against a real workspace
2088
+ # returned nought exceptions on a service that had been failing all morning: nothing in
2089
+ # these applications calls RecordException, so every failure they care about is caught in
2090
+ # code, logged through ILogger, and lands in AppTraces alone.
2091
+ ae '^ AppExceptions$' "$APP_ERRORS_SH" 'does not query AppExceptions by name'
2092
+ ae '^ AppTraces$' "$APP_ERRORS_SH" 'does not query AppTraces, where caught failures land'
2093
+ # The heredoc alone. The comment above it names the console tables in order to say they are
2094
+ # never read, and a grep over the whole file cannot tell the warning from the offence.
2095
+ query_body="$(sed -n '/^read -r -d .. QUERY <<KQL/,/^KQL$/p' "$APP_ERRORS_SH")"
2096
+ if printf '%s' "$query_body" | grep -qE 'ConsoleLogs|AppRequests|AppDependencies|union \*|search '; then
2097
+ AE_OK=0
2098
+ echo "FAIL: collect-app-errors reads a table it has no business reading" >&2
2099
+ fi
2100
+
2101
+ # 2. Counts are scaled for sampling. A pre environment samples at 0.3, so count() reads a
2102
+ # problem that happened two hundred times as sixty, and it falls under a floor set to catch
2103
+ # it. This is a correctness guard that looks like a style one.
2104
+ ae 'sum\(ItemCount\)' "$APP_ERRORS_SH" 'counts rows instead of summing ItemCount'
2105
+
2106
+ # 3. The field allowlist is what actually holds the line, because a redaction rule filters
2107
+ # values and cannot see a field somebody adds next year. Compared as a set, so reordering is
2108
+ # fine and an addition is not. run_id is the one that matters: it is unhashed on this
2109
+ # telemetry and joins to a conversation and to a person.
2110
+ expected_fields="exceptionType frames fingerprint firstSeen lastSeen occurrences operationName operations problemId roleName"
2111
+ actual_fields="$(sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" |
2112
+ grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort | tr '\n' ' ' | sed 's/ $//')"
2113
+ if [ "$actual_fields" = "$(echo "$expected_fields" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')" ]; then
2114
+ PASS=$((PASS + 1))
2115
+ else
2116
+ FAIL=$((FAIL + 1))
2117
+ echo "FAIL: collect-app-errors reportable fields changed: ${actual_fields}" >&2
2118
+ fi
2119
+ # Plain grep, not the count() helper: that is `grep || true`, so an `if count -q` is true
2120
+ # whatever it finds and the guard never fires. Three of these were written that way first
2121
+ # and all three failed open, which is the same shape of bug this file exists to catch.
2122
+ if sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" | grep -qiE 'run_?id'; then
2123
+ AE_OK=0
2124
+ echo "FAIL: collect-app-errors lists a run id as reportable; it joins telemetry to a person" >&2
2125
+ fi
2126
+
2127
+ # 4. Scrub, then scan, then refuse. Scrubbing masks what is common and safely replaceable so
2128
+ # one unlucky operation name does not leave the job red and filing nothing forever; the
2129
+ # scanner is the backstop for whatever that missed; and a withheld report still turns the
2130
+ # run red so the field that carried it gets fixed.
2131
+ ae 'export const SCRUBS = \[' "$APP_ERRORS_MJS" 'declares no scrubbing'
2132
+ ae 'export const LEAK_CHECKS = \[' "$APP_ERRORS_MJS" 'declares no leak scanner'
2133
+ ae 'core\.setFailed.*withheld by the leak scanner' "$APP_ERRORS_YML" 'does not go red on a withheld report'
2134
+ ae 'not in the reportable field list' "$APP_ERRORS_MJS" 'does not refuse an unlisted field'
2135
+ ae 'core\.setFailed\(`Nothing was filed' "$APP_ERRORS_YML" 'does not stop when the module refuses'
2136
+
2137
+ # A GUID must be masked on the way in, not merely detected on the way out. Both, in fact:
2138
+ # the scrub is what keeps the job alive and the check is what keeps it honest.
2139
+ ae '\{guid\}' "$APP_ERRORS_MJS" 'does not mask GUIDs'
2140
+
2141
+ # 5. No model. This is the same assertion the error report carries, for the same reason: a
2142
+ # model summarising a stack trace paraphrases it, and a paraphrase is a wrong issue.
2143
+ if grep -qiE '(engine:|opencode|safe-outputs|OPENAI_API_KEY)' "$APP_ERRORS_YML"; then
2144
+ AE_OK=0
2145
+ echo "FAIL: collect-app-errors has grown a model" >&2
2146
+ fi
2147
+
2148
+ if [ "$AE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2149
+ fi
2150
+
2063
2151
  echo
2064
2152
  if [ "$FAIL" -eq 0 ]; then
2065
2153
  echo "Route matrix: ${PASS} passed"