Skip to content

feat(actions): add pluggable custom actions with background jobs - #1085

Open
l0gicgate wants to merge 12 commits into
mainfrom
feat/36-custom-actions
Open

l0gicgate wants to merge 12 commits into
mainfrom
feat/36-custom-actions

Conversation

@l0gicgate

@l0gicgate l0gicgate commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Description

Adds the custom actions and background Jobs workflow agreed in #36. Actions are portable folders under $XDG_CONFIG_HOME/strata/actions/<id>/, containing action.toml and an optional Python/Bash script. Command actions use an installed program with explicit arguments. Settings manages the same files users can edit by hand.

The action editor now follows the supplied three-tab design:

  • General: name/id, description, inline Lucide icon picker, and enabled state.
  • Script: shared runtime selector, themed line-numbered editor, or multiline command arguments. Python and Bash drafts survive runtime switches.
  • Behavior: compact setting rows, with failure policy enabled only for per-item execution.

The active-tab underline sits above the divider; the header and footer stay outside the scrolling pages. Validation selects the relevant tab without losing edits. Existing modal, form, settings, segmented-control, icon, and source-theme components are reused.

The execution contract is unchanged: trusted local programs run with the user's permissions, without shell interpolation; paths remain byte-exact, links are refused, imported actions start disabled, and progress/log/history storage is bounded. Jobs can run in the background or be explicitly cancelled. Parameter forms, parallel per-item execution, remote inputs, restart/resume, sandboxing, and migration of built-in transfer jobs remain deferred. See docs/custom-actions.md.

Visual evidence

Pending owner upload through GitHub's description/comment editor. No supported authenticated attachment-upload route is available here; local captures are retained, not committed or presented as attached evidence.

Editor captures are in /home/l0gicgate/Documents/github/strata/target/1085-ui/evidence/:

  • before.png — previous long, scrolling action form (native GTK).
  • general.png — new General tab and inline icons.
  • script.png — new Script tab with line numbers and syntax colors.
  • behavior.png — new Behavior tab with per-item failure policy disabled by default.
  • command.png — program and multiline arguments.
  • light-general.png, light-script.png, light-behavior.png — light-theme equivalents.

After captures use the pinned GTK 4.14 environment on private Xvfb/D-Bus. Earlier context-menu, Actions Settings, and Jobs captures remain in /home/l0gicgate/Documents/github/strata-worktrees/36-custom-actions/target/36-captures/.

How to test

  1. Open Settings → Actions → New action…. In General, name the action Checksums, leave the suggested id, and choose an icon.
  2. In Script, review the Python starter script. Switch to Bash, edit its script, then switch back: each draft should remain intact. Alternatively, choose Command and enter sha256sum with {paths} on its own argument line.
  3. In Behavior, set Extensions to bin, check the run/placement controls, and choose Create action. Invalid fields should return you to the appropriate tab without saving.
  4. Right-click a local *.bin file and choose Actions → Checksums, then open the footer's Jobs indicator.
  5. Reopen the action in Settings, change a field, and press Escape. Reopen it again to confirm the unsaved edit was discarded.

Expected result:

Three tabs retain edits, the active indicator is above the divider, and the action saves once across all tabs. Only matching entries offer it. The Jobs dashboard reports execution and progress; hiding it leaves work running, while Cancel stops the job. Missing programs remain visible but unavailable; invalid manifests appear under Problems.

Related issue

Refs #36. Implements the agreed first-release scope; deferred items remain open.

Users can now add their own scripts to the file and folder context menus.

Definitions are portable TOML directories under
`$XDG_CONFIG_HOME/strata/actions/<id>/`, holding `action.toml` plus an optional
script. `model::action` owns the pure contract (parsing, validation, matching,
token expansion, shebang handling), `services::actions` owns the cached catalog
and matching, `adapters::local_actions` owns the filesystem, and
`adapters::local_jobs` runs one invocation as a child process. `services::jobs`
owns the queue, per-item iteration, progress, cancellation, bounded logs, and
history, which the footer's Jobs dashboard observes.

Boundaries that keep this safe to ship:

- No shell. Programs receive argv entries directly, argument tokens expand only
  to absolute paths, and a token inside a larger string is rejected, so a
  selected file name can never be read as a command or an option.
- Paths travel as bytes (NUL-delimited file plus environment variables) so
  names that are not valid UTF-8 survive, and matching is byte-exact.
- Opening a menu only evaluates declarative rules; user code runs on invocation.
- Manifests, scripts, action directories, entrypoints, and the scratch base are
  read as regular files and refused when they are links; generated files are
  owner-only inside an owner-only directory.
- Output, progress events, and history are bounded, and progress lines are
  validated before they reach the UI.
- Imported actions arrive disabled and copy only the manifest and its entrypoint.

Settings gains an Actions page with a manager and an editor for the same files,
and the footer gains a Jobs indicator that stays reachable when keybinding hints
are off. Dismissing the dashboard never cancels work.
Records the manifest schema, matching rules, execution modes, environment and
progress contract, the bundled Python helper, the Jobs dashboard behaviour, and
the trust model, plus where the new boundary sits in the architecture notes.
@l0gicgate

Copy link
Copy Markdown
Contributor Author

Validation notes from building this out:

  • Two defects were caught by the gates rather than by reasoning, and both are fixed: the Jobs pump timer was attaching its GLib source from whichever thread first touched shared() (which panicked in an unrelated settings test from a stale thread guard), and the submenu popover was unparented before its popdown completed, so the surface stayed mapped. The timer now installs only from window composition, and the submenu detaches from its own closed signal.
  • The submenu is a real popover anchored to the right of its row, covered by an end-to-end test that opens the item menu, follows Actions, and asserts the click reaches the shared job service.
  • Local gate: cargo fmt --all --check, cargo clippy --locked --all-targets --all-features -- -D warnings, and the full native suite (2010 passed; 0 failed; 18 ignored). Canonical container phases were also run locally with rootless Podman (quality.sh fmt, quality.sh clippy).
  • Visual captures are still pending an owner upload; paths and captions are in the description.

@wmfeht

wmfeht commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Review of 495b685 (round 1). request-changes — the #36 v1 contract is in place (no shell, argv-only tokens, byte paths, declarative matching, links refused, bounded jobs, imported actions disabled). Two blockers:

1. Duplicate of a max-length action id overwrites the original

unique_copy_id in src/ui/settings/actions.rs builds {id}-copy / {id}-copy-{n} and truncates to MAX_ACTION_ID_CHARS (64). For an id that is already 64 characters (what suggest_id can emit), every candidate collapses to the original id. After 1000 attempts it returns that same id, and PageState::duplicate then saves on top of the source directory.

LocalActionStore::unique_id already skips over-length candidates instead of truncating. duplicate_ids_avoid_collisions_and_stay_within_the_limit uses an empty store, never plants a colliding id, and currently asserts the truncated original id as success, so it cannot catch the overwrite.

2. Test quality: tautological / duplicate tests

Drop these. Do not add more coverage in the same change.

  • a_stalled_cancellation_is_finalized_after_the_deadline — Instant arithmetic on a one-line helper; never touches JobService.
  • a_runner_that_ignores_cancellation_stays_cancelling_until_the_deadline — the “stays Cancelling while the runner is silent” assert is real; the trailing cancel_deadline_reached(now, now + deadline, deadline) is disconnected from the job and cannot fail. The name claims deadline finalization that is not exercised.
  • catalog_lookup_is_by_id — trivial get() getter.
  • unloadable_definitions_are_reported_without_hiding_the_restActionCatalog::new(…) round-trip of the same failure value. Real load-failure coverage is already in local_actions.
  • progress_fractions_never_invent_completion in src/ui/jobs/tests.rs — duplicates progress_fractions_are_honest_about_unknown_totals in the jobs service. The UI does not wrap fraction.
  • the_service_is_shared_across_windowsRc::ptr_eq of the thread-local singleton; no windows.

CI is green on this SHA; local suite was not re-run.

The test asked the waiter to wait for pid `u32::MAX`, which is not a portable way
to describe an already-exited process. bash, which Arch installs as `/bin/sh`,
wraps that value so `kill -0` reports every process as alive and the waiter loop
never exits, hanging the suite on those hosts. Waiting for a reaped pid expresses
the same intent and terminates on every shell.
@l0gicgate

Copy link
Copy Markdown
Contributor Author

Merged the latest main (including trusted_command, the theme syntax colors, and the search reveal work) with no conflicts, and all checks are green again.

Two notes from the merge:

  • Deliberate boundary: custom actions still resolve their program on the user PATH, unlike the sandbox helpers that now go through trusted_command::resolve. That is intentional — an action is an ordinary user program and must find tools in ~/.local/bin, a version-manager shim, or a distribution prefix. The safety comes from the invocation boundary (direct spawn, argv[0] only, absolute-path arguments, no shell), and the reasoning is now recorded next to resolve_executable.
  • Incidental test fix: restart_waiter_ignores_path_shadowing_and_preserves_application_path (added in fix(sandbox): resolve host helpers without PATH #1055) waits on pid u32::MAX. bash, which Arch installs as /bin/sh, wraps that value, so kill -0 reports every process as alive and the waiter loop never exits — the Rust suite hangs on Arch/Omarchy hosts. It now waits on a reaped pid instead, which means the same thing portably. Test-only change; production passes a real pid.

Local gate after the merge: cargo fmt --all --check, --all-targets Clippy with -D warnings, and the full native suite (2031 passed; 0 failed; 18 ignored).

@wmfeht

wmfeht commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Review of d6604b3 (round 2). request-changes — the #36 v1 contract is still in place. The two blockers from round 1 were not addressed on this SHA (merge of main, PATH comment, restart-waiter PID portability).

1. Duplicate of a max-length action id overwrites the original

unique_copy_id in src/ui/settings/actions.rs builds {id}-copy / {id}-copy-{n} and truncates to MAX_ACTION_ID_CHARS (64). For an id that is already 64 characters (what suggest_id can emit), every candidate collapses to the original id. After 1000 attempts it returns that same id, and PageState::duplicate then saves on top of the source directory.

LocalActionStore::unique_id already skips over-length candidates instead of truncating. duplicate_ids_avoid_collisions_and_stay_within_the_limit uses an empty store, never plants a colliding id, and currently asserts the truncated original id as success, so it cannot catch the overwrite.

2. Test quality: tautological / duplicate tests

Drop these. Do not add more coverage in the same change.

  • a_stalled_cancellation_is_finalized_after_the_deadline — Instant arithmetic on a one-line helper; never touches JobService.
  • a_runner_that_ignores_cancellation_stays_cancelling_until_the_deadline — the “stays Cancelling while the runner is silent” assert is real; the trailing cancel_deadline_reached(now, now + deadline, deadline) is disconnected from the job and cannot fail. The name claims deadline finalization that is not exercised.
  • catalog_lookup_is_by_id — trivial get() getter.
  • unloadable_definitions_are_reported_without_hiding_the_restActionCatalog::new(…) round-trip of the same failure value. Real load-failure coverage is already in local_actions.
  • progress_fractions_never_invent_completion in src/ui/jobs/tests.rs — duplicates progress_fractions_are_honest_about_unknown_totals in the jobs service. The UI does not wrap fraction.
  • the_service_is_shared_across_windowsRc::ptr_eq of the thread-local singleton; no windows.

Keeps the Actions page nav entry, the action search targets, the footer's
`set_activity` hook, and the single `storage::config_directory` helper while
taking main's preference split: `bind_preferences` now accepts a
`PreferenceManager`, and `ui::preferences` owns the settings file store while
delegating the config path to `storage::config_directory`.
@wmfeht

wmfeht commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Review of f3abce09 (round 3). request-changes — this SHA is a merge of main only. The two blockers from d6604b34 are still present.

1. Duplicate of a max-length action id overwrites the original

unique_copy_id in src/ui/settings/actions.rs builds {id}-copy / {id}-copy-{n} and truncates to MAX_ACTION_ID_CHARS (64). For an id that is already 64 characters (what suggest_id can emit), every candidate collapses to the original id. After 1000 attempts it returns that same id, and PageState::duplicate then saves on top of the source directory.

LocalActionStore::unique_id already skips over-length candidates instead of truncating. duplicate_ids_avoid_collisions_and_stay_within_the_limit uses an empty store, never plants a colliding id, and currently asserts the truncated original id as success, so it cannot catch the overwrite.

2. Test quality: tautological / duplicate tests

Drop these. Do not add more coverage in the same change.

  • a_stalled_cancellation_is_finalized_after_the_deadline — Instant arithmetic on a one-line helper; never touches JobService.
  • a_runner_that_ignores_cancellation_stays_cancelling_until_the_deadline — the “stays Cancelling while the runner is silent” assert is real; the trailing cancel_deadline_reached(now, now + deadline, deadline) is disconnected from the job and cannot fail. The name claims deadline finalization that is not exercised.
  • catalog_lookup_is_by_id — trivial get() getter.
  • unloadable_definitions_are_reported_without_hiding_the_restActionCatalog::new(…) round-trip of the same failure value. Real load-failure coverage is already in local_actions.
  • progress_fractions_never_invent_completion in src/ui/jobs/tests.rs — duplicates progress_fractions_are_honest_about_unknown_totals in the jobs service. The UI does not wrap fraction.
  • the_service_is_shared_across_windowsRc::ptr_eq of the thread-local singleton; no windows.

Two presentation defects in the new surfaces:

- The empty manager message lives inside a `settings-group`, where rows supply
  their own padding, so a lone label sat against the group border. It now pads
  itself the same way a row does.
- `GtkSwitch` fills whatever allocation it is given, so the editor's stacked
  Confirm and Enabled fields rendered as full-width ovals. Both switches now keep
  their natural size under their label.
@wmfeht

wmfeht commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Review of bac53cd5 (round 4). request-changes — this SHA merges main and insets the empty manager / compact editor switches. The two blockers from f3abce09 are still present.

1. Duplicate of a max-length action id overwrites the original

unique_copy_id in src/ui/settings/actions.rs builds {id}-copy / {id}-copy-{n} and truncates to MAX_ACTION_ID_CHARS (64). For an id that is already 64 characters (what suggest_id can emit), every candidate collapses to the original id. After 1000 attempts it returns that same id, and PageState::duplicate then saves on top of the source directory.

LocalActionStore::unique_id already skips over-length candidates instead of truncating. duplicate_ids_avoid_collisions_and_stay_within_the_limit uses an empty store, never plants a colliding id, and currently asserts the truncated original id as success, so it cannot catch the overwrite.

2. Test quality: tautological / duplicate tests

Drop these. Do not add more coverage in the same change.

  • a_stalled_cancellation_is_finalized_after_the_deadline — Instant arithmetic on a one-line helper; never touches JobService.
  • a_runner_that_ignores_cancellation_stays_cancelling_until_the_deadline — the “stays Cancelling while the runner is silent” assert is real; the trailing cancel_deadline_reached(now, now + deadline, deadline) is disconnected from the job and cannot fail. The name claims deadline finalization that is not exercised.
  • catalog_lookup_is_by_id — trivial get() getter.
  • unloadable_definitions_are_reported_without_hiding_the_restActionCatalog::new(…) round-trip of the same failure value. Real load-failure coverage is already in local_actions.
  • progress_fractions_never_invent_completion in src/ui/jobs/tests.rs — duplicates progress_fractions_are_honest_about_unknown_totals in the jobs service. The UI does not wrap fraction.
  • the_service_is_shared_across_windowsRc::ptr_eq of the thread-local singleton; no windows.

@l0gicgate

Copy link
Copy Markdown
Contributor Author

Action-editor UI follow-up — b64e864e

Replaced the long form with General / Script / Behavior tabs, matching the owner's references with the indicator above the divider. Also repaired dependent failure-policy state, runtime draft retention, editable multiline argv, and validation routing. The shared modal, forms, settings rows, grouped controls, Lucide assets, and source-buffer theming remain the component owners.

Scope: editor state/input and its persistence/menu callers. No runtime adapter, service, dependency, shared-control implementation, build, or harness changes. CSS is scoped to the action editor/picker. Full Rust/E2E suites were intentionally not rerun for this bounded change; full CI remains authoritative.

Final local evidence:

  • PATH="$HOME/.rustup/toolchains/1.98.1-x86_64-unknown-linux-gnu/bin:$PATH" ./scripts/test-headless.py actions::33 passed, including 11 editor regressions and action store/registry coverage. GTK cases ran on private Xvfb/D-Bus, with accessibility disabled and GTK required.
  • Same PATH with ./scripts/test-headless.py ui::browser::context_menu::tests::action_menu::1 passed, exercising menu matching, submenu placement, and job dispatch.
  • PATH="$PWD/target/1085-ui/bin:$PATH" STRATA_CONTAINER_ENGINE=podman ./scripts/e2e.sh tests/e2e/scenarios/test_custom_actions.py -n 01 collected, 1 passed. Real pointer/keyboard flow: create across tabs, multiline input, validation without disk writes, save, reopen, Escape cancellation, and menu visibility.
  • With that isolated Podman PATH and STRATA_CONTAINER_ENGINE=podman: ./scripts/quality.sh fmt and ./scripts/quality.sh clippypassed on pinned Rust 1.98.1.
  • git diff --check and staged diff check — passed.
  • Manually reviewed all three tabs, command mode, and per-item controls in pinned GTK 4.14, with dark/light captures and no GTK criticals or CSS parse warnings. Layout was reviewed visually, not asserted in tests.

The wrapper uses this session's isolated container configuration/storage/runtime under ignored target/1085-ui/containers; the published environment was pulled once and reused without rebuilding. Verified image ID: 670ee0e9b62df8729442d6708bf06f1d968576ffa403f567aef6f597075f3e8c.

Earlier failures were resolved, then scoped checks rerun: missing widget trait upcast during compilation; new test unwrap() Clippy violations; inherited GTK sensitivity not reaching the failure-policy buttons' accessibility state. The E2E regression now checks that state explicitly.

Captures and logs are retained under /home/l0gicgate/Documents/github/strata/target/1085-ui/. Visual attachment upload remains pending owner assistance as detailed in the PR description; nothing was committed as review evidence.

@blacksmith-sh

blacksmith-sh Bot commented Sep 18, 2026

Copy link
Copy Markdown

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
pytest/
test_large_table_header_sort_reaches_rows_beyond_old_limits[fixture_tree3-table.html]
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

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.

2 participants