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
46 changes: 45 additions & 1 deletion apps/server/src/research/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "bun:test";
import { describe, expect, it, spyOn } from "bun:test";

import { Admission } from "../auth/admission";
import { Sessions } from "../auth/session";
Expand Down Expand Up @@ -627,6 +627,11 @@ describe("research workspace routes", () => {
context.cookie,
));
expect(crossChannel?.status).toBe(404);
let topLevel = await context.router.handle(request(
`/api/channels/${context.channel.id}/research-provenance`,
context.cookie,
));
expect(topLevel?.status).toBe(404);

context.access.repository = {
...context.access.repository,
Expand All @@ -650,6 +655,45 @@ describe("research workspace routes", () => {
expect(noPull?.status).toBe(404);
});

it("returns provenance only for a readable child whose parent shares its repository", async () => {
let context = await setup();
let child = await createChild(childFixture(context));
let provenance = {
requestId: "workspace-1",
parentChannelId: context.channel.id,
brief: "What changed?",
sourceCount: 2,
startedBy: "octocat",
};
let lookup = spyOn(context.service, "provenance").mockResolvedValue(provenance);
let path = `/api/channels/${child.id}/research-provenance`;
let found = await context.router.handle(request(path, context.cookie));
expect(found?.status).toBe(200);
expect(await found!.json()).toEqual(provenance);
expect(lookup).toHaveBeenCalledWith(context.channel.id, child.id);

let read = context.storage.channels.get.bind(context.storage.channels);
let moved = spyOn(context.storage.channels, "get").mockImplementation(async id => {
let channel = await read(id);
return channel && id === context.channel.id
? { ...channel, repositoryId: OTHER_REPOSITORY_ID }
: channel;
});
expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404);
moved.mockRestore();

context.access.repository = {
...context.access.repository,
permissions: { pull: false, push: false, admin: false },
};
expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404);
context.access.affiliated = false;
expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404);
expect((await context.router.handle(request(path)))?.status).toBe(401);
expect(lookup).toHaveBeenCalledTimes(1);
lookup.mockRestore();
});

it("lists repository children in one bounded response without leaking another repository", async () => {
let context = await setup();
let created = await create(context);
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/research/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,29 @@ export function registerResearchWorkspaceRoutes(
},
);

router.on(
"GET",
"/api/channels/:channelId/research-provenance",
async (request, _url, params) => {
try {
let session = await auth.sessions.authenticate(request);
if (!session) return json({ error: "authentication required" }, 401);
let access = await channelAccess(auth, session, params.channelId!);
let parentId = access?.channel.parentChannelId;
let parent = parentId ? await auth.storage.channels.get(parentId) : undefined;
// Repository node IDs are authoritative; a parent elsewhere is never this child's source.
let provenance = access && parent
&& parent.repositoryId === access.channel.repositoryId
&& await service.provenance(parent.id, access.channel.id);
return provenance
? json(provenance)
: json({ error: "research provenance not found" }, 404);
} catch (err) {
return failure(err, auth);
}
},
);

router.on(
"GET",
"/api/channels/:channelId/research-requests/:workspaceId",
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/research/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,14 @@ describe("research workspace service", () => {

let replayed = await context.restart().request(context.channelId, started.request.id);
expect(replayed?.child?.id).toBe(childId);
expect(await context.service.provenance(context.channelId, childId)).toEqual({
requestId: started.request.id,
parentChannelId: context.channelId,
brief: "Which API contracts changed?",
sourceCount: 1,
startedBy: "octocat",
});
expect(await context.service.provenance(childId, childId)).toBeUndefined();
let listed = await context.storage.channels.list(REPOSITORY_ID, 100);
expect(listed.channels.filter(value => value.parentChannelId === context.channelId))
.toHaveLength(1);
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/research/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,26 @@ export class ResearchWorkspaceService {
});
}

async provenance(
parentChannelId: string,
childChannelId: string,
): Promise<Research.Provenance | undefined> {
if (!this.#validId(parentChannelId) || !this.#validId(childChannelId)) return undefined;
let found = await this.#storage.research.findPublished(parentChannelId, childChannelId);
if (!found) return undefined;
let view = await this.request(parentChannelId, found.workspace.id);
if (view?.stage !== "ready" || view.child.id !== childChannelId) return undefined;
let initial = found.turns.find(value => value.kind === "initial");
let user = initial && await this.#storage.users.get(initial.requestedBy);
return {
requestId: view.id,
parentChannelId,
brief: view.question,
sourceCount: view.child.sourceCount,
...(user ? { startedBy: user.login } : {}),
};
}

async reconcile(channelId: string, workspaceId: string): Promise<boolean> {
if (!this.#validId(channelId) || !this.#validId(workspaceId)) return false;
return this.#exclusive(
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/storage/memory/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,16 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore {
return workspaceId ? this.get(channelId, workspaceId) : undefined;
};

readonly findPublished = async (
channelId: string,
publishedChannelId: string,
): Promise<ResearchWorkspaceDetail | undefined> => {
let found = [...this.#workspaces.values()].find(value =>
value.channelId === channelId && value.publishedChannelId === publishedChannelId
);
return found ? this.get(channelId, found.id) : undefined;
};

readonly findTurnByJob = async (
channelId: string,
jobId: string,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/storage/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ export interface ResearchWorkspaceStore {
channelId: string,
idempotencyKey: string,
): Promise<ResearchWorkspaceDetail | undefined>;
/** The workspace in `channelId` whose report became the child document `publishedChannelId`. */
findPublished(
channelId: string,
publishedChannelId: string,
): Promise<ResearchWorkspaceDetail | undefined>;
findTurnByJob(channelId: string, jobId: string): Promise<ResearchTurn | undefined>;
}

Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/storage/postgres/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,18 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore {
return row ? this.get(channelId, text(row.id, "research workspace id")) : undefined;
});

readonly findPublished = (
channelId: string,
publishedChannelId: string,
): Promise<ResearchWorkspaceDetail | undefined> =>
this.#run("find published research workspace", async () => {
let [row] = await this.#sql<{ id: unknown }[]>`
SELECT id FROM research_workspaces
WHERE channel_id = ${channelId} AND published_channel_id = ${publishedChannelId}
`;
return row ? this.get(channelId, text(row.id, "research workspace id")) : undefined;
});

readonly findTurnByJob = (channelId: string, jobId: string): Promise<ResearchTurn | undefined> =>
this.#run("find research turn by job", async () => {
let rows = await this.#sql<
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/storage/research-publication-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export function researchPublicationContract(factory: StorageFactory): void {
sidecar: ready.initial.sidecar,
});
expect([...stored!.snapshot!.document]).toEqual([7, 8, 9]);
expect((await storage.research.findPublished(channelId, childId))?.workspace.id)
.toBe(ready.workspaceId);
expect(await storage.research.findPublished(childId, childId)).toBeUndefined();

await storage.channels.archive({
id: channelId,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export type ChatProps = {
onShowDecisions?: () => void;
/** Replaces the composer when this viewer cannot chat at all, such as in an archived document. */
notice?: string;
/** One calm line shown while the transcript has loaded and is still empty. */
emptyNotice?: string;
};

/** How many runs are still live, and how many are paused and resumable. */
Expand Down Expand Up @@ -127,6 +129,7 @@ export function Chat(
sourceDestination,
people = [],
notice,
emptyNotice,
onShowDecisions,
referencesEnabled,
repository,
Expand Down Expand Up @@ -445,6 +448,9 @@ export function Chat(
onRetryJob={onRetryJob}
conversationPlan={conversationPlan}
decisions={decisions}
empty={transcriptReady && entries.length === 0 && queue.length === 0 && !turn
? emptyNotice
: undefined}
researchOffers={researchOffers}
sourceDestination={sourceDestination}
entries={entries}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/chat/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export function Transcript(
conversationPlan,
conversationPlanJobs,
decisions,
empty,
researchOffers,
entries,
handle,
Expand Down Expand Up @@ -314,6 +315,7 @@ export function Transcript(
) => Promise<void>;
onRetryJob?: (jobId: string) => Promise<void>;
decisions?: TranscriptDecisions;
empty?: string;
researchOffers?: ResearchOfferControls;
entries: Chat.Entry[];
handle: string;
Expand Down Expand Up @@ -413,6 +415,14 @@ export function Transcript(
className="flex min-h-full flex-col gap-4 [&>*:first-child]:mt-auto"
data-chat-stack
>
{empty && (
<p
className="px-1 text-center text-sm text-balance text-text-tertiary"
data-chat-empty=""
>
{empty}
</p>
)}
{groups.map(item =>
item.kind === "research"
? (
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/child-provenance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useEffect, useId, useState } from "react";
import { ChevronIcon, SparkleIcon } from "@chopin/icons";

import { researchProvenance } from "./research-requests";

import type { Research } from "@chopin/protocol";
import type { ChildParent } from "./workspace-model";

function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}

/** A quiet line above a research child that names its parent, brief, and sources. */
export function ChildProvenance({ channelId, parent }: { channelId: string; parent: ChildParent }) {
let [provenance, setProvenance] = useState<Research.Provenance>();
let [briefOpen, setBriefOpen] = useState(false);
let briefId = useId();
useEffect(() => {
let controller = new AbortController();
setProvenance(undefined);
setBriefOpen(false);
researchProvenance(channelId, controller.signal)
.then(value => {
if (!controller.signal.aborted) setProvenance(value);
})
// Provenance is context, not content; the document stays usable without it.
.catch(() => {});
return () => controller.abort();
}, [channelId]);
if (!provenance || provenance.parentChannelId !== parent.id) return null;
let requestId = provenance.requestId;
let returnToCard = () => {
let card = document.querySelector<HTMLElement>(
`[data-workspace-room="${CSS.escape(parent.id)}"] [data-research-request="${
CSS.escape(requestId)
}"]`,
);
parent.onReturn(card ? { current: card } : undefined);
};
return (
<div className="child-provenance" data-child-provenance="">
<p className="child-provenance-line">
<span aria-hidden="true" className="plan-research-badge" data-tone="brand">
<SparkleIcon size={14} />
</span>
<span className="min-w-0">
Research from{" "}
<button
className="child-provenance-parent"
data-tooltip="Show the research card"
onClick={returnToCard}
type="button"
>
{parent.label}
</button>
<span className="plan-research-meta">
{provenance.startedBy && (
<>
{" "}
<span className="child-provenance-segment">· by {provenance.startedBy}</span>
</>
)}{" "}
<span className="child-provenance-segment">
· {provenance.sourceCount === 0
? "No sources"
: plural(provenance.sourceCount, "source")}
</span>
</span>
</span>
<button
aria-controls={briefId}
aria-expanded={briefOpen}
className="child-provenance-toggle btn btn-sm btn-ghost"
onClick={() => setBriefOpen(open => !open)}
type="button"
>
Brief
<ChevronIcon
aria-hidden="true"
className={briefOpen ? "rotate-90" : undefined}
/>
</button>
</p>
<blockquote className="child-provenance-brief" hidden={!briefOpen} id={briefId}>
{provenance.brief}
</blockquote>
</div>
);
}
14 changes: 12 additions & 2 deletions apps/web/src/document-workspace-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./document-workspace-state";

import type { ComponentType } from "react";
import type { ResearchOpener } from "@chopin/editor";
import type { ChildFocusToken } from "./anchored-child-surface";
import type { DocumentWorkspaceAction } from "./document-workspace-state";
import type { DocumentRouteIdentity } from "./document-route-swap";
Expand Down Expand Up @@ -102,7 +103,7 @@ export default function DocumentWorkspaceHost(
routeKey: DocumentRouteIdentity,
pathname: string,
) => void;
onChildClose: (parentId: string, parentPath: string) => void;
onChildClose: (parentId: string, parentPath: string, opener?: ResearchOpener) => void;
onChildClosing: (parentId: string, parentPath: string) => ChildFocusToken;
onParentRestored: (token: ChildFocusToken) => void;
onReady: (
Expand Down Expand Up @@ -405,7 +406,16 @@ export default function DocumentWorkspaceHost(
loaded.child,
agent,
user,
{ label: loaded.child.channel.title, onClose: closeChild, type: "child" },
{
label: loaded.child.channel.title,
onClose: closeChild,
parent: {
id: loaded.parent.channel.id,
label: loaded.parent.channel.title,
onReturn: opener => onChildClose(loaded.parent.channel.id, parentPath, opener),
},
type: "child",
},
childMetadataChanged,
)}
key={loaded.child.channel.id}
Expand Down
Loading
Loading