Pin node-target tasks to nodes
## Original requirement (verbatim, epic body)
> When creating a cron, allo user to pick a different type of target. Currently the target is a project. Other types: node, none (just run a task without a project - we have that impelemented), what else?
>
> When node is a target it runs a task on all selected nodes.
>
> Implement a nice target selector, that allows to quickly add all, remove all, do all except specific, etc. It has to be a very compact and robust control. It must work for both projects, nodes and other targets.
>
> Make sure this is naturally working and improves previous work - triggers, crons, etc. All of these things (triggers, webhooks, crons, etc.) are combined into a unified coherent system that is state of the art and uses best practices (do a web research).
Settled upstream (BA Task 5599 PRD decisions D1-D10; Architect Task 5600 design O1-O5). Do not reopen them: D1 no "None" target; D2 node target = one task per resolved node, pinned, in a node-level non-repo workspace; D3 selections are LIVE rules resolved at each firing; D4 crons + triggers merge into one Automation (When -> Target -> Action); D5 system crons merge too, operator-only with a System badge; D6 selector modes exactly All / None selected / Specific / All except; D7 Task action REUSES the task-create UI; D8 action registry (Task + System function), System function takes no target; D9 migrate in place (ingress URLs, secrets, history, agent verbs keep working); D10 overlap per target. O1 system "Nodes" project visible to org admin/owner (and operators) only; O2 no fan-out cap; O3 Task action adds Attachments + optional Assignee, excludes Dependencies and From Jira; O4 node tasks unprivileged; O5 fix dev docs reach at the root cause.
Epic chain (this breakdown): C2 Node targets API -> C1 Automation core API -> C3 Fire engine API; C4 TargetSelector web; C5 TaskSpecFields web; then C6 Automations UI (needs C1, C3, C4, C5) alongside C8 dev docs reach infra; then C7 reader docs (needs C6, C8).
## This child: C2 Node targets (API)
Build the server half of node targets (architecture section 3). Owns O1 visibility (API); contributes the API tests for AC2.1 and AC2.2.
Acceptance carried verbatim from the PRD:
- **AC2.1** A firing with target Nodes files **one task per resolved node**. Each task is pinned to that node, only that node may claim it, and it runs in a node-level, non-repo workspace. *Verify (this child's half):* An API test asserts that a second node is refused the claim.
- **AC2.2** Every node state at fire time is decided: An enabled, online node gets its task. An offline node still gets its task. The task waits ready until that node claims it and never runs on another node. A disabled node, or one deleted since the rule was saved, is skipped and recorded as skipped with a reason in the run record. A node whose previous task from this automation is still open is skipped per D10, with reason "previous still open". (this child ships the PLACEMENT side: a pinned task waits for its node only, and a pinned task whose node is gone or disabled is visibly unplaceable with its own cause; C3 ships the fire-time skip records.)
- **O1** "Visible as a 'system' project to org admin/owner only".
### How (settled design, implement exactly)
1. **First alembic revision of the epic (`node_targets`)**, additive only: `projects.system_kind` text NULL, CHECK in ('nodes'), partial unique (org_id, system_kind) WHERE NOT NULL, CHECK `system_kind IS NULL OR NOT has_repo`; `tasks.pinned_node_id` uuid NULL, **no FK** (deleting a node must never silently free the pin), index on pinned_node_id WHERE NOT NULL. C1's revision chains on top of this one.
2. New `apps/api/app/node_targets.py`: `ensure_nodes_project(session, org_id) -> Project` (idempotent upsert on the partial unique: name "Nodes", system_kind 'nodes', has_repo false, slot_mode 'static', fleet_enabled true, worktree_pool_enabled true, pool_max 4, default_static_slots 0 - pool_max must be >0 or `slot_allocation.add_dynamic` refuses, see `slot_allocation.py:182-186, 348-349`); `prepare(session, org_id, node_id) -> int` (ensure project, take `slot_allocation.lock_pair(N, nodes_project)`, ensure an explicit `node_projects` row with static_slots 0, return project id).
3. `placement_grants._allowed` node arm, AFTER `foreign_hosted_among` (around `placement_grants.py:162-204`): when `task.pinned_node_id` is set, `ids &= {pinned_node_id}`. **Change the memo key to (object_type, org, task.project_id, task.pinned_node_id)** - the old key leaks one task's pin to its siblings. New causes `CAUSE_PINNED_NODE_GONE` (node row absent or other org) and `CAUSE_PINNED_NODE_DISABLED` (enabled=false), checked BEFORE `_node_refusal`, added to `placement_grants.CAUSES`. Unpinned tasks: placement byte-for-byte unchanged.
4. Visibility: in `authz.scope_key_clause` (`authz.py:1035-1049`) when scope_type == 'project' and the principal is neither operator nor `is_org_admin`, AND both arms with `column.not_in(select(Project.id).where(Project.system_kind.is_not(None)))` (non-correlated). Same rule in `has_scope_key`. This one change covers visible_project_clause, the task arms, editable_task_clause and visible_feature_clause.
5. Project routes: refuse delete and any has_repo / repo / slot_mode / system_kind edit on a system_kind row (422 naming why). **Refuse task creation in a system project (422) everywhere** - C3's `automation_fire` will be the sole caller allowed, always with a pin; expose the internal path it will use (e.g. a keyword the create helper accepts) without opening it to the route. Project payloads carry `system_kind` so the UI can render a **System** chip (C6 renders it).
6. **Prove the lease end to end** with an integration test: a node-target task is leased and gets a folder slot (`has_repo=false`) on the pinned node. If the lease path does not create the folder slot, add the call at the lease (`slot_state.lease` -> `slot_allocation.add_dynamic`, the mechanism documented at `slot_allocation.py:14-15`).
Credentials & reach: none beyond the worktree for the tests (architecture section 12: "All API/component/migration tests - Worktree only - verified"). The post-merge Jaah Deploy watch reads GitHub Actions `jaah-deploy.yml` with `jaah-forge` under your own forge login (verified).
Out of scope: Do NOT touch automations tables, the fire engine, any web code, or the reader docs (C1/C3/C4-C7 own them). Do NOT change placement for tasks with `pinned_node_id IS NULL`. Do NOT add a FK on pinned_node_id. Do NOT special-case the Nodes project as "hosted everywhere" in `slot_allocation.hosting_node_ids` (rejected: a second hosting decision). No privilege change for node tasks (O4). Epic out of scope (PRD section 5, binds every child): No "None" target type for tasks and no projectless-task feature exposed to users (D1). No group, tag, node-group, cloud/self-hosted or online-only selector rules (D6). No new action types beyond Task and System function: no Slack, outbound webhook, email or playbook-start actions (D8). Do not change the webhook ingress URL scheme, existing secrets, signature verification or the dedup rules (D9, AC7.3). Do not change the task-create form's own behaviour to fit automations; any extension must leave regular task creation unchanged (D7). Do not change task claim/placement for non-automation tasks; a node pin applies only to node-target automation tasks (D2). Do not alter what any system activity does when it runs, only how it is listed and edited (D5). Do not change Automation access roles (AC4.7). Do not rename or remove the agent verbs (AC5.5). Playbooks are untouched apart from their tab position. No feature flag and no "ships disabled". Deferred, NOT built here (architecture section 9): F-contract (moving pause to automations.paused_at, dropping moved columns, deleting old console routes and the orphan adopter, stopping created_by_cron_id/created_by_trigger_id writes, ORM/table renames); node_projects hygiene for the Nodes project; elevated (root) node tasks; Dependencies and From Jira in automation Task actions; pause-on-failure; a catch-up window.
Done means:
- Migration upgrades and downgrades cleanly on the test DB and is additive only - oracle: the repo's migration tests in `./dc check full` pass.
- AC2.1 (API half): a task pinned to node A is refused the claim by node B and allowed for node A - oracle: a new API test calling the claim path (`routes/tasks.py:3537-3592`) for both nodes.
- Memo leak closed: two tasks in the Nodes project pinned to different nodes resolve to their own node in ONE memo pass - oracle: a unit test on `placement_grants`.
- AC2.2 (placement half): a pinned task whose node was deleted / moved to another org reports `CAUSE_PINNED_NODE_GONE`; one whose node was disabled reports `CAUSE_PINNED_NODE_DISABLED`; neither shows the "assign the project" sentence - oracle: unit tests.
- Folder-slot lease: a node-target task is leased and gets a `has_repo=false` folder slot on the pinned node - oracle: the integration test above, observed passing.
- O1: a member holding org-wide projects:view/tasks:view sees neither the Nodes project nor its tasks in the project list, task list / by-id / counts, board, features, Activity, Sessions, search, notifications or node list; an org admin and an operator do - oracle: an API test matrix over those reads.
- System project guards: delete, repo/has_repo/slot_mode/system_kind edits and a plain `POST /api/tasks` into the Nodes project each return 422 with a reason - oracle: API tests.
- Unpinned placement unchanged - oracle: the existing placement / dispatch / lease tests pass unchanged.
- `./dc check full` green; PR merged to develop; Jaah Deploy for the merge commit succeeds (fix and re-merge if not).
Outcome
Node targets API: rev 0416 (system_kind, pinned_node_id), Nodes folder project, pin-only placement + gone/disabled causes, O1 hiding, system-project guards. Full gate green; PR #4920 merged; Jaah Deploy success. Deferred: cron/trigger target refusal (C1/C3).
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)