diff --git a/src/app/shared/task-execution-inputs-panel/input-copy.spec.ts b/src/app/shared/task-execution-inputs-panel/input-copy.spec.ts new file mode 100644 index 0000000..067c8d1 --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/input-copy.spec.ts @@ -0,0 +1,130 @@ +import { TaskExecution } from '@models/task-execution'; +import { EditableExecutionInput } from './task-execution-inputs-panel'; +import { describeInputCopy, planInputCopy } from './input-copy'; + +function target(overrides: Partial = {}): EditableExecutionInput { + return { + key: 'global:role', + scope: 'global', + nodeId: null, + inputName: 'role', + title: 'Flow', + subtitle: 'role', + type: 'TEXT', + multiple: false, + value: '', + provided: false, + ...overrides + }; +} + +function source(context: Partial = {}): TaskExecution { + return { + id: 'run-1', + name: 'Run 1', + creationTime: 1, + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + waitingSteps: [], + status: 'SUCCESS', + steps: {}, + ...context + } + } as unknown as TaskExecution; +} + +describe('planInputCopy', () => { + it('takes a global input from the other run', () => { + const role = target(); + const plan = planInputCopy([role], source({ globalInputs: { role: 'Backend Developer' } })); + + expect(plan.copied).toEqual([{ input: role, value: 'Backend Developer' }]); + expect(plan.skipped).toEqual([]); + }); + + it('falls back to the descriptor when the value map does not carry it', () => { + const plan = planInputCopy([target()], source({ + globalInputDescriptors: { role: { name: 'role', value: 'From descriptor' } } as any + })); + + expect(plan.copied[0].value).toBe('From descriptor'); + }); + + it('takes a node input by its step and port', () => { + const cv = target({ key: 's1:cv', scope: 'node', nodeId: 's1', inputName: 'cv', subtitle: 'cv' }); + const plan = planInputCopy([cv], source({ inputs: { 's1:cv': 'Candidate: Jordan Lee' } })); + + expect(plan.copied[0].value).toBe('Candidate: Jordan Lee'); + }); + + it('keeps a list input a list', () => { + const questions = target({ inputName: 'questions', multiple: true, value: [] }); + const plan = planInputCopy([questions], source({ globalInputs: { questions: ['first', 'second'] } })); + + expect(plan.copied[0].value).toEqual(['first', 'second']); + }); + + it('refuses a file input instead of copying a path to another run\'s upload', () => { + // The value is a temp file on the server. The backend copies it by reference when it reruns; + // from here there is only a path, and it would point at the other execution's upload. + const cv = target({ inputName: 'cv', type: 'FILE' }); + const plan = planInputCopy([cv], source({ globalInputs: { cv: '/tmp/cv1234.pdf' } })); + + expect(plan.copied).toEqual([]); + expect(plan.skipped[0].reason).toBe('files have to be uploaded again'); + }); + + it('reports an input the other run never had, rather than blanking this one', () => { + const plan = planInputCopy([target()], source({ globalInputs: {} })); + + expect(plan.copied).toEqual([]); + expect(plan.skipped[0].reason).toBe('not set in that run'); + }); + + it('treats an empty string in the other run as not set', () => { + const plan = planInputCopy([target()], source({ globalInputs: { role: ' ' } })); + + expect(plan.skipped[0].reason).toBe('not set in that run'); + }); + + it('does not offer a change that changes nothing', () => { + // It would otherwise land in the unsaved count and invite a pointless save. + const plan = planInputCopy([target({ value: 'same' })], source({ globalInputs: { role: 'same' } })); + + expect(plan.copied).toEqual([]); + expect(plan.skipped[0].reason).toBe('already the same'); + }); + + it('copies what it can and reports the rest, in one pass', () => { + const role = target(); + const cv = target({ key: 'g:cv', inputName: 'cv', type: 'FILE' }); + const missing = target({ key: 'g:notes', inputName: 'notes' }); + + const plan = planInputCopy([role, cv, missing], source({ globalInputs: { role: 'Backend Developer' } })); + + expect(plan.copied.map((one) => one.input.inputName)).toEqual(['role']); + expect(plan.skipped.map((one) => one.reason)) + .toEqual(['files have to be uploaded again', 'not set in that run']); + }); +}); + +describe('describeInputCopy', () => { + it('accounts for what was copied and what was not', () => { + const role = target(); + const cv = target({ key: 'g:cv', inputName: 'cv', type: 'FILE' }); + const missing = target({ key: 'g:notes', inputName: 'notes' }); + const plan = planInputCopy([role, cv, missing], source({ globalInputs: { role: 'Backend' } })); + + expect(describeInputCopy(plan)).toBe( + 'Copied 1 value, 1 not set in that run, 1 file has to be uploaded again.'); + }); + + it('says so when there was nothing to do', () => { + const plan = planInputCopy([target({ value: 'same' })], source({ globalInputs: { role: 'same' } })); + + expect(describeInputCopy(plan)).toBe('Every input already matches that run.'); + }); +}); diff --git a/src/app/shared/task-execution-inputs-panel/input-copy.ts b/src/app/shared/task-execution-inputs-panel/input-copy.ts new file mode 100644 index 0000000..fd93f0e --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/input-copy.ts @@ -0,0 +1,99 @@ +import { TaskExecution } from '@models/task-execution'; +import { hasStoredValue, normalizeEditableInputValue } from '@shared/task-execution-viewer/execution-viewer.utils'; +import { EditableExecutionInput } from './task-execution-inputs-panel'; + +export type CopiedInput = { + input: EditableExecutionInput; + value: string | string[]; +}; + +export type SkippedInput = { + input: EditableExecutionInput; + reason: string; +}; + +export type InputCopyPlan = { + copied: CopiedInput[]; + skipped: SkippedInput[]; +}; + +function isFileInput(input: EditableExecutionInput): boolean { + return input.type.includes('FILE') || input.type.includes('BINARY'); +} + +/** + * The value the other run held for this input. + * + * Globals live in their own map; a node input is keyed `stepId:inputName` in `context.inputs`, + * which holds only what a human supplied - values that arrived over a wire are never in there, and + * would be meaningless to copy anyway since the upstream node recomputes them. + */ +function sourceValueFor(input: EditableExecutionInput, source: TaskExecution): unknown { + if (input.scope === 'global') { + const globals = source.context.globalInputs ?? {}; + if (Object.prototype.hasOwnProperty.call(globals, input.inputName)) { + return globals[input.inputName]; + } + return source.context.globalInputDescriptors?.[input.inputName]?.value; + } + return (source.context.inputs ?? {})[`${input.nodeId}:${input.inputName}`]; +} + +/** + * Works out what can be taken from another run of the same flow, and what cannot. + * + * Deliberately partial. A file input holds a temp file on the server, which the backend copies by + * reference when it reruns an execution; from here there is only a path, so copying it would + * produce a value that points at another run's upload. Credentials are not here at all - the vault + * decrypts them server-side. Both are reported rather than dropped quietly, because a copy that + * silently leaves gaps is worse than one that says where they are. + */ +export function planInputCopy( + targets: EditableExecutionInput[], + source: TaskExecution +): InputCopyPlan { + const copied: CopiedInput[] = []; + const skipped: SkippedInput[] = []; + + for (const input of targets) { + if (isFileInput(input)) { + skipped.push({ input, reason: 'files have to be uploaded again' }); + continue; + } + + const raw = sourceValueFor(input, source); + if (!hasStoredValue(raw)) { + skipped.push({ input, reason: 'not set in that run' }); + continue; + } + + const value = normalizeEditableInputValue(raw, input.multiple); + // Offering a change that changes nothing would put it in the unsaved count for no reason. + if (JSON.stringify(value) === JSON.stringify(normalizeEditableInputValue(input.value, input.multiple))) { + skipped.push({ input, reason: 'already the same' }); + continue; + } + + copied.push({ input, value }); + } + + return { copied, skipped }; +} + +/** A one-line account of what a copy did, for the panel to show. */ +export function describeInputCopy(plan: InputCopyPlan): string { + const copied = plan.copied.length; + const blocked = plan.skipped.filter((entry) => entry.reason === 'files have to be uploaded again'); + const missing = plan.skipped.filter((entry) => entry.reason === 'not set in that run'); + + if (!copied && !blocked.length && !missing.length) { + return 'Every input already matches that run.'; + } + + const parts = [`Copied ${copied} ${copied === 1 ? 'value' : 'values'}`]; + if (missing.length) parts.push(`${missing.length} not set in that run`); + if (blocked.length) { + parts.push(`${blocked.length} ${blocked.length === 1 ? 'file has' : 'files have'} to be uploaded again`); + } + return `${parts.join(', ')}.`; +} diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css index f70b3d2..fbe2606 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css @@ -450,3 +450,85 @@ height: 16px; line-height: 16px; } + +/* Retyping three long answers you already typed last run is the thing this avoids. */ +.inputs-panel-copy { + display: flex; + align-items: center; + gap: 4px; + align-self: flex-start; + padding: 2px 7px 2px 4px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #ffffff; + color: #475569; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.inputs-panel-copy:hover { + border-color: #c7d2fe; + background: #eef2ff; + color: #3730a3; +} + +.inputs-panel-copy .mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; +} + +/* A partial copy has to say where its gaps are; silence would read as a complete one. */ +.inputs-panel-copy-summary { + padding: 5px 8px; + border: 1px solid #c7d2fe; + border-radius: 6px; + background: #eef2ff; + color: #3730a3; + font-size: 11px; + line-height: 1.4; +} + +.inputs-panel-copy-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.inputs-panel-copy-row { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #ffffff; + text-align: left; + cursor: pointer; +} + +.inputs-panel-copy-row:hover { + border-color: #c7d2fe; + background: #f8fafc; +} + +.inputs-panel-copy-row-label { + font-size: 12.5px; + font-weight: 700; + color: #0f172a; +} + +.inputs-panel-copy-row-detail { + color: #64748b; + font-size: 11.5px; +} + +.inputs-panel-copy-note { + margin: 10px 0 0; + color: #64748b; + font-size: 11.5px; + line-height: 1.45; +} diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index 2e585a3..a944d4e 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -78,6 +78,16 @@ + @if (copySources().length && !readOnly()) { + + } + @if (copySummary(); as summary) { +
{{ summary }}
+ } + @if (globalExecutionInputs().length) { + } + + +

+ Files and credentials are not copied: an uploaded file belongs to the run it was uploaded to, + and credentials never leave the server. +

+ +
+ +
+ +} diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts index b3c7ffa..f194667 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts @@ -28,6 +28,8 @@ async function build(inputs: EditableExecutionInput[], options: { saving?: Record; readOnly?: boolean; saveError?: string | null; + copySources?: Array<{ id: string; label: string; detail: string }>; + copySummary?: string | null; } = {}) { await TestBed.configureTestingModule({ imports: [TaskExecutionInputsPanelComponent] }).compileComponents(); @@ -37,6 +39,8 @@ async function build(inputs: EditableExecutionInput[], options: { fixture.componentRef.setInput('savingInputs', options.saving ?? {}); fixture.componentRef.setInput('readOnly', options.readOnly ?? false); fixture.componentRef.setInput('saveError', options.saveError ?? null); + fixture.componentRef.setInput('copySources', options.copySources ?? []); + fixture.componentRef.setInput('copySummary', options.copySummary ?? null); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -291,6 +295,58 @@ describe('TaskExecutionInputsPanelComponent', () => { expect(fixture.nativeElement.querySelector('.inputs-panel-alert')).toBeNull(); }); + it('offers to copy from another run only when there is one to copy from', async () => { + const withoutSources = await build([makeInput()]); + expect(withoutSources.nativeElement.querySelector('.inputs-panel-copy')).toBeNull(); + TestBed.resetTestingModule(); + + const fixture = await build([makeInput()], { + copySources: [{ id: 'e1', label: 'Run #1', detail: 'SUCCESS' }] + }); + expect(fixture.nativeElement.querySelector('.inputs-panel-copy')).not.toBeNull(); + }); + + it('hides the copy action when the panel is read-only', async () => { + const fixture = await build([makeInput()], { + readOnly: true, + copySources: [{ id: 'e1', label: 'Run #1', detail: 'SUCCESS' }] + }); + + expect(fixture.nativeElement.querySelector('.inputs-panel-copy')).toBeNull(); + }); + + it('asks the host for the run the user picked, and closes', async () => { + const fixture = await build([makeInput()], { + copySources: [ + { id: 'e1', label: 'Run #1', detail: 'SUCCESS' }, + { id: 'e2', label: 'Run #2', detail: 'ERROR' } + ] + }); + + fixture.componentInstance.openCopyPicker(); + fixture.detectChanges(); + const rows = fixture.nativeElement.querySelectorAll('.inputs-panel-copy-row'); + expect(rows.length).toBe(2); + expect(rows[1].textContent).toContain('Run #2'); + + const requested = vi.fn(); + fixture.componentInstance.copyRequested.subscribe(requested); + rows[1].click(); + + expect(requested).toHaveBeenCalledWith('e2'); + expect(fixture.componentInstance.copyPickerOpen()).toBe(false); + }); + + it('says what a copy did, gaps included', async () => { + // Silence after a partial copy would read as a complete one. + const fixture = await build([makeInput()], { + copySummary: 'Copied 2 values, 1 file has to be uploaded again.' + }); + + expect(fixture.nativeElement.querySelector('.inputs-panel-copy-summary').textContent) + .toContain('1 file has to be uploaded again'); + }); + it('offers the JSON import only on a multi-value input', async () => { const fixture = await build([ makeInput({ key: 'g:single', inputName: 'positionTitle' }), diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index e1311f1..6905a40 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -56,6 +56,13 @@ export function parseJsonArrayInput(text: string): JsonArrayParseResult { return { values, error: null }; } +/** A run the inputs could be taken from: identity plus enough to tell it apart at a glance. */ +export type InputCopySource = { + id: string; + label: string; + detail: string; +}; + export type EditableExecutionInput = { key: string; scope: 'global' | 'node'; @@ -97,6 +104,12 @@ export class TaskExecutionInputsPanelComponent { * and implied N separate problems. */ readonly saveError = input(null); + /** Other runs of the same flow, newest first, that inputs could be taken from. */ + readonly copySources = input([]); + /** What the last copy did, so a partial copy says where its gaps are. */ + readonly copySummary = input(null); + + readonly copyRequested = output(); readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>(); @@ -216,6 +229,23 @@ export class TaskExecutionInputsPanelComponent { this.closeEditor(); } + readonly copyPickerOpen = signal(false); + + openCopyPicker(event?: Event) { + event?.stopPropagation(); + if (this.readOnly() || !this.copySources().length) return; + this.copyPickerOpen.set(true); + } + + closeCopyPicker() { + this.copyPickerOpen.set(false); + } + + copyFrom(executionId: string) { + this.copyRequested.emit(executionId); + this.closeCopyPicker(); + } + readonly importTarget = signal(null); readonly importText = signal(''); readonly importError = signal(null); diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 5a74f07..5c25be8 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -401,11 +401,14 @@ [readOnly]="inputsReadOnly()" [pendingKeys]="pendingInputKeys()" [saveError]="globalSaveError()" + [copySources]="inputCopySources()" + [copySummary]="inputCopySummary()" (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" (textInputSubmit)="submitTextInput($event)" (submitAllInputs)="submitAllTextInputs()" + (copyRequested)="copyInputsFrom($event)" (fileInputChange)="onFileInputChange($event.input, $event.files)"> } @else if (activeAsideTab() === 'intermediate') { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 58f2f6f..8bc9e84 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -32,8 +32,10 @@ import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-pro import { VaultSecret } from '@models/assistant'; import { EditableExecutionInput, + InputCopySource, TaskExecutionInputsPanelComponent } from '@shared/task-execution-inputs-panel/task-execution-inputs-panel'; +import { describeInputCopy, planInputCopy } from '@shared/task-execution-inputs-panel/input-copy'; import { ReteEditor } from '@shared/rete-editor/rete-editor'; import { HumanInteractionChatMessage, @@ -145,6 +147,51 @@ export class TaskExecutionViewerComponent implements OnDestroy { /** Edited but not yet sent, so the panel can offer one Save for the lot. */ readonly pendingInputKeys = computed(() => Object.keys(this.pendingTextInputs())); + + /** What the last copy from another run did, cleared as soon as the user edits anything. */ + readonly inputCopySummary = signal(null); + + /** + * Other runs of the same flow whose inputs could be reused, newest first. + * + * Confined to the execution's own group, which is keyed by source flow: a run of a different + * flow has different inputs, and matching them by name would be a coincidence, not a copy. + */ + readonly inputCopySources = computed(() => { + const execution = this.execution(); + if (!execution || this.inputsReadOnly()) return []; + const group = this.taskExecutionsService.taskExecutionGroups() + .find((candidate) => candidate.executions.some((one) => one.id === execution.id)); + if (!group) return []; + + return group.executions + .filter((candidate) => candidate.id !== execution.id) + .sort((a, b) => (b.runNumber ?? 0) - (a.runNumber ?? 0) || b.creationTime - a.creationTime) + .map((candidate) => ({ + id: candidate.id, + label: candidate.runNumber ? `Run #${candidate.runNumber}` : candidate.name || candidate.id, + detail: [ + candidate.context?.status, + isBiasVariantContext(candidate.biasExecutionContext) ? 'bias variant' : null + ].filter(Boolean).join(' \u00b7 ') + })); + }); + + /** Fills the pending edits from another run, so the panel's single Save still governs the write. */ + copyInputsFrom(executionId: string) { + if (this.inputsReadOnly()) return; + const source = this.taskExecutionsService.taskExecutionGroups() + .flatMap((group) => group.executions) + .find((candidate) => candidate.id === executionId); + if (!source) return; + + const plan = planInputCopy(this.editableInputs(), source); + for (const { input, value } of plan.copied) { + this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); + } + this.globalSaveError.set(null); + this.inputCopySummary.set(describeInputCopy(plan)); + } /** Set when the single request carrying every edited global fails; cleared on the next edit. */ readonly globalSaveError = signal(null); @@ -1156,6 +1203,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { onTextInputChange(input: EditableExecutionInput, value: string | string[]) { if (this.inputsReadOnly()) return; this.globalSaveError.set(null); + this.inputCopySummary.set(null); this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); this.savingErrors.update((current) => { const next = { ...current };