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
@@ -0,0 +1,68 @@
-- Generated by drizzle-kit, then trimmed. Three statements it emitted are not
-- here, and deleting them is the point rather than an oversight.
--
-- 0005, 0012 and 0013 were hand-written and left no snapshot, so drizzle
-- diffed against 0011 and re-proposed their work: `ALTER TYPE pig_team_role
-- ADD VALUE 'viewer'` (0012, and without the IF NOT EXISTS that made that one
-- safe) and a drop-and-add of the learn_resources provider CHECK (0013). On
-- any database those migrations have already touched, the first fails outright
-- and the second is a needless drop of a live constraint. The 0014 snapshot
-- beside this file is kept in full, because it describes the schema as it
-- actually is — which is what re-synchronises the snapshot chain with the
-- hand-written migrations for whoever generates 0015.
--
-- Everything below is additive: two new tables and one new nullable column.
-- Nothing here reads or rewrites an existing row, so it cannot fail on data.
CREATE TABLE "piggy_conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"model" text,
"mode" text,
"read_capability" text DEFAULT 'book:read' NOT NULL,
"context" jsonb,
"last_message_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "piggy_conversations_mode_check" CHECK ("piggy_conversations"."mode" IS NULL OR "piggy_conversations"."mode" IN ('read_only', 'confirm', 'auto')),
CONSTRAINT "piggy_conversations_read_capability_check" CHECK ("piggy_conversations"."read_capability" IN ('book:read', 'economics:read', 'team:read')),
CONSTRAINT "piggy_conversations_title_check" CHECK (length("piggy_conversations"."title") > 0)
);
--> statement-breakpoint
CREATE TABLE "piggy_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"seq" integer NOT NULL,
"role" text NOT NULL,
"content" text DEFAULT '' NOT NULL,
"reasoning" text,
"model" text,
"mode" text,
"input_tokens" integer,
"output_tokens" integer,
"cost_micro_cents" integer,
"finish_reason" text,
"tool_call_id" text,
"tool_name" text,
"tool_arguments" jsonb,
"tool_result" jsonb,
"tool_ok" boolean,
"approval_id" text,
"approval_change" jsonb,
"approval_decision" text,
"approval_decided_at" timestamp with time zone,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "piggy_messages_role_check" CHECK ("piggy_messages"."role" IN ('user', 'assistant', 'tool')),
CONSTRAINT "piggy_messages_mode_check" CHECK ("piggy_messages"."mode" IS NULL OR "piggy_messages"."mode" IN ('read_only', 'confirm', 'auto')),
CONSTRAINT "piggy_messages_approval_decision_check" CHECK ("piggy_messages"."approval_decision" IS NULL OR "piggy_messages"."approval_decision" IN ('apply', 'reject')),
CONSTRAINT "piggy_messages_seq_check" CHECK ("piggy_messages"."seq" >= 0)
);
--> statement-breakpoint
ALTER TABLE "agent_runs" ADD COLUMN "piggy_conversation_id" uuid;--> statement-breakpoint
ALTER TABLE "piggy_conversations" ADD CONSTRAINT "piggy_conversations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "piggy_messages" ADD CONSTRAINT "piggy_messages_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_piggy_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("piggy_conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "piggy_conversations_user_recent_idx" ON "piggy_conversations" USING btree ("user_id","last_message_at" DESC NULLS LAST);--> statement-breakpoint
CREATE UNIQUE INDEX "piggy_messages_conversation_seq_key" ON "piggy_messages" USING btree ("conversation_id","seq");--> statement-breakpoint
CREATE INDEX "agent_runs_piggy_conversation_idx" ON "agent_runs" USING btree ("piggy_conversation_id");
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -104,7 +104,14 @@
"idx": 14,
"version": "7",
"when": 1786800000000,
"tag": "0014_motion",
"tag": "0014_piggy_conversations",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1786900000000,
"tag": "0015_motion",
"breakpoints": true
}
]
+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';
+22 -3
View File
@@ -46,8 +46,9 @@
* always run in; sections depend on ids the earlier ones return.
*/
import { quarterBoundsFor } from '@pig/core';
import { sql } from 'drizzle-orm';
import { createDatabase, type Database } from '../../client';
import { users } from '../../schema/index';
import { capacityCommitments, demandDeals, supplyDeals, users } from '../../schema/index';
import { seedSupplyActivities } from './activities';
import { seedFacts } from './agent';
import { seedCalendar } from './calendar';
@@ -182,14 +183,32 @@ export async function seedDemo(context: DemoContext): Promise<void> {
const facts = await seedFacts(context);
console.log(' 5 capacity commitments (4 live, 1 lapsed), with sites, MSAs and negotiated SLAs');
/*
* Counted, not asserted. These lines were hardcoded when the book was split
* into modules, and they drifted the moment the seed grew: the summary
* claimed 12 demand deals and 5 commitments against a database holding 13
* and 6. A seed that misreports what it wrote teaches an operator to
* distrust the only feedback the command gives them.
*/
const [written] = await context.db
.select({
commitments: sql<number>`(select count(*)::int from ${capacityCommitments})`,
demandDeals: sql<number>`(select count(*)::int from ${demandDeals})`,
supplyDeals: sql<number>`(select count(*)::int from ${supplyDeals})`,
demandStages: sql<number>`(select count(distinct stage)::int from ${demandDeals})`,
})
.from(sql`(select 1) as one`);
const book = written ?? { commitments: 0, demandDeals: 0, supplyDeals: 0, demandStages: 0 };
console.log(` ${book.commitments} capacity commitments, with sites, MSAs and negotiated SLAs`);
console.log(` ${facts.total} agent-derived facts (${facts.added} new) — 2 applied, 4 awaiting review`);
console.log(
` ${facts.tasks} agent tasks and ${facts.runs} Piggy runs, ${facts.actions} idempotency-keyed actions, ` +
`${(facts.costMicroCents / 1_000_000).toFixed(4)} cents of model spend`,
);
console.log(
' 12 demand deals across all ten stages — 2 won, 1 lost, 1 expansion off a closed parent — and 8 supply deals',
` ${book.demandDeals} demand deals across ${book.demandStages} stages — including a won parent with its ` +
`expansion child, and a loss with a reason — and ${book.supplyDeals} supply deals`,
);
console.log(' Allocations including one unconverted hold and internal research burn');
console.log(
+7 -2
View File
@@ -226,9 +226,14 @@ export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
},
{
slug: 'piggy-and-its-boundary',
title: 'Piggy, and what it will not do',
title: 'Piggy, and where it stops',
// This said Piggy had "no ability to write CRM records" — the headline
// capability of the release it ships beside, denied on the one track a
// public share code opens, two clicks from a front door reading "Piggy can
// change it, with your approval." The boundary is real and still worth
// stating; it is approval, not incapability.
summary:
'The docked agent reads through scoped, page-specific PIG tools — and has no shell, no filesystem, and no ability to write CRM records.',
'The docked agent reads through scoped, page-specific PIG tools, and proposes any change as a card that only a person can apply — no shell, no filesystem, and nothing written without a human pressing Apply.',
track: 'platform',
visibility: 'code',
durationSeconds: 28,