Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1e1c91c
let people belong to several organizations, with members, invitations…
hkonsti Oct 21, 2026
ddb1c74
make connections a member's own or shared with the organization
hkonsti Oct 21, 2026
a3e89dc
make agents a member's own or shared with the organization
hkonsti Oct 21, 2026
68ff7e6
center the organization's tile on the rail's icons
hkonsti Oct 21, 2026
253c885
keep password managers off the fields that take an address, a secret …
hkonsti Oct 22, 2026
aa1f951
show the organization's tile at full size on the rail, centered on it…
hkonsti Oct 22, 2026
4c458b2
merge main into organizations
hkonsti Oct 22, 2026
714bc65
keep the invite field from reading as an email field to password mana…
hkonsti Oct 22, 2026
1251436
put the organization in the address
hkonsti Oct 22, 2026
6cb42e0
allow any number of agent logins of a provider, each named, and renam…
hkonsti Oct 22, 2026
5bd4862
make the agent before setting it up, and set up that one agent
hkonsti Oct 22, 2026
8b8c747
merge main into organizations
hkonsti Oct 22, 2026
7a246d5
keep organization slugs off the app's and the api's own paths
hkonsti Oct 22, 2026
a1e09ea
share one's own connection when signing in to it again for everyone
hkonsti Oct 22, 2026
6c4b4c2
delete everything in an organization without members, not only the or…
hkonsti Oct 22, 2026
7487ced
give every organization a shared vault beside each member's own
hkonsti Oct 22, 2026
998110f
cut a former member off shared agents, say who asked and who signed i…
hkonsti Oct 22, 2026
417ff73
merge main into organizations
hkonsti Oct 22, 2026
ea06851
keep a shared agent's private doings private, scope the lanes, harden…
hkonsti Oct 22, 2026
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
34 changes: 22 additions & 12 deletions packages/api/src/activity/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {afterEach, beforeEach, describe, expect, mock, setSystemTime, test} from 'bun:test';
import {Ok} from 'ts-results-es';

const getUserOrgId = mock();
const listAuditEntries = mock();
const summarizeTaskRequests = mock();

mock.module('@proxy/db/organization', () => ({getUserOrgId}));
const listMemberNames = mock(async (): Promise<unknown> => Ok([]));

mock.module('@proxy/db/organization', () => ({getMembership: mock(async () => Ok({role: 'owner'})), listMemberNames}));

const requireViewer = mock(async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments: []}));

mock.module('../utils/user-org', () => ({requireViewer, requireOwner: requireViewer}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, summarizeTaskRequests}));

const listAgentTasks = mock();
Expand All @@ -19,7 +24,6 @@ function makeRequest(query: Record<string, string>) {

beforeEach(() => {
mock.clearAllMocks();
getUserOrgId.mockResolvedValue(Ok('org-1'));
listAuditEntries.mockResolvedValue(Ok([]));
listAgentTasks.mockResolvedValue(Ok([]));
listAgentTasksSince.mockResolvedValue(Ok([]));
Expand Down Expand Up @@ -55,8 +59,8 @@ describe('handleActivityLanesRoute', () => {
const {handleActivityLanesRoute} = await import('./route');
const result = await handleActivityLanesRoute(makeRequest({from: '2026-10-14T23:30:00Z'}) as never);

expect(listAgentTasksSince).toHaveBeenCalledWith('org-1', new Date('2026-10-14T23:30:00Z'), 200);
expect(summarizeTaskRequests).toHaveBeenCalledWith('org-1', ['task-1']);
expect(listAgentTasksSince).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), new Date('2026-10-14T23:30:00Z'), 200);
expect(summarizeTaskRequests).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), ['task-1']);
expect(result.unwrap()).toEqual({
now: '2026-10-15T23:30:00.000Z',
from: '2026-10-14T23:30:00.000Z',
Expand Down Expand Up @@ -85,7 +89,7 @@ describe('handleActivityLanesRoute', () => {
await handleActivityLanesRoute(makeRequest({from: '2020-01-01T00:00:00Z'}) as never);
const bad = await handleActivityLanesRoute(makeRequest({from: 'yesterday'}) as never);

expect(listAgentTasksSince).toHaveBeenCalledWith('org-1', new Date('2026-09-14T23:30:00Z'), 200);
expect(listAgentTasksSince).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), new Date('2026-09-14T23:30:00Z'), 200);
expect(bad.unwrapErr().kind).toBe('parse_error');
});
});
Expand All @@ -95,17 +99,17 @@ describe('handleListActivityRoute', () => {
const {handleListActivityRoute} = await import('./route');
await handleListActivityRoute(makeRequest({outcome: 'denied'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {outcome: 'denied'}, 500);
expect(listAuditEntries).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), {outcome: 'denied'}, 500);
});

test('what Personal Agent Proxy did itself is the entries without an agent', async () => {
const {handleListActivityRoute} = await import('./route');
await handleListActivityRoute(makeRequest({agentId: 'proxy'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {agentId: null}, 500);
expect(listAuditEntries).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), {agentId: null}, 500);
});

test('names the tasks the entries were made for, and the one asked for even without entries', async () => {
test('names the tasks the entries were made for, and the one asked for even without entries, and who asked for what Personal Agent Proxy did', async () => {
const entry = {
id: 'e1',
agentId: 'agent-1',
Expand All @@ -118,10 +122,12 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: 'task-1',
durationMs: 340,
userId: null,
error: null,
createdAt: new Date('2026-10-15T10:00:00Z'),
};
listAuditEntries.mockResolvedValue(Ok([entry, {...entry, id: 'e2', taskId: null, agentId: null, durationMs: null}]));
listMemberNames.mockResolvedValue(Ok([{orgId: 'org-1', userId: 'user-2', name: 'Dana'}]));
listAuditEntries.mockResolvedValue(Ok([entry, {...entry, id: 'e2', taskId: null, agentId: null, durationMs: null, userId: 'user-2'}]));
listAgentTasks.mockResolvedValue(
Ok([
{
Expand All @@ -140,8 +146,8 @@ describe('handleListActivityRoute', () => {
const {handleListActivityRoute} = await import('./route');
const result = await handleListActivityRoute(makeRequest({taskId: 'task-2'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {taskId: 'task-2'}, 500);
expect(listAgentTasks).toHaveBeenCalledWith('org-1', ['task-1', 'task-2']);
expect(listAuditEntries).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), {taskId: 'task-2'}, 500);
expect(listAgentTasks).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), ['task-1', 'task-2']);
expect(result.unwrap()).toEqual({
entries: [
{
Expand All @@ -156,6 +162,8 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: 'task-1',
durationMs: 340,
userId: null,
userName: null,
error: null,
at: '2026-10-15T10:00:00.000Z',
},
Expand All @@ -171,6 +179,8 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: null,
durationMs: null,
userId: 'user-2',
userName: 'Dana',
error: null,
at: '2026-10-15T10:00:00.000Z',
},
Expand Down
32 changes: 21 additions & 11 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {z} from 'zod';

import {type ApiError, Do, parseSchema} from '@proxy/utils';
import {listAuditEntries, summarizeTaskRequests} from '@proxy/db/audit';
import {listMemberNames} from '@proxy/db/organization';
import {type AgentTaskRow, listAgentTasks, listAgentTasksSince} from '@proxy/db/task';

import type {AuthenticatedRequest} from '../server/middleware/require-auth';
import {requireUserOrgId} from '../utils/user-org';
import {requireViewer} from '../utils/user-org';

const MAX_ENTRIES = 500;

Expand All @@ -33,6 +34,11 @@ export type AuditEntryResponse = {
// How long the request took; none for entries from before it was kept, and for what Personal
// Agent Proxy did itself.
durationMs: number | null;
// The member who asked for what Personal Agent Proxy did itself, and their name or address while
// they are in the organization; null for an agent's request, and for entries from before it was
// kept.
userId: string | null;
userName: string | null;
};

/** What an agent set out to do, as it said when it started: what the entries made for it were for. */
Expand Down Expand Up @@ -67,24 +73,28 @@ const querySchema = z.object({
});

/**
* The organization's agent requests, newest first, the latest 500, and the tasks they were made
* for; with `?taskId=`, that task too, even before any request was made for it.
* The requests the person sees in the organization, newest first, the latest 500: shared agents'
* and their own agents', on shared connections and their own; and the tasks they were made for;
* with `?taskId=`, that task too, even before any request was made for it.
*/
export function handleListActivityRoute(request: AuthenticatedRequest): Promise<Result<{entries: AuditEntryResponse[]; tasks: ActivityTaskResponse[]}, ApiError>> {
return Do(async ($) => {
const {agentId, connectionId, taskId, outcome} = $(parseSchema(querySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const viewer = $(await requireViewer(request));
const filter = {
...(agentId === undefined ? {} : {agentId: agentId === PROXY ? null : agentId}),
...(connectionId === undefined ? {} : {connectionId}),
...(taskId === undefined ? {} : {taskId}),
...(outcome === undefined ? {} : {outcome}),
};
const rows = $(await listAuditEntries(orgId, filter, MAX_ENTRIES));
const rows = $(await listAuditEntries(viewer, filter, MAX_ENTRIES));
const taskIds = new Set([...rows.flatMap((row) => (row.taskId === null ? [] : [row.taskId])), ...(taskId === undefined ? [] : [taskId])]);
const tasks = $(await listAgentTasks(orgId, [...taskIds]));
const userIds = new Set(rows.flatMap((row) => (row.userId === null ? [] : [row.userId])));
const tasks = $(await listAgentTasks(viewer, [...taskIds]));
const names = $(await listMemberNames([...userIds].map((userId) => ({orgId: viewer.orgId, userId}))));
const nameOf = (userId: string | null) => names.find((member) => member.userId === userId)?.name ?? null;
return {
entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()})),
entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString(), userName: nameOf(entry.userId)})),
tasks: tasks.map(toActivityTask),
};
});
Expand Down Expand Up @@ -113,17 +123,17 @@ export type ActivityLanesResponse = {
tasks: LaneTaskResponse[];
};

/** The organization's tasks under way at some point since `?from=`, with what was done for each. */
/** The tasks the person sees under way at some point since `?from=`, shared agents' and their own agents', with what was done for each. */
export function handleActivityLanesRoute(request: AuthenticatedRequest): Promise<Result<ActivityLanesResponse, ApiError>> {
return Do(async ($) => {
const query = $(parseSchema(lanesQuerySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const viewer = $(await requireViewer(request));
const now = new Date();
const from = new Date(Math.max(new Date(query.from).getTime(), now.getTime() - MAX_WINDOW_MS));
const rows = $(await listAgentTasksSince(orgId, from, MAX_TASKS));
const rows = $(await listAgentTasksSince(viewer, from, MAX_TASKS));
const requests = $(
await summarizeTaskRequests(
orgId,
viewer,
rows.map((row) => row.id),
),
);
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('handleAgentLoginRoute', () => {

describe('authenticateAgentRequest', () => {
test('looks the session up by the hash of the cookie', async () => {
getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []}));
getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'dot', name: 'Dots', orgSlug: 'org-1', experiments: []}));

const {authenticateAgentRequest} = await import('./session');
const result = await authenticateAgentRequest({cookie: 'proxy.agent-session=abc'});
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const saveAgentFeedback = mock();

mock.module('@proxy/db/feedback', () => ({saveAgentFeedback}));

const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []};
const agent = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'claude', name: 'Claude', orgSlug: 'org-1', experiments: []};

beforeEach(() => {
mock.clearAllMocks();
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const getAgentOpenTask = mock();

mock.module('./tasks', () => ({startAgentTask, finishAgentTask, listAgentOpenTasks, getAgentOpenTask}));

const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []};
const signedIn = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'dot', name: 'Dots', orgSlug: 'org-1', experiments: []};

function makeRequest(params: Record<string, string>, body: unknown = undefined) {
return {agent: signedIn, params, body, query: {}} as never;
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/agent-side/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const listOpenAgentTasks = mock();

mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, listOpenAgentTasks}));

const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []};
const agent = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'claude', name: 'Claude', orgSlug: 'org-1', experiments: []};
const caller = {agent, via: 'mcp' as const};
const row = {
id: 'task-1',
Expand Down Expand Up @@ -38,7 +38,7 @@ describe('startAgentTask', () => {
const {startAgentTask} = await import('./tasks');
const result = await startAgentTask(caller, {title: ' Triage inbox ', goal: 'Archive the newsletters\n'});

expect(createAgentTask).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', parentId: null, title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp'});
expect(createAgentTask).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', ownerUserId: 'user-1', parentId: null, title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp'});
expect(getAgentTask).not.toHaveBeenCalled();
expect(result.unwrap()).toEqual(response);
});
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('startAgentTask', () => {
const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: ' task-0 '});

expect(getAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-0');
expect(createAgentTask).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', parentId: 'task-0', title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp'});
expect(createAgentTask).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', ownerUserId: 'user-1', parentId: 'task-0', title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp'});
expect(result.unwrap()).toEqual({...response, parent: 'task-0'});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function startAgentTask(caller: AgentCaller, args: unknown): Promise<Resu
if (parentId !== null) {
$(await requireOpenTask(caller.agent, parentId));
}
const task = $(await createAgentTask({orgId: caller.agent.orgId, agentId: caller.agent.agentId, parentId, title, goal, via: caller.via}));
const task = $(await createAgentTask({orgId: caller.agent.orgId, agentId: caller.agent.agentId, ownerUserId: caller.agent.ownerUserId, parentId, title, goal, via: caller.via}));
// Only where the parent was finished, or abandoned, since it was checked above.
if (task === null) {
return $(Err(ApiErr.conflict(`Task ${parentId} is no longer under way. Start a new task for what you are doing now, and give its id as task.`)));
Expand Down
Loading
Loading