Skip to content

feat(actions): standalone convert action - #28

Draft
tkeairns wants to merge 9 commits into
mainfrom
standalone-actions
Draft

tkeairns wants to merge 9 commits into
mainfrom
standalone-actions

Conversation

@tkeairns

@tkeairns tkeairns commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Draft — blocked on a zelos-sdk release, see below.

Companion to zeloscloud/src#1345, which lets the agent serve and execute an extension's actions while the extension is stopped.

What

convert becomes a standalone action alongside the existing click command. Both delegate to convert_can_trace, so the two surfaces can't diverge. It runs with the extension stopped — no bus, no live connection — which is what makes it useful: converting a CAN log has never needed a running extension, but until now it was only reachable by hand from a source checkout.

  • database_file is optional and falls back to the first database_file in the saved extension config. Config is at-rest state (written on Start, persists across stop), so this resolves whether or not the extension is running. It's applied in the action body rather than as a schema default, because the inventory is dumped at package time, before any config exists.
  • Path inputs use @action.text(..., widget="file_path_picker"), not @action.file — the latter means "upload as a base64 data URL" and validates the value as one, so it rejects filesystem paths.
  • SERVICE_NAMEACTION_PREFIX, moved to the package and re-exported from main.py. The standalone dump reads it from the entry module and init() reads it for live registration, so one declaration keeps can/convert identical on both halves.

Blocked on

pyproject.toml pins zelos-sdk>=0.0.10, but standalone=True and zelos_sdk.extensions.actions are unreleased. Packaging today fails the inventory dump with "No module named zelos_sdk.extensions.actions", which the agent treats as harness-unavailable — so the extension would ship with no standalone inventory. This merges once the SDK release goes out, and the floor here should be raised to that version in the same change.

Verification

Run against the local SDK via a PYTHONPATH overlay:

  • Inventory dump emits action_prefix: can with convert only — the seven runtime-dependent actions are correctly excluded.
  • Executed at rest with the extension stopped: 200 messages converted to a valid 2.6 MB .trz.
  • With database_file omitted entirely, it resolved the configured DBC out of the installed extension's config.json while stopped.
  • 270 tests pass, 2 skipped.

convert becomes a standalone action alongside the existing click command, both
delegating to convert_can_trace so the two surfaces cannot diverge. It runs with
the extension stopped: no bus, no live connection.

- database_file is optional and falls back to the first database_file in the
  saved extension config, which is at-rest state (written on Start, persists
  across stop). Applied in the action body rather than as a schema default,
  because the inventory is dumped at package time before any config exists.
- Path inputs use text + file_path_picker, not @action.file — the latter means
  'upload as a base64 data URL' and validates the value as one, so it rejects
  filesystem paths.
- SERVICE_NAME moves to the package and is re-exported from main.py, so the live
  namespace (init(name=...)) and the packaged at-rest inventory read one
  constant and cannot drift apart.
The constant names the prefix actions are addressed under — `can/convert` —
so name it for that rather than for the SDK's `init(name=...)` parameter.
Value is unchanged, so live and at-rest action paths are unaffected.

The standalone inventory dump reads this constant from the entry module, and
`init()` reads it for the live registration, so one declaration keeps both
halves agreeing.
Actions signal failure by raising. Returning {"status": "error"} is a
normal return, which the harness reads as "no verdict" and the wire maps
to DONE, so every failed conversion reported success to the CLI (exit 0),
the app, and the AI. Fixed at every site that returned an error payload.

convert:
- timeout 600s -> 1800s. Conversion is I/O bound over files that reach
  multi-GB; the ceiling is for the pathological case, not an expectation.
  The AI tool bridge still clamps its own calls to 5 min.
- new `open_on_complete` (default false) hands the finished .trz to the
  desktop app via the OS file association. The opener gets its own
  session and detached stdio: a standalone action runs in a setsid
  one-shot whose process group the supervisor kills on abnormal exit, and
  a child inheriting the capture pipes holds them open past the action's
  return, which stalls the run and then trips the terminate path. Failing
  to open is reported in-band rather than raised, since the trace exists
  by then and raising would invite a re-run of finished work.

converter:
- decode through zelos_can.CanDecoder (Rust, GIL released) instead of the
  cantools-backed CanCodec. Both call sites share one `_make_decoder`.
- decoder.flush() replaces a time.sleep(2.0), which cost two seconds per
  conversion and never actually guaranteed the tail had landed.
- metrics sampled on the logging cadence rather than three Rust crossings
  per frame.

Source name stays `can_codec` and timestamp_mode stays `absolute`: the
first is the leading segment of every signal path in the output, and the
second preserves each frame's own timestamp. Changing either would
silently re-path or re-stamp every future conversion.
Timestamps: a frame stamped exactly 0.0 was being re-stamped with
wall-clock now. `decode_message` treats 0.0 as "no hardware timestamp",
which is right for a live bus (python-can reports 0.0 when the driver
gives nothing) and wrong for file replay, where 0.0 is the first frame of
any relative-timestamped capture. Converting a candump whose first line
reads (0.000000) produced a trace spanning from that frame's wall-clock
stamp to the rest of the file's epoch times, non-deterministically.
Timestamps are now passed explicitly via decode_frame, and the timing
stats use `is not None` rather than truthiness for the same reason.

export_trace_to_log: the zero-frames branch still returned an
error-shaped payload. A plain return means "no verdict", which the wire
maps to DONE, and export_to_candump writes no file in that case — so a
caller chaining on exit status proceeded against a file that does not
exist. Raises now, like every other failure path.

convert: resolve the output path. A relative path resolved against the
extension's working directory rather than the caller's, and an
unresolved path beginning with `-` reached the platform opener as a flag
(`open -a.trz` parses as `open -a <app>`).

Tests: the standalone `convert` had none — the existing suite covered
`convert_trace_file`, a different action. Adds its five raise paths, plus
the `_open_in_app` spawn contract (start_new_session and the DEVNULL
trio, both load-bearing against the supervisor's process-group kill and
pipe drain) and the rule that a failed open never fails a finished
conversion.
`extension.toml` declares `name = "CAN"`, which is what a user sees in the
extension list, but actions were served under `can/`. The address someone
reads and the address they type should be the same string.

`ACTION_PREFIX` is already the single source for both surfaces — the live
`zelos_sdk.init(name=ACTION_PREFIX, actions=True)` registration and the
at-rest inventory `main.py` re-exports — so one edit moves both. Verified
via the harness dump: `CAN/convert`.

The AI tool slug is unaffected. `action_tool_name` lowercases every ASCII
alphanumeric, so `CAN/convert` still surfaces as `ext_can_convert` and no
saved tool name or disabled-set entry changes.
The rename left four comments describing the old address, one of which gave
two different namespaces for the same surface in a single sentence. Since the
webapp now discovers the prefix rather than assuming it, the docs say
`<prefix>/<name>` instead of baking in a literal that the code deliberately
does not assert either.
Packaging never generated actions.json, so a registry install advertised
no standalone actions at all: the at-rest convert existed only on
developer machines, and every test gate stayed green while it did.
Generate it from the SDK harness before the archive is assembled, and
fail the run rather than warn -- an unattended release whose only gate is
"did an archive appear" would otherwise ship a stale or missing
inventory.

Conversions unlinked the destination and then wrote it in place, so a run
that raised or was killed left a truncated trace where a good one had
been, indistinguishable from a complete capture. Stage into a sibling
temp dir and rename onto the destination only on a clean return. Same
shape fixed in convert_trace_file and export_trace_to_log, the latter of
which could delete a good log and then raise without writing anything.

The Windows opener passed a caller-supplied path through cmd, which
re-parses it: list2cmdline quotes for spaces but not for cmd
metacharacters, so a space-free path could select a command. os.startfile
hands the path to ShellExecuteW with no shell at all.

Claude-Session: https://claude.ai/code/session_01KehJeZkLpffYEhp7Y2Ai2m
@tkeairns
tkeairns force-pushed the standalone-actions branch from b2b2641 to 69b31bc Compare August 20, 2026 23:01
Staging into a scratch dir and renaming on success was more machinery
than a convert tool warrants. The plain behavior is the expected one:
write to the path given, require overwrite to replace an existing file,
and whatever reaches disk is what the run produced -- a Ctrl+C partway
through leaves a partial trace, which is fine and is what every other
conversion tool does.

Claude-Session: https://claude.ai/code/session_01KehJeZkLpffYEhp7Y2Ai2m
Standalone actions need `zelos_sdk.extensions.actions`, which first ships
in 0.0.11a1. The floor has to name the prerelease to be satisfiable by
one at all: a resolver offered `>=0.0.10` picks the newest *final*
release, which has no harness, so an installed extension advertises its
at-rest actions and fails to run them.

The lockfile is the half that decides. Installs resolve through
`uv sync --frozen`, so a floor raised without relocking changes nothing.

Verified on a packaged install built only from this lockfile, with no
workspace SDK overlaid: zelos-sdk 0.0.11a1 from PyPI, `CAN/convert`
listed and executed while the extension was stopped.

Claude-Session: https://claude.ai/code/session_01KehJeZkLpffYEhp7Y2Ai2m
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