@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 +0 -0
- package/loops/actions/collect-app-errors/group-and-redact.mjs +33 -10
- package/loops/actions/collect-app-errors/query-app-errors.sh +8 -3
- package/loops/actions/verify-app-errors/verify-app-errors.mjs +52 -2
- package/loops/workflows/agent-apply-review.md +13 -0
- package/loops/workflows/agent-merge-gate.md +1 -0
- package/loops/workflows/agent-refine.md +6 -0
- package/package.json +8 -9
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
|
-
:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|