Copy the inputs of another run of the same flow

Starting a fresh run means retyping values you already typed. The panel now
offers the other runs of this execution's own group - the group is keyed by
source flow, so a run of a different flow is never offered: matching its inputs
by name would be a coincidence, not a copy.

The copy fills the panel's pending edits rather than writing anything. That is
the whole trick: the values arrive with the unsaved-change styling already on
them, the user reviews them, and the single Save sends them in one bulk request.
No new write path, no new error handling.

It is deliberately partial, and says so. 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, and copying it would point this run at another run's
upload. Credentials are not copyable at all - the vault decrypts them
server-side. Both are reported in the summary rather than dropped quietly,
because a copy that silently leaves gaps is worse than one that names them.

Worth knowing before reaching for this: a rerun, bias rerun included, already
arrives fully populated - createBiasRerun calls copyReusableInputs, which carries
over every node input, the global descriptors, the authorizations and the
simulation descriptor. This is for the case that copies nothing: a new run
created from the flow.

An input already matching the source is not offered as a change, since it would
join the unsaved count and invite a pointless save.

555 frontend tests green; initial bundle unchanged at 2.88 kB over budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 22:53:54 +02:00
parent 360ed5bd82
commit 1abdb5b591
8 changed files with 488 additions and 0 deletions

View File

@ -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> = {}): 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['context']> = {}): 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.');
});
});

View File

@ -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(', ')}.`;
}

View File

@ -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;
}

View File

@ -78,6 +78,16 @@
</div>
</div>
@if (copySources().length && !readOnly()) {
<button type="button" class="inputs-panel-copy" (click)="openCopyPicker($event)">
<mat-icon fontIcon="content_copy"></mat-icon>
<span>Copy from another run</span>
</button>
}
@if (copySummary(); as summary) {
<div class="inputs-panel-copy-summary">{{ summary }}</div>
}
@if (globalExecutionInputs().length) {
<button type="button" class="inputs-panel-group" [attr.aria-expanded]="globalsOpen()" (click)="toggleGlobals()">
<mat-icon [fontIcon]="globalsOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
@ -332,3 +342,33 @@
</footer>
</app-modal-shell>
}
@if (copyPickerOpen()) {
<app-modal-shell
title="Copy inputs from another run"
subtitle="Values are filled in as unsaved changes, so you can review them before saving."
ariaLabel="Pick a run to copy inputs from"
maxWidth="560px"
closeLabel="Cancel"
(backdropClick)="closeCopyPicker()"
(closeClick)="closeCopyPicker()">
<div class="inputs-panel-copy-list">
@for (candidate of copySources(); track candidate.id) {
<button type="button" class="inputs-panel-copy-row" (click)="copyFrom(candidate.id)">
<span class="inputs-panel-copy-row-label">{{ candidate.label }}</span>
<span class="inputs-panel-copy-row-detail">{{ candidate.detail }}</span>
</button>
}
</div>
<p class="inputs-panel-copy-note">
Files and credentials are not copied: an uploaded file belongs to the run it was uploaded to,
and credentials never leave the server.
</p>
<footer class="inputs-panel-import-footer">
<button type="button" mat-stroked-button (click)="closeCopyPicker()">Cancel</button>
</footer>
</app-modal-shell>
}

View File

@ -28,6 +28,8 @@ async function build(inputs: EditableExecutionInput[], options: {
saving?: Record<string, boolean>;
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' }),

View File

@ -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<string | null>(null);
/** Other runs of the same flow, newest first, that inputs could be taken from. */
readonly copySources = input<InputCopySource[]>([]);
/** What the last copy did, so a partial copy says where its gaps are. */
readonly copySummary = input<string | null>(null);
readonly copyRequested = output<string>();
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<EditableExecutionInput | null>(null);
readonly importText = signal('');
readonly importError = signal<string | null>(null);

View File

@ -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)">
</app-task-execution-inputs-panel>
} @else if (activeAsideTab() === 'intermediate') {

View File

@ -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<string | null>(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<InputCopySource[]>(() => {
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<string | null>(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 };