fix(app): require explicit workspace recovery

This commit is contained in:
Mohamed Boudra
2026-07-16 15:14:30 +02:00
parent b1e9ac0009
commit 05cb0f99e5
39 changed files with 1293 additions and 1129 deletions
+68
View File
@@ -838,6 +838,18 @@ export const WorkspacePinSetRequestSchema = z.object({
requestId: z.string(),
});
export const WorkspaceRecoveryInspectRequestSchema = z.object({
type: z.literal("workspace.recovery.inspect.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const WorkspaceRecoveryRestoreRequestSchema = z.object({
type: z.literal("workspace.recovery.restore.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -1471,6 +1483,47 @@ export const WorkspacePinSetResponseSchema = z.object({
payload: WorkspacePinSetResponsePayloadSchema,
});
export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("recoverable"),
workspaceId: z.string(),
workspaceName: z.string(),
action: z.enum(["unarchive", "restore"]),
branch: z.string().nullable(),
}),
z.object({
kind: z.literal("unavailable"),
workspaceId: z.string(),
reason: z.enum([
"workspace_not_found",
"workspace_not_archived",
"project_not_found",
"project_directory_missing",
"workspace_directory_missing",
"worktree_branch_missing",
]),
message: z.string(),
}),
]);
export const WorkspaceRecoveryInspectResponseSchema = z.object({
type: z.literal("workspace.recovery.inspect.response"),
payload: z.object({
requestId: z.string(),
state: WorkspaceRecoveryStateSchema,
}),
});
export const WorkspaceRecoveryRestoreResponseSchema = z.object({
type: z.literal("workspace.recovery.restore.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -2143,6 +2196,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema,
WorkspacePinSetRequestSchema,
WorkspaceRecoveryInspectRequestSchema,
WorkspaceRecoveryRestoreRequestSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -2449,6 +2504,8 @@ export const ServerInfoStatusPayloadSchema = z
projectAdd: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(),
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -4496,6 +4553,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema,
WorkspacePinSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
@@ -4660,6 +4719,13 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
>;
export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>;
export type WorkspacePinSetResponsePayload = z.infer<typeof WorkspacePinSetResponsePayloadSchema>;
export type WorkspaceRecoveryState = z.infer<typeof WorkspaceRecoveryStateSchema>;
export type WorkspaceRecoveryInspectResponse = z.infer<
typeof WorkspaceRecoveryInspectResponseSchema
>;
export type WorkspaceRecoveryRestoreResponse = z.infer<
typeof WorkspaceRecoveryRestoreResponseSchema
>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
@@ -4793,6 +4859,8 @@ export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
@@ -0,0 +1,86 @@
import { describe, expect, test } from "vitest";
import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";
describe("workspace recovery protocol", () => {
test("parses the read-only recovery inspection exchange", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.inspect.request",
requestId: "inspect-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.inspect.request" });
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-1",
state: {
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
},
},
}),
).toMatchObject({
payload: { state: { kind: "recoverable", action: "restore" } },
});
});
test("parses explicit restore success and retryable failure responses", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.restore.request",
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.restore.request" });
for (const payload of [
{
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
accepted: true,
error: null,
},
{
requestId: "restore-2",
workspaceId: "wks_15a1b5630ebaab33",
accepted: false,
error: "Project root is missing: /repo",
},
]) {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.restore.response",
payload,
}),
).toMatchObject({ payload });
}
});
test("keeps the capability optional for older daemons", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "old-host",
features: {},
}).features?.workspaceRecovery,
).toBeUndefined();
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "new-host",
features: { workspaceRecovery: true },
}).features?.workspaceRecovery,
).toBe(true);
});
});