fix: check selected workspace projects before offering retry (#17552)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task recovery explains access failures before a user retries saved
work.
> - A task project and its selected workspace are separate settings.
> - Moving a task can retain files from a project that the agent cannot
access.
> - The recovery notice checked only the task project or a past failure
record.
> - This pull request checks the projects required by the current
workspace selection.
> - The notice now offers Retry only after those project checks pass.

## Linked Issues or Issue Description

Follow-up to #17509. Related: #13230 concerns workspace binding
consistency; this fix changes recovery presentation only.

**What happened?**

A task could show “Project access is ready” after moving to an allowed
project while keeping a repository from a denied project. Typed failures
could also keep the notice blocked after an authorized workspace
replacement.

**Expected behavior**

Use the current task, workspace, and source project requirements. Honor
a valid pending selection for the next admission. Keep stale, archived,
or missing selections blocked without offering a project-permission
repair.

**Steps to reproduce**

1. Assign a low-trust agent to a task whose selected source belongs to a
denied project.
2. Persist a project access failure.
3. Move the task to an allowed project without changing its selected
files.
4. Reload the task. The notice must still explain the access block.
5. Select an allowed source through the workspace API. Reload the task.
The notice must offer an explicit retry without starting work.

**Paperclip version or commit**

Reproduced after #17509 on master. The regression failed on the old
projection and passed with this change.

## What Changed

- Batch the current workspace project requirements for tasks with
project access failures.
- Check retained workspace and source projects, plus valid pending
workspace selections.
- Keep unknown historical denial projects unnamed. Do not expose
unrelated project identifiers.
- Keep unavailable workspace selections in the non-retryable failure
state. Adding project permissions cannot repair these selections.
- Add real database regressions for project moves, pending and applied
repairs, stale revisions, archived pending selections, and
foreign-company workspaces.
- Update the recovery contract and verification recipe.

## Verification

- Red-green regression with the real issue update service and execution
access check.
- Focused tests: 17 tests passed across `agent-project-access.test.ts`
and `task-interruption-presentation.test.ts` after rebasing on current
master.
- Browser: the retained source stays blocked when the task project is
allowed. Manage project access opens Permissions / Trust with Allowed
projects and Add project.
- The normal workspace API accepted an allowed pending selection.
Returning to the task showed “Project access is ready” and Retry.
Permissions and run count did not change.
- Full repository typecheck and build passed before the final
presentation adjustment. The final adjustment passed server typecheck,
server build, and all 17 focused tests. The stale, archived, and
foreign-workspace cases failed before this adjustment. Map and
module-boundary checks passed.
- Browser: an archived pending workspace shows the existing failure
notice without Retry or a project-permission action.
- `pnpm test:run` was attempted and stopped after 15 native runtime
database tests failed because embedded PostgreSQL could not initialize
on this host. The focused database tests passed on isolated PostgreSQL
18 in Docker. All technical CI checks passed on `a6e9f977`. Fresh CI and
review are required for the presentation adjustment. One Runner shard
passed on its single rerun after a provider shutdown failure. A fresh
local Runner build also passed that case and its reuse sibling. The
prior Paperclip Review could not publish its assessment. Its saved
presentation finding is addressed. Merge waits for a fresh published
review and all checks on the current head.

## Risks

- Presentation must follow workspace selection precedence. Tests cover
retained bindings, valid pending changes, stale changes, and company
isolation.
- This adds at most three bounded reads for tasks with a project access
failure. It adds no reads for this purpose on unaffected tasks.
- Dispatch remains the authorization authority. This change does not
grant access, move files, or start execution. No migration is required.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository inspection, code
execution, and browser tools. The exact serving model identifier and
context window were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-26 07:30:27 -05:00
1 parent 1fbbef45a8
commit 4b89eec644
5 files changed
+186 -18

No files matched your search

+8 -2
View File
@@ -897,8 +897,14 @@ Permissions / Trust settings. Board operators with `agents:create` can add
same-company projects through `POST /api/agents/:id/project-access`; the operation
locks the agent, preserves existing permissions and audits added projects.
Saving does not retry work. The task offers explicit Retry after the required
project is allowed, and dispatch rechecks authorization. Existing setup failures
with the exact project-boundary denial also receive the explanation on reload.
projects are allowed, and dispatch rechecks authorization. Readiness checks the
task's current organizational project and selected workspace source, including
a valid pending selection. Moving the task to an allowed project does not clear
a retained source's access requirement. Existing setup failures with the exact
project-boundary denial also receive the explanation on reload, without assuming
that the task's current project caused the historical failure. A stale or
unavailable workspace selection stays in the non-retryable failure state; it
does not ask the user to grant project access that cannot repair the selection.
Cross-issue writes are contained per heartbeat run. An agent-authored comment
may wake the target assignee, including an explicit `resume: true` comment on a
+6
View File
@@ -53,6 +53,12 @@ A workspace project denial names the blocked access and links to the agent's
project, return to the saved task for an explicit **Retry task** action. Saving
permissions alone does not wake the agent. Historical setup denials receive the
same notice on reload; unrelated errors and prior assignees do not.
Move a denied task to a project the agent can access while retaining a source
from another project: the notice must stay blocked until the selected source is
also allowed or an authorized workspace selection replaces it. A valid pending
selection is checked for the next admission; a stale or unavailable selection
must show a non-retryable failure without a project-permission repair action.
Project changes alone preserve selected files.
Thinking appears immediately after Retry, before the request completes.
Technical source/run details remain in deliberate diagnostics. The
@@ -4,12 +4,14 @@ import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb,
heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js";
import { errorHandler } from "../middleware/error-handler.js";
import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
import { ProjectAccessDeniedError } from "../services/project-access-denial.js";
import { issueService } from "../services/issues.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("additive project access", () => {
@@ -43,6 +45,94 @@ describe("additive project access", () => {
}
const board = { type: "board" as const, source: "local_implicit" as const };
it("keeps a historical denial blocked after the task moves to an allowed project but retains its source", async () => {
const f = await seed();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[1]!.id,
name: "Original repository", cwd: "/tmp/project-access-original" }).returning();
await db.update(issues).set({ projectWorkspaceId: source!.id,
workspaceSelection: { version: 1, source: "project", selection: {
kind: "configured_source", projectWorkspaceId: source!.id, mode: "managed_isolated",
} } }).where(eq(issues.id, f.issue.id));
const moved = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
expect(moved).toMatchObject({ projectId: f.selected[0]!.id, projectWorkspaceId: source!.id });
const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
await expect(assertTaskWorkspaceExecutionAccess(db, actor, f.companyId, { issueId: f.issue.id }))
.rejects.toBeInstanceOf(ProjectAccessDeniedError);
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({
canRetry: false, userAction: "project_access", projectAccess: { ready: false, projectId: null },
});
expect((await request(app(board)).post(`/agents/${f.agentId}/project-access`)
.send({ projectIds: [f.selected[1]!.id] })).status).toBe(200);
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
await expect(assertTaskWorkspaceExecutionAccess(db, actor, f.companyId, { issueId: f.issue.id })).resolves.toBeDefined();
});
it.each(["applied", "pending"] as const)("rechecks the selected source after an %s repair instead of the old denied project", async repair => {
const f = await seed();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[0]!.id,
name: "Allowed repository", cwd: "/tmp/project-access-allowed" }).returning();
await db.update(heartbeatRuns).set({ errorCode: "project_access_denied", error: null,
resultJson: { projectAccessDenied: { projectId: f.selected[1]!.id } } }).where(eq(heartbeatRuns.id, f.runId));
const moved = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
if (repair === "applied") {
await issueService(db).update(f.issue.id, { projectWorkspaceId: source!.id, workspaceSelectionActor: board });
} else {
await executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issue.id, actor: board,
selection: { kind: "configured_source", projectWorkspaceId: source!.id, mode: "managed_isolated" },
expectedBindingRevision: 0, requestKey: randomUUID() });
}
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
// The original denied source was not granted, and presentation creates no execution.
expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0]!.permissions).toEqual(f.permissions);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
if (repair === "pending") {
await db.update(issues).set({ workspaceBindingRevision: 2 }).where(eq(issues.id, f.issue.id));
const stale = await taskInterruptionPresentation(db, moved!);
expect(stale).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(stale).not.toHaveProperty("projectAccess");
}
});
it("keeps a pending existing selection blocked if its workspace is archived before retry", async () => {
const f = await seed();
const task = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: f.selected[0]!.id, name: "Selected files", cwd: "/tmp/project-access-selected",
mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
const service = executionWorkspaceService(db);
await service.selectTaskWorkspace({ companyId: f.companyId, issueId: f.issue.id, actor: board,
selection: { kind: "existing", workspaceId: workspace!.id }, expectedBindingRevision: 0, requestKey: randomUUID() });
expect(await taskInterruptionPresentation(db, task!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
expect(await service.archiveWorkspaceUnderLifecycleLock({ id: workspace!.id, patch: {}, closedAt: new Date() }))
.toMatchObject({ outcome: "archived" });
await expect(service.applyPendingTaskWorkspaceSelection({ companyId: f.companyId, issueId: f.issue.id,
runId: f.runId, actor: { type: "agent", source: "agent_key", agentId: f.agentId, companyId: f.companyId } }))
.rejects.toThrow("Workspace is unavailable or inaccessible");
const archived = await taskInterruptionPresentation(db, task!);
expect(archived).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(archived).not.toHaveProperty("projectAccess");
});
it.each(["denied_project", "foreign_company"] as const)("retains the %s gate on a bound workspace even when its source is allowed", async boundary => {
const f = await seed(), foreign = boundary === "foreign_company" ? await seed() : null;
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[0]!.id,
name: "Allowed source", cwd: "/tmp/project-access-source" }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: foreign?.companyId ?? f.companyId,
projectId: foreign?.selected[0]!.id ?? f.selected[1]!.id, projectWorkspaceId: foreign ? null : source!.id,
name: "Retained files", cwd: "/tmp/project-access-bound", mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
const [task] = await db.update(issues).set({ projectId: f.selected[0]!.id, projectWorkspaceId: source!.id,
executionWorkspaceId: workspace!.id }).where(eq(issues.id, f.issue.id)).returning();
const blocked = await taskInterruptionPresentation(db, task!);
if (boundary === "denied_project") {
expect(blocked).toMatchObject({ canRetry: false,
userAction: "project_access", projectAccess: { ready: false, projectId: null } });
} else {
expect(blocked).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(blocked).not.toHaveProperty("projectAccess");
}
});
it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => {
const f = await seed();
const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
@@ -6,6 +6,7 @@ import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { readSystemRecovery } from "./system-recovery-state.js";
import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
import { authorizationService } from "./authorization.js";
import { taskWorkspaceProjectRequirements } from "./task-workspace-access.js";
type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
@@ -17,6 +18,8 @@ type ProjectionState = {
wait?: { id: string; requestedAt: Date };
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
projectAccessAllowed?: boolean;
projectAccessProjectId?: string | null;
workspaceSelectionAvailable?: boolean;
project?: Pick<typeof projects.$inferSelect, "pausedAt" | "pauseReason">;
company?: Pick<typeof companies.$inferSelect, "status" | "pauseReason">;
paused: boolean;
@@ -30,7 +33,7 @@ function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string |
|| (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
const detail = run.resultJson?.projectAccessDenied;
return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
? detail.projectId : historical ? issue.projectId : null;
? detail.projectId : null;
}
/** Presentation never grants authority. Dispatch repeats all gates under its
@@ -53,8 +56,11 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
const deniedProject = deniedProjectId(issue, run);
let projectAccess: TaskInterruptionPresentation["projectAccess"];
if (deniedProject !== undefined) {
// Permissions cannot repair a stale or unavailable workspace selection.
if (input.workspaceSelectionAvailable === false) return { ...base, state: "failed" };
const ready = input.projectAccessAllowed === true;
projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready };
const projectId = input.projectAccessProjectId === undefined ? deniedProject : input.projectAccessProjectId;
projectAccess = { agentId: run.agentId!, projectId: projectId === issue.projectId ? projectId : null, ready };
if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
userAction: "project_access", projectAccess };
}
@@ -120,13 +126,17 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
// Recheck current authority, including standard trust, root-issue grants and
// project policy. Do not reuse the failed run's frozen policy for a new retry.
// Only denied tasks need these reads; deduplicate projects and batch per agent.
// A task's organizational project can differ from its retained source. Old
// failures did not record the denied project, and even typed failures can
// become obsolete after a workspace selection changes.
const deniedIssues = selected.filter(issue => issue.assigneeAgentId && !["done", "cancelled"].includes(issue.status)
&& deniedProjectId(issue, runMap.get(issue.id)) !== undefined);
const requirements = await taskWorkspaceProjectRequirements(db, companyId, deniedIssues.map(issue => issue.id));
const deniedByAgent = new Map<string, Set<string>>();
for (const issue of selected) {
const projectId = deniedProjectId(issue, runMap.get(issue.id));
if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue;
const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set<string>();
ids.add(projectId);
deniedByAgent.set(issue.assigneeAgentId, ids);
for (const issue of deniedIssues) {
const ids = deniedByAgent.get(issue.assigneeAgentId!) ?? new Set<string>();
for (const projectId of requirements.get(issue.id)?.projectIds ?? []) ids.add(projectId);
if (ids.size) deniedByAgent.set(issue.assigneeAgentId!, ids);
}
const currentAccess = new Map<string, boolean>();
const authorization = authorizationService(db);
@@ -140,12 +150,18 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
const projectMap = new Map(projectRows.map(project => [project.id, project]));
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, {
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`),
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
}));
for (const issue of selected) {
const required = requirements.get(issue.id);
const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true);
result.set(issue.id, projectTaskInterruption(issue, {
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
projectAccessAllowed: denied?.length === 0,
projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null,
workspaceSelectionAvailable: required?.available ?? false,
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
}));
}
return result;
}
@@ -10,9 +10,59 @@ import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
import { ProjectAccessDeniedError } from "./project-access-denial.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** Read the project requirements of the next selected workspace, without
* admitting execution or moving files. Batch only the tasks needing a denial
* presentation; this adds at most three bounded reads, not queries per task. */
export async function taskWorkspaceProjectRequirements(db: Db, companyId: string, issueIds: string[]) {
const result = new Map<string, { projectIds: string[]; available: boolean }>();
if (!issueIds.length) return result;
const tasks = await db.select({ id: issues.id, projectId: issues.projectId,
executionWorkspaceId: issues.executionWorkspaceId, projectWorkspaceId: issues.projectWorkspaceId,
workspaceSelection: issues.workspaceSelection, workspacePendingSelection: issues.workspacePendingSelection,
workspaceBindingRevision: issues.workspaceBindingRevision,
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
const selections = tasks.map(task => {
const pending = task.workspacePendingSelection;
const intent = pending?.intent ?? (!task.executionWorkspaceId ? task.workspaceSelection : null);
const parsed = intent ? taskWorkspaceSelectionSchema.safeParse(intent.selection) : null;
const selection = parsed?.success ? parsed.data : null;
const available = (!pending || pending.expectedBindingRevision === task.workspaceBindingRevision)
&& (!parsed || parsed.success);
const workspaceId = selection ? selection.kind === "existing" ? selection.workspaceId : null : task.executionWorkspaceId;
return { task, selection, available, workspaceId };
});
const workspaceIds = [...new Set(selections.flatMap(row => row.workspaceId ? [row.workspaceId] : []))];
const workspaces = workspaceIds.length ? await db.select({ id: executionWorkspaces.id,
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
selectable: sql<boolean>`${taskWorkspaceSelectableCondition()}`,
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, workspaceIds))) : [];
const workspaceMap = new Map(workspaces.map(workspace => [workspace.id, workspace]));
const sources = selections.map(row => {
const workspace = row.workspaceId ? workspaceMap.get(row.workspaceId) : undefined;
const sourceId = row.selection
? row.selection.kind === "configured_source" ? row.selection.projectWorkspaceId : workspace?.projectWorkspaceId
: workspace?.projectWorkspaceId ?? row.task.projectWorkspaceId;
return { ...row, workspace, sourceId };
});
const sourceIds = [...new Set(sources.flatMap(row => row.sourceId ? [row.sourceId] : []))];
const sourceRows = sourceIds.length ? await db.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId })
.from(projectWorkspaces).where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, sourceIds))) : [];
const sourceMap = new Map(sourceRows.map(source => [source.id, source]));
for (const row of sources) {
const source = row.sourceId ? sourceMap.get(row.sourceId) : undefined;
result.set(row.task.id, {
projectIds: [...new Set([row.task.projectId, row.workspace?.projectId, source?.projectId].filter((id): id is string => Boolean(id)))],
available: row.available && (!row.workspaceId || Boolean(row.workspace)) && (!row.sourceId || Boolean(source))
&& (row.selection?.kind !== "existing" || row.workspace?.selectable === true),
});
}
return result;
}
export async function assertTaskWorkspaceSourceProjectAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null,
) {