Merge gitea/main into the Motion branch
Motion was written against a base five commits behind main, so the integration is the interesting part of this commit: - The migration is renumbered 0014 -> 0015. Main shipped 0014_piggy_conversations, and two migrations sharing an index is a journal that applies one of them. - The seed-idempotency gate keeps main's all-tables diff rather than the motion_templates counter this branch added; the general check subsumes the specific one. - Nav gains a Motion group alongside main's new Workspace group, and Piggy keeps the mark main gave it. - Stat keeps main's container-scaled figure, which already carries the min-w-0 this branch added for the same reason. - Piggy's page labels keep main's refusal wording for the four pages with no tool of their own, and gain the three Motion routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,15 @@
|
||||
*
|
||||
* 3. **Every outward action is idempotent.** `agentActions` carries a unique
|
||||
* idempotency key, so a retried task cannot send the same message twice.
|
||||
*
|
||||
* 4. **The conversation is ours, not the harness's.** `piggyConversations` and
|
||||
* `piggyMessages` hold Piggy's transcripts, including the tools it ran and
|
||||
* the approvals it was given. The long note above those tables explains why
|
||||
* the agent harness's own session store is deliberately unused.
|
||||
*/
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -30,6 +37,13 @@ import {
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
PIGGY_MODES,
|
||||
READ_CAPABILITIES,
|
||||
type PiggyApprovalDecision,
|
||||
type PiggyChatContext,
|
||||
type PiggyProposedChange,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
agentTaskKindEnum,
|
||||
agentTaskOutcomeEnum,
|
||||
@@ -39,6 +53,14 @@ import {
|
||||
import { accounts, contacts } from './crm';
|
||||
import { users } from './identity';
|
||||
|
||||
/**
|
||||
* Render a value set as a SQL `IN` list from the ontology constant. Same
|
||||
* reasoning as `learn.ts`: a vocabulary typed out again in a CHECK is a
|
||||
* vocabulary that drifts from the one the application validates against.
|
||||
*/
|
||||
const inList = (values: readonly string[]) =>
|
||||
sql.raw(values.map((value) => `'${value}'`).join(', '));
|
||||
|
||||
/**
|
||||
* The work queue. Leased rather than locked: a worker claims a row by stamping
|
||||
* `leasedUntil` into the future, and a crashed worker's rows become claimable
|
||||
@@ -150,6 +172,210 @@ export const facts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* What a transcript row can be.
|
||||
*
|
||||
* `tool` is a first-class role rather than an assistant message with metadata
|
||||
* hung off it, because the renderer draws it as its own card and because a
|
||||
* turn can call several tools between two sentences.
|
||||
*/
|
||||
export const PIGGY_MESSAGE_ROLES = ['user', 'assistant', 'tool'] as const;
|
||||
export type PiggyMessageRole = (typeof PIGGY_MESSAGE_ROLES)[number];
|
||||
|
||||
/**
|
||||
* `PiggyApprovalDecision` as a value.
|
||||
*
|
||||
* @pig/core states it as a type union, and a CHECK constraint needs values.
|
||||
* `satisfies` stops this list drifting into something the protocol does not
|
||||
* admit. The other direction — a decision added to the union and not here —
|
||||
* the compiler cannot see, so it fails as a rejected insert the first time
|
||||
* that decision is recorded, which is loud rather than silent.
|
||||
*/
|
||||
const PIGGY_APPROVAL_DECISIONS = [
|
||||
'apply',
|
||||
'reject',
|
||||
] as const satisfies readonly PiggyApprovalDecision[];
|
||||
|
||||
/**
|
||||
* Piggy's conversations, and why they are stored here rather than by the
|
||||
* harness.
|
||||
*
|
||||
* Prime Agent ships a `SessionManager` with file-backed sessions, and PIG
|
||||
* deliberately does not use it for storage. Three reasons, all of which look
|
||||
* like an unnecessary complication until the fourth week in production:
|
||||
*
|
||||
* - PIG's source of truth is Postgres. A transcript is a record of what the
|
||||
* company was told about its own book; it belongs with the book.
|
||||
* - A session must be per-user and stay that way. Ownership here is a column
|
||||
* and a predicate on every query, not a filename someone could guess.
|
||||
* - The chat server runs in a container and will one day run in two. A file
|
||||
* under the agent directory survives neither a restart nor a second
|
||||
* replica, and the failure is silent: a user's history simply empties.
|
||||
*
|
||||
* So the harness is handed `SessionManager.inMemory()` for the length of one
|
||||
* turn and the history is rehydrated from these tables. If you are here to
|
||||
* "simplify" that by pointing the harness at its own store, this is the note
|
||||
* saying it was considered and refused.
|
||||
*
|
||||
* The transcript keeps evidence, not just prose. Reopening a conversation has
|
||||
* to show what Piggy DID — which tool ran, with what arguments, what came
|
||||
* back, what was proposed for approval and how the user answered — because the
|
||||
* product's whole claim is that an agent writing to a CRM is auditable. A
|
||||
* transcript that reloads as a wall of assistant text quietly withdraws it.
|
||||
* The authoritative record of a mutation is still the `activities` row written
|
||||
* by `executeMutation`; this is the record of the conversation that led to it.
|
||||
*/
|
||||
export const piggyConversations = pgTable(
|
||||
'piggy_conversations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
/**
|
||||
* The owner, and the only person who may read it. `cascade` rather than
|
||||
* the `set null` used for the audit-ish references above: an ownerless
|
||||
* transcript is not a historical record anyone can act on, it is a pile of
|
||||
* somebody's private questions with no one left to answer for them.
|
||||
*/
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Derived from the first user message, renameable. Never empty. */
|
||||
title: text('title').notNull(),
|
||||
|
||||
/**
|
||||
* The last turn's model and mode, so reopening a conversation restores the
|
||||
* picker rather than silently answering the next question on a different
|
||||
* model from the one the rest of the thread was answered by.
|
||||
*/
|
||||
model: text('model'),
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
/**
|
||||
* The capability the transcript itself requires, raised by the relay to
|
||||
* whatever the turn's context needed.
|
||||
*
|
||||
* Without this, a person demoted out of `economics:read` keeps a doorway
|
||||
* to supplier cost and break-even: they cannot ask for margin any more,
|
||||
* but yesterday's answer is still sitting in their history. The floor in
|
||||
* READ_RULES cannot see it, because a path-keyed table cannot know what a
|
||||
* particular conversation was told.
|
||||
*/
|
||||
readCapability: text('read_capability', { enum: READ_CAPABILITIES })
|
||||
.notNull()
|
||||
.default('book:read'),
|
||||
|
||||
/** Where the conversation was opened from, for the sidebar's subtitle. */
|
||||
context: jsonb('context').$type<PiggyChatContext>(),
|
||||
|
||||
/**
|
||||
* When the transcript last gained a message — the sidebar's sort key.
|
||||
* Separate from `updatedAt` on purpose: renaming a conversation is not
|
||||
* activity, and should not shuffle it to the top of somebody's history.
|
||||
*/
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/** The only listing query there is: my conversations, most recent first. */
|
||||
index('piggy_conversations_user_recent_idx').on(t.userId, t.lastMessageAt.desc()),
|
||||
check('piggy_conversations_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_conversations_read_capability_check',
|
||||
sql`${t.readCapability} IN (${inList(READ_CAPABILITIES)})`,
|
||||
),
|
||||
check('piggy_conversations_title_check', sql`length(${t.title}) > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per entry the transcript renders: a user message, an assistant
|
||||
* message, or a tool call together with its approval record.
|
||||
*
|
||||
* The tool call and its approval share a row deliberately. They are one event
|
||||
* to a reader — "Piggy offered to log a call, you approved it, it wrote" — and
|
||||
* splitting them across rows means a reload can show the offer without the
|
||||
* answer, which is the one rendering that misleads.
|
||||
*
|
||||
* Persistence is best-effort with respect to the mutation: a crash between the
|
||||
* write and this row loses the transcript entry, never the audit trail.
|
||||
*/
|
||||
export const piggyMessages = pgTable(
|
||||
'piggy_messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.notNull()
|
||||
.references(() => piggyConversations.id, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Position in the transcript. `created_at` is not enough: a tool call and
|
||||
* its result are written in the same millisecond, and a Date carries no
|
||||
* more precision than that, so ordering on the timestamp alone renders
|
||||
* them in an arbitrary order about a third of the time.
|
||||
*/
|
||||
seq: integer('seq').notNull(),
|
||||
|
||||
role: text('role', { enum: PIGGY_MESSAGE_ROLES }).notNull(),
|
||||
/** Empty for a tool row, whose content is the call and its result. */
|
||||
content: text('content').notNull().default(''),
|
||||
/** Kept for reasoning models: what it thought, as the transcript showed it. */
|
||||
reasoning: text('reasoning'),
|
||||
|
||||
/** Which model answered. It varies per turn now that the user chooses. */
|
||||
model: text('model'),
|
||||
/** The mode the turn ran in — an approval card means nothing without it. */
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
inputTokens: integer('input_tokens'),
|
||||
outputTokens: integer('output_tokens'),
|
||||
/** Micro-cents, matching `agent_runs.cost_micro_cents`. */
|
||||
costMicroCents: integer('cost_micro_cents'),
|
||||
/** `length` when the answer was cut short by the token budget. */
|
||||
finishReason: text('finish_reason'),
|
||||
|
||||
toolCallId: text('tool_call_id'),
|
||||
toolName: text('tool_name'),
|
||||
toolArguments: jsonb('tool_arguments').$type<Record<string, unknown>>(),
|
||||
toolResult: jsonb('tool_result').$type<Record<string, unknown>>(),
|
||||
toolOk: boolean('tool_ok'),
|
||||
|
||||
/**
|
||||
* The approval record, stored as it was shown rather than as a pointer to
|
||||
* the record it touched. The summary and field values a user actually
|
||||
* agreed to are the evidence; re-deriving them from the row later would
|
||||
* show what the record says now, which is a different claim.
|
||||
*/
|
||||
approvalId: text('approval_id'),
|
||||
approvalChange: jsonb('approval_change').$type<PiggyProposedChange>(),
|
||||
/** Null means never answered — a turn that timed out or was abandoned. */
|
||||
approvalDecision: text('approval_decision', { enum: PIGGY_APPROVAL_DECISIONS }),
|
||||
approvalDecidedAt: timestamp('approval_decided_at', { withTimezone: true }),
|
||||
|
||||
/** A failed turn or a failed tool. Shown, not swallowed. */
|
||||
error: text('error'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/**
|
||||
* Ordering and the append guard in one index. A conversation is always
|
||||
* read whole and in order, so this is also the only index it needs — its
|
||||
* leading column serves the `conversation_id` lookups on its own.
|
||||
*/
|
||||
uniqueIndex('piggy_messages_conversation_seq_key').on(t.conversationId, t.seq),
|
||||
check('piggy_messages_role_check', sql`${t.role} IN (${inList(PIGGY_MESSAGE_ROLES)})`),
|
||||
check('piggy_messages_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_messages_approval_decision_check',
|
||||
sql`${t.approvalDecision} IS NULL OR ${t.approvalDecision} IN (${inList(PIGGY_APPROVAL_DECISIONS)})`,
|
||||
),
|
||||
check('piggy_messages_seq_check', sql`${t.seq} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/** One execution of the agent, for cost accounting and debugging. */
|
||||
export const agentRuns = pgTable(
|
||||
'agent_runs',
|
||||
@@ -159,6 +385,18 @@ export const agentRuns = pgTable(
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
/**
|
||||
* The Piggy conversation this run answered, when it was one — which makes
|
||||
* "what has this thread cost?" a sum over this column rather than a join
|
||||
* through the transcript. `set null` because the spend happened whatever
|
||||
* became of the conversation: deleting a thread must not delete the record
|
||||
* of the credit it burned.
|
||||
*/
|
||||
piggyConversationId: uuid('piggy_conversation_id').references(
|
||||
() => piggyConversations.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
|
||||
/** Which agent — 'piggy', or a user's own connected client. */
|
||||
agent: text('agent').notNull().default('piggy'),
|
||||
/** The person on whose behalf this ran. */
|
||||
@@ -183,6 +421,8 @@ export const agentRuns = pgTable(
|
||||
(t) => [
|
||||
index('agent_runs_task_idx').on(t.agentTaskId),
|
||||
index('agent_runs_principal_idx').on(t.principalUserId),
|
||||
/** Per-conversation spend is one indexed scan, not a table sweep. */
|
||||
index('agent_runs_piggy_conversation_idx').on(t.piggyConversationId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -227,3 +467,7 @@ export type NewAgentTask = typeof agentTasks.$inferInsert;
|
||||
export type Fact = typeof facts.$inferSelect;
|
||||
export type NewFact = typeof facts.$inferInsert;
|
||||
export type AgentRun = typeof agentRuns.$inferSelect;
|
||||
export type PiggyConversation = typeof piggyConversations.$inferSelect;
|
||||
export type NewPiggyConversation = typeof piggyConversations.$inferInsert;
|
||||
export type PiggyMessage = typeof piggyMessages.$inferSelect;
|
||||
export type NewPiggyMessage = typeof piggyMessages.$inferInsert;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*
|
||||
* The FK cycle between `motion_templates.origin_artifact_id` and
|
||||
* `engagement_artifacts.template_id` is intentional — it is the loop — and it
|
||||
* is why migration `0014_motion.sql` is hand-written, adding one of the two
|
||||
* is why migration `0015_motion.sql` is hand-written, adding one of the two
|
||||
* constraints in a separate `ALTER TABLE` after both tables exist.
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
Reference in New Issue
Block a user