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:
2026-08-17 18:30:45 -07:00
149 changed files with 37440 additions and 3502 deletions
+244
View File
@@ -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;
+1 -1
View File
@@ -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';