Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Because projects live on the server, the same project can be open in two browser

A similar banner appears if a project you have open is deleted from another tab or computer. Your work stays available until you choose what to do with it.

The banner shows above the project it concerns. While any open project is waiting for your decision, the status bar reads **Autosave: Needs attention**: hover it to see which projects and why. The project switcher marks those projects **Needs attention**; switch to each one to see its banner. Your other projects keep saving in the meantime.

:::tip
To avoid conflicts, work on a project in one tab at a time. Use the switcher to move between projects within that tab.
:::
Expand Down
14 changes: 13 additions & 1 deletion invokeai/frontend/webv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,8 @@
"unsavedLayoutChanges": "Unsaved layout changes"
},
"projectSwitcher": {
"trigger": "Switch project. Current project: {{name}}"
"trigger": "Switch project. Current project: {{name}}",
"triggerNeedsAttention": "Switch project. Current project: {{name}}. An open project needs attention"
},
"queue": {
"actions": "Queue actions",
Expand Down Expand Up @@ -529,6 +530,7 @@
"malformed": "\"{{name}}\" is damaged and was not opened. Its saved copy is untouched and can be exported.",
"unsupportedVersion": "\"{{name}}\" was saved by a newer version of Invoke and cannot be opened here."
},
"needsAttention": "Needs attention",
"newProject": "New project",
"newProjectStart": "Choose how to start",
"noSavedProjects": "No saved projects yet.",
Expand Down Expand Up @@ -3127,10 +3129,20 @@
}
},
"autosaveStatus": {
"attention": "Needs attention",
"attentionDescription": "These open projects can't sync until you choose what to keep. Switch to each one to resolve it:",
"attentionItem": "{{name}} — {{reason}}",
"chipLabel": "Autosave: {{status}}",
"error": "Failed",
"idle": "Idle",
"label": "Autosave",
"lastSaved": "Last saved: {{time}}",
"pending": "Unsaved changes",
"reasonDeleted": "deleted on the server",
"reasonRevision": "changed elsewhere",
"reasonSchema": "needs a newer version of Invoke",
"retrying": "The last save didn't finish. Retrying automatically.",
"saving": "Saving…",
"status": "Status: {{status}}"
},
"canvas": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ it('suspends shortcuts from the moment the palette opens, while its module is st
});
await expect.poll(() => host.querySelector('[data-testid="palette"]')).not.toBeNull();
// The stand-in steps aside for the loaded dialog, which this test replaces with one that announces nothing.
expect(isModalPresent()).toBe(false);
// Its passive effect cleanup can run after the loaded dialog reaches the DOM.
await expect.poll(() => isModalPresent()).toBe(false);
});
73 changes: 71 additions & 2 deletions invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ const snapshot = (state: WorkbenchState, savedAt = '2026-07-17T00:00:00.000Z'):
});

const saveResult = (state: WorkbenchState, savedAt?: string): WorkbenchSaveResult => ({
awaitsDecision: false,
conflicts: [],
error: null,
hasPendingChanges: false,
hasRetryablePendingChanges: false,
localDraftStatus: 'ok',
projectBoardAssignments: [],
shouldRetry: false,
Expand Down Expand Up @@ -102,6 +104,7 @@ const createAggregate = (initialState = createInitialWorkbenchState()) => {
reportLoadError: (error) => events.push(`load-error:${error}`),
reportLoadUnavailable: (error) => events.push(`load-unavailable:${error}`),
saveFailed: (error) => events.push(`save-failed:${error}`),
saveNeedsAttention: (savedAt) => events.push(`save-needs-attention:${savedAt}`),
savePending: (error) => events.push(`save-pending:${error}`),
saveScheduled: () => events.push('save-scheduled'),
saveStarted: () => events.push('save-started'),
Expand Down Expand Up @@ -512,11 +515,76 @@ describe('Workbench persistence runtime', () => {
expect(aggregate.events).toContain('save-succeeded:acknowledged');
});

it('retries pending work that awaits no decision instead of asking for attention', async () => {
const aggregate = createAggregate();
const { persistence } = createPersistence(() => Promise.resolve(null));
// A newer draft was staged while the push was in flight: acknowledged, yet still pending, with nothing to resolve.
vi.mocked(persistence.saveWorkbench)
.mockResolvedValueOnce({
...saveResult(aggregate.state, 'rebased'),
hasPendingChanges: true,
hasRetryablePendingChanges: true,
})
.mockResolvedValueOnce(saveResult(aggregate.state, 'acknowledged'));
const clock = new FakeClock();
const runtime = createWorkbenchPersistenceRuntime({ aggregate: aggregate.port, clock, persistence });
runtime.start();
await flushPromises();
aggregate.edit();
clock.runAll();
await flushPromises();

expect(aggregate.events.some((event) => event.startsWith('save-needs-attention:'))).toBe(false);
expect(aggregate.events).not.toContain('save-succeeded:rebased');
expect(aggregate.events).toContain('save-pending:Autosave is pending and will retry.');

clock.runAll();
await flushPromises();
expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2);
expect(aggregate.events.at(-1)).toBe('save-succeeded:acknowledged');
});

it("retries another project's pending work while one awaits a decision, then asks for attention", async () => {
const aggregate = createAggregate();
const { persistence } = createPersistence(() => Promise.resolve(null));
vi.mocked(persistence.saveWorkbench)
.mockResolvedValueOnce({
...saveResult(aggregate.state, 'first'),
awaitsDecision: true,
hasPendingChanges: true,
hasRetryablePendingChanges: true,
})
.mockResolvedValueOnce({
...saveResult(aggregate.state, 'second'),
awaitsDecision: true,
hasPendingChanges: true,
hasRetryablePendingChanges: false,
});
const clock = new FakeClock();
const runtime = createWorkbenchPersistenceRuntime({ aggregate: aggregate.port, clock, persistence });
runtime.start();
await flushPromises();
aggregate.edit();
clock.runAll();
await flushPromises();

expect(aggregate.events).toContain('save-pending:Autosave is pending and will retry.');
expect(aggregate.events).not.toContain('save-needs-attention:first');

clock.runAll();
await flushPromises();
expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2);
expect(aggregate.events.at(-1)).toBe('save-needs-attention:second');
clock.runAll();
expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2);
});

it('leaves a non-retrying conflict save in an explicit attention state', async () => {
const aggregate = createAggregate();
const { persistence } = createPersistence(() => Promise.resolve(null));
vi.mocked(persistence.saveWorkbench).mockResolvedValueOnce({
...saveResult(aggregate.state),
...saveResult(aggregate.state, 'conflicted'),
awaitsDecision: true,
hasPendingChanges: true,
shouldRetry: false,
});
Expand All @@ -528,7 +596,8 @@ describe('Workbench persistence runtime', () => {
clock.runAll();
await flushPromises();

expect(aggregate.events).toContain('save-pending:Autosave requires your attention.');
expect(aggregate.events).toContain('save-needs-attention:conflicted');
expect(aggregate.events.some((event) => event.startsWith('save-pending:'))).toBe(false);
expect(aggregate.events.some((event) => event.startsWith('save-succeeded:'))).toBe(false);
clock.runAll();
expect(persistence.saveWorkbench).toHaveBeenCalledOnce();
Expand Down
11 changes: 8 additions & 3 deletions invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface PersistenceAggregatePort {
/** Persisted projects the canvas version gate refused; they are absent from the hydrated state. */
reportRefusedProjects(refused: readonly RefusedWorkbenchProject[]): void;
saveFailed(error: string): void;
/** The save finished, but an open project awaits a user decision before it can sync. */
saveNeedsAttention(savedAt: string): void;
/** The save could not finish and will retry on its own. */
savePending(error: string): void;
/** Persisted content changed and its save is scheduled: the workbench is dirty until that save is acknowledged. */
saveScheduled(): void;
Expand Down Expand Up @@ -253,7 +256,9 @@ export const createWorkbenchPersistenceRuntime = ({
}
return;
}
if (result.shouldRetry) {
// Pending work that no decision blocks (a draft staged while its push was in flight) is pushed by a retry, even
// while another project awaits a decision; attention is reported once nothing retryable is left.
if (result.shouldRetry || result.hasRetryablePendingChanges) {
const message = 'Autosave is pending and will retry.';
aggregate.savePending(message);
publish({ error: message, phase: 'idle' });
Expand All @@ -264,8 +269,8 @@ export const createWorkbenchPersistenceRuntime = ({
failedRevision = null;
scheduledRevision = null;
retryAttempt = 0;
if (result.hasPendingChanges) {
aggregate.savePending('Autosave requires your attention.');
if (result.awaitsDecision) {
aggregate.saveNeedsAttention(result.snapshot.savedAt);
} else {
aggregate.saveSucceeded(result.snapshot.savedAt);
}
Expand Down
8 changes: 6 additions & 2 deletions invokeai/frontend/webv2/src/workbench/projectContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ export interface UndoRedoHistory {
}

export interface AutosaveState {
/** `pending`: persisted content changed since the last acknowledged save and its save has not started yet. */
status: 'idle' | 'pending' | 'saving' | 'saved' | 'error';
/**
* `pending`: persisted content changed since the last acknowledged save and its save has not started yet, or a save
* that could not finish will retry. `attention`: the last save finished for every other project, but an open project
* awaits a user decision (a sync conflict or an unsupported schema); the project sync snapshot says which.
*/
status: 'idle' | 'pending' | 'saving' | 'saved' | 'attention' | 'error';
lastSavedAt?: string;
error?: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ const createApi = () => {
return api;
};

const canvasSchemaRefusal = () =>
new ApiError(
JSON.stringify({
detail: { code: 'canvas_schema_unsupported', max_canvas_schema_version: 3, minimum_canvas_schema_version: 4 },
}),
412
);

const createService = (
owner: AccountScope,
api: DurableProjectPersistenceApi,
Expand Down Expand Up @@ -1443,13 +1451,156 @@ describe('durable project persistence', () => {
expect(result.conflicts).toEqual([
expect.objectContaining({ kind: 'revision', projectId: project.id, serverRevision: 2 }),
]);
// Only the user can resolve it: the save reports a decision, and nothing a retry could push.
expect(result).toMatchObject({
awaitsDecision: true,
error: null,
hasPendingChanges: true,
hasRetryablePendingChanges: false,
shouldRetry: false,
});
expect(api.createProject).not.toHaveBeenCalled();
await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({
draft: { conflict: { kind: 'revision', serverRevision: 2 }, state: 'conflict' },
kind: 'found',
});
});

it('reports a schema refusal as a decision that no retry can clear', async () => {
const owner = captureAccountScope();
const api = createApi();
const project = createDraftProject([]);
api.records.set(project.id, toRecord(project));
vi.mocked(api.loadSession).mockResolvedValue({
account: createInitialWorkbenchState().account,
activeProjectId: project.id,
openProjectIds: [project.id],
});
const service = createService(owner, api);
const loaded = await service.loadWorkbench();
vi.mocked(api.updateProject).mockRejectedValueOnce(canvasSchemaRefusal());

const result = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }]));

expect(result).toMatchObject({
awaitsDecision: true,
conflicts: [],
error: null,
hasPendingChanges: true,
hasRetryablePendingChanges: false,
shouldRetry: false,
});
expect(getProjectSyncSnapshot().projects[project.id]).toMatchObject({
schemaRefusal: { kind: 'canvas', maxCanvasSchemaVersion: 3, minimumCanvasSchemaVersion: 4 },
});
});

it('keeps retrying another project while one awaits a decision, then reports only the decision', async () => {
const owner = captureAccountScope();
const api = createApi();
const draftStore = createMemoryProjectDraftStore();
const conflicted = createDraftProject([]);
const rebased = createDraftProject([]);
api.records.set(conflicted.id, toRecord(conflicted));
vi.mocked(api.loadSession).mockResolvedValue({
account: createInitialWorkbenchState().account,
activeProjectId: conflicted.id,
openProjectIds: [conflicted.id],
});
const service = createService(owner, api, draftStore);
const loaded = await service.loadWorkbench();
api.records.set(conflicted.id, toRecord({ ...conflicted, name: 'remote edit' }, 2));
const originalCreate = api.createProject;
// A newer draft of the second project is staged while its creation is in flight, so it stays pending once acknowledged.
vi.mocked(api.createProject).mockImplementationOnce(async (request) => {
await draftStore.stage({
baseRevision: null,
documentJson: JSON.stringify({ ...request.data, name: 'newer edit' }),
documentSchemaVersion: 3,
editorSessionId: 'editor-1',
generation: 2,
projectId: rebased.id,
updatedAt: Date.parse(now) + 1,
writerToken: 'writer-1',
});
return originalCreate(request, owner);
});

const first = await service.saveWorkbench(
stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }, rebased])
);

expect(first.conflicts).toEqual([expect.objectContaining({ kind: 'revision', projectId: conflicted.id })]);
expect(first).toMatchObject({ awaitsDecision: true, hasPendingChanges: true, hasRetryablePendingChanges: true });

const second = await service.saveWorkbench(
stateWith([
{ ...loaded.state.projects[0]!, name: 'local edit' },
{ ...rebased, name: 'newer edit' },
])
);

expect(api.records.get(rebased.id)).toMatchObject({ name: 'newer edit', revision: 2 });
expect(second.conflicts).toEqual([expect.objectContaining({ kind: 'revision', projectId: conflicted.id })]);
expect(second).toMatchObject({ awaitsDecision: true, hasPendingChanges: true, hasRetryablePendingChanges: false });
});

it('stops reporting pending work for a project whose deletion left only the local draft behind', async () => {
const owner = captureAccountScope();
const api = createApi();
const backingStore = createMemoryProjectDraftStore();
const draftStore: ProjectDraftStore = {
...backingStore,
delete: vi.fn(() => Promise.resolve({ kind: 'unavailable' as const })),
};
const project = createDraftProject([]);
vi.mocked(api.createProject).mockRejectedValueOnce(new ApiError('Service unavailable.', 503));
const service = createService(owner, api, draftStore);

const unsynced = await service.saveWorkbench(stateWith([project]));
expect(unsynced).toMatchObject({ error: null, hasPendingChanges: true, shouldRetry: true });

// The server delete commits, so the project stays fenced as deleted even though the local removal failed.
await expect(service.deleteProjectOnServer(project.id)).rejects.toThrow('Retry deletion.');
expect(api.deleteProject).toHaveBeenCalledWith(project.id, expect.anything(), 'release');

const later = await service.saveWorkbench(stateWith([project]));

expect(later).toMatchObject({
awaitsDecision: false,
error: null,
hasPendingChanges: false,
hasRetryablePendingChanges: false,
shouldRetry: false,
});
expect(service.hasPendingChanges()).toBe(false);
expect(api.createProject).toHaveBeenCalledOnce();
});

it('forgets a schema refusal when its project is deleted', async () => {
const owner = captureAccountScope();
const api = createApi();
const project = createDraftProject([]);
api.records.set(project.id, toRecord(project));
vi.mocked(api.loadSession).mockResolvedValue({
account: createInitialWorkbenchState().account,
activeProjectId: project.id,
openProjectIds: [project.id],
});
const service = createService(owner, api);
const loaded = await service.loadWorkbench();
vi.mocked(api.updateProject).mockRejectedValueOnce(canvasSchemaRefusal());
const refused = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }]));
expect(refused.awaitsDecision).toBe(true);

await service.deleteProjectOnServer(project.id);
// The library broker closes the tab separately; an autosave can land first.
const result = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }]));

expect(result).toMatchObject({ awaitsDecision: false, hasPendingChanges: false });
expect(getProjectSyncSnapshot().projects[project.id]?.schemaRefusal).toBeUndefined();
});

it('restores an unresolved conflict and its local document after reload', async () => {
const owner = captureAccountScope();
const api = createApi();
Expand Down
Loading
Loading