Capacity unit consolidation. Three coherent themes, small commits inside. ## Census before/after (`node scripts/lifecycle-column-census.mjs`) | | before | after | |---|---:|---:| | triage column guards (the bar) | 10 | **10** | | `--strict` on main | ❌ **RED** | ✅ green | | baseline staleness | 14 files stale | **0** | This branch does **not** move the triage bar — its remaining 10 are moves.ts (dies with the flag), the dashboard cluster, and one deliberate site. It fixes the instrument that measures the bar, plus a live defect the comparison count cannot see. --- ## 1. `--strict` was RED on clean `origin/main`, and it was my fault ``` packages/dashboard/src/routes/register-task-workflow-routes.ts: 22 -> 23 ``` My merged #2621 added a v1-IR pre-WIP fallback answering a greptile P1 and shipped no marker or baseline update, so the program's measuring instrument has been failing on main since it landed. Fixed **at the site** with a `DELIBERATE-LITERAL` marker, not by bumping the baseline. That branch runs only when the IR declares no columns and no nodes, so there is no role to resolve — `resolveLifecycleColumns` returns nothing and the legacy pre-implementation ids are the only pre-WIP signal that exists there. It is *unconvertible*, not unfinished; the sibling `else` two lines down is the trait path for every IR that can answer. A rise that is genuinely correct belongs where a reader will see it. ## 2. The baseline was stale for 14 files — a hole, not cosmetics A stale allowance lets converted guards return while the check stays green. Measured gaps: ``` self-healing.ts allows 126, tree has 111 executor.ts allows 112, tree has 104 moves.ts allows 44, tree has 39 default-workflow-hooks allows 25, tree has 7 mission-feature-sync allows 5, tree has 0 MissionControlPanel allows 4, tree has 0 (+8 more) ``` **Only two of the fourteen are mine.** The other twelve are already-merged conversions by other workers where nobody re-recorded. Re-recorded all fourteen here rather than waiting for twelve PRs, because until it happens the ratchet is not holding the 779 it exists to hold. Flagging it plainly: those drops are other people's work being locked in, not mine being claimed. ## 3. Routines created tasks into the column U11 deleted The routine editor's "Target Column" defaulted to `triage`. That value is submitted as the create step's `taskColumn`, and an **explicit** column bypasses the workflow entry-column resolution added for column-less creates (#2589) — so every routine saved with the untouched default seeded its tasks into a column the board does not declare. Defaulting to `todo` would be the same mistake one column over: a custom workflow declaring no `todo` is seeded into an undeclared column just as surely, because an explicit column overrides entry resolution whatever its value. So the default sends **nothing** and each workflow's own intake resolution decides. The `triage` **option** is removed too, not merely un-defaulted — fixing the initializer alone left the operator able to pick the deleted column one click away, and it was the option labelled "Planning", the name the merged `todo` column now displays. Removing it retires that label inversion as well. Found by scanning **membership** forms rather than comparisons: the comparison census cannot see a `?? "triage"` default, so no count showed this and nobody was looking. Revert-proof — restoring the default fails with *"the default must not name a column at all"*. ## 4. "Worktrees off is INERT" had one unaudited reader The constraint was that `maxWorktrees` become genuinely inert, "not set very high and not skipped by convention". `resolveWorktreeCapacityLimit` returns `null` for that, and its unit tests can only prove the **resolver** is right — they cannot see a second reader, which is the only way the constraint breaks. Audited every `maxWorktrees` read that bounds anything. **Exactly two:** `scheduler.ts` (the admission gate, via the resolver, single call site, optional gate snapshot) and `self-healing.ts`'s `enforceWorktreeCap` — `(settings.maxWorktrees ?? 4) * 2`, a **raw** read. The second is **not a bug** and is left alone: it bounds worktree *directories on disk* and only removes *idle* ones. Worktrees still exist in OFF mode, so that bound must keep applying or idle directories accumulate unbounded. Recorded consequence: in OFF mode the number still governs disk retention while gating no admission — an edge you scoped out. The note says explicitly **not** to unify the two readers: routing hygiene through the resolver returns `null` in OFF mode and silently removes the disk bound, which is a leak dressed as a simplification. New ratchet requires every file bounding on `maxWorktrees` to be named with a reason, and rejects a **stale** allowlist entry. Proven by injecting `active >= (settings.maxWorktrees ?? 4)` into `hybrid-executor.ts`. --- ## Deliberately NOT included - **My own census script.** #2633 landed the canonical one, and it is better than mine — an AST classifier *plus* an independent text classifier with `--compare`, and a baseline that fails on unrecorded **drops** as well as rises. Mine only caught rises. I deleted mine rather than ship a second measuring instrument; three copies of "strip comments" is the drift shape this program keeps paying for, so the worktree ratchet now imports #2633's `stripComments`. - **My TaskContextMenu fix.** Superseded, and by a better answer: main's `isPureIntakeColumn` (intake *without* hold) keeps the merged Planning column shown and suppresses only a bare Ideas capture, which resolves the exact hold-lane objection coderabbit raised against my version. I briefly clobbered that merged work by checking my old file out wholesale, caught it in the diff, and reverted. ## Verification `pnpm lint` clean · core + dashboard `tsc` clean · census suite 23/23 · worktree ratchet 8/8 · RoutineEditor 49/49 · `routes-task-retry-planning-column` 16/16 · `lifecycle-column-census --strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Added after review (all four greptile threads were real, and two of them mattered) **The routine fix was half a fix.** `routine-runner.ts:515` *and* `cron-runner.ts:982` both did `column: (step.taskColumn as Column) || "triage"` **after** the step is read, so every routine — including ones saved through the fixed editor — still created tasks into the deleted column. Both now omit it. **The advanced steps editor MANUFACTURED the defect.** `ScheduleStepsEditor.tsx` had three `triage` defaults: the new-step template (`:64`), the per-step initializer (`:95`), and the select still offering it (`:344`). So the path I had *not* fixed produced the bug by default, on fresh data. Template names no column; initializer coerces a persisted `triage`; `triage` removed from the options; empty submits `undefined`. **Four pre-existing tests pinned the defect** and are rewritten to the corrected invariant rather than appeased: | test | asserted | |---|---| | `cron-runner`: "defaults column to triage when taskColumn is not set" | `column: "triage"` | | `ScheduleStepsEditor`: "adds a create-task step..." | `taskColumn` toBe `"triage"` | | `ScheduleStepsEditor`: "allows saving create-task step..." | the legacy column is **resubmitted** | | plus the explicit-column case added beside each, so the fix cannot swallow a deliberate choice | **The allowlist hole was the worst finding.** `AUDITED_BOUNDS` was keyed by FILE, so every bounding expression in an allowlisted file was exempt — a second raw bound in `scheduler.ts` stayed green, the one case that ratchet exists for. Per-expression now, and making it so **immediately surfaced a real second bound the file-level version was hiding** (`maxWorktreesGate.used >= maxWorktreesGate.limit`, safe by construction since the snapshot is `undefined` in OFF mode). Proven by injection. ## Found while re-reading my own deletion, not reported A **rendered tooltip** still named a deleted cap. The "Queued to plan" badge read *"planning starts when a concurrency slot frees up (maxConcurrent / globalMaxConcurrent)"*. The cross-project cap is gone — capacity is two numbers per project — so it told operators their planning waited on a limiter they can no longer find a setting for. Names the surviving dimension only now. ## Coding (Ideas): enforcing #2651 rather than repeating it I took the unowned coding-ideas IR merge, concluded it must not be done, then found **#2651 had already implemented, reverted and documented exactly that** — with better grounding than my own argument. It added no test, so nothing stops the next person reaching the same dead end. So this ships their reasoning as a ratchet, not a second opinion: triage discovery keys on the column's `autoTriage`, so a merged column is either never scanned (cards sit on a bootstrap stub until the **capacity hold** releases them, sending **unplanned** work into in-progress — worse than stalling) or scanning wins and the manual gate is gone. Their scope caveat is kept: `autoTriage` is a general trait field, so only *this preset's* collapse is dead, not manual intake as a concept. The registry does not reject the merged shape, which is why prose was not enough. ## Verification (re-run) `pnpm lint` clean · core + engine + dashboard-app `tsc` clean · `lifecycle-column-census --strict` exits 0 ("every file matches its baseline exactly") · routine-runner 24/24 · cron-runner 156/156 · ScheduleStepsEditor 41/41 · RoutineEditor 49/49 · worktree + coding-ideas 12/12. TaskCard has 2 failures **pre-existing on main** — confirmed identical with my changes stashed. --- ## Bears directly on the closing bar: this PR already removes the 67-guard ratchet slack Measured on current `origin/main` with the census itself: ``` tree total: 787 baseline total: 854 SLACK: 67 FILES ABOVE BASELINE (1): +1 packages/dashboard/src/routes/register-task-workflow-routes.ts (22 -> 23) FILES BELOW BASELINE: 13, totalling 68 unrecorded conversions -18 core/default-workflow-hooks.ts (25->7) -15 engine/self-healing.ts (126->111) -8 engine/executor.ts (112->104) -5 core/task-store/moves.ts (44->39) -5 engine/mission-feature-sync.ts (5->0) -4 core/live-agent-count.ts (10->6) ``` **The slack is not regression — it is 13 files of merged conversions nobody re-recorded**, against exactly **one** rise. This PR re-records the baseline **854 → 782 across 140 files**, which closes it. **And the "+3 that slipped in" is +1, and it is mine.** `register-task-workflow-routes.ts 22 → 23` is the v1-IR pre-WIP fallback my #2621 added; it is justified (that branch runs only when the IR declares no columns or nodes, so there is no role to resolve) but it shipped with no marker and no baseline update — which is why `--strict` has been **red on main since it merged**. Fixed here at the site with a `DELIBERATE-LITERAL` marker rather than by bumping the baseline, because a rise that is genuinely correct belongs where a reader will see it. Sequencing note for the auto-lowering change: if this lands first, that work is purely the mechanism (auto-lower, or fail with tighten instructions) rather than a cleanup, and the two re-records will not collide in the same file. Also worth carrying into that mechanism, from building the same guard here: **`--update` must refuse to RAISE.** An earlier version of mine wrote current counts verbatim, so a developer who added a literal and ran the documented update command locked the regression in as the new ceiling — the mirror of the high-water problem. Lowering can be unattended; raising should be a hand edit with the reason recorded. ## Third piece of residue from my own deletion `updateGlobalConcurrency` in the dashboard API client PUT to `/api/global-concurrency`, a route removed when the machine-wide cap went. Zero callers; the only reference was the `legacy.ts` barrel re-export. Deleted both. `fetchGlobalConcurrency` **survives on purpose** — the GET route remains and serves live utilization telemetry to the footer and Command Center; nothing gates on it. That is the third: after the second raw `maxWorktrees` reader and the "Queued to plan" tooltip. A deletion is not finished when the enforcement goes — the client, the label and the tooltip outlive it. --- ## Re-greened the dashboard API tests: 117 failures on main, ONE root cause These would have polluted the closing verification pass, and nobody owned them. `api()` builds headers via `new Headers(...)` and returns `Object.fromEntries(headers.entries())` — and `Headers.entries()` **lowercases every key**, so the object reaching `fetch` is `content-type`, not `Content-Type`. `ab87d0d80` then added `x-fusion-client: dashboard-ui` for run-audit attribution. Both changes are correct; neither is visible at a call site, so **114 assertions across 7 files** kept asserting the old shape and went red together. Fixed by naming the shape **once** in `app/test/apiRequestHeaders.ts` rather than patching 114 literals — restating a shared fact 114 times is what made a two-line client change look like 117 failures. Deliberately not a loose `objectContaining`: these tests are the only thing pinning that the attribution header is sent *at all*. **117 → 4.** The remaining 4 are unrelated pre-existing CSS failures (`task-detail-modal-tablet-width` ×3, `space-token-defined` ×1) — confirmed identical on clean main with my changes stashed. ### A gap this surfaced, recorded not papered over Three routes failed in the *opposite* direction — they send the old shape because they call `fetch()` **directly**, bypassing `api()`, so they never get the attribution header. `client.ts` claims the opposite: > "Applied once here rather than per-call so no future mutation route has to remember it." That does not hold for a route that bypasses the helper it is applied in. **Measured in `app/api/`: 8 files make direct `fetch()` calls and 7 include mutations (POST/DELETE)** — among them `ai-sessions.ts`'s DELETE, which is the same class as the four-delete incident the header was added for. So the attribution fix has a hole in exactly its motivating case. Not fixed here: routing those onto `api()` is a behaviour change across the API layer and belongs to its owner, not to a test re-green. Those assertions use a separate `API_JSON_HEADERS_NO_ATTRIBUTION` constant so the gap stays **visible** — if a route is later moved onto `api()`, its test fails and points at the note explaining why. --- ## This branch takes the triage bar 10 → 5, and makes `--strict` green `node scripts/lifecycle-column-census.mjs` on this branch reports **triage 5**, against **10** on `origin/main`. The five removed are the ScheduleStepsEditor template/initializer/option and the RoutineEditor default/option — the automation paths that were creating tasks into the deleted column. **`--strict` was also RED on clean main, twice over, and both causes were the same mistake:** a thorough written rationale the tool cannot read, because the marker was not where the census looks. The census reads a comparison node's **leading comments**; a `DELIBERATE-LITERAL` in the JSDoc above the enclosing function or declaration does not reach the comparison inside it. | site | why it is legitimate | why the tool could not see it | |---|---|---| | `columnRoles.ts:80` `isHoldColumnRole` | degrades to `columnId === "todo"` only when a column has **no resolved traits** — identical in kind to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` directly above, which escapes counting only because a Set is a membership form | rationale written, **no marker token** | | `MissionControlPanel.tsx` ×3 | the SDLC funnel **alias table** — maps `to-do`/`ready`/`review`/`shipped` onto one display stage with an explicit `other` bucket, and nothing branches on it | marker in the JSDoc; the comparisons are arrow bodies **inside the array literal**, which it does not reach | The second only surfaced because converting the `triage` stage to a Set removed its count and exposed the siblings — red gate, justification sitting three lines above, unreachable. Both are markers, no behaviour change. Neither is a conversion candidate: resolving the funnel table to traits would **drop the non-column aliases it exists to accept**. **For the auto-lowering work:** the marker-placement rule is now the recurring trap — three instances, three different authors, including me. A marker that does not register is indistinguishable from no marker, and the failure mode is a red gate with a written explanation nobody can act on. If the census accepted a marker anywhere in the enclosing declaration's comments, none of the three would have happened. Baseline re-recorded per the tool's own instruction ("Re-record the baseline in the SAME PR that lowered the count"). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Task “Actions” menus no longer appear on bare cards in the Planning column. - Routines, scheduled tasks, and create-task steps now respect each board’s configured workflow intake column instead of using a retired default. - Legacy tasks saved with the retired intake column are migrated to automatic workflow resolution. - Target-column selection now offers only “Automatic (workflow intake)” and “Planning,” removing the obsolete option. - Capacity/planning messaging and related UI tooltip text were clarified; concurrency cap updates are managed per project. - **Tests** - Added/updated coverage for workflow intake resolution, create-task target column behavior (including legacy coercion), capacity safeguards, and API request consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, GripVertical } from "lucide-react";
|
|
import { AUTOMATION_SELECTABLE_TOOLS } from "@fusion/core";
|
|
import type { AutomationStep, AutomationStepType } from "@fusion/core";
|
|
import { StepTypeBadge } from "./StepTypeBadge";
|
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
|
import { fetchModels } from "../api";
|
|
import type { ModelInfo } from "../api";
|
|
|
|
interface ScheduleStepsEditorProps {
|
|
steps: AutomationStep[];
|
|
onChange: (steps: AutomationStep[]) => void;
|
|
/** Called when editing state changes. Useful for parent form validation. */
|
|
onEditingChange?: (isEditing: boolean) => void;
|
|
}
|
|
|
|
function generateStepId(): string {
|
|
// crypto.randomUUID() may be unavailable in non-secure contexts (HTTP),
|
|
// older browsers, or some test environments. Fall back to a
|
|
// cryptographically-acceptable alternative when needed.
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
// Deterministic fallback: timestamp + random hex
|
|
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
}
|
|
|
|
const ALL_AUTOMATION_TOOLS = [...AUTOMATION_SELECTABLE_TOOLS];
|
|
|
|
function normalizeAllowedTools(selectedTools: string[]): string[] | undefined {
|
|
return selectedTools.length === ALL_AUTOMATION_TOOLS.length ? undefined : selectedTools;
|
|
}
|
|
|
|
function resolveAllowedToolSelection(step?: AutomationStep): string[] {
|
|
return step?.allowedTools === undefined ? ALL_AUTOMATION_TOOLS : step.allowedTools;
|
|
}
|
|
|
|
function createEmptyStep(type: AutomationStepType): AutomationStep {
|
|
if (type === "command") {
|
|
return {
|
|
id: generateStepId(),
|
|
type,
|
|
name: "New Command Step",
|
|
command: "",
|
|
continueOnFailure: false,
|
|
};
|
|
}
|
|
if (type === "ai-prompt") {
|
|
return {
|
|
id: generateStepId(),
|
|
type,
|
|
name: "New AI Prompt Step",
|
|
prompt: "",
|
|
continueOnFailure: false,
|
|
};
|
|
}
|
|
// create-task
|
|
return {
|
|
id: generateStepId(),
|
|
type,
|
|
name: "New Create Task Step",
|
|
taskDescription: "",
|
|
/*
|
|
FNXC:Automations 2026-07-30-18:30 (greptile #2652 — the ADVANCED editor was the bigger hole):
|
|
No `taskColumn`. This template used to stamp `"triage"` on EVERY new create-task step, so the
|
|
advanced editor manufactured the defect the simple form had just been fixed for — and it did so by
|
|
default, not only on legacy data. U11 deletes `triage` from the default workflow, and an explicit
|
|
column of any value bypasses the workflow entry-column resolution used for column-less creates, so
|
|
the step must name no column and let each workflow resolve its own intake.
|
|
*/
|
|
continueOnFailure: false,
|
|
};
|
|
}
|
|
|
|
interface StepEditorProps {
|
|
step: AutomationStep;
|
|
onSave: (step: AutomationStep) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
|
const { t } = useTranslation("app");
|
|
const [name, setName] = useState(step.name);
|
|
const [type, setType] = useState<AutomationStepType>(step.type);
|
|
const [command, setCommand] = useState(step.command ?? "");
|
|
const [prompt, setPrompt] = useState(step.prompt ?? "");
|
|
const [modelProvider, setModelProvider] = useState(step.modelProvider ?? "");
|
|
const [modelId, setModelId] = useState(step.modelId ?? "");
|
|
/*
|
|
FNXC:Automations 2026-07-12-19:14:
|
|
Multi-step schedule and routine AI-capable model selectors capture an optional per-step thinking level. Empty string means inherit the default; concrete values persist on the AutomationStep JSON payload.
|
|
*/
|
|
const [thinkingLevel, setThinkingLevel] = useState(step.thinkingLevel ?? "");
|
|
/*
|
|
FNXC:AutomationTools 2026-06-26-00:00:
|
|
Multi-step AI prompts share the simple form's default-all contract: undefined means every selectable coding tool, while [] intentionally removes all tools for the step.
|
|
*/
|
|
const [allowedTools, setAllowedTools] = useState<string[]>(() => resolveAllowedToolSelection(step));
|
|
const [taskTitle, setTaskTitle] = useState(step.taskTitle ?? "");
|
|
const [taskDescription, setTaskDescription] = useState(step.taskDescription ?? "");
|
|
/*
|
|
FNXC:Automations 2026-07-30-18:30 DELIBERATE-LITERAL:
|
|
Coerces a PERSISTED legacy value, which is a migration and not a lifecycle decision — a step saved
|
|
before this fix holds a column the select no longer offers, invisible and unchangeable, and it would be
|
|
resubmitted by any unrelated edit. There is no workflow IR in scope here (a routine names no workflow),
|
|
so there is no role to resolve; recognising that exact legacy string is the whole point.
|
|
*/
|
|
const [taskColumn, setTaskColumn] = useState(step.taskColumn === "triage" ? "" : step.taskColumn ?? "");
|
|
const [timeoutMs, setTimeoutMs] = useState<number | undefined>(step.timeoutMs);
|
|
const [continueOnFailure, setContinueOnFailure] = useState(step.continueOnFailure ?? false);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
|
|
|
// Fetch models on mount
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setModelsLoading(true);
|
|
setModelsError(null);
|
|
|
|
fetchModels()
|
|
.then((response) => {
|
|
if (!cancelled) {
|
|
setModels(response.models);
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!cancelled) {
|
|
setModelsError(err instanceof Error ? err.message : "Failed to load models");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setModelsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const validate = useCallback((): boolean => {
|
|
const e: Record<string, string> = {};
|
|
if (!name.trim()) e.name = t("schedule.stepNameRequired", "Step name is required");
|
|
if (type === "command" && !command.trim()) e.command = t("schedule.commandRequired", "Command is required");
|
|
if (type === "ai-prompt" && !prompt.trim()) e.prompt = t("schedule.promptRequired", "Prompt is required");
|
|
if (type === "create-task" && !taskDescription.trim()) e.taskDescription = t("schedule.taskDescriptionRequired", "Task description is required");
|
|
// Model pairing validation for ai-prompt and create-task
|
|
if ((type === "ai-prompt" || type === "create-task") && (modelProvider || modelId)) {
|
|
if (!modelProvider || !modelId) {
|
|
e.modelProvider = t("schedule.modelProviderRequired", "Both model provider and model ID must be set together");
|
|
}
|
|
}
|
|
if (timeoutMs !== undefined && timeoutMs < 1000) {
|
|
e.timeoutMs = t("schedule.timeoutMinimum", "Timeout must be at least 1 second (1000ms)");
|
|
}
|
|
setErrors(e);
|
|
return Object.keys(e).length === 0;
|
|
}, [name, type, command, prompt, taskDescription, modelProvider, modelId, thinkingLevel, timeoutMs, t]);
|
|
|
|
// Compute combined model value from separate fields
|
|
const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : "";
|
|
|
|
// Handle model selection from the dropdown
|
|
const handleModelChange = useCallback((value: string) => {
|
|
if (!value) {
|
|
setModelProvider("");
|
|
setModelId("");
|
|
} else {
|
|
const slashIdx = value.indexOf("/");
|
|
if (slashIdx !== -1) {
|
|
setModelProvider(value.slice(0, slashIdx));
|
|
setModelId(value.slice(slashIdx + 1));
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!validate()) return;
|
|
|
|
// Clear fields that don't apply to this step type
|
|
const baseStep = {
|
|
...step,
|
|
name: name.trim(),
|
|
type,
|
|
command: type === "command" ? command.trim() : undefined,
|
|
prompt: type === "ai-prompt" ? prompt.trim() : undefined,
|
|
taskTitle: type === "create-task" && taskTitle.trim() ? taskTitle.trim() : undefined,
|
|
taskDescription: type === "create-task" && taskDescription.trim() ? taskDescription.trim() : undefined,
|
|
// Empty means "let the workflow decide" — an explicit column overrides entry resolution.
|
|
taskColumn: type === "create-task" ? (taskColumn || undefined) : undefined,
|
|
modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined,
|
|
modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined,
|
|
thinkingLevel: (type === "ai-prompt" || type === "create-task") && thinkingLevel.trim() ? thinkingLevel.trim() : undefined,
|
|
allowedTools: type === "ai-prompt" ? normalizeAllowedTools(allowedTools) : undefined,
|
|
timeoutMs: timeoutMs || undefined,
|
|
continueOnFailure,
|
|
};
|
|
|
|
// Clear ai-prompt and create-task specific fields when switching to command
|
|
if (type !== "ai-prompt") {
|
|
delete baseStep.prompt;
|
|
delete baseStep.allowedTools;
|
|
}
|
|
if (type !== "create-task") {
|
|
delete baseStep.taskTitle;
|
|
delete baseStep.taskDescription;
|
|
delete baseStep.taskColumn;
|
|
}
|
|
if (type !== "ai-prompt" && type !== "create-task") {
|
|
delete baseStep.thinkingLevel;
|
|
}
|
|
|
|
onSave(baseStep as AutomationStep);
|
|
}, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, thinkingLevel, allowedTools, timeoutMs, continueOnFailure]);
|
|
|
|
return (
|
|
<div className="step-editor">
|
|
<div className="form-group">
|
|
<label htmlFor={`step-name-${step.id}`}>{t("schedule.stepName", "Step Name")}</label>
|
|
<input
|
|
id={`step-name-${step.id}`}
|
|
type="text"
|
|
placeholder={t("schedule.stepNamePlaceholder", "e.g. Run tests")}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
aria-invalid={!!errors.name}
|
|
/>
|
|
{errors.name && <small className="field-error">{errors.name}</small>}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-type-${step.id}`}>{t("schedule.stepType", "Step Type")}</label>
|
|
<select
|
|
id={`step-type-${step.id}`}
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value as AutomationStepType)}
|
|
>
|
|
<option value="command">{t("schedule.commandType", "Command")}</option>
|
|
<option value="ai-prompt">{t("schedule.aiPromptType", "AI Prompt")}</option>
|
|
<option value="create-task">{t("schedule.createTaskType", "Create Task")}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{type === "command" && (
|
|
<div className="form-group">
|
|
<label htmlFor={`step-command-${step.id}`}>{t("schedule.command", "Command")}</label>
|
|
<textarea
|
|
id={`step-command-${step.id}`}
|
|
placeholder={t("schedule.commandPlaceholder", "e.g. npm test")}
|
|
value={command}
|
|
onChange={(e) => setCommand(e.target.value)}
|
|
rows={2}
|
|
aria-invalid={!!errors.command}
|
|
/>
|
|
{errors.command && <small className="field-error">{errors.command}</small>}
|
|
</div>
|
|
)}
|
|
|
|
{type === "ai-prompt" && (
|
|
<>
|
|
<div className="form-group">
|
|
<label htmlFor={`step-prompt-${step.id}`}>{t("schedule.prompt", "Prompt")}</label>
|
|
<textarea
|
|
id={`step-prompt-${step.id}`}
|
|
placeholder={t("schedule.promptPlaceholder", "e.g. Summarize the test results and highlight any failures")}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
rows={3}
|
|
aria-invalid={!!errors.prompt}
|
|
/>
|
|
{errors.prompt && <small className="field-error">{errors.prompt}</small>}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-model-${step.id}`}>{t("schedule.modelOptional", "Model (optional)")}</label>
|
|
<CustomModelDropdown
|
|
id={`step-model-${step.id}`}
|
|
label={t("schedule.model", "Model")}
|
|
models={models}
|
|
value={modelValue}
|
|
onChange={handleModelChange}
|
|
placeholder={t("schedule.useDefault", "Use default")}
|
|
disabled={modelsLoading}
|
|
thinkingLevel={thinkingLevel}
|
|
onThinkingLevelChange={setThinkingLevel}
|
|
defaultThinkingLevel="off"
|
|
showThinkingLevel
|
|
/>
|
|
{modelsError && <small className="field-error">{modelsError}</small>}
|
|
<small>{t("schedule.modelHelp", "AI model for this step. Uses default if not selected.")}</small>
|
|
</div>
|
|
|
|
<fieldset className="form-group automation-tool-selector">
|
|
<legend>{t("schedule.allowedToolsLabel", "Allowed tools")}</legend>
|
|
<small>{t("schedule.allowedToolsHint", "AI prompt steps use all tools by default. Clear tools only when this automation should run without tool access.")}</small>
|
|
<div className="automation-tool-selector__actions">
|
|
<button type="button" className="btn btn-sm" onClick={() => setAllowedTools(ALL_AUTOMATION_TOOLS)}>
|
|
{t("schedule.selectAllTools", "Select all")}
|
|
</button>
|
|
<button type="button" className="btn btn-sm" onClick={() => setAllowedTools([])}>
|
|
{t("schedule.clearTools", "Clear")}
|
|
</button>
|
|
</div>
|
|
<div className="automation-tool-selector__grid">
|
|
{ALL_AUTOMATION_TOOLS.map((tool) => (
|
|
<label key={tool} className="checkbox-label automation-tool-selector__option">
|
|
<input
|
|
type="checkbox"
|
|
checked={allowedTools.includes(tool)}
|
|
onChange={(event) => {
|
|
setAllowedTools((current) => event.target.checked
|
|
? [...current, tool].filter((value, index, array) => array.indexOf(value) === index)
|
|
: current.filter((value) => value !== tool));
|
|
}}
|
|
/>
|
|
{tool}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
</>
|
|
)}
|
|
|
|
{type === "create-task" && (
|
|
<>
|
|
<div className="form-group">
|
|
<label htmlFor={`step-task-title-${step.id}`}>{t("schedule.taskTitleOptional", "Task Title (optional)")}</label>
|
|
<input
|
|
id={`step-task-title-${step.id}`}
|
|
type="text"
|
|
placeholder={t("schedule.taskTitlePlaceholder", "e.g. Review weekly dependencies")}
|
|
value={taskTitle}
|
|
onChange={(e) => setTaskTitle(e.target.value)}
|
|
/>
|
|
<small>{t("schedule.taskTitleHelp", "Leave blank to auto-summarize from description")}</small>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-task-description-${step.id}`}>{t("schedule.taskDescription", "Task Description *")}</label>
|
|
<textarea
|
|
id={`step-task-description-${step.id}`}
|
|
placeholder={t("schedule.taskDescriptionPlaceholder", "e.g. Check all npm dependencies for security vulnerabilities and update outdated packages")}
|
|
value={taskDescription}
|
|
onChange={(e) => setTaskDescription(e.target.value)}
|
|
rows={4}
|
|
aria-invalid={!!errors.taskDescription}
|
|
/>
|
|
{errors.taskDescription && <small className="field-error">{errors.taskDescription}</small>}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-task-column-${step.id}`}>{t("schedule.targetColumn", "Target Column")}</label>
|
|
<select
|
|
id={`step-task-column-${step.id}`}
|
|
value={taskColumn}
|
|
onChange={(e) => setTaskColumn(e.target.value)}
|
|
>
|
|
{/*
|
|
`triage` is REMOVED, not merely un-defaulted: leaving it offered lets an operator pick a
|
|
column the default workflow no longer declares, and it was labelled "Planning" — the name
|
|
the merged `todo` column now displays — so it was the natural choice. `todo` is relabelled
|
|
to match the board, and "Automatic" resolves each workflow's own intake.
|
|
*/}
|
|
<option value="">{t("schedule.autoColumn", "Automatic (workflow intake)")}</option>
|
|
<option value="todo">{t("schedule.planningColumn", "Planning")}</option>
|
|
</select>
|
|
<small>{t("schedule.targetColumnHelp", "Column where the new task will be created")}</small>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-task-model-${step.id}`}>{t("schedule.executorModelOptional", "Executor Model (optional)")}</label>
|
|
<CustomModelDropdown
|
|
id={`step-task-model-${step.id}`}
|
|
label={t("schedule.model", "Model")}
|
|
models={models}
|
|
value={modelValue}
|
|
onChange={handleModelChange}
|
|
placeholder={t("schedule.useDefault", "Use default")}
|
|
disabled={modelsLoading}
|
|
thinkingLevel={thinkingLevel}
|
|
onThinkingLevelChange={setThinkingLevel}
|
|
defaultThinkingLevel="off"
|
|
showThinkingLevel
|
|
/>
|
|
{modelsError && <small className="field-error">{modelsError}</small>}
|
|
{errors.modelProvider && <small className="field-error">{errors.modelProvider}</small>}
|
|
<small>{t("schedule.executorModelHelp", "AI model for executing the created task. Uses default if not selected.")}</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-timeout-${step.id}`}>{t("schedule.timeoutOptional", "Timeout (ms, optional)")}</label>
|
|
<input
|
|
id={`step-timeout-${step.id}`}
|
|
type="number"
|
|
min={1000}
|
|
step={1000}
|
|
placeholder={t("schedule.timeoutPlaceholder", "Override schedule timeout")}
|
|
value={timeoutMs ?? ""}
|
|
onChange={(e) => setTimeoutMs(e.target.value ? Number(e.target.value) : undefined)}
|
|
aria-invalid={!!errors.timeoutMs}
|
|
/>
|
|
{errors.timeoutMs && <small className="field-error">{errors.timeoutMs}</small>}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor={`step-continue-${step.id}`} className="checkbox-label">
|
|
<input
|
|
id={`step-continue-${step.id}`}
|
|
type="checkbox"
|
|
checked={continueOnFailure}
|
|
onChange={(e) => setContinueOnFailure(e.target.checked)}
|
|
/>
|
|
{t("schedule.continueOnFailure", "Continue on failure")}
|
|
</label>
|
|
<small>{t("schedule.continueOnFailureHelp", "If checked, the next step will run even if this one fails")}</small>
|
|
</div>
|
|
|
|
<div className="step-editor-actions">
|
|
<button type="button" className="btn btn-sm" onClick={onCancel}>
|
|
{t("actions.cancel", "Cancel")}
|
|
</button>
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={handleSave}>
|
|
{t("schedule.saveStep", "Save Step")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ScheduleStepsEditor({ steps, onChange, onEditingChange }: ScheduleStepsEditorProps) {
|
|
const { t } = useTranslation("app");
|
|
const [editingStepId, setEditingStepId] = useState<string | null>(null);
|
|
|
|
// Notify parent when editing state changes
|
|
useEffect(() => {
|
|
onEditingChange?.(editingStepId !== null);
|
|
}, [editingStepId, onEditingChange]);
|
|
|
|
const handleAddStep = useCallback((type: AutomationStepType) => {
|
|
const newStep = createEmptyStep(type);
|
|
onChange([...steps, newStep]);
|
|
setEditingStepId(newStep.id);
|
|
}, [steps, onChange]);
|
|
|
|
const handleDeleteStep = useCallback((stepId: string) => {
|
|
onChange(steps.filter((s) => s.id !== stepId));
|
|
if (editingStepId === stepId) setEditingStepId(null);
|
|
}, [steps, onChange, editingStepId]);
|
|
|
|
const handleMoveStep = useCallback((stepId: string, direction: "up" | "down") => {
|
|
const index = steps.findIndex((s) => s.id === stepId);
|
|
if (index < 0) return;
|
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (newIndex < 0 || newIndex >= steps.length) return;
|
|
const newSteps = [...steps];
|
|
[newSteps[index], newSteps[newIndex]] = [newSteps[newIndex], newSteps[index]];
|
|
onChange(newSteps);
|
|
}, [steps, onChange]);
|
|
|
|
const handleSaveStep = useCallback((updatedStep: AutomationStep) => {
|
|
onChange(steps.map((s) => (s.id === updatedStep.id ? updatedStep : s)));
|
|
setEditingStepId(null);
|
|
}, [steps, onChange]);
|
|
|
|
return (
|
|
<div className="steps-editor">
|
|
<div className="steps-editor-header">
|
|
<span className="steps-editor-title">{t("schedule.steps", "Steps")} ({steps.length})</span>
|
|
</div>
|
|
|
|
{steps.length === 0 && (
|
|
<div className="steps-empty-state">
|
|
<p>{t("schedule.noStepsEmpty", "No steps added yet. Add a command or AI prompt step to get started.")}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="steps-list">
|
|
{steps.map((step, index) => (
|
|
<div key={step.id} className="step-card">
|
|
{editingStepId === step.id ? (
|
|
<StepEditor
|
|
step={step}
|
|
onSave={handleSaveStep}
|
|
onCancel={() => setEditingStepId(null)}
|
|
/>
|
|
) : (
|
|
<div className="step-card-row">
|
|
<div className="step-card-drag">
|
|
<GripVertical size={14} />
|
|
</div>
|
|
<span className="step-card-index">{index + 1}</span>
|
|
<StepTypeBadge type={step.type} />
|
|
<span className="step-card-name">{step.name}</span>
|
|
{step.continueOnFailure && (
|
|
<span className="step-card-flag" title={t("schedule.continuesOnFailure", "Continues on failure")}>⚡</span>
|
|
)}
|
|
<div className="step-card-actions">
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => handleMoveStep(step.id, "up")}
|
|
disabled={index === 0}
|
|
title={t("schedule.moveUp", "Move up")}
|
|
aria-label={t("schedule.moveStepUp", "Move {{name}} up", { name: step.name })}
|
|
>
|
|
<ChevronUp />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => handleMoveStep(step.id, "down")}
|
|
disabled={index === steps.length - 1}
|
|
title={t("schedule.moveDown", "Move down")}
|
|
aria-label={t("schedule.moveStepDown", "Move {{name}} down", { name: step.name })}
|
|
>
|
|
<ChevronDown />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => setEditingStepId(step.id)}
|
|
title={t("actions.edit", "Edit")}
|
|
aria-label={t("schedule.editStep", "Edit {{name}}", { name: step.name })}
|
|
>
|
|
<Pencil />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => handleDeleteStep(step.id)}
|
|
title={t("actions.delete", "Delete")}
|
|
aria-label={t("schedule.deleteStep", "Delete {{name}}", { name: step.name })}
|
|
>
|
|
<Trash2 />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="steps-add-buttons">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => handleAddStep("command")}
|
|
>
|
|
<Plus size={14} />
|
|
{t("schedule.addCommandStep", "Add Command Step")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => handleAddStep("ai-prompt")}
|
|
>
|
|
<Plus size={14} />
|
|
{t("schedule.addAiPromptStep", "Add AI Prompt Step")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => handleAddStep("create-task")}
|
|
>
|
|
<Plus size={14} />
|
|
{t("schedule.addCreateTaskStep", "Add Create Task Step")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|