Skip to content

Commit a49c793

Browse files
Merge pull request #17 from StepenkoAnatoli/fix/live-workflow-and-python-probe
Make the live workflow's green mean something, and stop python hiding behind python
2 parents 304c07f + 9b6a189 commit a49c793

7 files changed

Lines changed: 231 additions & 60 deletions

File tree

‎.github/workflows/live-collection.yml‎

Lines changed: 129 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,30 @@ name: live-collection
55
# `offline-suite` deliberately cannot reach the network, which is right: a reviewer or a
66
# fork must be able to re-run every check without being given a key. But that means the
77
# real network boundary is represented entirely by stubs, and the suite stays green while
8-
# real collection fails on a changed payload, a proxy, a platform TLS quirk, a 429, or a
9-
# partial response.
8+
# real collection fails on a changed payload, a proxy, a platform TLS quirk, or a 429.
109
#
11-
# This exists to exercise that boundary on purpose, with the cost visible and bounded.
10+
# ONE SUCCESS PREDICATE, AND IT IS NOT "the steps ran".
1211
#
13-
# It is:
14-
# manual only no push, no pull_request, no schedule - nothing runs it by
15-
# accident, and a fork cannot trigger it
16-
# budgeted `max_pages` is an input with a small default, and the run
17-
# reports the credits it actually spent
18-
# credential-scoped one secret, read from the environment, never written to disk
19-
# corpus-isolated it collects into a scratch project under RUNNER_TEMP and
20-
# commits nothing. The repository's own corpus is evidence for
21-
# its own unknowns, not a place to put test captures
22-
# redacted the key never reaches a log line; the adapter refuses a query
23-
# containing it before transmit, and `command()` renders
24-
# pre-redacted
12+
# The first version of this workflow ran the right checks and then swallowed every one of
13+
# them with `|| true`, so a green run proved only that the repository was not modified.
14+
# That is worse than no workflow: it is a signal that looks like verification. Every
15+
# required condition below now fails the job, and the summary states which ones were
16+
# checked - so a green run means all of this held:
17+
#
18+
# 1. the vendor CLI is a major this adapter supports
19+
# 2. the collection command exited 0
20+
# 3. a ledger was written, and it parses
21+
# 4. handoff exits 0 - the corpus arrived whole
22+
# 5. preflight exits 0 - the corpus passes the same checks a synthetic one does
23+
# 6. the repository is unchanged
24+
#
25+
# Diagnostics are still captured when a step fails, but they are captured in a way that
26+
# preserves the exit status rather than discarding it.
2527
#
2628
# WHEN TO RUN IT
2729
# - before trusting a Firecrawl CLI version this adapter has not seen
28-
# - after changing anything under lib/firecrawl.mjs, lib/http-transport.mjs,
29-
# lib/serpapi.mjs or lib/collect.mjs
30+
# - after changing lib/firecrawl.mjs, lib/http-transport.mjs, lib/serpapi.mjs, lib/collect.mjs
3031
# - when a real collection fails in a way the offline suite cannot reproduce
31-
#
32-
# WHAT A FAILURE HERE MEANS
33-
# The offline suite passing and this failing is the interesting case, and the expected
34-
# one: it means the stubs and the vendor have diverged. Read the version report first.
3532

3633
on:
3734
workflow_dispatch:
@@ -46,6 +43,22 @@ on:
4643
default: 'firecrawl-cli'
4744
type: choice
4845
options: [firecrawl-cli, http-keyless]
46+
cli_version:
47+
description: 'Firecrawl CLI version to install. "tested" pins the version the adapter was written against; "latest" is compatibility surveillance and may legitimately fail.'
48+
required: true
49+
default: 'tested'
50+
type: choice
51+
options: [tested, latest]
52+
53+
# Paid runs are SERIALISED.
54+
#
55+
# max_pages bounds one run. It does not bound the meter: two dispatches a second apart
56+
# spend concurrently, and a reader who sees "max 10 pages" reasonably believes that is the
57+
# ceiling. `cancel-in-progress: false` is deliberate - cancelling a paid collection
58+
# mid-flight wastes the credits already spent and can leave a scratch ledger torn.
59+
concurrency:
60+
group: live-collection
61+
cancel-in-progress: false
4962

5063
permissions:
5164
contents: read
@@ -55,7 +68,6 @@ jobs:
5568
name: live
5669
runs-on: ubuntu-latest
5770
timeout-minutes: 15
58-
# A budget nobody can raise by editing an input: the job itself refuses a large run.
5971
environment: live-collection
6072
defaults:
6173
run:
@@ -67,32 +79,61 @@ jobs:
6779
with:
6880
node-version: '22'
6981

82+
- name: announce the run, its budget and its policy
83+
run: |
84+
{
85+
echo "| setting | value |"
86+
echo "|---|---|"
87+
echo "| run | ${{ github.run_id }} |"
88+
echo "| transport | ${{ inputs.transport }} |"
89+
echo "| max pages | ${{ inputs.max_pages }} |"
90+
echo "| CLI policy | ${{ inputs.cli_version }} |"
91+
} >> "$GITHUB_STEP_SUMMARY"
92+
7093
- name: refuse an unbounded run
7194
run: |
7295
pages='${{ inputs.max_pages }}'
7396
case "$pages" in (''|*[!0-9]*) echo "max_pages must be a whole number"; exit 1;; esac
7497
if [ "$pages" -lt 1 ] || [ "$pages" -gt 10 ]; then
7598
echo "max_pages=$pages is outside 1..10."
76-
echo "This workflow spends real credits; a large run belongs on a machine where"
99+
echo "This workflow spends real credits; a larger run belongs on a machine where"
77100
echo "somebody is watching the meter, not in CI."
78101
exit 1
79102
fi
80-
echo "budget: $pages page(s)"
81103
82-
- name: install the vendor CLI, and report the version this run used
104+
# Installation failure and INCOMPATIBILITY are different facts and are reported
105+
# separately. The first version suppressed both with `|| true`, so "the package
106+
# moved" and "the CLI is a major we do not support" looked identical.
107+
- name: install the vendor CLI
108+
id: cli
109+
if: inputs.transport == 'firecrawl-cli'
110+
run: |
111+
set -e
112+
tested=$(node -e 'import("./research-kit/lib/firecrawl.mjs").then(m => process.stdout.write(m.TESTED_CLI_VERSION))')
113+
if [ '${{ inputs.cli_version }}' = 'tested' ]; then
114+
spec="firecrawl@$tested"
115+
echo "pinning to the version this adapter was written against: $spec"
116+
else
117+
spec="firecrawl@latest"
118+
echo "SURVEILLANCE MODE: installing $spec. A refusal here is the workflow doing its job."
119+
fi
120+
if ! npm install -g "$spec"; then
121+
echo "::error::could not install $spec - this is an INSTALLATION failure, not an incompatibility"
122+
echo "| CLI install | \`failed: $spec\` |" >> "$GITHUB_STEP_SUMMARY"
123+
exit 1
124+
fi
125+
installed=$(firecrawl --version)
126+
echo "installed=$installed" >> "$GITHUB_OUTPUT"
127+
echo "| CLI installed | \`$installed\` (tested against \`$tested\`) |" >> "$GITHUB_STEP_SUMMARY"
128+
129+
- name: the CLI is a major this adapter supports
83130
if: inputs.transport == 'firecrawl-cli'
84131
run: |
85-
npm install -g @mendable/firecrawl-cli >/dev/null 2>&1 || npm install -g firecrawl >/dev/null 2>&1 || true
86-
installed=$(firecrawl --version 2>/dev/null || echo 'not installed')
87-
echo "firecrawl CLI: $installed"
88-
# The version is the first thing to read when this workflow fails, so it goes in
89-
# the summary rather than only in the log.
90-
echo "| firecrawl CLI | \`$installed\` |" >> "$GITHUB_STEP_SUMMARY"
91132
node -e '
92133
import("./research-kit/lib/firecrawl.mjs").then((m) => {
93134
const c = m.cliCompatibility();
94135
console.log(`${c.level}: ${c.detail}`);
95-
if (!c.supported) { console.error(c.remedy); process.exit(1); }
136+
if (!c.supported) { console.error(`::error::${c.remedy}`); process.exit(1); }
96137
});
97138
'
98139
@@ -101,12 +142,13 @@ jobs:
101142
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
102143
run: |
103144
if [ -z "${FIRECRAWL_API_KEY:-}" ] && [ '${{ inputs.transport }}' = 'firecrawl-cli' ]; then
104-
echo "FIRECRAWL_API_KEY is not set for this environment."
145+
echo "::error::FIRECRAWL_API_KEY is not set for the live-collection environment."
105146
echo "Add it under Settings > Environments > live-collection, or run with transport=http-keyless."
106147
exit 1
107148
fi
108149
echo "credential present: ${FIRECRAWL_API_KEY:+yes}${FIRECRAWL_API_KEY:-no (keyless route)}"
109150
151+
# From here every step is REQUIRED. `set -e` is on and nothing is swallowed.
110152
- name: collect into a scratch project, never the repository's corpus
111153
env:
112154
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
@@ -123,36 +165,60 @@ jobs:
123165
# a CI capture in it would be evidence of nothing.
124166
node "$kit/bin/new-project.mjs" . --topic "live transport check"
125167
126-
node "$kit/bin/doctor.mjs" || true
168+
# doctor is diagnostic and may legitimately report a missing key on the keyless
169+
# route, so its status is recorded rather than required - the only step of which
170+
# that is true, and it is said here rather than hidden behind `|| true`.
171+
node "$kit/bin/doctor.mjs" || echo "doctor reported findings (not required to pass)"
172+
127173
node "$kit/bin/research.mjs" --dry-run --depth quick
128174
node "$kit/bin/research.mjs" --depth quick --max-scrapes '${{ inputs.max_pages }}'
129175
130-
echo "--- what it cost ---"
131-
node "$kit/bin/research.mjs" --status || true
176+
- name: a ledger was written, and it parses
177+
run: |
178+
set -e
179+
cd "$RUNNER_TEMP/live-project"
180+
ledger='research/raw/.fetches.jsonl'
181+
if [ ! -s "$ledger" ]; then
182+
echo "::error::collection produced no fetch ledger; nothing was recorded"
183+
exit 1
184+
fi
185+
node -e '
186+
const fs = require("fs");
187+
const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").filter(Boolean);
188+
const parsed = rows.map((line, i) => { try { return JSON.parse(line); } catch { throw new Error(`ledger line ${i + 1} does not parse`); } });
189+
const fetched = parsed.filter((r) => r.op !== "fail");
190+
if (!fetched.length) throw new Error("the ledger records no successful fetch");
191+
console.log(`${parsed.length} ledger entries, ${fetched.length} successful`);
192+
' "$ledger"
132193
133-
- name: the corpus that arrived verifies
134-
env:
135-
RESEARCH_KIT_TRANSPORT: ${{ inputs.transport }}
194+
- name: the corpus arrived whole, and passes its own checks
136195
run: |
196+
set -e
137197
cd "$RUNNER_TEMP/live-project"
138-
# The point of collecting at all: a real capture must pass the same checks a
139-
# synthetic one does - chain verifies, every cited capture on disk, bodies
140-
# unmodified.
141-
node "$GITHUB_WORKSPACE/research-kit/bin/handoff.mjs" || true
142-
node "$GITHUB_WORKSPACE/research-kit/bin/preflight.mjs" --json > preflight.json || true
198+
kit="$GITHUB_WORKSPACE/research-kit"
199+
200+
# REQUIRED, both of them. The point of collecting at all is that a real capture
201+
# must pass the same checks a synthetic one does - chain verifies, every cited
202+
# capture on disk, bodies unmodified. Diagnostics are printed on failure
203+
# WITHOUT discarding the exit status.
204+
if ! node "$kit/bin/handoff.mjs"; then
205+
echo "::error::handoff failed: the collected corpus did not arrive whole"
206+
exit 1
207+
fi
208+
if ! node "$kit/bin/preflight.mjs" --json > preflight.json; then
209+
echo "::error::preflight failed on the collected corpus"
210+
cat preflight.json || true
211+
exit 1
212+
fi
143213
node -e '
144214
const r = JSON.parse(require("fs").readFileSync("preflight.json", "utf8"));
145215
console.log(`preflight: ${r.status ?? r.verdict}`);
146-
const chain = (r.checks ?? []).find((c) => /chain|ledger/.test(c.name ?? ""));
147-
if (chain) console.log(` ${chain.name}: ${chain.status}`);
148216
'
149217
150218
- name: the repository is unchanged
151219
run: |
152-
# The guarantee this workflow rests on. If a live run ever wrote into the
153-
# checkout, every later claim about corpus provenance would be suspect.
154220
if [ -n "$(git status --porcelain)" ]; then
155-
echo "a live collection modified the repository:"
221+
echo "::error::a live collection modified the repository"
156222
git status --porcelain
157223
exit 1
158224
fi
@@ -161,15 +227,24 @@ jobs:
161227
- name: sanitized diagnostics
162228
if: always()
163229
run: |
164-
cd "$RUNNER_TEMP/live-project" || exit 0
165-
# Deliberately NOT the captures: they are someone else's pages, and this is a
166-
# public log. Only the shape of what happened.
230+
cd "$RUNNER_TEMP/live-project" 2>/dev/null || exit 0
231+
# HOSTNAMES ONLY, deliberately.
232+
#
233+
# This summary is public. A URL is not a credential, but a path and a query
234+
# string disclose what was being researched - search terms, private targets,
235+
# account-scoped routes, signed parameters. The first version printed the first
236+
# 60 characters of each URL, which is exactly enough to leak the subject and not
237+
# enough to be useful. The shape of what happened needs no path.
167238
node -e '
168239
const fs = require("fs");
169240
const led = "research/raw/.fetches.jsonl";
170241
if (!fs.existsSync(led)) { console.log("no ledger written"); process.exit(0); }
171-
const rows = fs.readFileSync(led, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse);
242+
const rows = fs.readFileSync(led, "utf8").trim().split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
243+
console.log("| op | status | transport | completeness | host |");
244+
console.log("|---|---|---|---|---|");
172245
for (const r of rows) {
173-
console.log([r.op, r.statusCode ?? "-", r.transport ?? "-", r.completeness ?? "-", (r.url ?? "").slice(0, 60)].join(" "));
246+
let host = "-";
247+
try { host = r.url ? new URL(r.url).hostname : "-"; } catch { host = "unparseable"; }
248+
console.log(`| ${r.op ?? "-"} | ${r.statusCode ?? "-"} | ${r.transport ?? "-"} | ${r.completeness ?? "-"} | ${host} |`);
174249
}
175250
' >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine ro
146146
| `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). | ADR-0011, ADR-0020 |
147147
| `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, and (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. | ADR-0028 |
148148
| `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 |
149-
| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.12+, reading either stream because Python 2 printed its version to stderr), and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. | — |
149+
| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.12+, reading either stream because Python 2 printed its version to stderr, trying `python3` **first** and continuing past an old interpreter rather than stopping at it — `python` is an alias for 2.7 on the most common Linux and older-macOS layout, and stopping there reported "no usable Python" with a good 3.12 one name away), and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. | — |
150150
| `scaffold.mjs` | Project shape: `LAYOUT` (12 entries), the **four** `GATE_MARKERS` (pinned by ADR-0001 and its test), template rendering, placeholder validation, hook executability. `.gitattributes` is a LAYOUT entry but **not** a gate marker: a project without it still gates, it just breaks on a Windows builder (ADR-0020). | ADR-0001, ADR-0020 |
151151
| `installer.mjs` | Installs the git hook (records/restores prior `hooksPath`) and the edit-time gate (backs up, repairs known corruption, refuses unfamiliar states). The hook was renamed (A10.4): the installer recognises a registration left at a retired name, replaces it rather than adding a second, and reports the repair — `retiredRepairNote`. | ADR-0012 |
152152
| `render.mjs` | Terminal table rendering for CLI output. | — |

0 commit comments

Comments
 (0)