diff --git a/packages/core/src/__tests__/task-fields.test.ts b/packages/core/src/__tests__/task-fields.test.ts index a35e0c855d..5306ad9547 100644 --- a/packages/core/src/__tests__/task-fields.test.ts +++ b/packages/core/src/__tests__/task-fields.test.ts @@ -432,6 +432,81 @@ describe("store: updateWorkflowDefinition field-type change coercion (U11)", () // y orphaned but retained. expect(got?.customFields).toEqual({ x: "a", y: "b" }); }); + + // T1 (store.ts:12410): a field-schema edit that adds a new required+default + // field must backfill the default onto EVERY occupant, including occupants + // that currently hold no custom field values — not only ones already populated. + it("backfills a new required+default field onto occupants with no existing values", async () => { + const { taskId, workflowId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]); + // Occupant deliberately has NO custom field values stored. + const before = await store.getTask(taskId); + expect(before?.customFields ?? {}).toEqual({}); + + await store.updateWorkflowDefinition(workflowId, { + ir: irWith([ + F({ id: "x", type: "string" }), + F({ id: "tier", type: "string", required: true, default: "bronze" }), + ]), + }); + + const got = await store.getTask(taskId); + expect(got?.customFields).toEqual({ tier: "bronze" }); + }); +}); + +// ── Archive → unarchive customFields round-trip ────────────────────────────── + +describe("store: archive → unarchive preserves customFields (T0)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + const irWith = (fields: WorkflowFieldDefinition[], name = "WF"): WorkflowIr => + ({ + version: "v2", + name, + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields, + }) as unknown as WorkflowIr; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("restores customFields after an archive → unarchive round-trip", async () => { + const def = await (store as any).createWorkflowDefinition({ + name: "WF", + ir: irWith([F({ id: "sev", type: "enum", options: enumOpts }), F({ id: "pts", type: "number" })]), + }); + const t = await store.createTask({ description: "round-trip" }); + await (store as any).selectTaskWorkflow(t.id, def.id); + await (store as any).updateTaskCustomFields(t.id, { sev: "high", pts: 5 }); + + // Move through the legacy transition chain to reach 'done', then archive. + await store.moveTask(t.id, "todo"); + await store.moveTask(t.id, "in-progress"); + await store.moveTask(t.id, "in-review"); + await store.moveTask(t.id, "done"); + const archived = await store.archiveTask(t.id); + expect(archived.column).toBe("archived"); + + const restored = await store.unarchiveTask(t.id); + expect(restored.customFields).toEqual({ sev: "high", pts: 5 }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ sev: "high", pts: 5 }); + }); }); // ── JSON round-trip stability ──────────────────────────────────────────────── diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 85d706e40d..352b281004 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -11703,6 +11703,7 @@ export class TaskStore extends EventEmitter { dependencies: entry.dependencies, steps: entry.steps, currentStep: entry.currentStep, + customFields: entry.customFields ?? undefined, size: entry.size, reviewLevel: entry.reviewLevel, prInfo: entry.prInfo, @@ -12381,7 +12382,6 @@ ${stepsSection}`; if (fieldsChanged) { const occupantTaskIds = this.listWorkflowOccupantTaskIds(id, false); const occupantsByField = new Map(); - const occupantsWithFields: string[] = []; for (const taskId of occupantTaskIds) { const row = this.db.prepare("SELECT customFields FROM tasks WHERE id = ?").get(taskId) as | { customFields: string | null } @@ -12389,8 +12389,11 @@ ${stepsSection}`; const values = row?.customFields ? (fromJson>(row.customFields) ?? {}) : {}; + // Incompatible-change detection only blocks on occupants that already + // HOLD a value for a field, so count only those. Reconciliation itself + // must still touch every occupant so new required+default fields get + // backfilled onto tasks that currently have no custom field values. if (Object.keys(values).length === 0) continue; - occupantsWithFields.push(taskId); for (const key of Object.keys(values)) { occupantsByField.set(key, (occupantsByField.get(key) ?? 0) + 1); } @@ -12406,7 +12409,7 @@ ${stepsSection}`; pendingFieldReconcile = { oldFields, newFields, - occupantTaskIds: occupantsWithFields, + occupantTaskIds, coerce: updates.coerce, }; } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 81e06dc866..8c4b40e8a6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5058,6 +5058,16 @@ export function fetchTraits(projectId?: string): Promise { ); } +/** Fetch the step-parser id catalog (built-ins + registered plugin parsers) for + * the parse-steps node inspector (KTD-12). Registry-backed, read-only, + * session-scoped. Mirrors fetchTraits. */ +export function fetchStepParsers(projectId?: string): Promise { + const path = withProjectId("/step-parsers", projectId); + return dedupe(path, () => + api<{ parsers: Array<{ id: string }> }>(path).then((res) => res.parsers.map((p) => p.id)), + ); +} + /** Fetch a single workflow definition. */ export function fetchWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId)); diff --git a/packages/dashboard/app/components/TaskFieldsSection.tsx b/packages/dashboard/app/components/TaskFieldsSection.tsx index 67d7cfb76e..db5eaa730c 100644 --- a/packages/dashboard/app/components/TaskFieldsSection.tsx +++ b/packages/dashboard/app/components/TaskFieldsSection.tsx @@ -30,7 +30,7 @@ * nothing (null), so a task on a field-less workflow is byte-identical to * today's UI (snapshot-guarded by the test suite). */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronRight, ChevronDown } from "lucide-react"; import type { @@ -92,10 +92,22 @@ function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { const fieldError = error && error.fieldId === field.id ? error : null; const disabled = readOnly || !onSave; + // Serialize per-field saves: rapid chip/toggle/blur edits to the same field + // would otherwise fire overlapping PATCHes whose responses can resolve out of + // order, letting an older request clobber a newer selection. We chain each + // save onto the previous one for this field so they apply in click order. + const saveTailRef = useRef>(Promise.resolve()); const commit = useCallback( (next: unknown) => { if (!onSave) return; - void onSave({ [field.id]: next }); + const run = () => onSave({ [field.id]: next }); + // Run after any in-flight save for this field, regardless of its outcome, + // so a rejected save doesn't permanently break the chain. The tail is kept + // settled-always (.catch) so its own rejection never floats unhandled and + // never blocks the next queued save — the caller surfaces failures via + // `error`, so we intentionally swallow here for ordering purposes only. + const prev = saveTailRef.current; + saveTailRef.current = prev.then(run, run).catch(() => {}); }, [onSave, field.id], ); @@ -103,6 +115,29 @@ function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { const labelId = `task-field-label-${field.id}`; const controlId = `task-field-${field.id}`; + // Prop-derived string value for the uncontrolled-style inputs (date / text / + // string / number / url). These were previously rendered with `defaultValue`, + // which only seeds on mount — so an external refresh of `customFields` (SSE or + // a save round-trip) left the DOM showing a stale value, and a later blur would + // commit that stale value back over the refreshed one. We make them controlled + // and re-sync to the latest prop whenever it changes. + const propTextValue = + field.type === "date" + ? typeof value === "string" + ? value.slice(0, 10) + : "" + : field.type === "number" + ? typeof value === "number" + ? String(value) + : "" + : typeof value === "string" + ? value + : ""; + const [localValue, setLocalValue] = useState(propTextValue); + useEffect(() => { + setLocalValue(propTextValue); + }, [propTextValue]); + const renderControl = () => { // enum → select / radio / chips (single) if (field.type === "enum") { @@ -218,18 +253,18 @@ function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { // date → date input if (field.type === "date") { - const current = typeof value === "string" ? value.slice(0, 10) : ""; return ( setLocalValue(e.target.value)} onBlur={(e) => { const next = e.target.value; - if (next === current) return; + if (next === propTextValue) return; commit(next === "" ? null : next); }} /> @@ -238,17 +273,17 @@ function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { // text → textarea if (field.type === "text") { - const current = typeof value === "string" ? value : ""; return (