Skip to content

feat: implement model method run command#7

Merged
adamhjk merged 1 commit intomainfrom
feat/model-method-run
Jan 28, 2026
Merged

feat: implement model method run command#7
adamhjk merged 1 commit intomainfrom
feat/model-method-run

Conversation

@adamhjk
Copy link
Copy Markdown
Contributor

@adamhjk adamhjk commented Jan 28, 2026

Summary

  • Add model method run <model_id_or_name> <method_name> command to execute model methods and persist resulting resources
  • Add MethodExecutionService domain service for method execution with input validation
  • Add interactive and JSON output rendering for method execution results
  • Update input's resourceId after successful method execution

Command Hierarchy

swamp model
  ├── create <type> <name>
  ├── validate [model_id_or_name]
  └── method
       └── run <model_id_or_name> <method_name>

Test plan

  • Unit tests for MethodExecutionService (validation success/failure)
  • Unit tests for output rendering (interactive and JSON modes)
  • Unit tests for command module loading
  • Integration tests for full CLI flow
  • Integration tests for error cases (unknown model, unknown method, missing attributes)
  • All 216 tests pass
  • Manual verification of end-to-end flow

🤖 Generated with Claude Code

Add the `model method run <model_id_or_name> <method_name>` command to
execute model methods and persist resulting resources.

Changes:
- Add MethodExecutionService domain service for method execution with
  input validation against method schemas
- Add model_method_run CLI command with support for lookup by ID or name
- Add interactive and JSON output rendering for method execution results
- Update input's resourceId after successful method execution
- Register method subcommand under the model command

Command hierarchy:
  swamp model method run <model_id_or_name> <method_name>

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@adamhjk adamhjk merged commit 0cb1c77 into main Jan 28, 2026
3 checks passed
@adamhjk adamhjk deleted the feat/model-method-run branch January 28, 2026 21:24
johnrwatson pushed a commit that referenced this pull request Apr 7, 2026
## Summary

Adds a GitHub Action that auto-moves every new issue filed against
`systeminit/swamp` into the swamp.club lab, posts a comment on the
GitHub issue linking to the new lab home, and closes the GitHub issue.
Also migrates the existing `@swamp/issue-lifecycle` extension client to
the numeric lab issue id contract introduced by
systeminit/swamp-club#364 and #369.

## User-visible behaviour change

When anyone files an issue against `systeminit/swamp`:

1. The issue body (with an `Automoved by swampadmin from GitHub issue
#<n>` footer appended) is POSTed to
`https://swamp.club/api/v1/lab/issues/ensure`.
2. A comment is posted on the GitHub issue that reads:
> Thank you for opening an issue. This issue is now managed in the
Claude lab.
   > 
   > https://swamp.club/lab/<number>
3. The GitHub issue is closed.

If the swamp-club call fails for any reason, the action fails and **the
GitHub issue stays open** — there is no failure mode in which we close a
GitHub issue without confirming the lab counterpart exists. See "Why the
close is gated" below.

The previous behaviour (welcome comment + `needs-triage` label, with a
maintainer skip) is removed. Every new issue gets moved unconditionally
— issues filed by maintainers go through the same path as community
issues, by design.

## Contract change in swamp-club#369

systeminit/swamp-club#364 introduced a sequential, human-friendly
`number` on every lab issue (allocated by an atomic Mongo counter) and
migrated the UI route from `/lab/{uuid}` to `/lab/{number}`.

systeminit/swamp-club#369 finished the job: it collapsed
`/api/v1/lab/issues/{uuid}` and `/api/v1/lab/issues/by-number/{n}` into
a single `/api/v1/lab/issues/{number}` tree, so every endpoint that used
to take a UUID now takes the sequential number. The internal UUID is
still the Mongo foreign key (comments, events, lifecycle entries
reference it) but **it never appears in a URL**.

Endpoints affected, all of which this client now hits via the new shape:

| Endpoint | Method |
|---|---|
| `/api/v1/lab/issues/{number}` | `PATCH` (used by `transitionStatus`) |
| `/api/v1/lab/issues/{number}/lifecycle` | `POST` (used by
`postLifecycleEntry`) |
| `/api/v1/lab/issues/ensure` | `POST` (unchanged — still no path id) |

This client previously cached the UUID it received from `/ensure` and
interpolated it into the lifecycle/PATCH paths. After #369, those calls
would 400 with `\"Invalid issue number\"` because the server-side
`parseLabIssueNumberParam` validator (`^\d+$` + `Number.isSafeInteger`)
rejects UUIDs. The migration in this PR is necessary to keep the
issue-lifecycle integration working at all.

### Tracking issue
This PR closes the client-side half of the work I tracked in
systeminit/swamp-club#367, which I filed when I first noticed the
contract drift. The server-side half shipped as #369 (already merged).

## Safe-integer validation rationale

swamp-club#369 added a strict validator `lib/lab-issue-number-param.ts`
that uses `Number.isSafeInteger` instead of `Number.isInteger`. The PR
description explains why: during live testing,
`Number.parseInt(\"7d03f113-80a5-...\", 10)` silently truncated a UUID
to `7` and routed a request meant for one issue onto issue #7,
**clobbering its title**. The strict validator catches that hazard.

This client doesn't parse strings (it consumes a JSON number from
`/ensure` and emits it back into a URL path), so the parseInt hazard is
not directly reachable. But mirroring the server's safe-integer bound is
still the right call for two reasons:

1. **Contract symmetry.** If the server ever returns a value the client
would happily emit but the next request would 400 on, we'd have a
confusing failure mode. Validating with the same predicate the server
uses means \"if the client accepts it, the server accepts it.\"
2. **Defence against upstream contract drift.** The client previously
asserted the response shape with a TypeScript `as` cast —
runtime-trivial. The new code reads `data?.issue?.number` defensively
(typed as `unknown`) and runs it through the same `Number.isSafeInteger`
+ positive check. A malformed response can no longer throw a `TypeError`
before we hit our validator, and a numerically-suspicious response (NaN,
Infinity, fractions, anything past 2^53-1) is rejected with a structured
log line.

## Why the GitHub Action close is gated on a valid lab number

The action's only safety property is: **a GitHub issue must never be
closed unless its lab counterpart exists.** If we close before
confirming, an issue can disappear from GitHub without ever being filed
in the lab — losing the report.

The flow is therefore strictly sequential and short-circuits on every
error:

1. POST to `/api/v1/lab/issues/ensure`. If non-2xx →
`core.setFailed(...)` and `return` (issue stays open).
2. Parse JSON. If `data.issue.number` is missing or not a safe positive
integer → `core.setFailed(...)` and `return` (issue stays open).
3. Post the auto-responder comment with the lab URL.
4. **Only then** close the GitHub issue.

`core.setFailed` does not stop execution by itself in
`actions/github-script`, so each guard is paired with an explicit
`return`. Steps 3 and 4 are unreachable unless we have a confirmed
`labIssueNumber`.

## Files changed

### `.github/workflows/auto-response.yml`

Repurposed end-to-end:
- Removed the maintainer-permission probe and the maintainer-only skip
path.
- Removed the `needs-triage` label step and the welcome-comment step.
- Single new step (`actions/github-script@v7`, Node 20 fetch) that:
- Reads the issue title, body, and author from `context.payload.issue`.
- Builds the lab body: original body + `\n\n---\nAutomoved by swampadmin
from GitHub issue #<n>` footer (handles empty bodies).
- POSTs to `https://swamp.club/api/v1/lab/issues/ensure` with `{
githubRepoFullName, githubIssueNumber, title, body, type: \"feature\",
githubAuthorLogin }`, 15s `AbortSignal.timeout`.
- Validates the response: defensive `data?.issue?.number` access,
`Number.isSafeInteger`, `> 0`. Mirrors swamp-club's
`parseLabIssueNumberParam`.
- Posts a comment on the GitHub issue: `\"Thank you for opening an
issue. This issue is now managed in the Claude
lab.\\n\\nhttps://swamp.club/lab/<number>\"`.
  - Closes the GitHub issue via `issues.update({ state: \"closed\" })`.
- Reads the API key from the repo secret `SWAMP_API_KEY` (already
configured).
- Trigger is unchanged: `on.issues.types: [opened]`. Permissions are
unchanged: `issues: write`, `contents: read`.

### `extensions/models/_lib/swamp_club.ts`

- Renamed `private issueId: string | null` → `private labIssueNumber:
number | null`. The cached value is now the sequential lab id, not the
UUID.
- `ensureIssue` now returns `Promise<number | null>` (was
`Promise<string | null>`).
- Reads `data?.issue?.number` with `unknown` typing (was `data.issue.id`
with a `{ id: string }` cast).
- Validates with `typeof === \"number\"` + `Number.isSafeInteger` +
positive (was a UUID regex). Mirrors swamp-club's
`parseLabIssueNumberParam`.
- Logs `\"swamp-club returned invalid lab issue number: {number}\"` on
validation failure (was `\"...invalid issue ID...\"`).
- `postLifecycleEntry` now builds
`${baseUrl}/api/v1/lab/issues/${this.labIssueNumber}/lifecycle` and
gates on `this.labIssueNumber === null` (was `!this.issueId`).
- `transitionStatus` now builds
`${baseUrl}/api/v1/lab/issues/${this.labIssueNumber}` and gates on
`this.labIssueNumber === null` (was `!this.issueId`).
- New public `labUrl()` helper: returns
`${baseUrl}/lab/${labIssueNumber}` if the issue has been ensured, or
`null` otherwise. Lets callers display the public lab URL without
re-fetching anything.
- Updated `createSwampClubClient` doc comment: \"The lab issue number is
resolved lazily — no extra arg needed.\" (was \"The issue ID is resolved
lazily — no swampClubIssueId arg needed.\")

### `extensions/models/issue_lifecycle.ts`

- `ensureSwampClub`: renamed local `id` → `labIssueNumber`, replaced `if
(!id)` with `if (labIssueNumber === null)`. The strict null check guards
against a future `0` being misread as missing — the server validates `>
0`, so `0` is currently unreachable, but `=== null` is the right pattern
for a `number | null` return type either way.
- Updated the doc comment from \"the issueId cache is lost\" to \"the
lab issue number cache is lost.\"
- The other `ensureIssue` call site (`triage_started` lifecycle entry,
line ~435) discards the return value, so no change needed.

### `extensions/models/README.md`

- Added a paragraph in the \"Swamp Club Integration / How it works\"
section:
> Each lab issue is assigned a sequential, human-friendly number (`#1`,
`#2`, ...) that is used in every lab URL — both the dashboard and the
API. After the model has run against an issue you can find it at
`https://swamp.club/lab/<number>`.

## Test plan

### Local verification
- [x] `deno fmt --check` — clean (1114 files)
- [x] `deno lint` — clean (1008 files)
- [x] `deno run test` — **4193 passed, 0 failed** (3m30s)
- [x] `deno check extensions/models/_lib/swamp_club.ts
extensions/models/issue_lifecycle.ts` — clean

### Client behaviour (manual / by-inspection)

- [x] `ensureIssue` happy path: server returns `{ issue: { number: 42,
... }, created: true }` → cache populated, `42` returned.
- [x] `ensureIssue` cache hit: second call short-circuits via
`this.labIssueNumber !== null` and returns the cached number without
re-fetching.
- [x] `ensureIssue` rejects malformed responses without throwing:
missing `issue` key, missing `number` key, `number: \"42\"` (string),
`number: 0`, `number: -1`, `number: 1.5`, `number: NaN`, `number: 2 **
53` (past safe integer). Each path logs a structured warning and returns
`null`.
- [x] `postLifecycleEntry` and `transitionStatus` no-op when
`labIssueNumber === null` (i.e. before `ensureIssue` succeeds).
- [x] `labUrl()` returns `null` before `ensureIssue`, then
`${baseUrl}/lab/{number}` after.

### GitHub Action

- [ ] **Smoke test:** file a throwaway issue against `systeminit/swamp`
after this PR merges. Expect:
  - The issue closes within seconds of being filed.
- A comment appears with the auto-responder text and a
`https://swamp.club/lab/<n>` link.
- The lab issue exists at that URL with the original body plus the
`Automoved by swampadmin from GitHub issue #<n>` footer.
- [ ] **Failure-mode test:** temporarily revoke the `SWAMP_API_KEY`
secret value (or point at an unreachable host) and file a throwaway
issue. Expect:
  - The action run fails with a `core.setFailed` message.
  - The GitHub issue stays **open**.
  - No auto-responder comment is posted.
  - Restore the secret afterwards.
- [ ] **Validator test:** if a future swamp-club regression returns a
non-number `id` field, expect the action to fail with `\"swamp.club
ensure returned no lab issue number: ...\"` and the issue to stay open.

I have not pre-merged the smoke tests because they require the workflow
to be on `main` to fire on real `issues: opened` events. Suggest doing
them as the first action after merge with throwaway issues.

## Related

- systeminit/swamp-club#367 — issue I filed to track migrating the lab
API endpoints from UUID to sequential number (closed by #369).
- systeminit/swamp-club#369 — the merged server-side PR that shipped the
contract this client now consumes. Adds `parseLabIssueNumberParam` and
caught a real data-corruption hazard with `Number.parseInt`.
- systeminit/swamp-club#364 — the original PR that introduced the
sequential `number` field on lab issues and migrated the UI route to
`/lab/{number}`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant