Drive PyErr Desk from your own code
The page reads pasted Python in the browser for free — one or more files joined into one
paste, each introduced by a # file: <path> line — with its line numbers. It
lexes strings and comments out first, rebuilds the block structure, matches every
except to its try, inventories every except block and what its handler
really does, every raise, every exception class and every file, connection, socket,
session, client, pool or temporary file with how it is released, and runs 20 deterministic checks
drawn from Python error-handling and resource-management practice (catalogue ids PE-01..PE-20, plus
PE-21 for context): bare and broad excepts that swallow errors or drop the traceback, specific
exceptions ignored without a comment, failed items dropped inside a batch loop, failures turned into
None, lost exception chains, raise Exception, messages that say nothing,
assert as validation, oversized try blocks, BaseException
subclasses, resources never closed or closed only on success, an __exit__ that
suppresses everything, a @contextmanager whose cleanup is skipped, return
in finally, a commit with no rollback, += string building and
__del__ cleanup. Its two paid steps are available over HTTP. You send one JSON object
with task set to "audit" or "harden", the paste as
code and the page’s check as a facts string, and the model replies
with one JSON object: an audit that rates the four areas (exceptions, validation, partial failure,
resources), answers every flag and gives exact before/after edits per finding, or the whole hardened
file(s) with the list of changes and an exception catalogue.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. The paths below (/guest,
/me, /estimate, /run, /jobs/{job_id},
/run-stream) are relative to it, exactly as sdk.js calls them. A
plain-text summary of the app for agents is at /llms.txt. Both lanes run on
the gpt-terra model with a 10% publisher markup.
The response envelope
Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s
SDK (sdk.js) decides success from the HTTP status, not from ok: on a
2xx it returns data; otherwise it raises an error whose message is
error.message (or the HTTP status text when the body carries none), with
status, code (error.code) and details
(error.details) attached. A body that is not JSON is treated as {}.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Error codes
Branch on the HTTP status and error.code, show error.message. The page
handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause. |
401 | UNAUTHORIZED | No token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token. |
402 | INSUFFICIENT_CREDITS | The balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry). |
429 | RATE_LIMITED | Too many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key. |
Failures that do not arrive as an HTTP status:
| What | What happens |
|---|---|
event: error on /run-stream | The SDK raises an error carrying the event’s message, code and job_id. |
Job status: "failed" | Polling stops; the job’s error field says why. |
| Polling past 180 s | The SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled. |
The task field: two lanes
task routes the run, and it takes exactly two values. Each lane has its own reply
contract, and a reply never mixes them. The skill’s instructions tell the model to do the
audit lane when task is missing or anything else (and the page’s own
Errkit.buildInput turns any lane that is not "harden" into
"audit"), so a typo silently gets you an audit: always send one of the two values.
task | What it does | verdict | Use it when |
|---|---|---|---|
"audit" | Reviews the module the way a senior engineer does before it runs unattended: says what the code does and how it fails today, rates the four areas (exceptions, validation, partial failure, resources) as good, partial, poor or n_a, confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what loses errors or data first (swallowed failures, a suppressing __exit__, return in finally, partial commits), then leaks and lost tracebacks, then validation and messages, then hygiene. | blocked | needs_work | ready | Before shipping: where does every error end up, is bad input refused early, is one bad item handled, is every resource released when something raises, and what should change first. |
"harden" | Writes the hardened version of the pasted code: the whole of every Python file it changes (and at most one new module, for example for the exception classes), with specific exceptions that say what failed, chains kept with raise ... from err, validation at the boundary, batches that keep going and make every failure visible, and every resource in a with block or released in a finally — plus an exceptions catalogue of every exception class in the code, a changes list of every change a caller will notice, and what it left open. The success path stays the same: same functions, arguments in the same order, same return values. | hardened | partial | When you want the code changed, not a list of findings: a module whose failure paths were never designed, or what is left after an audit. |
The page chains the two: after an audit, “Apply every edit and harden the rest” puts
every audit edit into the paste, switches to the harden lane on that edited paste and adds
an audit_handoff string (below) with the audit’s verdict, headline and findings.
Over the API that is a second run with task: "harden", the edited paste as
code, facts recomputed from the edited paste, and that string. The page
drops the handoff as soon as the paste is edited again.
Guards the page applies before a run and the API does not: it refuses to price or run a paste with no
Python it can read (“No Python file was found. Paste .py source, with a # file: <path>.py
line above each file.”, or “This does not read as Python source: no def, class, import or
call was found.”), it warns before a run on a paste with no function (“No function was
found; the run will see module-level code only.”), and before a harden run on code with no
try, raise or resource (“This code has no try, raise or resource yet -
the harden lane will add the error handling it needs.”).
Input fields
This is exactly what the page sends (errkit.js buildInput(lane, A, note,
handoff), checked by mustBeObject; app.js run), and
what input-schema.json declares. The platform’s input schema is scalar-only, so
every value is a string. buildInput emits them in the order task,
code, facts, note, audit_handoff:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "harden" (the lanes above). |
code | string | yes | The pasted Python, each file introduced by a # file: <path> line; line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files, with a # [pyerr-desk: not sent: ...] line at the end (below). |
facts | string | yes | The page’s free check, Errkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it. |
note | string | no | What the engineer says: the goal, a constraint (“callers only catch ImportFailed”, “we cannot add dependencies”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against code and answers it in summary. |
audit_handoff | string | no | harden lane only: a JSON string built from an audit reply on this code (recon.js hardenHandoff, below). buildInput drops it for the audit lane. |
retry_note | string | no | Sent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it. |
mustBeObject only insists on a plain object that is not wrapped in
{"input": ...}; it is buildInput that leaves a blank note out, which is why
an empty note is simply absent.
The code string
code is the paste itself. A line # file: <path> (a path with a
. or / in it) starts each file, and a --- line just before it
only separates files. Files dropped on the page (or a dropped package folder) are joined that way by
Errkit.joinFiles: each file under its # file: line, trailing whitespace
removed, joined by newlines, ending in a newline. A paste with no # file: line is read as
one file, app.py. The page normalises line endings to \n. A
.py/.pyi path is checked as Python; requirements*.txt,
pyproject.toml, setup.cfg, Pipfile and
poetry.lock are read as dependency files; anything else is other. Send the
text you want the reply to quote: every file in an audit reply must be a
path from facts.files, every line must fall inside that file,
every evidence must be copied from it, and every edit’s before must
occur in it exactly once.
The page reads up to 400 000 characters (MAX_TEXT, cut at a line) and 40 files
(MAX_FILES), and sends at most 60 000 characters (MAX_SEND): past that
buildInput drops whole files from the end — the last non-Python file first, then
the last file — until the rest fits, cuts a single remaining file at a line (“the end of
<path>”) if it still does not, and ends the string with one line naming what it left
out:
# [pyerr-desk: not sent: pkg/b.py, pkg/c.py]
The model is told never to quote or rewrite what was not sent, and facts.notes gets the
sentence “Not sent (the paste is over 60,000 characters): ... The page checked them; the run
did not see them.” facts is still computed from the whole paste. Both worked
examples below are small, so their code is the paste unchanged.
The facts string
facts is optional to compute but required to send. It is produced by the page’s
free check: Errkit.analyze splits the paste into files, lexes and inventories them and
runs the rules, and Errkit.facts(A) turns that analysis into the object below (it does
not depend on the lane: an audit and a harden run of the same paste carry the same facts). It is how
the model knows which flags to answer, which file paths and line ranges to name, and which functions
and exception classes must survive. Its top-level keys, in the order built:
| Key | Contents |
|---|---|
module | Counts: python_files, functions, except_blocks, exception_classes, resources. |
files | Every file: path (use it verbatim), kind (python, deps, other), line and end_line (the paste lines the file occupies, without its # file: line). |
functions | Every def (up to 200): name (Class.method for methods), file, line, params (without self/cls). The harden lane must keep every one, with its arguments in order. |
exception_classes | Every exception class defined in the paste: name, base, file, line. |
excepts | Every except block (up to 150): file, line, type ((bare) for a bare except), broad, raises, logs, traceback (the traceback is kept), in_loop, does (re-raises, logs, logs, returns a value, records, ignores, returns a value, handles). |
raises | Every raise (up to 150): file, line, type ((re-raise) for a bare raise), chained, in_except. |
resources | Every file, connection, socket, session, client, pool, temporary file or process that is opened (up to 120): file, line, call, kind, var, managed (with, finally, returned, owned, module, happy = closed only on the success path, none = never closed). |
flags | Every flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (PE-01..PE-21), sev (high | medium | low | info), file, line, text, detail, and lines only on a flag that aggregates several lines (every line it covers). An audit must answer every non-info flag by its uid. |
verdict_candidate | The audit verdict the flags point at (the page’s own reading): blocked with any high flag, needs_work with any medium, otherwise ready. |
notes | Plain sentences about what was not read or not sent (empty for a paste that fits). |
The simplest source is errkit.js itself: the page loads it from
/errkit.js, and it also runs in Node (it sets module.exports).
This script prints what the page would send as code and facts (with the
60 000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact
them back into the page’s string byte for byte. Save the paste as
statements-paste.txt and redirect the output into statements-check.json:
// build-check.js -- run: node build-check.js statements-paste.txt > statements-check.json (errkit.js saved next to it)
const fs = require("fs");
const Errkit = require("./errkit.js");
const A = Errkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Errkit.buildInput("audit", A, "", null); // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.length + " file(s), " + A.functions.length + " function(s); " + real.length + " flags (" +
A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);
For the statements example it reports 1 file(s), 5 function(s); 5 flags (0 high, 3 medium, 2 low); verdict_candidate needs_work; for the digest example,
1 file(s), 3 function(s); 5 flags (0 high, 4 medium, 1 low); verdict_candidate needs_work. For both worked examples its code and
facts are exactly what the page sends.
Without the page’s check. The platform only checks that facts
is a string, so a minimal object works — "{}", or a hand-built one such as
{"flags": [], "notes": ["No page check was run; read the code directly."]}
— and the model still reads code. But you lose what the check adds: with no
flags there is nothing to confirm or dismiss, with no files there are no
paths and line ranges to name, and with no functions the harden lane has no list of
functions and arguments it must keep. Build it with errkit.js as above when you can.
The audit_handoff string
Only for task: "harden", and only for the code the audit was about with every
audit edit applied. The page builds both from the normalised audit reply
res (recon.js normalize, below) and the analysis
A of the audited paste, in recon.js hardenHandoff:
const run = Recon.applyFindings(A, res.findings, Errkit); // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it
That is {"verdict", "headline", "findings": [{id, severity, file, line, title, why, edits}],
"applied", "failed"}, where each finding’s edits is the number of
edits it carried. applyFindings applies every finding’s edits with
Errkit.applyEdits, in the order listed: each before must occur exactly once
in its file as the earlier edits left it (leading and trailing blank lines ignored), or that edit is
reported as failed and skipped. It is a previous reply, not evidence: the model checks it against
code and hardens what is left. This script does the whole step — the edited
paste, its recomputed facts and the handoff string, in the same shape as
build-check.js prints, so the samples read it the same way:
// handoff.js -- run: node handoff.js statements-paste.txt statements-reply.txt > statements-harden-check.json (errkit.js, recon.js next to it)
const fs = require("fs");
const Errkit = require("./errkit.js");
const Recon = require("./recon.js");
const A = Errkit.analyze(fs.readFileSync(process.argv[2], "utf8")); // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.hardenHandoff(A, res); // every edit, in order
const B = Errkit.analyze(ho.text); // facts recomputed from the edited paste
const inp = Errkit.buildInput("harden", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");
Save the audit’s output.output as statements-reply.txt; for the
statements example it reports 6 edit(s) applied, 0 did not apply.
The output contracts
The model is instructed (SKILL.md) to reply with one JSON object and
nothing else — no prose, no code fences — with every array of its lane
present ([] when empty). The run’s output text is that object. In both lanes it
must ground everything in code: a caller, a retry, a scheduler, a framework error
handler, a logging configuration or a database setting that is not in the paste is never claimed to
exist — it goes into questions — and an imported function’s behaviour
is unknown unless the paste shows it (“if render_statement raises ...”, never
“render_statement raises ...”). It must never write a secret value: a line that holds a
literal credential is never copied into the reply (not in evidence, not in an edit, not in a file),
and no credential, real hostname or real endpoint is invented. It keeps what the code does on the
success path: same functions, names, arguments and return values; what happens on failure may change,
and it says what a caller will now see. A new try/except catches specific
exceptions, or — at a boundary that must keep going — catches Exception,
logs it with the traceback and records it; any other new except ends with a bare
raise. It uses only the standard library and what the code already imports. A bug that
is not about error handling, validation, partial failure or resources goes into
questions, not into a finding. In the audit lane every page flag whose sev
is not info is answered exactly once in flag_answers, and an info flag is
never reported as a finding.
task: "audit"
task:"audit".verdict:blocked(any finding is high) |needs_work(the worst is medium) |ready(nothing above low; low findings and questions do not stop a release).headline: one sentence.module: one or two sentences — what the code does and how it fails today.areas[]: exactly four entries, in the order exceptions, validation, partial_failure, resources:{area, status, note}withstatusgood|partial|poor|n_a(n_aonly when the code has nothing of that kind: no batch or loop over items, or no resource).findings[]:{id, flags[], file, line, severity, area, title, evidence, why, edits[]}.idis a catalogue id (PE-01..PE-20) orother;flagslists the pageuids it confirms ([]for something the page did not flag);fileis apathfromfacts.files, copied exactly;lineis the line ofcodewith the offending text, inside that file’sline..end_line;severityishigh(an error vanishes completely,__exit__suppresses every exception,return/break/continuein afinallydiscards an exception in flight, or a failure path saves or commits partial or wrong data as if it succeeded) |medium(a failure is hidden, misreported or loses information, or a resource leaks) |low(hygiene);areaisexceptions|validation|partial_failure|resources;evidenceis one to three whole lines copied verbatim fromcodeas pasted.edits[]:{before, after}on that one file. Eachbeforeis a contiguous, exact slice of the file — whole lines with their indentation — that occurs exactly once in the file as it stands when the edit is applied;afteris its replacement. Findings are applied in the order listed and their edits in order, each on top of the ones before, so a later finding that touches the same lines writes itsbeforeas the earlier one left it.editsmay be[]only for a low finding or a change outside the paste.flag_answers[]:{flag, status, reason}, exactly one per non-info page flag,statusconfirmed(a finding lists that uid) |dismissed(with the reason).plan[]: the findings in the order to apply them.questions[](only what the paste cannot answer) andsummary(two to four sentences, answeringnoteif one was sent).
task: "harden"
task:"harden".verdict:hardened(openis empty) |partial.headline: one sentence.files[]:{path, content}— every pasted Python file it changes, whole (every line, without the# file:line, four spaces, never tabs), under itspathfromfacts.files; at most one new module (for example<package>/errors.pyfor the exception classes) next to the pasted files. Every function infacts.functionskeeps its arguments in order (a keyword argument with a default may be added after them); no dependency is added.exceptions[]:{name, base, raised_in[], why}— every exception class in the code once the files are put in (the pasted ones it kept and the ones it adds), with its direct base as written in the class statement; every entry is a class in the code.changes[]:{area, where, what}, one entry per change,areaexceptions|validation|partial_failure|resources,wherethe function or class name as written in the code (ormodule). Every change a caller will notice (a new exception type, an error that used to be swallowed now raised,Noneno longer returned on failure) is here and insummary.open[]:{item, reason}for what needs a decision the code cannot make or code outside the paste.questions[]andsummary, as in the audit lane; with anaudit_handoff, it hardens what the audit left.
How the page reads a reply (recon.js parseResult and
normalize): it strips a code fence and parses from the first { to the
last }. A reply is read as the lane that was asked for, unless it names the other lane
and carries that lane’s body (a files array for harden, a
findings array for audit). An unknown verdict shows as
needs_work or partial and counts as a disagreement; an unknown
severity becomes medium; ids like PE7 or pe 7
become PE-07; flags keeps only F<n> values; findings
with no file, evidence and title are dropped; file may also arrive as
path, and an open item as path; questions is
capped at 10 and plan at 30. An audit reply carries 9 sections (verdict, headline,
module, areas, findings, flag_answers, plan, questions, summary) and a harden reply 8 (verdict,
headline, files, exceptions, changes, open, questions, summary), which is what the page counts when
it recovers a partial reply.
Worked examples
Both are examples the page ships (the example buttons on the app page; the full
pastes are in /example.js, ids statements and
digest), built exactly as the page builds them:
Errkit.buildInput(lane, Errkit.analyze(text), note), with a real model reply for each.
The pastes, their facts and the audit reply are shown whole; the request bodies and the hardened
file inside the harden reply are trimmed for display, and each trim is marked. What is shown is
verbatim. Neither paste holds a credential (the FTP password reaches the code as a function
argument).
Audit: a statement exporter whose branch archive silently never arrives
One file, statements/export.py: it renders every account’s statement into a
per-branch ZIP archive with zipfile and uploads it over FTPS with ftplib
through a @contextmanager, with one custom exception (StatementError) and
one broad except at the top of upload(). The page’s free check raises
5 flags (3 medium, 2 low) (plus the PE-21 context flag); its own reading of the verdict is
needs_work. The engineer’s note: “Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?”
The code string, whole: 1,762 characters, 63 lines ending in a
newline. Save it as statements-paste.txt and run
node build-check.js statements-paste.txt > statements-check.json:
# file: statements/export.py
"""Builds the monthly statement archive for Alderbrook Credit Union branches and uploads it."""
import ftplib
import logging
import os
import tempfile
import zipfile
from contextlib import contextmanager
from statements.render import TemplateMissing, render_statement
logger = logging.getLogger(__name__)
FTP_HOST = "drop.alderbrook.example"
class StatementError(Exception):
"""Raised when a statement cannot be produced."""
@contextmanager
def ftp_session(user, password):
ftp = ftplib.FTP_TLS(FTP_HOST, timeout=30)
ftp.login(user, password)
ftp.prot_p()
yield ftp
ftp.quit()
def build_statement(account):
try:
return render_statement(account["id"], account["template"])
except TemplateMissing:
raise StatementError(f"no template {account['template']!r} for account {account['id']}")
def summary_lines(accounts):
text = ""
for account in accounts:
text += f"{account['id']},{account['balance']}\n"
return text
def build_archive(branch, accounts):
path = os.path.join(tempfile.gettempdir(), f"{branch}-statements.zip")
archive = zipfile.ZipFile(path, "w")
for account in accounts:
archive.writestr(f"{account['id']}.pdf", build_statement(account))
archive.writestr("summary.csv", summary_lines(accounts))
archive.close()
return path
def upload(branch, accounts, user, password):
try:
path = build_archive(branch, accounts)
with ftp_session(user, password) as ftp:
with open(path, "rb") as fh:
ftp.storbinary(f"STOR {branch}.zip", fh)
except Exception:
logger.exception("statement upload failed", extra={"branch": branch})
return None
return path
The facts object, decoded and whole. The string the page sends is its compact
JSON.stringify, 3,892 characters:
{
"module": {
"python_files": 1,
"functions": 5,
"except_blocks": 2,
"exception_classes": 1,
"resources": 3
},
"files": [
{
"path": "statements/export.py",
"kind": "python",
"line": 2,
"end_line": 64
}
],
"functions": [
{
"name": "ftp_session",
"file": "statements/export.py",
"line": 22,
"params": [
"user",
"password"
]
},
{
"name": "build_statement",
"file": "statements/export.py",
"line": 30,
"params": [
"account"
]
},
{
"name": "summary_lines",
"file": "statements/export.py",
"line": 37,
"params": [
"accounts"
]
},
{
"name": "build_archive",
"file": "statements/export.py",
"line": 44,
"params": [
"branch",
"accounts"
]
},
{
"name": "upload",
"file": "statements/export.py",
"line": 54,
"params": [
"branch",
"accounts",
"user",
"password"
]
}
],
"exception_classes": [
{
"name": "StatementError",
"base": "Exception",
"file": "statements/export.py",
"line": 17
}
],
"excepts": [
{
"file": "statements/export.py",
"line": 33,
"type": "TemplateMissing",
"broad": false,
"raises": true,
"logs": false,
"traceback": false,
"in_loop": false,
"does": "re-raises"
},
{
"file": "statements/export.py",
"line": 60,
"type": "Exception",
"broad": true,
"raises": false,
"logs": true,
"traceback": true,
"in_loop": false,
"does": "logs, returns a value"
}
],
"raises": [
{
"file": "statements/export.py",
"line": 34,
"type": "StatementError",
"chained": false,
"in_except": true
}
],
"resources": [
{
"file": "statements/export.py",
"line": 23,
"call": "ftplib.FTP_TLS",
"kind": "connection",
"var": "ftp",
"managed": "returned"
},
{
"file": "statements/export.py",
"line": 46,
"call": "zipfile.ZipFile",
"kind": "archive",
"var": "archive",
"managed": "happy"
},
{
"file": "statements/export.py",
"line": 58,
"call": "open",
"kind": "file",
"var": null,
"managed": "with"
}
],
"flags": [
{
"uid": "F1",
"id": "PE-16",
"sev": "medium",
"file": "statements/export.py",
"line": 26,
"text": "the @contextmanager ftp_session() yields outside try/finally, so the code after the yield is skipped when the block raises",
"detail": "An exception in the with block is re-raised at the yield; the 1 statement after it never run, so whatever was acquired before the yield is never released. Wrap the yield: try: yield ... finally: <cleanup>."
},
{
"uid": "F2",
"id": "PE-14",
"sev": "medium",
"file": "statements/export.py",
"line": 50,
"text": "archive.close() runs only if nothing before it raises",
"detail": "2 statements between zipfile.ZipFile(...) at line 46 and the close() can raise, and then the archive stays open. Use with zipfile.ZipFile(...) as archive: (or close it in a finally)."
},
{
"uid": "F3",
"id": "PE-06",
"sev": "medium",
"file": "statements/export.py",
"line": 60,
"text": "except Exception turns every failure into `return None`",
"detail": "The caller gets an ordinary-looking value for a failure and cannot tell \"not found\" from \"the database is down\"; the error surfaces later, somewhere else, as a confusing None. Raise a specific exception (chained with from) or return an explicit result type."
},
{
"uid": "F4",
"id": "PE-07",
"sev": "low",
"file": "statements/export.py",
"line": 34,
"text": "raise StatementError(...) inside an except drops the explicit chain",
"detail": "Without `from err` the original error is attached only as implicit context (\"During handling of the above exception, another exception occurred\"), which reads as a second crash. Write raise StatementError(...) from err to say one caused the other, or from None to hide it deliberately."
},
{
"uid": "F5",
"id": "PE-19",
"sev": "low",
"file": "statements/export.py",
"line": 40,
"text": "text += ... builds a string in a loop",
"detail": "Each += on a str copies everything accumulated so far, so building a large result this way is quadratic. Append the pieces to a list and \"\".join() them once."
},
{
"uid": "F6",
"id": "PE-21",
"sev": "info",
"file": "statements/export.py",
"line": 2,
"text": "context: 1 Python file, 5 function(s), 2 except block(s), 3 resource acquisition(s) inside functions",
"detail": "Only what is pasted is checked: how callers handle these errors, and what a framework does with an unhandled one, is outside the check."
}
],
"verdict_candidate": "needs_work",
"notes": []
}
The request body the page sends, trimmed for display: the full body is
6,354 characters of compact JSON, and the two long strings are cut here after their first 160
characters, with the cut marked inside the string. The step 4 samples build the untrimmed body from
statements-check.json.
{
"task": "audit",
"code": "# file: statements/export.py\n\"\"\"Builds the monthly statement archive for Alderbrook Credit Union branches and uploads it.\"\"\"\nimport ftplib\nimport logging\nimport ... [trimmed for display: 160 of 1,762 characters shown]",
"facts": "{\"module\":{\"python_files\":1,\"functions\":5,\"except_blocks\":2,\"exception_classes\":1,\"resources\":3},\"files\":[{\"path\":\"statements/export.py\",\"kind\":\"python\",\"line\": ... [trimmed for display: 160 of 3,892 characters shown]",
"note": "Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?"
}
The page sends it with Idempotency-Key: pyerr-desk:audit:8d578244-18d2:a1 (see step 5). The reply,
whole (the model’s output.output, parsed and indented here):
{
"task": "audit",
"verdict": "needs_work",
"headline": "The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.",
"module": "export.py renders every account's statement into a per-branch ZIP and ships it to Alderbrook's drop server over FTPS; today any failure anywhere in that pipeline - a missing template, a bad login, a dropped connection - is caught once at the very top, logged, and turned into `return None`, so a branch's archive can fail to arrive with nothing but one log line to explain why, and the FTP connection or the ZIP file is left open when that happens.",
"areas": [
{
"area": "exceptions",
"status": "partial",
"note": "build_statement narrows its catch and translates it into a domain exception (only missing `from err`), but upload()'s boundary catch turns every failure - including that domain exception - into an indistinguishable `return None`."
},
{
"area": "validation",
"status": "poor",
"note": "no input is validated anywhere: account fields, branch and credentials are used unchecked, so a malformed record would surface later as a raw KeyError instead of a clear error naming the bad field."
},
{
"area": "partial_failure",
"status": "poor",
"note": "build_archive's loop over accounts has no per-item handling: the first account whose template is missing aborts the whole archive instead of being recorded so the rest can still be built."
},
{
"area": "resources",
"status": "poor",
"note": "two of the three acquired resources (the FTP session and the ZIP archive) are released only when nothing raises; only `open(path, \"rb\")` is closed unconditionally."
}
],
"findings": [
{
"id": "other",
"flags": [],
"file": "statements/export.py",
"line": 47,
"severity": "medium",
"area": "partial_failure",
"title": "One account's render failure aborts the whole branch archive unnoticed",
"evidence": " for account in accounts:\n archive.writestr(f\"{account['id']}.pdf\", build_statement(account))",
"why": "build_statement re-raises StatementError the instant one account's template is missing, and nothing in this loop catches it, so the first bad account stops every later account from being rendered too. That StatementError propagates straight into upload()'s broad except (line 60), which today just logs it and returns None, so a branch with 500 good accounts and 1 missing template produces no archive at all - and the only trace is one log line naming a single account. This is the mechanism behind 'a branch archive never arrives and nobody knows why' from the note. After this edit, build_archive keeps rendering every account, logs and records each failure with its account id, and - only if any failed - raises one StatementError naming all of them after the loop; a caller that used to get a silent abort on account #1 now gets a complete report of every bad account in one run.",
"edits": [
{
"before": " for account in accounts:\n archive.writestr(f\"{account['id']}.pdf\", build_statement(account))",
"after": " failures = []\n for account in accounts:\n try:\n archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n except StatementError as exc:\n logger.exception(\n \"statement render failed\",\n extra={\"branch\": branch, \"account\": account.get(\"id\")},\n )\n failures.append((account.get(\"id\"), exc))\n if failures:\n failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n raise StatementError(\n f\"{len(failures)} of {len(accounts)} statements failed to render \"\n f\"for branch {branch!r}: {failed_ids}\"\n ) from failures[0][1]"
}
]
},
{
"id": "PE-06",
"flags": [
"F3"
],
"file": "statements/export.py",
"line": 60,
"severity": "medium",
"area": "exceptions",
"title": "upload() turns every failure into `return None`",
"evidence": " except Exception:\n logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n return None",
"why": "Whatever failed - a missing template, a bad FTP login, a dropped connection - is logged with its traceback and then thrown away as None. A caller that checks the return value cannot tell 'nothing to upload' from 'it broke', and one that ignores the return value never learns anything failed at all; either way the branch's statements silently never leave. After this edit, upload() still logs with the traceback first, then re-raises the original exception instead of returning None, so a caller now sees the failure instead of a routine-looking empty result.",
"edits": [
{
"before": " except Exception:\n logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n return None",
"after": " except Exception:\n logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n raise"
}
]
},
{
"id": "PE-16",
"flags": [
"F1"
],
"file": "statements/export.py",
"line": 26,
"severity": "medium",
"area": "resources",
"title": "ftp_session's cleanup is skipped when the block raises",
"evidence": " yield ftp\n ftp.quit()",
"why": "ftp.quit() sits after the yield with no try/finally, so when the code inside `with ftp_session(...)` raises (a storbinary failure, for example), the exception resumes the generator at the yield and propagates out immediately - ftp.quit() never runs, and the FTPS connection to drop.alderbrook.example is left open until it times out on its own instead of being released. After this edit the yield is wrapped in try/finally, so ftp.quit() runs whether the block succeeds or raises; the return value and behavior on success are unchanged.",
"edits": [
{
"before": " ftp.prot_p()\n yield ftp\n ftp.quit()",
"after": " ftp.prot_p()\n try:\n yield ftp\n finally:\n ftp.quit()"
}
]
},
{
"id": "PE-14",
"flags": [
"F2"
],
"file": "statements/export.py",
"line": 50,
"severity": "medium",
"area": "resources",
"title": "the ZIP archive is closed only when nothing raises",
"evidence": " archive.writestr(\"summary.csv\", summary_lines(accounts))\n archive.close()",
"why": "archive.close() only runs after every writestr before it succeeds; if a later step raises (including the StatementError raised by the finding above when accounts fail), build_archive exits without closing the ZipFile, leaving the file handle - and the half-written .zip - open. After this edit the archive is opened with a `with zipfile.ZipFile(...) as archive:` block, so it is always finalized and closed on the way out, success or failure; build_archive still returns the same path on success.",
"edits": [
{
"before": " archive = zipfile.ZipFile(path, \"w\")\n failures = []\n for account in accounts:\n try:\n archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n except StatementError as exc:\n logger.exception(\n \"statement render failed\",\n extra={\"branch\": branch, \"account\": account.get(\"id\")},\n )\n failures.append((account.get(\"id\"), exc))\n if failures:\n failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n raise StatementError(\n f\"{len(failures)} of {len(accounts)} statements failed to render \"\n f\"for branch {branch!r}: {failed_ids}\"\n ) from failures[0][1]\n archive.writestr(\"summary.csv\", summary_lines(accounts))\n archive.close()",
"after": " with zipfile.ZipFile(path, \"w\") as archive:\n failures = []\n for account in accounts:\n try:\n archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n except StatementError as exc:\n logger.exception(\n \"statement render failed\",\n extra={\"branch\": branch, \"account\": account.get(\"id\")},\n )\n failures.append((account.get(\"id\"), exc))\n if failures:\n failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n raise StatementError(\n f\"{len(failures)} of {len(accounts)} statements failed to render \"\n f\"for branch {branch!r}: {failed_ids}\"\n ) from failures[0][1]\n archive.writestr(\"summary.csv\", summary_lines(accounts))"
}
]
},
{
"id": "PE-07",
"flags": [
"F4"
],
"file": "statements/export.py",
"line": 34,
"severity": "low",
"area": "exceptions",
"title": "StatementError drops the original exception's chain",
"evidence": " except TemplateMissing:\n raise StatementError(f\"no template {account['template']!r} for account {account['id']}\")",
"why": "Without `from err`, the traceback shows TemplateMissing only as implicit context ('During handling of the above exception...'), which reads like a second, unrelated crash rather than the actual cause. After this edit the chain is explicit, so a log or debugger shows the original TemplateMissing as the direct cause of the StatementError; the message and StatementError type raised are unchanged.",
"edits": [
{
"before": " except TemplateMissing:\n raise StatementError(f\"no template {account['template']!r} for account {account['id']}\")",
"after": " except TemplateMissing as err:\n raise StatementError(f\"no template {account['template']!r} for account {account['id']}\") from err"
}
]
},
{
"id": "PE-19",
"flags": [
"F5"
],
"file": "statements/export.py",
"line": 40,
"severity": "low",
"area": "resources",
"title": "summary_lines builds its string with +=",
"evidence": " text = \"\"\n for account in accounts:\n text += f\"{account['id']},{account['balance']}\\n\"",
"why": "Each += copies everything accumulated so far, so summary_lines is quadratic in the number of accounts - needless CPU for a large branch on every run. After this edit it collects the lines in a list and joins once; the returned string is byte-for-byte identical.",
"edits": [
{
"before": " text = \"\"\n for account in accounts:\n text += f\"{account['id']},{account['balance']}\\n\"\n return text",
"after": " lines = []\n for account in accounts:\n lines.append(f\"{account['id']},{account['balance']}\\n\")\n return \"\".join(lines)"
}
]
},
{
"id": "PE-11",
"flags": [],
"file": "statements/export.py",
"line": 55,
"severity": "low",
"area": "exceptions",
"title": "upload()'s try block spans two unrelated failure modes",
"evidence": " try:\n path = build_archive(branch, accounts)",
"why": "One try wraps both building the archive (rendering every statement) and the whole FTP upload; both land on the same 'statement upload failed' log line, so distinguishing a rendering problem from a login or network problem means reading the traceback that follows rather than the log line itself. This is a hygiene note, not a behavior bug: once the finding above stops the boundary from hiding the traceback, the information needed to tell the two apart is present, just not summarized in the log message. No edit is proposed here because narrowing the try would mean restructuring upload() around a judgment call (whether a render failure and an upload failure should ever be handled differently) that the paste does not answer.",
"edits": []
}
],
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"reason": "ftp_session yields at line 26 with no try/finally; ftp.quit() at line 27 is skipped whenever the with-block raises - fixed by wrapping the yield in try/finally."
},
{
"flag": "F2",
"status": "confirmed",
"reason": "archive.close() at line 50 only runs if every writestr before it succeeds - fixed by opening the ZipFile with a with block."
},
{
"flag": "F3",
"status": "confirmed",
"reason": "the except Exception at line 60 logs with a traceback but returns None on every failure - fixed to re-raise after logging."
},
{
"flag": "F4",
"status": "confirmed",
"reason": "the raise at line 34 has no from, so the TemplateMissing chain is only implicit - fixed with from err."
},
{
"flag": "F5",
"status": "confirmed",
"reason": "text += ... at line 40 builds the CSV body in a loop - fixed with a list and join."
}
],
"plan": [
"Make build_archive keep rendering every account when one fails, record each failure, and raise one StatementError naming every failed account after the loop, instead of letting the first bad template abort the whole run unnoticed.",
"Make upload() re-raise after logging instead of returning None, so a failed branch is never indistinguishable from an empty successful one.",
"Wrap ftp_session's yield in try/finally so the FTPS connection is always closed.",
"Open the ZIP archive with a with block so it is always closed, even when a statement fails to render.",
"Add `from err` to the StatementError raised in build_statement.",
"Build summary_lines with a list and str.join instead of +=.",
"Optionally split upload()'s try block or tag its log line so a render failure and an FTP failure read differently in the logs - a nice-to-have, not required once the traceback is restored."
],
"questions": [
"Is upload() called from an outer loop over all branches? Re-raising here (finding on line 60) means a failing branch now stops that loop unless it already wraps each branch in its own try/except - please confirm that's the intended behavior outside this paste.",
"Are account dicts (id, template, balance) already validated before reaching this module, or should build_statement/summary_lines check their shape and raise a clear ValueError instead of letting a malformed record surface as a bare KeyError?",
"ftp_session's new finally calls ftp.quit() unconditionally; if the connection is already broken when the with-block raises, quit() can itself raise and replace the original exception - is a best-effort quit (catching and logging just that secondary error) wanted, or should the original failure always win?",
"build_archive writes {branch}-statements.zip to the shared system temp directory under a predictable name, and nothing in this paste ever deletes it, success or failure - is cleanup handled by something outside this module, or should upload() remove it after the STOR (or the attempt)?"
],
"summary": "export.py's only failure boundary is upload()'s broad except, which logs with a traceback but then returns None - so today a single account with a missing template aborts build_archive's loop, and that error is swallowed into an ordinary-looking empty result, which is why a branch's archive can silently never arrive some months. The findings make every account's render failure visible and named instead of aborting at the first one, stop the boundary from hiding failures behind None, and close the FTP connection and ZIP archive on every path, not just the happy one. Two low-severity hygiene items (a missing exception chain and a += string build) round out the change. Nothing on the success path changes: same functions, arguments and return values throughout."
}
Held to the page’s own reconciliation (recon.js reconcile, on this
exact body and reply), this reply has 0 disagreements in 8 statements
checked: the four areas are rated once each, every one of the 7 findings names a pasted file, a
line inside it and evidence copied from it, the verdict fits the worst finding, all 5 flags are
answered once, and all 6 edits apply in order with each before found exactly once. With
every edit applied, every flag a finding with edits carries is gone and all 5 functions are still
there. The one finding with no edit (PE-11, a wide try in upload()) is low,
as the contract allows. The recheck.js script under
The page checks the reply against the code runs that reconciliation for you.
What the step 5 samples print for this reply (the Python form; charged is whatever the run cost):
truncated: False charged: ...
needs_work - The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.
area exceptions partial
area validation poor
area partial_failure poor
area resources poor
other statements/export.py 47 medium 1 edit(s)
PE-06 statements/export.py 60 medium 1 edit(s)
PE-16 statements/export.py 26 medium 1 edit(s)
PE-14 statements/export.py 50 medium 1 edit(s)
PE-07 statements/export.py 34 low 1 edit(s)
PE-19 statements/export.py 40 low 1 edit(s)
PE-11 statements/export.py 55 low 0 edit(s)
answer F1 confirmed
answer F2 confirmed
answer F3 confirmed
answer F4 confirmed
answer F5 confirmed
Had you pressed “Apply every edit and harden the rest” after this reply, the page would
put the 6 edits into the paste, recompute facts from the edited paste, and send the
harden lane (with the same note) with this audit_handoff — what
handoff.js prints as audit_handoff — shown decoded and
trimmed to 2 of its 7 findings; in the body it is the compact
JSON.stringify of it (5,268 characters). That body is 10,827 characters and its
key would be pyerr-desk:harden:d896d985-2a4b:a1:
{
"verdict": "needs_work",
"headline": "The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.",
"findings": [
{
"id": "other",
"severity": "medium",
"file": "statements/export.py",
"line": 47,
"title": "One account's render failure aborts the whole branch archive unnoticed",
"why": "build_statement re-raises StatementError the instant one account's template is missing, and nothing in this loop catches it, so the first bad account stops every later account from being rendered too. That StatementError propagates straight into upload()'s broad except (line 60), which today just logs it and returns None, so a branch with 500 good accounts and 1 missing template produces no archive at all - and the only trace is one log line naming a single account. This is the mechanism behind 'a branch archive never arrives and nobody knows why' from the note. After this edit, build_archive keeps rendering every account, logs and records each failure with its account id, and - only if any failed - raises one StatementError naming all of them after the loop; a caller that used to get a silent abort on account #1 now gets a complete report of every bad account in one run.",
"edits": 1
},
{
"id": "PE-06",
"severity": "medium",
"file": "statements/export.py",
"line": 60,
"title": "upload() turns every failure into `return None`",
"why": "Whatever failed - a missing template, a bad FTP login, a dropped connection - is logged with its traceback and then thrown away as None. A caller that checks the return value cannot tell 'nothing to upload' from 'it broke', and one that ignores the return value never learns anything failed at all; either way the branch's statements silently never leave. After this edit, upload() still logs with the traceback first, then re-raises the original exception instead of returning None, so a caller now sees the failure instead of a routine-looking empty result.",
"edits": 1
},
... [trimmed for display: 5 more findings, same shape]
],
"applied": 6,
"failed": 0
}
Harden: a mail digest sender that ignores refused recipients
One file, digest/sender.py: it loads library members from a JSON file (opened inline and
never closed) and sends each one a digest over smtplib.SMTP in a loop, where a refused
recipient is passed over silently, any other error is logged without its traceback, and
server.quit() runs only when nothing raised. The free check raises 5 flags (4 medium,
1 low); its reading of the verdict is needs_work. This example has no audit before it,
so it carries no audit_handoff. The engineer’s note: “Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.”
The code string, whole: 1,181 characters, 44 lines ending in a
newline. Save it as digest-paste.txt and run
node build-check.js digest-paste.txt > digest-check.json:
# file: digest/sender.py
"""Sends the weekly reading digest to Wrenmoor Library members."""
import json
import logging
import smtplib
from email.message import EmailMessage
logger = logging.getLogger(__name__)
SMTP_HOST = "smtp.wrenmoor-library.example"
def load_members(path):
data = json.load(open(path))
return [m for m in data if m.get("digest")]
def build_message(member, books):
msg = EmailMessage()
msg["To"] = member["email"]
msg["From"] = "digest@wrenmoor-library.example"
msg["Subject"] = "Your weekly reading digest"
msg["X-Member-Id"] = str(member["id"])
body = ""
for book in books:
body += f"- {book['title']} by {book['author']}\n"
msg.set_content(body)
return msg
def send_digest(members_path, books):
server = smtplib.SMTP(SMTP_HOST, 587, timeout=20)
server.starttls()
sent = 0
for member in load_members(members_path):
try:
server.send_message(build_message(member, books))
sent += 1
except smtplib.SMTPRecipientsRefused:
pass
except Exception as e:
logger.error("digest failed: %s", e)
server.quit()
return sent
The facts object, decoded and whole (3,190 characters as the page’s string):
{
"module": {
"python_files": 1,
"functions": 3,
"except_blocks": 2,
"exception_classes": 0,
"resources": 2
},
"files": [
{
"path": "digest/sender.py",
"kind": "python",
"line": 2,
"end_line": 45
}
],
"functions": [
{
"name": "load_members",
"file": "digest/sender.py",
"line": 13,
"params": [
"path"
]
},
{
"name": "build_message",
"file": "digest/sender.py",
"line": 18,
"params": [
"member",
"books"
]
},
{
"name": "send_digest",
"file": "digest/sender.py",
"line": 31,
"params": [
"members_path",
"books"
]
}
],
"exception_classes": [],
"excepts": [
{
"file": "digest/sender.py",
"line": 39,
"type": "smtplib.SMTPRecipientsRefused",
"broad": false,
"raises": false,
"logs": false,
"traceback": false,
"in_loop": true,
"does": "ignores"
},
{
"file": "digest/sender.py",
"line": 41,
"type": "Exception",
"broad": true,
"raises": false,
"logs": true,
"traceback": false,
"in_loop": true,
"does": "logs"
}
],
"raises": [],
"resources": [
{
"file": "digest/sender.py",
"line": 14,
"call": "open",
"kind": "file",
"var": null,
"managed": "none"
},
{
"file": "digest/sender.py",
"line": 32,
"call": "smtplib.SMTP",
"kind": "connection",
"var": "server",
"managed": "happy"
}
],
"flags": [
{
"uid": "F1",
"id": "PE-13",
"sev": "medium",
"file": "digest/sender.py",
"line": 14,
"text": "open(...) is opened inline and never closed",
"detail": "The file is released only when the garbage collector gets to it - at once in CPython on the happy path, much later on PyPy or when an exception holds a reference to the frame - so descriptors and connections leak under errors and load. Use with open(...) as ...:."
},
{
"uid": "F2",
"id": "PE-05",
"sev": "medium",
"file": "digest/sender.py",
"line": 39,
"text": "except smtplib.SMTPRecipientsRefused inside a loop skips the item without recording it",
"detail": "The batch silently loses the items that raise smtplib.SMTPRecipientsRefused: the caller cannot tell how many were skipped or which. Count or collect the skipped items and report them with the result."
},
{
"uid": "F3",
"id": "PE-03",
"sev": "medium",
"file": "digest/sender.py",
"line": 42,
"text": "the broad except logs the error without its traceback and does not re-raise",
"detail": "logger.error(...) keeps only the message; the stack trace that says where it failed is lost, so an unexpected error cannot be found from the log. Use logger.exception(...) or pass exc_info=True."
},
{
"uid": "F4",
"id": "PE-14",
"sev": "medium",
"file": "digest/sender.py",
"line": 43,
"text": "server.quit() runs only if nothing before it raises",
"detail": "4 statements between smtplib.SMTP(...) at line 32 and the quit() can raise, and then the connection stays open. Use with smtplib.SMTP(...) as server: (or close it in a finally)."
},
{
"uid": "F5",
"id": "PE-19",
"sev": "low",
"file": "digest/sender.py",
"line": 26,
"text": "body += ... builds a string in a loop",
"detail": "Each += on a str copies everything accumulated so far, so building a large result this way is quadratic. Append the pieces to a list and \"\".join() them once."
},
{
"uid": "F6",
"id": "PE-21",
"sev": "info",
"file": "digest/sender.py",
"line": 2,
"text": "context: 1 Python file, 3 function(s), 2 except block(s), 2 resource acquisition(s) inside functions",
"detail": "Only what is pasted is checked: how callers handle these errors, and what a framework does with an unhandled one, is outside the check."
}
],
"verdict_candidate": "needs_work",
"notes": []
}
The request body, trimmed for display the same way (the full body is 5,185
characters), sent with Idempotency-Key: pyerr-desk:harden:190dd873-1441:a1:
{
"task": "harden",
"code": "# file: digest/sender.py\n\"\"\"Sends the weekly reading digest to Wrenmoor Library members.\"\"\"\nimport json\nimport logging\nimport smtplib\nfrom email.message import Em ... [trimmed for display: 160 of 1,181 characters shown]",
"facts": "{\"module\":{\"python_files\":1,\"functions\":3,\"except_blocks\":2,\"exception_classes\":0,\"resources\":2},\"files\":[{\"path\":\"digest/sender.py\",\"kind\":\"python\",\"line\":2,\"e ... [trimmed for display: 160 of 3,190 characters shown]",
"note": "Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error."
}
The reply, parsed and indented, with the one file’s content
cut after 200 characters (marked inside the string); every other value is whole:
{
"task": "harden",
"verdict": "hardened",
"headline": "digest/sender.py now closes its file and SMTP connection on every path, counts and logs every refused or failed member by id (never by email), and still returns the number of digests sent.",
"files": [
{
"path": "digest/sender.py",
"content": "\"\"\"Sends the weekly reading digest to Wrenmoor Library members.\"\"\"\nimport json\nimport logging\nimport smtplib\nfrom email.message import EmailMessage\n\nlogger = logging.getLogger(__name__)\n\nSMTP_HOST = \"sm ... [trimmed for display: 200 of 1,612 characters shown; the whole file is below]"
}
],
"exceptions": [],
"changes": [
{
"area": "resources",
"where": "load_members",
"what": "open(path) is now opened in a with block so the file descriptor is closed as soon as the JSON is read, on the success path and on a parse error alike."
},
{
"area": "resources",
"where": "send_digest",
"what": "the SMTP connection is opened with `with smtplib.SMTP(...) as server:`, so it is closed (QUIT, then the socket) whether starttls, load_members, or a member's send raises, not only when nothing raises before the old server.quit(). A failure to construct the connection itself still happens before the with block, so it still propagates unchanged and fails the whole run."
},
{
"area": "partial_failure",
"where": "send_digest",
"what": "smtplib.SMTPRecipientsRefused is now counted (refused) and logged with the member id and the refusal's SMTP response codes (exc.recipients.values(), never the email key) instead of being passed silently."
},
{
"area": "exceptions",
"where": "send_digest",
"what": "the broad `except Exception` now uses logger.exception (keeps the traceback) keyed only by member id, and counts the failure (failed), instead of logger.error(\"digest failed: %s\", e) which dropped the traceback and could echo back address-bearing exception text."
},
{
"area": "partial_failure",
"where": "send_digest",
"what": "added one summary log line (sent/refused/failed counts) after the loop; the return value is unchanged (still just `sent`), per the note that counts only need to reach the log."
},
{
"area": "resources",
"where": "build_message",
"what": "the digest body is now built by appending to a list and \"\".join(...) once, instead of += on a str in the loop."
}
],
"open": [],
"questions": [
"books is shared across every member in one send_digest call and is not validated up front; a single malformed entry (missing 'title' or 'author') will make build_message raise for every member in the batch, so failed would equal the whole member list and sent would be 0 - each failure is still recorded and logged (nothing vanishes), but should books be validated once before the loop so a bad catalogue entry is reported as one error instead of once per member?",
"logger.exception on the broad except keeps the full traceback as required, but if an unexpected SMTP error's own message happens to echo the recipient address back from the server, that address could still appear inside the traceback text even though the log call itself never references member['email']. Is that acceptable for this log, or should the exception text be scrubbed before logging?"
],
"summary": "Both resources the module opens (the members file and the SMTP connection) are now closed on every path via with blocks, closing the two medium resource flags. Inside the batch, a refused recipient is now counted and logged by member id with its SMTP response codes (never the email), and an unexpected per-member error is logged with logger.exception so its traceback is kept; both are counted and a sent/refused/failed summary line is logged after the loop, while send_digest keeps returning just the sent count as asked. The one low finding (+= string building) was fixed too. No new dependency, exception class, or public signature was introduced."
}
The content of digest/sender.py in that reply, whole (1,612 characters):
"""Sends the weekly reading digest to Wrenmoor Library members."""
import json
import logging
import smtplib
from email.message import EmailMessage
logger = logging.getLogger(__name__)
SMTP_HOST = "smtp.wrenmoor-library.example"
def load_members(path):
with open(path) as f:
data = json.load(f)
return [m for m in data if m.get("digest")]
def build_message(member, books):
msg = EmailMessage()
msg["To"] = member["email"]
msg["From"] = "digest@wrenmoor-library.example"
msg["Subject"] = "Your weekly reading digest"
msg["X-Member-Id"] = str(member["id"])
lines = []
for book in books:
lines.append(f"- {book['title']} by {book['author']}\n")
msg.set_content("".join(lines))
return msg
def send_digest(members_path, books):
sent = 0
refused = 0
failed = 0
with smtplib.SMTP(SMTP_HOST, 587, timeout=20) as server:
server.starttls()
for member in load_members(members_path):
try:
server.send_message(build_message(member, books))
sent += 1
except smtplib.SMTPRecipientsRefused as exc:
refused += 1
logger.error(
"digest refused for member %s (smtp response: %s)",
member.get("id"), list(exc.recipients.values()),
)
except Exception:
failed += 1
logger.exception("digest failed for member %s", member.get("id"))
logger.info(
"digest run complete: sent=%d refused=%d failed=%d", sent, refused, failed
)
return sent
Held to the page’s reconciliation, this reply has 0 disagreements in
8 statements checked: the one file comes back whole with no tabs; with it put into the paste the
free check raises no high or medium flag (and no low one either); all 3 functions are still there
and keep their arguments in order; the empty exceptions list matches the code (the
hardened file defines no exception class); every one of the 6 changes names a function that is in
the hardened code; and the verdict hardened fits the empty open list.
What the step 6 samples print for this reply (the Python form):
truncated: False charged: ...
hardened - digest/sender.py now closes its file and SMTP connection on every path, counts and logs every refused or failed member by id (never by email), and still returns the number of digests sent.
file digest/sender.py 1612 characters
change resources load_members - open(path) is now opened in a with block so the file descriptor is closed as soon as the JSON is read, on the success path and on a parse error alike.
change resources send_digest - the SMTP connection is opened with `with smtplib.SMTP(...) as server:`, so it is closed (QUIT, then the socket) whether starttls, load_members, or a member's send raises, not only when nothing raises before the old server.quit(). A failure to construct the connection itself still happens before the with block, so it still propagates unchanged and fails the whole run.
change partial_failure send_digest - smtplib.SMTPRecipientsRefused is now counted (refused) and logged with the member id and the refusal's SMTP response codes (exc.recipients.values(), never the email key) instead of being passed silently.
change exceptions send_digest - the broad `except Exception` now uses logger.exception (keeps the traceback) keyed only by member id, and counts the failure (failed), instead of logger.error("digest failed: %s", e) which dropped the traceback and could echo back address-bearing exception text.
change partial_failure send_digest - added one summary log line (sent/refused/failed counts) after the loop; the return value is unchanged (still just `sent`), per the note that counts only need to reach the log.
change resources build_message - the digest body is now built by appending to a list and "".join(...) once, instead of += on a str in the loop.
question books is shared across every member in one send_digest call and is not validated up front; a single malformed entry (missing 'title' or 'author') will make build_message raise for every member in the batch, so failed would equal the whole member list and sent would be 0 - each failure is still recorded and logged (nothing vanishes), but should books be validated once before the loop so a bad catalogue entry is reported as one error instead of once per member?
question logger.exception on the broad except keeps the full traceback as required, but if an unexpected SMTP error's own message happens to echo the recipient address back from the server, that address could still appear inside the traceback text even though the log call itself never references member['email']. Is that acceptable for this log, or should the exception text be scrubbed before logging?
The page checks the reply against the code; the API does not
What comes back from the API is the model’s raw text in output.output. The page
holds it to account in the browser before showing it (recon.js
reconcile, with the facts, the code and the handoff it sent): it applies every edit or
returned file to the paste and re-runs its own checks on the result. None of that happens in the API.
A direct API caller gets the reply unchecked and must do the applying and re-checking itself.
- Both lanes: the reply is the lane you asked for (one written for the other task is a disagreement), it carries a valid verdict for that lane, and no credential from the paste is repeated anywhere in it (every credential value the free check found in the paste).
- Audit: the areas table. Exactly four rows, covering exceptions, validation, partial failure and resources once each, each with a status of
good,partial,poororn_a. - Audit: every finding is anchored. Its
idis in the catalogue (PE-01..PE-21, orother; PE-21 is context and never a finding); itsfileis a pasted file; itslineis inside that file; itsevidenceis found line by line in that file ofcode(indentation trimmed; a line containing***is skipped) and on or within three lines ofline; every citeduidis a non-info flag the page raised. A finding that carries a high flag at a lower severity, or a medium or high finding with no edit, is pointed out. - Audit: every flag is answered once, consistently. Every non-info flag answered exactly once and no other; a
confirmedanswer needs a finding carrying that uid; adismissedone must not be carried by a finding and should give a reason. The verdict must fit the worst severity (blockedwith a high finding,needs_workwith a medium, otherwiseready). - Audit: every edit is put in place and re-checked. The findings’ edits are applied in the listed order, each on top of the ones before (
Errkit.applyEdits): abeforethat is not found, is found more than once, or changes nothing is a disagreement. After each finding the free check runs again: the code must still read and no new high flag may appear. With every edit applied, every confirmed flag a finding with edits carries must be gone, every function of the paste must still exist, and a new medium flag is pointed out. - Harden: the files. At least one file, at most one new one; none empty, none with a tab or a
# file:line, every path a.pyfile. - Harden: put into the paste and re-checked. The files replace the pasted files of the same path (or are added) (
Errkit.replaceFiles) and the free check runs again: the code must read, no high or medium flag may remain, and every function must still exist with its arguments in order. - Harden: the exception catalogue and the changes match the code. Every exception class in the hardened code is listed in
exceptionswith its base, and every listed one is defined; everychangesentry names a function or class that is in the hardened code (ormodule), and an empty list is pointed out. The verdict must fit theopenlist.
recon.js also runs in Node, so you can hold a reply to exactly the page’s standard.
Save /errkit.js and /recon.js next to this script,
pass the file you built the body from (build-check.js or handoff.js output),
the reply text and the lane; it prints every statement, the count, and the paste with every edit (or
file) put back:
// recheck.js -- run: node recheck.js statements-check.json statements-reply.txt audit > statements-edited.txt (errkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js"); // loads ./errkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane); // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, "")); // the paste with every edit (or file) put back
For the statements audit reply it reports:
ok: The areas table rates exceptions, validation, partial failure and resources once each.
ok: Every one of the 7 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (needs_work) fits the worst finding.
ok: Every one of the page's 5 flags is answered once.
ok: All 6 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every function of the paste is still there after the edits.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked
and for the digest harden reply (node recheck.js digest-check.json digest-reply.txt harden):
ok: 1 file returned whole, no tabs.
ok: With the hardened files in place the free check raises no high or medium flag (0 low).
ok: Every one of the 3 functions is still there.
ok: Every function keeps its arguments, in order.
ok: exceptions matches the code: 0 exception classes with their bases.
ok: Every one of the 6 changes names a function or class that is in the hardened code.
ok: The verdict (hardened) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked
After that, run the module’s own tests — including one that makes a single item fail and one that makes the connection fail — and read the log lines each failure leaves before you ship anything.
Step 1 — a tiny client helper
One function per language that does what sdk.js does for every call: JSON in,
bearer token if you have one, data out on a 2xx, an error carrying
status, error.code and error.message otherwise. The later
steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside
main / the top-level statements; Java’s makeInput from step 4 goes
in the class).
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # step 2
# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
method="$1"; path="$2"; shift 2
curl -sS -X "$method" "$BASE$path" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data' failure: jq '.error | {code, message}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// pyerr-desk.mjs -- Node 18+ (global fetch), run with: node pyerr-desk.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// PyErrDesk.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class PyErrDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits, so a run
needs a signed-in user (the page says “The audit run is metered, so it needs an
account.”, or “The harden run is metered, so it needs an account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "pyerr-desk"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# personal token (needed for a run): copy it from /tokens.html, then
# export SKILLSAFE_TOKEN=... and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"pyerr-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "pyerr-desk"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "pyerr-desk" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "pyerr-desk"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "pyerr-desk"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "pyerr-desk" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "pyerr-desk"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "pyerr-desk" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
Step 3 — GET /me
Who the token belongs to and what it can spend. sdk.js documents the shape as
{subject_type, subject_id, credits, profile?}. The page treats the caller as signed
in only when subject_type is "user", shows credits as the
balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it
with the estimate in step 4 before it lets you run.
api GET /me | jq '.data // .error'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
Step 4 — build the input, then POST /estimate
First a small makeInput that does what buildInput does: task,
then code as the paste the page would send, facts as a compact JSON string,
the note when it is not blank, and — for the harden lane only — the
audit_handoff string when the file carries one. It reads the file
build-check.js (or handoff.js) printed. The samples build the
audit input of the statements example from statements-check.json; step 6 reuses
makeInput for the digest (harden) example. Then price it. The body of
/estimate is the same input you will send to /run; no job is created and
nothing is charged — /estimate is free. From the response the page reads:
| Field | Meaning |
|---|---|
hold_credits | A reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true). |
min_credits | The smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run. |
The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size
of code and facts, so read them from the call rather than hard-coding them.
# statements-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?" \
'{task: "audit", code, facts: (.facts | tojson), note: $note}' statements-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Harden lane after an audit: read handoff.js's output instead and add its string:
# '{task: "harden", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'
api POST /estimate -d "$BODY" \
| jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'
def make_input(task, check_path, note=None):
"""The body the page sends: task, code, facts as a JSON STRING, an optional note, and
(harden lane only) the audit_handoff string -- every value a string."""
with open(check_path, encoding="utf-8") as f:
check = json.load(f) # build-check.js (or handoff.js) output
inp = {
"task": task,
"code": check["code"], # the paste as the page sends it
"facts": json.dumps(check["facts"], separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "harden" and check.get("audit_handoff"):
inp["audit_handoff"] = check["audit_handoff"] # optional; already a JSON string
return inp
inp = make_input("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (harden lane only) the audit_handoff string -- every value a string.
function makeInput(task, checkPath, note) {
const check = JSON.parse(readFileSync(checkPath, "utf8")); // build-check.js (or handoff.js) output
const input = {
task,
code: check.code, // the paste as the page sends it
facts: JSON.stringify(check.facts), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "harden" && check.audit_handoff) input.audit_handoff = check.audit_handoff; // optional
return input;
}
const input = makeInput("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, code, facts as a JSON STRING,
// an optional note and (harden lane only) the audit_handoff string.
makeInput := func(task, checkPath, note string) map[string]any {
raw, err := os.ReadFile(checkPath) // build-check.js (or handoff.js) output
if err != nil {
panic(err)
}
var check struct {
Code string `json:"code"`
Facts json.RawMessage `json:"facts"`
AuditHandoff string `json:"audit_handoff"`
}
if err := json.Unmarshal(raw, &check); err != nil {
panic(err)
}
var facts bytes.Buffer
if err := json.Compact(&facts, check.Facts); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "code": check.Code, "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "harden" && check.AuditHandoff != "" {
in["audit_handoff"] = check.AuditHandoff // optional; already a JSON string
}
return in
}
input := makeInput("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the PyErrDesk class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String checkPath, String note) throws Exception {
JsonNode check = JSON.readTree(Files.readString(Path.of(checkPath))); // build-check.js (or handoff.js) output
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("code", check.path("code").asText()); // the paste as the page sends it
in.put("facts", JSON.writeValueAsString(check.path("facts"))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("harden") && check.hasNonNull("audit_handoff")) in.put("audit_handoff", check.get("audit_handoff").asText());
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?");
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, code, facts as a JSON STRING, an optional note,
# and (harden lane only) the audit_handoff string -- every value a string.
def make_input(task, check_path, note = nil)
check = JSON.parse(File.read(check_path, encoding: "UTF-8")) # build-check.js (or handoff.js) output
input = {
task: task,
code: check["code"], # the paste as the page sends it
facts: JSON.generate(check["facts"]) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = check["audit_handoff"] if task == "harden" && !check["audit_handoff"].to_s.empty?
input
end
input = make_input("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (harden lane only) the audit_handoff string -- every value a string.
function make_input(string $task, string $checkPath, string $note = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$check = json_decode(file_get_contents($checkPath)); // build-check.js (or handoff.js) output
$input = [
"task" => $task,
"code" => $check->code, // the paste as the page sends it
"facts" => json_encode($check->facts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
];
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "harden" && !empty($check->audit_handoff)) $input["audit_handoff"] = $check->audit_handoff;
return $input;
}
$input = make_input("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (harden lane only) the audit_handoff string -- every value a string.
static Dictionary<string, string> MakeInput(string task, string checkPath, string? note = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var check = JsonNode.Parse(File.ReadAllText(checkPath))!; // build-check.js (or handoff.js) output
var input = new Dictionary<string, string>
{
["task"] = task,
["code"] = check["code"]!.GetValue<string>(), // the paste as the page sends it
["facts"] = check["facts"]!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
var handoff = check["audit_handoff"]?.GetValue<string>();
if (task == "harden" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff;
return input;
}
var input = MakeInput("audit", "statements-check.json",
"Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
Step 5 — POST /run, then poll the job (audit)
Send the same input to /run with an Idempotency-Key header, using a
personal token. Its data carries a job_id. Poll
GET /jobs/{job_id} the way sdk.js waitForJob does: every
second until status is succeeded or failed, giving up on
the client side after 180 seconds. On the finished job, read:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each area’s status, each finding’sid,file,line,severityand number of edits, then each flag answer. Keep the text:recheck.jsreads it.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 9 sections recovered” for an audit, of 8 for a harden run), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
Idempotency key. Resending a request with the same key must not start a second
paid run, so derive the key from the input, not from the clock — and include the lane, so an
audit and a harden run of the same paste never share a key. The page uses
pyerr-desk:<lane>:<inputHash>:a<attempt>, where
<lane> is audit or harden,
<inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a
dash, and the string’s length in hex (app.js inputHash, over the
input without retry_note), and <attempt> is 1
for a normal run and 2 for the automatic reformat retry (which also adds
retry_note). For the two worked examples that is
pyerr-desk:audit:8d578244-18d2:a1 and pyerr-desk:harden:190dd873-1441:a1. The format is the page’s
convention, not a requirement: any stable, unique string does. The samples below use the lane and a
SHA-256 prefix.
The page itself uses /run-stream (step 6), not /run; both take the
same body and header.
KEY="pyerr-desk:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"
JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
for i in $(seq 180); do # 1 s interval, 180 s client-side limit
J=$(api GET "/jobs/$JOB")
S=$(printf '%s' "$J" | jq -r '.data.status')
if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
sleep 1
done
printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
| jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
flag_answers: [(.flag_answers // [])[] | {flag, status}]}'
body = json.dumps(inp)
key = "pyerr-desk:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for s in reply.get("areas", []):
print("area", s["area"], s["status"])
for f in reply.get("findings", []):
print(f["id"], f["file"], f.get("line"), f["severity"], len(f.get("edits") or []), "edit(s)")
for a in reply.get("flag_answers", []):
print("answer", a["flag"], a["status"])
const key = "pyerr-desk:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const s of reply.areas || []) console.log("area", s.area, s.status);
for (const f of reply.findings || []) console.log(f.id, f.file, f.line, f.severity, (f.edits || []).length, "edit(s)");
for (const a of reply.flag_answers || []) console.log("answer", a.flag, a.status);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "pyerr-desk:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
areas, _ := reply["areas"].([]any)
for _, e := range areas {
s, _ := e.(map[string]any)
fmt.Println("area", s["area"], s["status"])
}
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
edits, _ := f["edits"].([]any)
fmt.Println(f["id"], f["file"], f["line"], f["severity"], len(edits), "edit(s)")
}
answers, _ := reply["flag_answers"].([]any)
for _, e := range answers {
a, _ := e.(map[string]any)
fmt.Println("answer", a["flag"], a["status"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "pyerr-desk:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode s : reply.path("areas")) {
System.out.println("area " + s.path("area").asText() + " " + s.path("status").asText());
}
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("file").asText() + " " + f.path("line") + " "
+ f.path("severity").asText() + " " + f.path("edits").size() + " edit(s)");
}
for (JsonNode a : reply.path("flag_answers")) {
System.out.println("answer " + a.path("flag").asText() + " " + a.path("status").asText());
}
key = "pyerr-desk:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["areas"] || []).each { |s| puts "area #{s["area"]} #{s["status"]}" }
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["file"]} #{f["line"].inspect} #{f["severity"]} #{(f["edits"] || []).size} edit(s)" }
(reply["flag_answers"] || []).each { |a| puts "answer #{a["flag"]} #{a["status"]}" }
$key = "pyerr-desk:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->areas ?? [] as $s) echo "area ", $s->area, " ", $s->status, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->file, " ", json_encode($f->line ?? null), " ", $f->severity, " ", count($f->edits ?? []), " edit(s)\n";
foreach ($reply->flag_answers ?? [] as $a) echo "answer ", $a->flag, " ", $a->status, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "pyerr-desk:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var s in reply["areas"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"area {s?["area"]} {s?["status"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["file"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]} {f?["edits"]?.AsArray().Count ?? 0} edit(s)");
foreach (var a in reply["flag_answers"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"answer {a?["flag"]} {a?["status"]}");
Step 6 — POST /run-stream (Server-Sent Events, harden)
The alternative to step 5, and what the page uses: same kind of body, same
Idempotency-Key header, one request. Run one or the other for a given input, not
both. To show the second lane, the samples build the harden input of the
digest example (with its own key) and stream it; to stream the audit input instead, pass the
step 4 input and key. The response is text/event-stream. sdk.js splits
it into frames on a blank line, takes the event name from an event: line (default
message) and concatenates the data: lines, skips frames with no data or
with data that is not JSON, and handles these events:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated exactly like done. If it carries a job_id and no output, poll that job as in step 5. |
error | {message, code, job_id} | After the stream ends, raises an error with those fields. |
If the response is not text/event-stream, it is an ordinary JSON envelope:
the SDK returns its data (an idempotent replay returns this way) or raises the
error on a non-2xx status. Handle both shapes.
From the final payload the page reads output.output (falling back to the
concatenated delta text), truncated and charged_credits.
Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the
final payload; if the stream fails part-way, it tries to recover a partial reply from whatever
delta text arrived. The samples print the verdict and headline, each returned file’s path and
size, each entry of the exception catalogue (name, base and
raised_in), each change (area, where, what),
each open item and each question. To put the files into the
paste and re-check them the way the page’s “Put every file into the paste” does,
keep the reply text and run recheck.js with the lane harden. To
harden a module you audited, read handoff.js’s output instead of
digest-check.json: makeInput then carries its audit_handoff.
# the harden worked example: digest-check.json from build-check.js
# (for an audited module, read handoff.js's output instead; the + (...) adds its audit_handoff)
IBODY=$(jq -c --arg note "Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error." \
'{task: "harden", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
digest-check.json)
IKEY="pyerr-desk:harden:$(printf '%s' "$IBODY" | shasum -a 256 | cut -c1-16):a1"
# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $IKEY" -d "$IBODY")
# frames look like:
# event: job
# data: {...}
#
# event: done
# data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"harden\",...}"}}
# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
| awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
| jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
files: [(.files // [])[] | {path, characters: (.content | length)}],
exceptions: [(.exceptions // [])[] | {name, base, raised_in}],
changes: [(.changes // [])[] | {area, where, what}], open, questions}'
# the harden worked example: digest-check.json from build-check.js
# (for an audited module, pass handoff.js's output instead: make_input adds its audit_handoff)
hard_inp = make_input("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.")
hard_key = "pyerr-desk:harden:" + hashlib.sha256(json.dumps(hard_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": hard_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(hard_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for f in result.get("files", []):
print("file", f["path"], len(f.get("content") or ""), "characters")
for x in result.get("exceptions", []):
print("exception", x["name"], "base", x.get("base"), "raised in", ",".join(x.get("raised_in") or []) or "-")
for c in result.get("changes", []):
print("change", c["area"], c["where"], "-", c["what"])
for o in result.get("open", []):
print("open", o["item"], "-", o.get("reason"))
for q in result.get("questions", []):
print("question", q)
// the harden worked example: digest-check.json from build-check.js
// (for an audited module, pass handoff.js's output instead: makeInput adds its audit_handoff)
const hardInput = makeInput("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.");
const hardKey = "pyerr-desk:harden:" +
createHash("sha256").update(JSON.stringify(hardInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": hardKey },
body: JSON.stringify(hardInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const f of result.files || []) console.log("file", f.path, (f.content || "").length, "characters");
for (const x of result.exceptions || []) console.log("exception", x.name, "base", x.base, "raised in", (x.raised_in || []).join(",") || "-");
for (const c of result.changes || []) console.log("change", c.area, c.where, "-", c.what);
for (const o of result.open || []) console.log("open", o.item, "-", o.reason);
for (const q of result.questions || []) console.log("question", q);
// the harden worked example: digest-check.json from build-check.js
// (for an audited module, pass handoff.js's output instead: makeInput adds its audit_handoff)
hardInput := makeInput("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.")
hardJSON, _ := json.Marshal(hardInput)
hardSum := sha256.Sum256(hardJSON)
hardKey := "pyerr-desk:harden:" + hex.EncodeToString(hardSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", hardInput, map[string]string{"Idempotency-Key": hardKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
files, _ := result["files"].([]any)
for _, e := range files {
f, _ := e.(map[string]any)
c, _ := f["content"].(string)
fmt.Println("file", f["path"], len([]rune(c)), "characters")
}
excs, _ := result["exceptions"].([]any)
for _, e := range excs {
x, _ := e.(map[string]any)
var where []string
if ws, ok := x["raised_in"].([]any); ok {
for _, w := range ws {
where = append(where, fmt.Sprint(w))
}
}
in := strings.Join(where, ",")
if in == "" {
in = "-"
}
fmt.Println("exception", x["name"], "base", x["base"], "raised in", in)
}
changes, _ := result["changes"].([]any)
for _, e := range changes {
c, _ := e.(map[string]any)
fmt.Println("change", c["area"], c["where"], "-", c["what"])
}
open, _ := result["open"].([]any)
for _, e := range open {
o, _ := e.(map[string]any)
fmt.Println("open", o["item"], "-", o["reason"])
}
questions, _ := result["questions"].([]any)
for _, q := range questions {
fmt.Println("question", q)
}
// the harden worked example: digest-check.json from build-check.js
// (for an audited module, pass handoff.js's output instead: makeInput adds its audit_handoff)
Map<String, Object> hardInput = makeInput("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.");
byte[] hardDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(hardInput));
String hardKey = "pyerr-desk:harden:" + HexFormat.of().formatHex(hardDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", hardInput, Map.of("Idempotency-Key", hardKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode f : result.path("files")) {
System.out.println("file " + f.path("path").asText() + " " + f.path("content").asText("").codePointCount(0, f.path("content").asText("").length()) + " characters");
}
for (JsonNode x : result.path("exceptions")) {
List<String> where = new ArrayList<>();
x.path("raised_in").forEach(w -> where.add(w.asText()));
System.out.println("exception " + x.path("name").asText() + " base " + x.path("base").asText()
+ " raised in " + (where.isEmpty() ? "-" : String.join(",", where)));
}
for (JsonNode c : result.path("changes")) {
System.out.println("change " + c.path("area").asText() + " " + c.path("where").asText() + " - " + c.path("what").asText());
}
for (JsonNode o : result.path("open")) System.out.println("open " + o.path("item").asText() + " - " + o.path("reason").asText());
for (JsonNode q : result.path("questions")) System.out.println("question " + q.asText());
# the harden worked example: digest-check.json from build-check.js
# (for an audited module, pass handoff.js's output instead: make_input adds its audit_handoff)
hard_input = make_input("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.")
hard_key = "pyerr-desk:harden:#{Digest::SHA256.hexdigest(JSON.generate(hard_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", hard_input, { "Idempotency-Key" => hard_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["files"] || []).each { |f| puts "file #{f["path"]} #{f["content"].to_s.length} characters" }
(result["exceptions"] || []).each do |x|
where = (x["raised_in"] || []).join(",")
puts "exception #{x["name"]} base #{x["base"]} raised in #{where.empty? ? "-" : where}"
end
(result["changes"] || []).each { |c| puts "change #{c["area"]} #{c["where"]} - #{c["what"]}" }
(result["open"] || []).each { |o| puts "open #{o["item"]} - #{o["reason"]}" }
(result["questions"] || []).each { |q| puts "question #{q}" }
// the harden worked example: digest-check.json from build-check.js
// (for an audited module, pass handoff.js's output instead: make_input adds its audit_handoff)
$hardInput = make_input("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.");
$hardKey = "pyerr-desk:harden:" . substr(hash("sha256", json_encode($hardInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $hardKey]),
CURLOPT_POSTFIELDS => json_encode($hardInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk; // collects the response in memory
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->files ?? [] as $f) echo "file ", $f->path, " ", mb_strlen($f->content ?? ""), " characters\n";
foreach ($result->exceptions ?? [] as $x) {
$where = implode(",", $x->raised_in ?? []);
echo "exception ", $x->name, " base ", $x->base ?? "", " raised in ", $where === "" ? "-" : $where, "\n";
}
foreach ($result->changes ?? [] as $c) echo "change ", $c->area ?? "", " ", $c->where ?? "", " - ", $c->what ?? "", "\n";
foreach ($result->open ?? [] as $o) echo "open ", $o->item, " - ", $o->reason ?? "", "\n";
foreach ($result->questions ?? [] as $q) echo "question ", $q, "\n";
// the harden worked example: digest-check.json from build-check.js
// (for an audited module, pass handoff.js's output instead: MakeInput adds its audit_handoff)
var hardInput = MakeInput("harden", "digest-check.json",
"Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.");
var hardDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(hardInput)));
var hardKey = "pyerr-desk:harden:" + Convert.ToHexString(hardDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", hardInput, new Dictionary<string, string> { ["Idempotency-Key"] = hardKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var f in result["files"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"file {f?["path"]} {(f?["content"]?.GetValue<string>() ?? "").Length} characters");
foreach (var x in result["exceptions"]?.AsArray() ?? new JsonArray())
{
var where = string.Join(",", (x?["raised_in"]?.AsArray() ?? new JsonArray()).Select(w => w?.GetValue<string>()));
Console.WriteLine($"exception {x?["name"]} base {x?["base"]} raised in {(where == "" ? "-" : where)}");
}
foreach (var c in result["changes"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"change {c?["area"]} {c?["where"]} - {c?["what"]}");
foreach (var o in result["open"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"open {o?["item"]} - {o?["reason"]}");
foreach (var q in result["questions"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"question {q}");
What the page does that you now do
- Reads the paste in the browser and builds
codeandfactsfrom it (errkit.js), joining dropped files under# file:lines and cutting a large paste on whole files with a# [pyerr-desk: not sent: ...]line; you send the same two strings. - Prices every input with
/estimateand refuses to run belowmin_credits, or on a paste with no Python it can read. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands an audit to the harden lane as
audit_handoff, with every edit applied to the paste first, and only while that edited paste is unchanged. - Checks the reply against the code (
recon.js), in the browser: files, lines, evidence, every edit applied in order and the free check re-run after each finding, flag answers and verdict; the hardened files put back, re-checked, every function and its arguments kept, and the exception catalogue matched against the classes in the code; and repeated credentials.recheck.jsabove runs the same checks for you.
Or skip all of this and use PyErr Desk in the browser: the check, the flags, the except-block, raise, exception-class and resource inventories, putting an edit or the hardened files back into the paste, and the Markdown and CSV exports are free.