diff --git a/server/src/__integration__/runtime-server.test.ts b/server/src/__integration__/runtime-server.test.ts index 7376fbcf..711aa69e 100644 --- a/server/src/__integration__/runtime-server.test.ts +++ b/server/src/__integration__/runtime-server.test.ts @@ -332,7 +332,7 @@ test('[integration] runtime: FUSE whole-file writes authenticate before their la }) test('[integration] runtime: FUSE preserves authenticated whole-file writes above 4MB', async () => { - const { agentId, token } = await seedAgent() + const { agentId, companyId, token } = await seedAgent() const fileBody = 'x'.repeat(5 * 1024 * 1024) const r = await call('/runtime/fs/write', { method: 'PUT', @@ -341,13 +341,14 @@ test('[integration] runtime: FUSE preserves authenticated whole-file writes abov }) assert.equal(r.status, 200) assert.deepEqual(r.body, { ok: true }) - const { rows } = await pool.query<{ bytes: number }>( - `SELECT OCTET_LENGTH(body)::int AS bytes + const { rows } = await pool.query<{ bytes: number; company_id: string }>( + `SELECT OCTET_LENGTH(body)::int AS bytes, company_id FROM agent_workspace WHERE agent_id = $1 AND path = 'large-workspace-file.txt'`, [agentId], ) assert.equal(rows[0]?.bytes, Buffer.byteLength(fileBody)) + assert.equal(rows[0]?.company_id, companyId) }) test('[integration] runtime: wrong scheme (Basic instead of Bearer) → 401', async () => { diff --git a/server/src/__integration__/workspace-management.test.ts b/server/src/__integration__/workspace-management.test.ts index f023b594..95f93d00 100644 --- a/server/src/__integration__/workspace-management.test.ts +++ b/server/src/__integration__/workspace-management.test.ts @@ -429,6 +429,23 @@ test('[integration] workspace deletion purges FK-backed and legacy soft-scoped d `INSERT INTO boards (id, company_id, title, created_by) VALUES ('board-managed', 'co-managed', 'Managed board', $1)`, [OWNER_ID], ) + await pool.query( + `INSERT INTO agent_workspace (agent_id, path, body, company_id) + VALUES ('agent-managed', 'notes.md', 'workspace content', 'co-managed')`, + ) + await pool.query( + `INSERT INTO agent_tasks (id, agent_id, title, company_id) + VALUES ('task-managed', 'agent-managed', 'test task', 'co-managed')`, + ) + await pool.query( + `INSERT INTO agent_climate (agent_id, about_id, company_id, last_note) + VALUES ('agent-managed', $1, 'co-managed', 'climate note')`, + [OWNER_ID], + ) + await pool.query( + `INSERT INTO agent_log (id, agent_id, kind, body, company_id) + VALUES ('log-managed', 'agent-managed', 'note', 'log body', 'co-managed')`, + ) const runtimeIdentity = await pool.query<{ runtime_assignment_id: string }>( `SELECT runtime_assignment_id @@ -460,6 +477,10 @@ test('[integration] workspace deletion purges FK-backed and legacy soft-scoped d ['computers', `company_id = 'co-managed'`], ['agent_runs', `company_id = 'co-managed'`], ['boards', `company_id = 'co-managed'`], + ['agent_workspace', `company_id = 'co-managed' OR agent_id = 'agent-managed'`], + ['agent_tasks', `company_id = 'co-managed' OR agent_id = 'agent-managed'`], + ['agent_climate', `company_id = 'co-managed' OR agent_id = 'agent-managed'`], + ['agent_log', `company_id = 'co-managed' OR agent_id = 'agent-managed'`], ] as const) { const remaining = await pool.query(`SELECT 1 FROM ${table} WHERE ${predicate}`) assert.equal(remaining.rowCount, 0, `${table} retained workspace rows`) diff --git a/server/src/agents/cli.ts b/server/src/agents/cli.ts index e3a5fffb..2d95c615 100644 --- a/server/src/agents/cli.ts +++ b/server/src/agents/cli.ts @@ -4125,8 +4125,8 @@ async function cmdMemory(parsed: ParsedArgs): Promise { ) } await pool.query( - `INSERT INTO agent_log (id, agent_id, kind, body, ref) VALUES ($1, $2, 'note', $3, $4::jsonb)`, - [`log-${randomUUID().slice(0, 12)}`, me, `noted: ${body.slice(0, 120)}`, JSON.stringify({ memoryId: id, path })], + `INSERT INTO agent_log (id, agent_id, company_id, kind, body, ref) VALUES ($1, $2, $3, 'note', $4, $5::jsonb)`, + [`log-${randomUUID().slice(0, 12)}`, me, tenant, `noted: ${body.slice(0, 120)}`, JSON.stringify({ memoryId: id, path })], ) return ok(`saved memory ${id}`, [{ event: 'memory.written', @@ -4246,16 +4246,18 @@ async function cmdClimate(parsed: ParsedArgs): Promise { ...prevHistory.slice(-19), { at: new Date().toISOString(), affinity: nextAffinity, trust: nextTrust, note: note.slice(0, 400) }, ] + const tenant = (await agentCompany(me)) ?? 'personal' await pool.query( - `INSERT INTO agent_climate (agent_id, about_id, affinity, trust, last_note, history, updated_at) - VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW()) + `INSERT INTO agent_climate (agent_id, about_id, company_id, affinity, trust, last_note, history, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW()) ON CONFLICT (agent_id, about_id) DO UPDATE - SET affinity = EXCLUDED.affinity, + SET company_id = EXCLUDED.company_id, + affinity = EXCLUDED.affinity, trust = EXCLUDED.trust, last_note = EXCLUDED.last_note, history = EXCLUDED.history, updated_at = NOW()`, - [me, aboutId, nextAffinity, nextTrust, note.slice(0, 400), JSON.stringify(newHistory)], + [me, aboutId, tenant, nextAffinity, nextTrust, note.slice(0, 400), JSON.stringify(newHistory)], ) return ok(`climate updated: ${me} → ${aboutId} affinity=${nextAffinity.toFixed(2)} trust=${nextTrust.toFixed(2)}`, [{ event: 'climate.updated', @@ -4293,9 +4295,10 @@ async function cmdLog(parsed: ParsedArgs): Promise { const body = parsed.positional[1] if (!body) return err('usage: log note [--as id]') const id = `log-${randomUUID().slice(0, 12)}` + const tenant = await agentCompany(me) await pool.query( - `INSERT INTO agent_log (id, agent_id, kind, body) VALUES ($1, $2, 'note', $3)`, - [id, me, body], + `INSERT INTO agent_log (id, agent_id, company_id, kind, body) VALUES ($1, $2, $3, 'note', $4)`, + [id, me, tenant, body], ) return ok(`logged ${id}`) } @@ -4471,8 +4474,8 @@ async function cmdTasks(parsed: ParsedArgs): Promise { if (!title) return err('usage: tasks add [--as id]') const id = `task-${randomUUID().slice(0, 12)}` await pool.query( - `INSERT INTO agent_tasks (id, agent_id, title) VALUES ($1, $2, $3)`, - [id, me, title], + `INSERT INTO agent_tasks (id, agent_id, company_id, title) VALUES ($1, $2, $3, $4)`, + [id, me, companyId, title], ) return ok(`added task ${id}: ${title}`, [{ event: 'task.created', diff --git a/server/src/agents/climate.ts b/server/src/agents/climate.ts index c07f6818..1698061e 100644 --- a/server/src/agents/climate.ts +++ b/server/src/agents/climate.ts @@ -48,23 +48,25 @@ export async function bumpClimate(args: BumpArgs): Promise<void> { try { // Guard: only insert/update climate WHEN agent_id refers to an agent. // (Reaction events fire for human authors too; skip those.) - const { rows: kind } = await pool.query<{ kind: string }>( - `SELECT kind FROM participants WHERE id = $1 LIMIT 1`, [agentId], + const { rows: part } = await pool.query<{ kind: string; company_id: string | null }>( + `SELECT kind, company_id FROM participants WHERE id = $1 LIMIT 1`, [agentId], ) - if (!kind[0] || kind[0].kind !== 'agent') return + if (!part[0] || part[0].kind !== 'agent') return + const tenant = part[0].company_id ?? 'personal' await pool.query( - `INSERT INTO agent_climate (agent_id, about_id, affinity, trust, last_note, updated_at) - VALUES ($1, $2, - GREATEST(-1, LEAST(1, $3::real)), + `INSERT INTO agent_climate (agent_id, about_id, company_id, affinity, trust, last_note, updated_at) + VALUES ($1, $2, $3, GREATEST(-1, LEAST(1, $4::real)), - $5, NOW()) + GREATEST(-1, LEAST(1, $5::real)), + $6, NOW()) ON CONFLICT (agent_id, about_id) DO UPDATE - SET affinity = GREATEST(-1, LEAST(1, agent_climate.affinity + $3::real)), - trust = GREATEST(-1, LEAST(1, agent_climate.trust + $4::real)), - last_note = COALESCE($5, agent_climate.last_note), + SET company_id = EXCLUDED.company_id, + affinity = GREATEST(-1, LEAST(1, agent_climate.affinity + $4::real)), + trust = GREATEST(-1, LEAST(1, agent_climate.trust + $5::real)), + last_note = COALESCE($6, agent_climate.last_note), updated_at = NOW()`, - [agentId, aboutId, clamp(affinity), clamp(trust), note ?? null], + [agentId, aboutId, tenant, clamp(affinity), clamp(trust), note ?? null], ) } catch (e) { console.warn('[climate] bump failed', e) diff --git a/server/src/agents/runtime/fs-endpoints.ts b/server/src/agents/runtime/fs-endpoints.ts index 003206aa..de7ec7f7 100644 --- a/server/src/agents/runtime/fs-endpoints.ts +++ b/server/src/agents/runtime/fs-endpoints.ts @@ -127,13 +127,14 @@ export function attachFsEndpoints( ? await memoryMetaForWrite(c.sub, { path: p, conversationId: body?.conversationId ?? null }) : metaForPath(p) await pool.query( - `INSERT INTO agent_workspace (agent_id, path, body, meta, updated_at) - VALUES ($1, $2, $3, $4::jsonb, NOW()) + `INSERT INTO agent_workspace (agent_id, path, body, meta, company_id, updated_at) + VALUES ($1, $2, $3, $4::jsonb, $5, NOW()) ON CONFLICT (agent_id, path) DO UPDATE SET body = EXCLUDED.body, + company_id = COALESCE(EXCLUDED.company_id, agent_workspace.company_id), meta = COALESCE(agent_workspace.meta, EXCLUDED.meta), updated_at = NOW()`, - [c.sub, p, text, JSON.stringify(meta)], + [c.sub, p, text, JSON.stringify(meta), c.companyId], ) // Memory paths get re-embedded asynchronously by the embeddings // worker on the server (fire-and-forget). Done out-of-band so the diff --git a/server/src/api/router.ts b/server/src/api/router.ts index 97c9776e..6c5a00b5 100644 --- a/server/src/api/router.ts +++ b/server/src/api/router.ts @@ -1828,6 +1828,12 @@ api.delete('/companies/:id', safe(async (req, res) => { } if (agentIds.length > 0) { await client.query(`DELETE FROM board_mention_reads WHERE user_id = ANY($1::text[])`, [agentIds]) + const agentScopedTables = [ + 'agent_workspace', 'agent_memory', 'agent_log', 'agent_tasks', 'agent_climate', + ] as const + for (const table of agentScopedTables) { + await client.query(`DELETE FROM ${table} WHERE agent_id = ANY($1::text[])`, [agentIds]) + } } await client.query(`DELETE FROM documents WHERE company_id = $1`, [companyId]) await client.query(`DELETE FROM conversations WHERE company_id = $1`, [companyId])