@plainconceptsplatform/workflows 0.28.5 → 0.28.7

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/dist/index.js CHANGED
File without changes
@@ -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(
@@ -408,6 +408,12 @@ max-ai-credits: 5000
408
408
 
409
409
  permissions: read-all
410
410
 
411
+ # push-to-pull-request-branch with target "*" cannot reach a branch the shallow clone
412
+ # does not have. Fetch full history so the agent can merge on conflict without rewriting.
413
+ checkout:
414
+ fetch: ["*"]
415
+ fetch-depth: 0
416
+
411
417
  # Rung 3. A reviewer often makes one point across several comments, so the agent needs the
412
418
  # whole conversation, not just the one that triggered it. Fetching all of it is deterministic.
413
419
  steps:
@@ -459,6 +465,7 @@ safe-outputs:
459
465
  # with "requires pull request context", so the agent's fix is computed and discarded.
460
466
  push-to-pull-request-branch:
461
467
  target: "*"
468
+ max-patch-size: 10240
462
469
  add-comment:
463
470
  target: "*"
464
471
 
@@ -481,6 +488,12 @@ timeout-minutes: 90
481
488
  It has already been confirmed that this pull request is open, that we authored it, and that
482
489
  the reviewer has write access. Do not re-check any of that.
483
490
 
491
+ You are on the pull request branch. Never rebase, reset, amend or otherwise rewrite
492
+ history: the workflow applies your commits as a bundle with a fast-forward-only push and
493
+ discards anything that is not a descendant of the branch tip. The
494
+ `push_to_pull_request_branch` tool's own description recommends rebasing; in this
495
+ repository that advice is wrong. Merge, commit, and let the workflow push.
496
+
484
497
  2. Read `${{ env.ISSUE_CONTEXT_PATH }}`. It contains the issue body and discussion this pull
485
498
  request closes. The acceptance criteria there define what the implementation must still
486
499
  satisfy after the feedback is applied.
@@ -807,6 +807,7 @@ safe-outputs:
807
807
  push-to-pull-request-branch:
808
808
  target: "*"
809
809
  required-title-prefix: "[bot] "
810
+ max-patch-size: 10240
810
811
  # A failed bot PR may already contain protected files. Permit a verified repair push,
811
812
  # but protected_changes still prevents the later green-CI cycle from auto-merging it.
812
813
  protected-files: allowed
@@ -310,7 +310,13 @@ jobs:
310
310
  issue-number: ${{ inputs.issue-number }}
311
311
  conclude:
312
312
  needs: [activation, agent, safe_outputs, validate_output]
313
+ # `always()` is load-bearing: without it GitHub prepends `success()`, which returns false
314
+ # if any needed job was skipped. If activation was skipped (Pliny-Bot #321: refuse_big_issue
315
+ # skip-poisoned activation in v0.28.0), both conclude AND incomplete skipped and the refine
316
+ # label never transitioned. The explicit conditions below are still the guard; `always()`
317
+ # just prevents a skipped need from short-circuiting the whole expression.
313
318
  if: >
319
+ always() &&
314
320
  needs.agent.result == 'success' &&
315
321
  needs.safe_outputs.result == 'success' &&
316
322
  needs.validate_output.outputs.valid == 'true'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.28.5",
3
+ "version": "0.28.7",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -25,13 +25,6 @@
25
25
  "loops",
26
26
  "README.md"
27
27
  ],
28
- "scripts": {
29
- "build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
30
- "test": "vitest run",
31
- "typecheck": "tsc --noEmit --project tsconfig.json",
32
- "release": "pnpm build && pnpm test && npm publish --access public",
33
- "prepack": "node ./scripts/copy-loops.mjs"
34
- },
35
28
  "engines": {
36
29
  "node": ">=20.19"
37
30
  },
@@ -42,5 +35,11 @@
42
35
  "@types/node": "^22.0.0",
43
36
  "typescript": "^5.8.0",
44
37
  "vitest": "^3.0.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --noEmit --project tsconfig.json",
43
+ "release": "pnpm build && pnpm test && npm publish --access public"
45
44
  }
46
- }
45
+ }