Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bcce29e
feat(harness-adapter): credential manifest + gateway manifest with dr…
aaronjmars Oct 2, 2026
9a335b4
feat(cli): aeon init, GH_GLOBAL scope check, manifest-driven onboard
aaronjmars Oct 2, 2026
6eb4df0
docs: lead setup with ./aeon init, one PAT story, credential table
aaronjmars Oct 2, 2026
8c3cdf2
fix(init): resumable switch-over, explicit origin pushes, TTY-safe pr…
aaronjmars Oct 2, 2026
a2e0e6a
fix(init): move a template-history branch onto the instance, safer --dir
aaronjmars Oct 2, 2026
4707f47
fix(aeon): quiet first-run install and an aligned dashboard banner
aaronjmars Oct 3, 2026
b825272
docs: soften the Claude subscription token warning for hosted runners
aaronjmars Oct 3, 2026
0c34a46
feat(skills): add connect-check skill for post-connect model test
aaronjmars Oct 2, 2026
9d9247d
chore(catalog): register connect-check (skills.json, packs.json, icon…
aaronjmars Oct 2, 2026
517e2c1
chore: list connect-check in aeon.yml and skill counts
aaronjmars Oct 2, 2026
19ef94e
feat(dashboard): connect API - paste detection, OpenRouter PKCE, conn…
aaronjmars Oct 2, 2026
ea5a96d
feat(dashboard): one ConnectModal, HQ setup checklist, Telegram chat …
aaronjmars Oct 2, 2026
0248112
fix(dashboard): keep the default workflow token when enabling PR crea…
aaronjmars Oct 2, 2026
b98faf8
feat(dashboard): list HIVEMINDOS_CREDIT_TOKEN in Settings
aaronjmars Oct 2, 2026
b8b8adb
fix(dashboard): address #1157 review, read connect data from the mani…
aaronjmars Oct 2, 2026
d951987
fix(dashboard): aeon sync pushes to origin explicitly, refuses the te…
aaronjmars Oct 2, 2026
70534ed
fix(dashboard): read connect-check runs from real gh logs (UNKNOWN STEP)
aaronjmars Oct 3, 2026
a2a0fcf
fix(dashboard): tighten capture checks, cancel stale polls, Telegram …
aaronjmars Oct 3, 2026
c673857
fix(dashboard): show the usage notice's own token total
aaronjmars Oct 3, 2026
efd138d
feat(init,dashboard): test the model connection right after setup
aaronjmars Oct 3, 2026
bd29a3d
Merge main (squashed #1158) into feat/dashboard-one-shot-connect
aaronjmars Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/aeon/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ Three at a time, not twelve. Every enabled skill is a recurring notification, an
./aeon packs ls # the six first-party packs
```

`ls` footers with `85 skills · 1 enabled` — read it to them before proposing anything. First run installs the CLI runtime (tsx + yaml, ~12MB); the npm noise is one-time and expected. Grep-only equivalents: `references/layout.md`.
`ls` footers with `86 skills · 1 enabled` — read it to them before proposing anything. First run installs the CLI runtime (tsx + yaml, ~12MB); the npm noise is one-time and expected. Grep-only equivalents: `references/layout.md`.

Packs are a visibility filter, not a runtime switch — revealing one runs nothing. Core (12), Evolution (9) and Basics (18) show by default; Dev (16), Crypto (19) and Productivity (11) are on demand.

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/aeon/references/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Where everything lives in an Aeon repo, and the fastest way to see what's on.
./aeon skills ls --enabled --json # for building the Mode 2 timeline
```

`ls` prints `SKILL / ON / SCHEDULE / PACK / DESCRIPTION` and a footer — `85 skills · 1 enabled`. First run installs the CLI runtime (tsx + yaml, ~12MB, one-time); the noise is expected.
`ls` prints `SKILL / ON / SCHEDULE / PACK / DESCRIPTION` and a footer — `86 skills · 1 enabled`. First run installs the CLI runtime (tsx + yaml, ~12MB, one-time); the noise is expected.

**The `SCHEDULE` column is populated for disabled skills too** — it's their `aeon.yml` entry, not proof anything fires. Only the `●` in `ON` means it runs.

Expand Down Expand Up @@ -59,7 +59,7 @@ AGENTS.md GENERATED from CLAUDE.md (STRATEGY.md inlined) for every non-
harness. Never hand-edit; run `node scripts/gen-agents-md.js`
(gated by ci-agents-md).

skills/<name>/SKILL.md the skills themselves - 85 upstream. One prompt per file.
skills/<name>/SKILL.md the skills themselves - 86 upstream. One prompt per file.
soul/ SOUL.md + STYLE.md + examples/ — voice, read on every run (Mode 7).
memory/ durable state between runs:
logs/<date>.md per-run append under `### <skill-name>`. The dedup substrate.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/aeon/references/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Two unrelated things share the name. Get this wrong and nothing works.
| | |
|---|---|
| **`.mcp.json`** — *external MCP servers, called BY Aeon skills* | Wired via the dashboard MCP panel or `./aeon mcp add`. This is what you want when a skill needs a tool. |
| **`bin/add-mcp`** — *Aeon itself AS an MCP server* | Builds `apps/mcp-server` and registers it with Claude Code / Desktop, so all 85 skills appear as `aeon-*` tools **in your local Claude**. Nothing to do with a skill calling out. |
| **`bin/add-mcp`** — *Aeon itself AS an MCP server* | Builds `apps/mcp-server` and registers it with Claude Code / Desktop, so all 86 skills appear as `aeon-*` tools **in your local Claude**. Nothing to do with a skill calling out. |

The rest of this doc is the first one. For the second: `bin/add-mcp`, `--desktop` for a Claude Desktop snippet, `--uninstall` to remove, `claude mcp list` to verify.

Expand Down
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ per run by a [claude-code-router](https://github.com/musistudio/claude-code-rout
sidecar, like Venice/Surplus).

1. **`apps/dashboard/lib/gateway-registry.ts`** — add `slug: { label, secretName, prefixes, domain }` (empty `prefixes: []` = dropdown-only, no auto-detect). This is the **single source of truth**: it auto-flows to the `GatewayProvider` union (`lib/types.ts`), `CLAUDE_AUTH_SECRETS` (`lib/constants.ts`), the secrets route's gateway-key detection, the auth key-prefix detection (`lib/auth-provider.ts`), and the service-icon domain.
2. **`apps/dashboard/components/AuthModal.tsx`** — add the slug to `PROVIDER_OPTIONS` (this dropdown list is **not** registry-derived).
2. **Connect modal** - nothing to edit. Its provider dropdown and key-prefix detection (`apps/dashboard/lib/connect-detect.ts`) read `harness-adapter/gateways.json`, so the gateway shows up once step 6 regenerates it.
3. **`apps/dashboard/lib/secrets-catalog.ts`** — add a `BUILTIN_SECRETS` row (description only) so the secret shows in Settings (and in `aeon secrets ls`).
4. **`scripts/llm-gateway.sh`** — add an `aeon_present()` case, add the slug to the auto-resolver's default `GATEWAY_ORDER`, and add a `case` branch (a **native** provider exports `ANTHROPIC_BASE_URL` + the auth token; a **sidecar** provider calls `start_ccr_sidecar <slug> <openai-url> <key> <model>`).
5. **`.github/workflows/aeon.yml`** — pass the new secret (and any `*_MODEL` override **variables**) into the run's `env:` (also `messages.yml`), so the resolver can see it.
Expand Down
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

### New LLM gateway

- [ ] Wired through the five files (`gateway-registry.ts`, `AuthModal.tsx`, `secrets-catalog.ts`, `scripts/llm-gateway.sh`, `.github/workflows/*.yml`) — see [Contributing an LLM gateway](CONTRIBUTING.md#contributing-an-llm-gateway)
- [ ] Wired through the five files (`gateway-registry.ts`, `adapters/claude.sh` gw-meta, `secrets-catalog.ts`, `scripts/llm-gateway.sh`, `.github/workflows/*.yml`) - see [Contributing an LLM gateway](CONTRIBUTING.md#contributing-an-llm-gateway)
- [ ] Added a row to the gateway table in [`docs/CONFIGURATION.md`](../docs/CONFIGURATION.md#llm-gateways)
- [ ] Verified end to end — a run logs `gateway=auto resolved to <slug>`

Expand Down
6 changes: 3 additions & 3 deletions .github/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="../docs/assets/hero-animated.svg" alt="AEON - the most autonomous agent framework. 85 skills across 9 harnesses (Claude Code, Grok, Codex, Pi, Vibe, Kimi, fx, Cursor, Hermes), running unattended on GitHub Actions: it ships features to your repos, privately discloses real vulnerabilities, deploys live apps, runs deep research, and writes new skills for itself. Keywords: autonomous AI agent, agent framework, GitHub Actions automation, self-improving agent, multi-agent orchestration, LLM skills, cron agent." width="100%" />
<img src="../docs/assets/hero-animated.svg" alt="AEON - the most autonomous agent framework. 86 skills across 9 harnesses (Claude Code, Grok, Codex, Pi, Vibe, Kimi, fx, Cursor, Hermes), running unattended on GitHub Actions: it ships features to your repos, privately discloses real vulnerabilities, deploys live apps, runs deep research, and writes new skills for itself. Keywords: autonomous AI agent, agent framework, GitHub Actions automation, self-improving agent, multi-agent orchestration, LLM skills, cron agent." width="100%" />
</p>

<p align="center">
Expand Down Expand Up @@ -102,10 +102,10 @@ metadata:
The prompt *is* the skill. You schedule it, hand it a `var`, chain it into others, and a cheap model rates every run (Haiku on Claude). How packs work: [`docs/skill-packs.md`](../docs/skill-packs.md).

<p align="center">
<img src="../docs/assets/packs-aeon.jpg" alt="Six skill packs, 85 skills total: Core (fleet coordination, self-config, liveness), Evolution (authors and heals its own skills), Basics (simple runnable skills), Dev & Code, Crypto & Markets, and Productivity." width="100%" />
<img src="../docs/assets/packs-aeon.jpg" alt="Six skill packs, 86 skills total: Core (fleet coordination, self-config, liveness), Evolution (authors and heals its own skills), Basics (simple runnable skills), Dev & Code, Crypto & Markets, and Productivity." width="100%" />
</p>

<p align="center"><a href="../docs/skill-packs.md#full-catalog-all-85-skills-by-pack"><b>Full catalog - all 85 skills by pack →</b></a></p>
<p align="center"><a href="../docs/skill-packs.md#full-catalog-all-86-skills-by-pack"><b>Full catalog - all 86 skills by pack →</b></a></p>

<p align="center"><a href="../docs/community-skill-packs.md#listed-packs"><b>Community skill packs →</b></a></p>

Expand Down
1 change: 1 addition & 0 deletions aeon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ skills:

# --- Evening / meta (6 PM UTC) ---
strategy-builder: { enabled: false, schedule: "workflow_dispatch", var: "" } # on-demand — draft STRATEGY.md (north-star / priorities / audience / constraints) from a brief + repo README + memory. var: bare goal text, or structured brief "repo=owner/repo | links=url1,url2 | goal=free text". Powers the dashboard Strategy → Build my strategy button. No API key needed.
connect-check: { enabled: false, schedule: "workflow_dispatch", var: "" } # on-demand - dashboard "Test connection": one tiny model call that must answer AEON_CONNECT_OK; proves the saved model key works on a runner. No notify, no memory.
skill-health: { enabled: false, schedule: "0 18 * * *", var: "" } # self-healing health audit + analytics. var: empty=health (files/resolves issues) | <skill>=single | analytics[:hours]=fleet run/pass-rate metrics (folds in skill-analytics)
aeon-doctor: { enabled: false, schedule: "0 15 * * 1", var: "" } # weekly Mon 15:00 UTC — static config-correctness linter (silent-failure class: unquoted schedules, dup keys, unconfigured-on-disk, requires-as-list, mode typo, mcp ${VAR}, multi-line entries). read-only; notifies only on problems. var: empty=whole config | <skill>=one skill

Expand Down
5 changes: 3 additions & 2 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ the web dashboard, and `./aeon <command> …` runs this CLI.
Setting up a new instance? `./aeon init` does it end to end from a clone of
`aeonfun/aeon`: creates your repo from the template, points this folder and `gh`
at it, enables Actions, stores `GH_GLOBAL`, connects a model (from the credential
manifest in `harness-adapter/harnesses.json`) and links Telegram. Every step checks
manifest in `harness-adapter/harnesses.json`), tests it with one tiny
`connect-check` run on GitHub (skip with `--no-test`) and links Telegram. Every step checks
first, so it is safe to re-run; `./aeon init --dry-run` shows what it would do.

```sh
Expand Down Expand Up @@ -75,7 +76,7 @@ runs pick the change up), call `gh` (secrets/auth), or dispatch a workflow.
| `aeon skills rm <name> --yes` | delete the skill dir + config entry |
| `aeon skills run <name> [--var\|--model]` | dispatch a run (`gh workflow run aeon.yml`) |
| `aeon secrets set <NAME> --stdin` · `aeon secrets rm <NAME>` | manage secrets via `gh` |
| `aeon init [--name\|--private\|--dir\|--harness\|--yes]` | set up an instance end to end (interactive, idempotent) |
| `aeon init [--name\|--private\|--dir\|--harness\|--no-test\|--yes]` | set up an instance end to end (interactive, idempotent) |
| `aeon auth --harness claude-code \| --key <k> [--provider\|--base-url]` | set Claude auth (`--oauth` still works) |
| `aeon auth --harness <h> [--key <k>]` | another harness's login or key (`grok` included); `--github` copies the gh token to `GH_GLOBAL` (needs `repo` + `workflow`) |
| `aeon sync [--status]` | commit + push local changes |
Expand Down
94 changes: 87 additions & 7 deletions apps/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
// 4. Actions enabled + Actions may open PRs (the default token is left as is)
// 5. GH_GLOBAL from your gh token (only when it has repo + workflow)
// 6. Model: pick a harness and connect one of its credentials (manifest-driven)
// 7. Telegram (optional): bot token + chat id via a /start deep link
// 8. Summary checklist, then optionally start the dashboard
// 7. Test connection: run the tiny connect-check skill on GitHub and read the
// verdict (same lib as the dashboard's "Test connection")
// 8. Telegram (optional): bot token + chat id via a /start deep link
// 9. Summary checklist, then optionally start the dashboard
import { parseArgs } from 'node:util'
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
Expand All @@ -28,6 +30,9 @@ import { parseConfig } from '../../../dashboard/lib/config.ts'
import { HARNESSES, type Harness } from '../../../dashboard/lib/types.ts'
import { loadGateways, loadHarnesses, runnableSecrets, type Credential, type Gateway, type HarnessManifest } from '../manifest.ts'
import { grokLogin, storeGrokKey } from '../grok.ts'
import { createMemoryStore } from '../../../dashboard/lib/connect-store.ts'
import { ConnectCheckMissing, dispatchConnectCheck, readConnectCheck } from '../../../dashboard/lib/connect-check-server.ts'
import { pollConnectCheck, type CheckResult } from '../../../dashboard/lib/connect-check.ts'
import { c, fail, isDryRun, isUpstreamRepo } from '../output.ts'

const USAGE = `aeon init - set up your own Aeon instance, end to end (safe to re-run)
Expand All @@ -41,6 +46,7 @@ Options:
--dir <path> Clone your instance into <path> and continue there,
instead of turning this folder into it
--harness <h> Preselect the agent: ${HARNESSES.join(' | ')}
--no-test Skip the connection test (a tiny skill run on GitHub)
--no-telegram Skip the Telegram step
--no-dashboard Do not offer to start the dashboard at the end
-y, --yes Accept every default (still asks for keys it cannot guess)
Expand All @@ -59,6 +65,7 @@ interface Opts {
private: boolean
dir?: string
harness?: string
test: boolean
telegram: boolean
dashboard: boolean
yes: boolean
Expand Down Expand Up @@ -343,7 +350,7 @@ async function cloneElsewhere(opts: Opts, target: string): Promise<never> {
report('clone', 'fixed', `cloned ${target} into ${dir}; continuing there`)
printSummary()
const args = ['init', ...(opts.yes ? ['--yes'] : []), ...(opts.harness ? ['--harness', opts.harness] : []),
...(opts.telegram ? [] : ['--no-telegram']), ...(opts.dashboard ? [] : ['--no-dashboard'])]
...(opts.test ? [] : ['--no-test']), ...(opts.telegram ? [] : ['--no-telegram']), ...(opts.dashboard ? [] : ['--no-dashboard'])]
const env = { ...process.env, AEON_REPO_ROOT: dir }
const r = spawnSync(launcher, args, { cwd: dir, stdio: 'inherit', env })
if (r.error) {
Expand Down Expand Up @@ -777,6 +784,78 @@ async function stepModel(opts: Opts, secrets: Set<string> | null) {
if (h.id !== configured) await switchHarness(h.id)
}

// --- Test connection --------------------------------------------------------
// The harness aeon.yml runs, if one of its credentials is set on the instance.
function connectedHarness(secrets: Set<string> | null): HarnessManifest | null {
const h = loadHarnesses().find((x) => x.id === currentHarness())
return h && runnableSecrets(h, loadGateways()).some((s) => secrets?.has(s)) ? h : null
}

const STATE_TEXT: Record<string, string> = {
queued: 'waiting for a runner', running: 'running on GitHub', none: 'looking for the run',
}

// Dispatch connect-check on the configured harness and wait for the verdict:
// the run must succeed AND the model must answer (token usage, or the exact
// reply on harnesses that report no usage). A saved key that GitHub rejects
// (a Claude subscription token, typically) fails here instead of on the first
// scheduled run.
async function stepTest(opts: Opts, secrets: Set<string> | null) {
heading(7, 'Test connection')
const h = connectedHarness(secrets)
if (!h) { report('test', 'skip', 'no model connected, nothing to test'); return }
if (!opts.test) { report('test', 'skip', 'skipped (--no-test)', './aeon init, or Test connection on the dashboard HQ'); return }
note('Runs the tiny connect-check skill once on GitHub (about 1 to 3 minutes) to prove the model answers.')
if (!(await confirm(opts, `Test ${h.label} now?`, true))) {
report('test', 'skip', 'connection not tested', './aeon init, or Test connection on the dashboard HQ')
return
}
if (isDryRun()) { report('test', 'skip', `would run the connect-check skill on ${h.id} and wait for the result`); return }

const store = createMemoryStore()
let dispatchId: string
try {
dispatchId = (await dispatchConnectCheck(store, h.id)).dispatchId
} catch (e) {
if (e instanceof ConnectCheckMissing) {
report('test', 'warn', 'this instance has no connect-check skill yet, so the model was not tested',
'update your instance (git pull upstream main, then ./aeon sync), then re-run ./aeon init')
} else {
report('test', 'fail', `could not start the test run: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}`, 'check that Actions is enabled, then re-run ./aeon init')
}
return
}

let last = ''
const progress = (r: CheckResult) => {
const text = STATE_TEXT[r.state]
if (!text || text === last) return
last = text
if (interactive) process.stdout.write(`\r ${c.dim(`${text}...`)}\x1b[K`)
else console.log(c.dim(` ${text}...`))
}
progress({ state: 'queued' })
const pollMs = Number(process.env.AEON_CONNECT_CHECK_POLL_MS) || 5000
const result = await pollConnectCheck({
read: () => readConnectCheck(store, h.id, dispatchId),
onUpdate: progress,
sleep: (ms) => sleep(ms).then(() => undefined),
now: Date.now,
cancelled: () => false,
intervalMs: pollMs,
timeoutMs: Math.max(10 * 60_000, pollMs * 20),
})
if (interactive) process.stdout.write('\r\x1b[K')
if (result.state === 'pass') {
const tokens = result.usage && result.usage.total > 0 ? ` (${result.usage.total} tokens)` : ''
report('test', 'ok', `${h.label} answered from GitHub${tokens}`)
return
}
const fix = [result.hint, result.fix ? `then: ${result.fix.cli}` : '', result.runUrl ? `run: ${result.runUrl}` : '']
.filter(Boolean).join(' ')
report('test', 'fail', `${h.label} test failed: ${result.reason ?? 'no verdict'}`, fix || './aeon init')
}

// --- Telegram ---------------------------------------------------------------
interface TgUpdate { message?: { text?: string; chat?: { id?: number } } }

Expand All @@ -793,7 +872,7 @@ async function manualChatId(why: string): Promise<string> {
}

async function stepTelegram(opts: Opts, secrets: Set<string> | null) {
heading(7, 'Telegram (optional)')
heading(8, 'Telegram (optional)')
if (!opts.telegram) { report('telegram', 'skip', 'skipped (--no-telegram)'); return }
if (secrets?.has('TELEGRAM_BOT_TOKEN') && secrets.has('TELEGRAM_CHAT_ID')) {
report('telegram', 'ok', 'TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set')
Expand Down Expand Up @@ -890,18 +969,18 @@ function finish(completed: boolean): never {

export async function initCommand(argv: string[]) {
if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return }
let values: { name?: string; private?: boolean; dir?: string; harness?: string; 'no-telegram'?: boolean; 'no-dashboard'?: boolean; yes?: boolean }
let values: { name?: string; private?: boolean; dir?: string; harness?: string; 'no-test'?: boolean; 'no-telegram'?: boolean; 'no-dashboard'?: boolean; yes?: boolean }
try {
;({ values } = parseArgs({ args: argv, options: {
name: { type: 'string' }, private: { type: 'boolean' }, dir: { type: 'string' }, harness: { type: 'string' },
'no-telegram': { type: 'boolean' }, 'no-dashboard': { type: 'boolean' }, yes: { type: 'boolean', short: 'y' },
'no-test': { type: 'boolean' }, 'no-telegram': { type: 'boolean' }, 'no-dashboard': { type: 'boolean' }, yes: { type: 'boolean', short: 'y' },
} }))
} catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') }
const name = values.name ?? 'aeon'
if (!/^[A-Za-z0-9._-]+$/.test(name)) fail(`--name must be a plain repo name (got '${name}')`)
const opts: Opts = {
name, private: Boolean(values.private), dir: values.dir, harness: values.harness,
telegram: !values['no-telegram'], dashboard: !values['no-dashboard'], yes: Boolean(values.yes),
test: !values['no-test'], telegram: !values['no-telegram'], dashboard: !values['no-dashboard'], yes: Boolean(values.yes),
}

console.log(c.bold('Aeon setup') + c.dim(` - each step checks first and only fixes what is missing; safe to re-run${isDryRun() ? ' (dry run: nothing changes)' : ''}`))
Expand All @@ -920,6 +999,7 @@ export async function initCommand(argv: string[]) {
if (!secrets) note('Could not list the repo secrets; the steps below will offer to set everything.')
await stepGhGlobal(opts, secrets)
await stepModel(opts, secrets)
await stepTest(opts, secretNames(slug) ?? secrets)
await stepTelegram(opts, secretNames(slug) ?? secrets)

const failed = rows.some((r) => r.status === 'fail')
Expand Down
Loading
Loading