@plainconceptsplatform/workflows 0.28.6 → 0.28.9

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.
@@ -32,6 +32,9 @@ export const FINDING_FIELDS = [
32
32
  "firstSeen",
33
33
  "lastSeen",
34
34
  "frames",
35
+ "topFrames",
36
+ "message",
37
+ "category",
35
38
  "fingerprint",
36
39
  ];
37
40
 
@@ -112,6 +115,16 @@ export function ownFrames(details, prefix) {
112
115
  .slice(0, MAX_FRAMES);
113
116
  }
114
117
 
118
+ export function topFrames(details) {
119
+ const stacks = (Array.isArray(details) ? details : []).flatMap((detail) =>
120
+ Array.isArray(detail?.parsedStack) ? detail.parsedStack : []);
121
+
122
+ return stacks
123
+ .filter((frame) => typeof frame?.method === "string")
124
+ .map((frame) => clip(frame.method))
125
+ .slice(0, MAX_FRAMES);
126
+ }
127
+
115
128
  /** Masks everything in SCRUBS. Applied to every string before it is rendered or scanned. */
116
129
  export function scrub(value) {
117
130
  return SCRUBS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), value);
@@ -162,6 +175,9 @@ export function toFindings(rows, { environment, ownCodePrefix = "", minOccurrenc
162
175
  firstSeen: clip(row.FirstSeen),
163
176
  lastSeen: clip(row.LastSeen),
164
177
  frames: ownFrames(row.AnyDetails, ownCodePrefix),
178
+ topFrames: topFrames(row.AnyDetails),
179
+ message: clip(row.AnyMessage),
180
+ category: clip(row.AnyCategory),
165
181
  fingerprint: fingerprint(clip(row.ProblemId), clip(row.OperationName), environment),
166
182
  }))
167
183
  .filter((finding) => finding.occurrences >= minOccurrences && finding.operations >= minOperations)
@@ -177,34 +193,41 @@ export function toFindings(rows, { environment, ownCodePrefix = "", minOccurrenc
177
193
  */
178
194
  export function render(finding, { environment, lookbackHours, ownCodePrefix = "" }) {
179
195
  const marker = `<!-- pcp-app-error: ${finding.fingerprint} -->`;
180
- const title = `${finding.exceptionType} in ${finding.operationName || finding.roleName || environment}`;
196
+ const title = `${finding.exceptionType} in ${finding.operationName || finding.category || finding.roleName || environment}`;
181
197
 
182
198
  const body = [
183
199
  marker,
184
200
  `**${finding.occurrences} occurrence(s)** in the last ${lookbackHours}h in \`${environment}\`, across ${finding.operations} operation(s).`,
185
201
  "",
186
202
  `- Exception: \`${finding.exceptionType}\``,
203
+ finding.message ? `- Message: \`${finding.message}\`` : null,
187
204
  `- Operation: \`${finding.operationName || "(none recorded)"}\``,
205
+ finding.category ? `- Source: \`${finding.category}\`` : null,
188
206
  `- Role: \`${finding.roleName || "(none recorded)"}\``,
189
207
  `- First seen: ${finding.firstSeen}`,
190
208
  `- Last seen: ${finding.lastSeen}`,
191
209
  finding.frames.length > 0
192
210
  ? `\n**Our own frames, outermost first**\n\n\`\`\`\n${finding.frames.join("\n")}\n\`\`\``
193
- : ownCodePrefix
194
- // The prefix is set and nothing matched: every frame belongs to the framework or to a
195
- // dependency. Routine for an exception raised inside framework code -- a cancelled task
196
- // is the common one -- and nothing for the reader to go and configure.
197
- ? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's. That is usual for an exception thrown inside framework code, such as a cancelled task._`
198
- // No prefix, so no frame can be recognised as this repository's own and the
199
- // stack is dropped rather than publishing somebody else's file paths.
200
- : "\n_No stack frames are shown: this repository has not said which assemblies are its own. Set `OWN_CODE_PREFIX` in the workflow to name them._",
211
+ : finding.topFrames.length > 0
212
+ ? ownCodePrefix
213
+ // The prefix is set and nothing matched: every frame belongs to the framework or to a
214
+ // dependency. Show the top frames anyway, because a framework stack is still better
215
+ // than nothing when the exception is something like a cancelled task raised inside
216
+ // framework code.
217
+ ? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's._\n\n**Top stack frames, outermost first**\n\n\`\`\`\n${finding.topFrames.join("\n")}\n\`\`\``
218
+ : `\n**Top stack frames, outermost first**\n\n\`\`\`\n${finding.topFrames.join("\n")}\n\`\`\``
219
+ : ownCodePrefix
220
+ ? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's. That is usual for an exception thrown inside framework code, such as a cancelled task._`
221
+ // No prefix, so no frame can be recognised as this repository's own and the
222
+ // stack is dropped rather than publishing somebody else's file paths.
223
+ : "\n_No stack frames are shown: this repository has not said which assemblies are its own. Set `OWN_CODE_PREFIX` in the workflow to name them._",
201
224
  "",
202
225
  "---",
203
226
  "",
204
227
  "Filed automatically from Application Insights. Nothing here was read by a model, and no",
205
228
  "identifier that joins back to a person is included, so the reproduction is still somebody's",
206
229
  "to work out. Close this with the `error-accepted` label to stop it being filed again.",
207
- ].join("\n");
230
+ ].filter(Boolean).join("\n");
208
231
 
209
232
  return { title, body, marker };
210
233
  }
@@ -47,20 +47,25 @@ let window = ago(${LOOKBACK_HOURS}h);
47
47
  let thrown =
48
48
  AppExceptions
49
49
  | where TimeGenerated > window
50
- | extend Problem = ProblemId, Kind = ExceptionType, Stack = Details;
50
+ | extend Problem = ProblemId, Kind = ExceptionType, Stack = Details,
51
+ Msg = outerMessage, Category = "";
51
52
  let logged =
52
53
  AppTraces
53
54
  | where TimeGenerated > window
54
55
  | where SeverityLevel >= 3
55
56
  | extend Problem = tostring(coalesce(Properties["OriginalFormat"], Message)),
56
57
  Kind = tostring(coalesce(Properties["ExceptionType"], "LoggedError")),
57
- Stack = dynamic([]);
58
+ Stack = dynamic([]),
59
+ Msg = Message,
60
+ Category = tostring(coalesce(Properties["SourceContext"], Properties["Category"], ""));
58
61
  union thrown, logged
59
62
  | summarize Occurrences = sum(ItemCount),
60
63
  Operations = dcount(OperationName),
61
64
  FirstSeen = min(TimeGenerated),
62
65
  LastSeen = max(TimeGenerated),
63
- AnyDetails = any(Stack)
66
+ AnyDetails = any(Stack),
67
+ AnyMessage = any(Msg),
68
+ AnyCategory = any(Category)
64
69
  by ProblemId = Problem, ExceptionType = Kind, OperationName, AppRoleName
65
70
  | where Occurrences >= ${MIN_OCCURRENCES}
66
71
  | order by Occurrences desc
@@ -14,6 +14,7 @@ import {
14
14
  fingerprint,
15
15
  leaks,
16
16
  ownFrames,
17
+ topFrames,
17
18
  prepare,
18
19
  render,
19
20
  scrub,
@@ -49,6 +50,8 @@ function row(overrides = {}) {
49
50
  Operations: 3,
50
51
  FirstSeen: "2026-09-13T01:10:00Z",
51
52
  LastSeen: "2026-09-13T06:40:00Z",
53
+ AnyMessage: "Sequence contains no elements",
54
+ AnyCategory: "Pliny.Application.Agents.RunExecutor",
52
55
  AnyOperationId: "6f1c1b0e9a2f4d55",
53
56
  AnyDetails: [
54
57
  {
@@ -113,6 +116,16 @@ check(
113
116
 
114
117
  check("a row with no details does not throw", ownFrames(undefined, "Pliny").length === 0);
115
118
 
119
+ check(
120
+ "topFrames returns frames without prefix filter",
121
+ topFrames(row().AnyDetails).length >= 1);
122
+
123
+ check(
124
+ "topFrames includes framework methods",
125
+ topFrames(row().AnyDetails).includes("Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke"));
126
+
127
+ check("topFrames empty when no details", topFrames(undefined).length === 0);
128
+
116
129
  section("Fingerprints");
117
130
 
118
131
  check(
@@ -158,6 +171,16 @@ check(
158
171
  "run_id is not a reportable field",
159
172
  !FINDING_FIELDS.includes("runId") && !FINDING_FIELDS.includes("run_id"));
160
173
 
174
+ check(
175
+ "new fields are in the allowlist",
176
+ FINDING_FIELDS.includes("message") && FINDING_FIELDS.includes("category") && FINDING_FIELDS.includes("topFrames"));
177
+
178
+ check(
179
+ "toFindings maps the new fields",
180
+ toFindings([row()], options)[0].message === "Sequence contains no elements" &&
181
+ toFindings([row()], options)[0].category === "Pliny.Application.Agents.RunExecutor" &&
182
+ toFindings([row()], options)[0].topFrames.length >= 1);
183
+
161
184
  section("Rendering and the scanner");
162
185
 
163
186
  const rendered = render(toFindings([row()], options)[0], options);
@@ -175,13 +198,18 @@ check(
175
198
  "no build-machine path reaches the body",
176
199
  !rendered.body.includes("/home/vsts"));
177
200
 
178
- // Two ways to have no frames, and they ask the reader for different things. Pliny-Bot #191 was
201
+ // Two ways to have no own frames, and they ask the reader for different things. Pliny-Bot #191 was
179
202
  // filed with OWN_CODE_PREFIX set to "Pliny" and still said the repository had not said which
180
203
  // assemblies were its own -- it sent a reader to configure a knob that had been set the day
181
204
  // before. Only the unconfigured case was covered here, which is why the wording survived.
182
205
  check(
183
- "no prefix configured says which knob to set",
206
+ "no prefix configured but top frames exist shows top frames",
184
207
  render(toFindings([row()], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
208
+ .body.includes("Top stack frames"));
209
+
210
+ check(
211
+ "no prefix configured and no frames at all says which knob to set",
212
+ render(toFindings([row({ AnyDetails: [] })], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
185
213
  .body.includes("has not said which assemblies are its own"));
186
214
 
187
215
  check(
@@ -194,6 +222,28 @@ check(
194
222
  render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
195
223
  .body.includes("No frame in this exception belongs to `NoSuchAssembly`"));
196
224
 
225
+ check("body includes the message", rendered.body.includes("Message: `Sequence contains no elements`"));
226
+
227
+ check("body includes the source", rendered.body.includes("Source: `Pliny.Application.Agents.RunExecutor`"));
228
+
229
+ check(
230
+ "title uses category as fallback",
231
+ render(toFindings([row({ OperationName: "" })], options)[0], options).title ===
232
+ "System.InvalidOperationException in Pliny.Application.Agents.RunExecutor");
233
+
234
+ check("body shows own frames when available", rendered.body.includes("Our own frames"));
235
+
236
+ check(
237
+ "body shows top frames when no own frames",
238
+ render(toFindings([row({
239
+ ProblemId: "System.Threading.Tasks.TaskCanceledException",
240
+ ExceptionType: "System.Threading.Tasks.TaskCanceledException",
241
+ AnyDetails: [{ parsedStack: [{ method: "System.Net.Http.HttpClient.SendAsync" }] }],
242
+ AnyMessage: "A task was canceled.",
243
+ })], { ...options, ownCodePrefix: "Pliny" })[0],
244
+ { ...options, ownCodePrefix: "Pliny" })
245
+ .body.includes("Top stack frames"));
246
+
197
247
  section("Fail-closed, but not fail-dead");
198
248
 
199
249
  const mixed = prepare(
@@ -152,6 +152,17 @@ post-steps:
152
152
  npm install -g "agent-browser@${{ env.AGENT_BROWSER_VERSION }}"
153
153
  # The npm package alone cannot drive a browser: the Chromium binary is a separate
154
154
  # download, and a self-hosted Linux runner needs the system dependencies too.
155
+ # --with-deps is agent-browser's best effort but misses libraries on ubuntu-24.04.
156
+ chrome_dir=$(agent-browser path 2>/dev/null | grep -oE '/[^ ]+chrome[^ ]*' | head -1 || true)
157
+ if [ -n "$chrome_dir" ] && [ -d "$(dirname "$chrome_dir")/.." ]; then
158
+ ldd "$chrome_dir" 2>/dev/null | grep "not found" || true
159
+ fi
160
+ # Install the known missing Chromium dependencies on Debian/ubuntu runners.
161
+ # agent-browser install --with-deps may leave these out depending on the runner image.
162
+ sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
163
+ libnss3 libatk1.0-0 libatk-bridge2.0-0 libdrm2 libgbm1 \
164
+ libasound2t64 libxkbcommon0 libcairo2 libpango-1.0-0 libxcomposite1 \
165
+ libxdamage1 libxfixes3 libxrandr2 2>/dev/null || true
155
166
  agent-browser install --with-deps
156
167
  agent-browser --version
157
168
 
@@ -205,6 +216,9 @@ post-steps:
205
216
  echo "Found $waypoint_count waypoints"
206
217
  captured=0
207
218
  i=0
219
+ # agent-browser uses Chromium which needs --no-sandbox on CI runners where
220
+ # there is no SYS_ADMIN capability. Set it once via the config file.
221
+ export AGENT_BROWSER_ARGS="${AGENT_BROWSER_ARGS:-} --no-sandbox"
208
222
  while [ "$i" -lt "$waypoint_count" ]; do
209
223
  i=$((i + 1))
210
224
  url=$(jq -r ".waypoints[$((i - 1))].url" "$PLAN_FILE")
@@ -224,6 +238,10 @@ post-steps:
224
238
  captured=$((captured + 1))
225
239
  else
226
240
  echo "::warning::waypoint $i: screenshot failed"
241
+ # Diagnostic: show what's in the output dir and any browser errors
242
+ ls -la "$OUTPUT_DIR/" 2>/dev/null || true
243
+ echo "--- agent-browser debug ---"
244
+ agent-browser screenshot "$OUTPUT_DIR/debug-$i.png" --full 2>&1 || true
227
245
  fi
228
246
  done
229
247
  echo "captured=$([ "$captured" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
@@ -233,7 +251,7 @@ post-steps:
233
251
  # instead (app never started, no plan, or every waypoint failed). `always()` because it
234
252
  # must run even when the capture step above warned its way to the end.
235
253
  - name: Attach screenshots to the issue
236
- if: always() && needs.subject.outputs.found == 'true' && needs.subject.outputs.enabled == 'true'
254
+ if: always() && env.VISUAL_VERIFY_ENABLED == 'true' && env.VISUAL_VERIFY_START_COMMAND != ''
237
255
  uses: ./.github/actions/attach-screenshots
238
256
  with:
239
257
  token: ${{ github.token }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.28.6",
3
+ "version": "0.28.9",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",