fix: isolate execution workspace summaries (#10790)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip gives operators a summary for each workspace. > - An execution workspace detail page used the parent project-workspace summary slot. > - Two execution workspaces under one project workspace could therefore show the same summary. > - This pull request gives each execution workspace its own summary scope. > - It also limits the summary snapshot and generated issue to that execution workspace. > - The benefit is that a new or parallel execution workspace cannot inherit unrelated status. ## Linked Issues or Issue Description **What happened?** An execution workspace detail page read and refreshed the summary slot for its parent project workspace. Parallel execution workspaces could show the same status and include issues from each other. **Expected behavior** Each execution workspace must have one isolated summary slot. Its generated snapshot must include only issues assigned to that execution workspace. **Steps to reproduce** 1. Create two execution workspaces under one project workspace. 2. Add different issues to each execution workspace. 3. Generate the summary in the first execution workspace. 4. Open the second execution workspace. 5. Observe that the old implementation could reuse the first summary. **Paperclip version or commit** The problem exists on `master` before this pull request. **Deployment mode** The issue affects both local trusted and authenticated deployments. ## What Changed - Added `execution_workspace` to the shared summary-slot scope contract. - Validated execution-workspace ownership and stored generated summary issues on the correct execution workspace. - Limited execution-workspace snapshots to issues with the matching execution workspace ID. - Updated the execution workspace page to use its own summary slot. - Updated Summarizer instructions, routine options, catalog metadata, documentation, and regression tests. ## Verification - `NODE_ENV=test pnpm exec vitest run packages/shared/src/summary-slot.test.ts server/src/__tests__/summary-slots.test.ts ui/src/pages/ExecutionWorkspaceDetail.test.tsx` — 30 focused tests passed; the embedded-Postgres server tests were run outside the process-restricted sandbox. - `pnpm check:token-gates` — passed. - `pnpm --filter @paperclipai/skills-catalog validate` — passed with 17 catalog skills. - [Latest-head GitHub Actions](https://github.com/paperclipai/paperclip/actions/runs/31491475405) — all 22 jobs passed on `beea14cbaf`, including typecheck, build, server/workspace tests, serialized suites, e2e, canary, and aggregate verification. One unrelated adapter cleanup test initially hit an `ENOTEMPTY` temp-directory race; its single permitted rerun passed. - Greptile — 5/5 confidence on `beea14cbaf`, 12 files reviewed, zero comments added, and zero unresolved threads. ## Risks - Low risk. The new scope is additive. - Existing project and project-workspace summary slots keep their current keys and behavior. - A summary generated for an execution workspace now excludes sibling workspace issues by design. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The deployment does not expose a more specific model ID or context-window value. It used agentic reasoning, repository tools, code execution, and GitHub tooling. ## 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 - [x] All Paperclip CI gates are green - [x] 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:
parent
9cdaa5416e
commit
b58ce27a02
|
|
@ -1032,6 +1032,7 @@ Dashboard payload must include:
|
|||
|
||||
The current app also exposes V1-supporting surfaces for:
|
||||
|
||||
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
|
||||
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
|
||||
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
|
||||
|
|
|
|||
|
|
@ -221,7 +221,12 @@ export const ISSUE_HARNESS_KINDS = ["skill_test"] as const;
|
|||
export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number];
|
||||
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
|
||||
|
||||
export const SUMMARY_SLOT_SCOPE_KINDS = ["project", "workspaces_overview", "project_workspace"] as const;
|
||||
export const SUMMARY_SLOT_SCOPE_KINDS = [
|
||||
"project",
|
||||
"workspaces_overview",
|
||||
"project_workspace",
|
||||
"execution_workspace",
|
||||
] as const;
|
||||
export type SummarySlotScopeKind = (typeof SUMMARY_SLOT_SCOPE_KINDS)[number];
|
||||
export const SUMMARY_SLOT_KEYS = ["header"] as const;
|
||||
export type SummarySlotKey = (typeof SUMMARY_SLOT_KEYS)[number];
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("summary slot shared contract", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("allows scoped project and project-workspace header slots", () => {
|
||||
it("allows scoped project, project-workspace, and execution-workspace header slots", () => {
|
||||
expect(summarySlotScopeSelectorSchema.parse({
|
||||
scopeKind: "project",
|
||||
scopeId,
|
||||
|
|
@ -32,6 +32,12 @@ describe("summary slot shared contract", () => {
|
|||
scopeId,
|
||||
slotKey: "header",
|
||||
})).toEqual({ scopeKind: "project_workspace", scopeId, slotKey: "header" });
|
||||
|
||||
expect(summarySlotScopeSelectorSchema.parse({
|
||||
scopeKind: "execution_workspace",
|
||||
scopeId,
|
||||
slotKey: "header",
|
||||
})).toEqual({ scopeKind: "execution_workspace", scopeId, slotKey: "header" });
|
||||
});
|
||||
|
||||
it("treats workspaces_overview as a company-scoped singleton", () => {
|
||||
|
|
@ -56,6 +62,10 @@ describe("summary slot shared contract", () => {
|
|||
scopeKind: "project_workspace",
|
||||
slotKey: "header",
|
||||
})).toThrow("project_workspace summary slots require scopeId");
|
||||
expect(() => summarySlotScopeSelectorSchema.parse({
|
||||
scopeKind: "execution_workspace",
|
||||
slotKey: "header",
|
||||
})).toThrow("execution_workspace summary slots require scopeId");
|
||||
});
|
||||
|
||||
it("validates summary write payload revision and generation metadata", () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ tags:
|
|||
|
||||
# Summarize status
|
||||
|
||||
You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision.
|
||||
You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision.
|
||||
|
||||
**Open with what the reader needs to do.** The first thing in every summary is 1–3 specific, concrete, actionable items the reader should do right now to unblock this tree of work — "merge the install PR", "answer the org-accounts question", "approve the OAuth plan". Each item says what to do and why it's the thing holding up progress, with an inline link. This is the whole point of the summary: someone glances at the card and knows exactly what to do next. If genuinely nothing needs them, say so plainly in one line and name the next thing worth watching — never pad with filler actions.
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ This is a **read-and-report** loop. You never change the underlying issues, work
|
|||
|
||||
## When to use
|
||||
|
||||
- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, or `project_workspace`) and slot (`header`).
|
||||
- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`) and slot (`header`).
|
||||
- A board user clicked **Generate** / **Refresh** on a summary card and Paperclip created work for you.
|
||||
- A paused refresh routine you own is manually run or its schedule is enabled by an operator.
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ This is a **read-and-report** loop. You never change the underlying issues, work
|
|||
|
||||
From the generation issue / run context:
|
||||
|
||||
- `scopeKind` — `project`, `workspaces_overview`, or `project_workspace`.
|
||||
- `scopeId` — the project or project-workspace id. Omitted for `workspaces_overview` (it has no scopeId).
|
||||
- `scopeKind` — `project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`.
|
||||
- `scopeId` — the project, project-workspace, or execution-workspace id. Omitted for `workspaces_overview` (it has no scopeId).
|
||||
- `slotKey` — currently always `header`.
|
||||
- `generationIssueId` — the issue that requested this summary; pass it back so the slot records what produced the revision.
|
||||
- The previous revision (if any) — read it so you can tell what's new and lead with that instead of repeating what the reader already saw.
|
||||
|
|
@ -55,6 +55,7 @@ Use these routes directly. Do not guess unscoped `/api/issues` or alternate summ
|
|||
- Read the current slot: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}?scopeId=...`
|
||||
- Read revision history only when the current-slot response is missing its latest document: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions?scopeId=...`
|
||||
- Gather project issues: `GET /api/companies/{companyId}/issues?projectId=...`
|
||||
- Gather execution-workspace issues: `GET /api/companies/{companyId}/issues?executionWorkspaceId=...`
|
||||
- Write the new revision: `PUT /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}` with `scopeId`, `markdown`, `changeSummary`, `baseRevisionId`, `generationIssueId`, and `model` in the JSON body.
|
||||
|
||||
For `workspaces_overview`, omit `scopeId` from the read query and send it as `null` in the write body. All calls use the run-scoped Paperclip API URL and bearer token already present in the environment.
|
||||
|
|
|
|||
|
|
@ -172,11 +172,11 @@
|
|||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 8682,
|
||||
"sha256": "c5f459ced4e97e6ae33c3ffbe7b3fb7d4c1fe7bfb0121187f0c2f9000a670b37"
|
||||
"sizeBytes": 8877,
|
||||
"sha256": "4cb3d177f5d16e3cc2052d2f80e7fb245ab95e79c1e947abdc84cd4ee3d8c61e"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:32d2f231a35fc3a658b244f13dd726b2f2bc642db6d3512559fdf9a2b680838d"
|
||||
"contentHash": "sha256:3e49ee2b8d83d1371fb0f59197bb96c46b64f722540405f46d4a5a756fbe1b89"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:task-planning",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
|
|
@ -50,6 +51,7 @@ describeEmbeddedPostgres("summary slot service", () => {
|
|||
await db.delete(documents);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(activityLog);
|
||||
|
|
@ -78,6 +80,35 @@ describeEmbeddedPostgres("summary slot service", () => {
|
|||
return projectId;
|
||||
}
|
||||
|
||||
async function seedProjectWorkspace(companyId: string, projectId: string) {
|
||||
const projectWorkspaceId = randomUUID();
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
});
|
||||
return projectWorkspaceId;
|
||||
}
|
||||
|
||||
async function seedExecutionWorkspace(
|
||||
companyId: string,
|
||||
projectId: string,
|
||||
projectWorkspaceId: string | null = null,
|
||||
) {
|
||||
const executionWorkspaceId = randomUUID();
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: `Execution workspace ${executionWorkspaceId}`,
|
||||
});
|
||||
return executionWorkspaceId;
|
||||
}
|
||||
|
||||
async function seedSummarizer(companyId: string, ready = true) {
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
|
|
@ -117,6 +148,15 @@ describeEmbeddedPostgres("summary slot service", () => {
|
|||
return { companyId, scopeKind: "project", slotKey: "header", scopeId: projectId };
|
||||
}
|
||||
|
||||
function executionWorkspaceSelector(companyId: string, executionWorkspaceId: string) {
|
||||
return {
|
||||
companyId,
|
||||
scopeKind: "execution_workspace",
|
||||
slotKey: "header",
|
||||
scopeId: executionWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reads and target visibility", () => {
|
||||
it("returns an empty slot state before any generation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
|
|
@ -144,6 +184,18 @@ describeEmbeddedPostgres("summary slot service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("rejects an execution workspace owned by another company", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const otherCompanyId = await seedCompany();
|
||||
const otherProjectId = await seedProject(otherCompanyId);
|
||||
const foreignExecutionWorkspaceId = await seedExecutionWorkspace(otherCompanyId, otherProjectId);
|
||||
const svc = summarySlotService(db);
|
||||
|
||||
await expect(
|
||||
svc.getSlot(executionWorkspaceSelector(companyId, foreignExecutionWorkspaceId)),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("rejects a workspaces_overview selector that carries a scopeId", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = summarySlotService(db);
|
||||
|
|
@ -268,6 +320,65 @@ describeEmbeddedPostgres("summary slot service", () => {
|
|||
expect(issueRow.description).not.toContain("Other project issue");
|
||||
});
|
||||
|
||||
it("keeps summaries and snapshots isolated between execution workspaces", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const projectId = await seedProject(companyId);
|
||||
const projectWorkspaceId = await seedProjectWorkspace(companyId, projectId);
|
||||
const firstExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
|
||||
const secondExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
|
||||
await seedSummarizer(companyId);
|
||||
const svc = summarySlotService(db);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: firstExecutionWorkspaceId,
|
||||
identifier: `${issuePrefix(companyId)}-201`,
|
||||
issueNumber: 201,
|
||||
title: "First workspace task",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: secondExecutionWorkspaceId,
|
||||
identifier: `${issuePrefix(companyId)}-202`,
|
||||
issueNumber: 202,
|
||||
title: "Second workspace task",
|
||||
status: "blocked",
|
||||
priority: "high",
|
||||
},
|
||||
]);
|
||||
|
||||
const firstSelector = executionWorkspaceSelector(companyId, firstExecutionWorkspaceId);
|
||||
const secondSelector = executionWorkspaceSelector(companyId, secondExecutionWorkspaceId);
|
||||
const generated = await svc.generate(firstSelector, { userId: "board-user" });
|
||||
|
||||
expect(generated.slot).toMatchObject({
|
||||
scopeKind: "execution_workspace",
|
||||
scopeId: firstExecutionWorkspaceId,
|
||||
status: "generating",
|
||||
});
|
||||
const generationIssue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, generated.generatingIssue.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(generationIssue.description).toContain("First workspace task");
|
||||
expect(generationIssue.description).not.toContain("Second workspace task");
|
||||
expect(generationIssue.description).toContain('"scopeKind": "execution_workspace"');
|
||||
expect(generationIssue.description).toContain(`"scopeId": "${firstExecutionWorkspaceId}"`);
|
||||
await expect(svc.getSlot(secondSelector)).resolves.toEqual({
|
||||
slot: null,
|
||||
document: null,
|
||||
generatingIssue: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes concurrent generate clicks without creating an orphan task", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const projectId = await seedProject(companyId);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ You are Summarizer, a built-in reporting agent at Paperclip.
|
|||
|
||||
When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat.
|
||||
|
||||
Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes.
|
||||
Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes.
|
||||
|
||||
## Core responsibilities
|
||||
|
||||
- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace`, plus `scopeId` and `slotKey`).
|
||||
- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace` | `execution_workspace`, plus `scopeId` and `slotKey`).
|
||||
- Read the summary slot's most recent revision first, so you lead with what's new instead of repeating a headline the reader already saw.
|
||||
- Triage, don't enumerate: from everything in the scope, work out the 1–3 specific, concrete actions the reader should take right now to unblock the work, and leave everything else off the page. Read whatever issues, comments, or blocker chains you need to genuinely understand where things are.
|
||||
- Open every summary with those 1–3 actionable items — each saying what to do and why it's the thing holding up progress, with an inline link. If genuinely nothing needs the reader, say so plainly in one line and name the next thing worth watching.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ variables:
|
|||
- project
|
||||
- workspaces_overview
|
||||
- project_workspace
|
||||
- execution_workspace
|
||||
triggers:
|
||||
- kind: schedule
|
||||
label: Daily stale-summary refresh
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ const FALLBACK_REFLECTION_COACH_SKILL = [
|
|||
const FALLBACK_SUMMARIZER_INSTRUCTIONS = [
|
||||
"You are Summarizer, a built-in reporting agent at Paperclip.",
|
||||
"",
|
||||
"Turn the current state of a Paperclip scope (project, workspaces overview, or a single project workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
|
||||
"Turn the current state of a Paperclip scope (project, workspaces overview, project workspace, or execution workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
|
||||
"",
|
||||
"Read-and-report only: never change issues, workspaces, or code. Cite issue identifiers, never fabricate status, keep every read company-scoped, and run on the low-cost model profile lane by default.",
|
||||
"",
|
||||
|
|
@ -401,7 +401,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
|
|||
displayName: "Summarizer",
|
||||
featureKeys: ["summarizer"],
|
||||
shortPurpose:
|
||||
"Writes short, human-readable Markdown status summaries into project, workspaces-overview, and project-workspace summary slots on demand.",
|
||||
"Writes short, human-readable Markdown status summaries into project, workspaces-overview, project-workspace, and execution-workspace summary slots on demand.",
|
||||
defaultInstructions: SUMMARIZER_INSTRUCTIONS,
|
||||
defaultRole: "general",
|
||||
defaultTitle: "Summarizer",
|
||||
|
|
@ -419,7 +419,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
|
|||
},
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
bundle: {
|
||||
stockVersion: "2026-07-15",
|
||||
stockVersion: "2026-08-02",
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
files: {
|
||||
|
|
@ -452,7 +452,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
|
|||
type: "select",
|
||||
defaultValue: "all",
|
||||
required: true,
|
||||
options: ["all", "project", "workspaces_overview", "project_workspace"],
|
||||
options: ["all", "project", "workspaces_overview", "project_workspace", "execution_workspace"],
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
|
|
@ -123,7 +124,9 @@ function scopeLabel(scopeKind: SummarySlotScopeKind): string {
|
|||
case "project":
|
||||
return "project";
|
||||
case "project_workspace":
|
||||
return "workspace";
|
||||
return "project workspace";
|
||||
case "execution_workspace":
|
||||
return "execution workspace";
|
||||
case "workspaces_overview":
|
||||
return "workspaces overview";
|
||||
default:
|
||||
|
|
@ -175,6 +178,15 @@ export function summarySlotService(db: Db) {
|
|||
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Summary target not found");
|
||||
return;
|
||||
}
|
||||
if (sel.scopeKind === "execution_workspace") {
|
||||
const row = await db
|
||||
.select({ id: executionWorkspaces.id })
|
||||
.from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Summary target not found");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,9 +311,10 @@ export function summarySlotService(db: Db) {
|
|||
async function resolveGenerationTargetProject(sel: ResolvedSelector): Promise<{
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
}> {
|
||||
if (sel.scopeKind === "project") {
|
||||
return { projectId: sel.scopeId, projectWorkspaceId: null };
|
||||
return { projectId: sel.scopeId, projectWorkspaceId: null, executionWorkspaceId: null };
|
||||
}
|
||||
if (sel.scopeKind === "project_workspace" && sel.scopeId) {
|
||||
const row = await db
|
||||
|
|
@ -309,14 +322,34 @@ export function summarySlotService(db: Db) {
|
|||
.from(projectWorkspaces)
|
||||
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return { projectId: row?.projectId ?? null, projectWorkspaceId: sel.scopeId };
|
||||
return {
|
||||
projectId: row?.projectId ?? null,
|
||||
projectWorkspaceId: sel.scopeId,
|
||||
executionWorkspaceId: null,
|
||||
};
|
||||
}
|
||||
return { projectId: null, projectWorkspaceId: null };
|
||||
if (sel.scopeKind === "execution_workspace" && sel.scopeId) {
|
||||
const row = await db
|
||||
.select({
|
||||
projectId: executionWorkspaces.projectId,
|
||||
projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return {
|
||||
projectId: row?.projectId ?? null,
|
||||
projectWorkspaceId: row?.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: sel.scopeId,
|
||||
};
|
||||
}
|
||||
return { projectId: null, projectWorkspaceId: null, executionWorkspaceId: null };
|
||||
}
|
||||
|
||||
function scopeIssueConditions(sel: ResolvedSelector) {
|
||||
if (sel.scopeKind === "project") return [eq(issues.projectId, sel.scopeId!)];
|
||||
if (sel.scopeKind === "project_workspace") return [eq(issues.projectWorkspaceId, sel.scopeId!)];
|
||||
if (sel.scopeKind === "execution_workspace") return [eq(issues.executionWorkspaceId, sel.scopeId!)];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +497,7 @@ export function summarySlotService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
const { projectId, projectWorkspaceId } = await resolveGenerationTargetProject(sel);
|
||||
const { projectId, projectWorkspaceId, executionWorkspaceId } = await resolveGenerationTargetProject(sel);
|
||||
const scopeSnapshot = await buildScopeSnapshot(sel, existing?.lastGeneratedAt ?? null);
|
||||
const createdAt = new Date();
|
||||
const generationVersion = existing?.generatingIssueId ?? existing?.updatedAt.toISOString() ?? "initial";
|
||||
|
|
@ -472,6 +505,7 @@ export function summarySlotService(db: Db) {
|
|||
const created = await issuesSvc.create(sel.companyId, {
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId,
|
||||
title: generationIssueTitle(sel, createdAt),
|
||||
description: generationIssueDescription(sel, scopeSnapshot),
|
||||
status: "todo",
|
||||
|
|
|
|||
|
|
@ -288,15 +288,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the linked project workspace summary above tasks", async () => {
|
||||
it("shows a summary scoped to the execution workspace above tasks", async () => {
|
||||
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ projectWorkspaceId: "project-workspace-1" }));
|
||||
|
||||
await render();
|
||||
|
||||
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
scopeKind: "project_workspace",
|
||||
scopeId: "project-workspace-1",
|
||||
scopeKind: "execution_workspace",
|
||||
scopeId: "workspace-1",
|
||||
title: "Workspace summary",
|
||||
}));
|
||||
const summary = container.querySelector('[data-testid="summary-slot-card"]');
|
||||
|
|
@ -307,11 +307,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
|
|||
expect(summary.compareDocumentPosition(issues) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
||||
});
|
||||
|
||||
it("does not show a project workspace summary for standalone execution workspaces", async () => {
|
||||
it("shows an isolated summary for standalone execution workspaces", async () => {
|
||||
await render();
|
||||
|
||||
expect(mockSummarySlotCard).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-testid="summary-slot-card"]')).toBeNull();
|
||||
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
scopeKind: "execution_workspace",
|
||||
scopeId: "workspace-1",
|
||||
}));
|
||||
expect(container.querySelector('[data-testid="summary-slot-card"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not mount plugin slots scoped to other entity types", async () => {
|
||||
|
|
|
|||
|
|
@ -1523,15 +1523,13 @@ export function ExecutionWorkspaceDetail() {
|
|||
</Card>
|
||||
) : activeTab === "issues" ? (
|
||||
<div className="space-y-6">
|
||||
{workspace.projectWorkspaceId ? (
|
||||
<SummarySlotCard
|
||||
companyId={workspace.companyId}
|
||||
scopeKind="project_workspace"
|
||||
scopeId={workspace.projectWorkspaceId}
|
||||
title="Workspace summary"
|
||||
description="Summarizer keeps the latest workspace status, next step, and operator-needed items here."
|
||||
/>
|
||||
) : null}
|
||||
<SummarySlotCard
|
||||
companyId={workspace.companyId}
|
||||
scopeKind="execution_workspace"
|
||||
scopeId={workspace.id}
|
||||
title="Workspace summary"
|
||||
description="Summarizer keeps the latest workspace status, next step, and operator-needed items here."
|
||||
/>
|
||||
<ExecutionWorkspaceIssuesList
|
||||
companyId={workspace.companyId}
|
||||
workspace={workspace}
|
||||
|
|
|
|||
Loading…
Reference in New Issue