Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

490 lines
19 KiB
Go

package storesqlite
import (
"context"
"database/sql"
"fmt"
)
// applyWorkspaceAgentActivityTurnsV1 creates the protocol v2 turn and
// interaction entities and backfills historical turn records from message
// turn ids.
//
// Backfill semantics (agent-gui refactor plan, rule nine adjacent):
// - every distinct non-empty message turn_id becomes a settled turn;
// - outcome defaults to completed; the newest turn of a failed/canceled
// session inherits that session outcome (older turns of the same
// session had to complete for a newer one to exist);
// - reruns are harmless: inserts use INSERT OR IGNORE and the outcome
// repair only touches rows the insert created (settled + backfill
// marker), so live rows written after the migration are never touched.
func (s *Store) applyWorkspaceAgentActivityTurnsV1(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityTurnsV1)
if err != nil {
return err
}
if applied {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin workspace agent activity turns v1: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if _, err := tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS workspace_agent_turns (
workspace_id TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
turn_id TEXT NOT NULL CHECK (length(turn_id) > 0),
identity_anchor_turn_id TEXT
CHECK (identity_anchor_turn_id IS NULL OR
(length(trim(identity_anchor_turn_id)) > 0 AND identity_anchor_turn_id != turn_id)),
phase TEXT NOT NULL CHECK (phase IN ('submitted','running','waiting','settling','settled')),
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('completed','failed','canceled','interrupted')),
error_json TEXT,
file_changes_json TEXT,
completed_command_json TEXT,
backfilled INTEGER NOT NULL DEFAULT 0,
turn_origin TEXT NOT NULL DEFAULT 'legacy_unknown'
CHECK (turn_origin IN ('user_prompt','goal_arm','goal_continuation','provider_initiated','legacy_unknown')),
source_goal_operation_id TEXT,
source_goal_revision INTEGER CHECK (source_goal_revision IS NULL OR source_goal_revision >= 0),
source_goal_repair_epoch INTEGER CHECK (source_goal_repair_epoch IS NULL OR source_goal_repair_epoch >= 0),
started_at_unix_ms INTEGER NOT NULL DEFAULT 0,
settled_at_unix_ms INTEGER,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
root_provider_turn_id TEXT,
provider_turn_binding_json TEXT NOT NULL DEFAULT '{}'
CHECK (json_valid(provider_turn_binding_json) AND json_type(provider_turn_binding_json) = 'object'),
root_provider_turn_phase TEXT CHECK (root_provider_turn_phase IS NULL OR root_provider_turn_phase IN ('running','completed')),
root_provider_turn_outcome TEXT CHECK (root_provider_turn_outcome IS NULL OR root_provider_turn_outcome IN ('completed','failed','canceled','interrupted')),
root_provider_turn_error_json TEXT,
root_provider_turn_completed_command_json TEXT,
root_provider_turn_updated_at_unix_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (workspace_id, agent_session_id, turn_id),
FOREIGN KEY (workspace_id, agent_session_id)
REFERENCES workspace_agent_sessions(workspace_id, agent_session_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workspace_agent_turns_session_phase
ON workspace_agent_turns(workspace_id, agent_session_id, phase);
CREATE TABLE IF NOT EXISTS workspace_agent_interactions (
workspace_id TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
request_id TEXT NOT NULL CHECK (length(request_id) > 0),
turn_id TEXT NOT NULL CHECK (length(turn_id) > 0),
kind TEXT NOT NULL CHECK (kind IN ('approval','question','plan')),
status TEXT NOT NULL CHECK (status IN ('pending','answered','superseded')),
tool_name TEXT NOT NULL DEFAULT '',
input_json TEXT NOT NULL DEFAULT '{}',
output_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
PRIMARY KEY (workspace_id, agent_session_id, turn_id, request_id),
FOREIGN KEY (workspace_id, agent_session_id, turn_id)
REFERENCES workspace_agent_turns(workspace_id, agent_session_id, turn_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workspace_agent_interactions_session_status
ON workspace_agent_interactions(workspace_id, agent_session_id, status);
`); err != nil {
return fmt.Errorf("migrate workspace agent activity turns v1: %w", err)
}
hasActiveTurnID, err := hasColumnTx(ctx, tx, "workspace_agent_sessions", "active_turn_id")
if err != nil {
return err
}
if !hasActiveTurnID {
if _, err := tx.ExecContext(ctx, `ALTER TABLE workspace_agent_sessions ADD COLUMN active_turn_id TEXT;`); err != nil {
return fmt.Errorf("migrate workspace agent sessions active turn id: %w", err)
}
}
if err := backfillWorkspaceAgentTurnsTx(ctx, tx); err != nil {
return err
}
if err := recordMigrationTx(ctx, tx, schemaMigrationWorkspaceAgentActivityTurnsV1); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit workspace agent activity turns v1: %w", err)
}
committed = true
return nil
}
func (s *Store) applyWorkspaceAgentActivityInteractionsV2(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityInteractionsV2)
if err != nil || applied {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin workspace agent interactions v2: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
CREATE TABLE workspace_agent_interactions_v2 (
workspace_id TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
request_id TEXT NOT NULL CHECK (length(request_id) > 0),
turn_id TEXT NOT NULL CHECK (length(turn_id) > 0),
kind TEXT NOT NULL CHECK (kind IN ('approval','question','plan')),
status TEXT NOT NULL CHECK (status IN ('pending','answered','superseded')),
tool_name TEXT NOT NULL DEFAULT '',
input_json TEXT NOT NULL DEFAULT '{}',
output_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
PRIMARY KEY (workspace_id, agent_session_id, turn_id, request_id),
FOREIGN KEY (workspace_id, agent_session_id, turn_id)
REFERENCES workspace_agent_turns(workspace_id, agent_session_id, turn_id)
ON DELETE CASCADE
);
INSERT INTO workspace_agent_interactions_v2 (
workspace_id, agent_session_id, request_id, turn_id, kind, status, tool_name,
input_json, output_json, metadata_json, created_at_unix_ms, updated_at_unix_ms
)
SELECT workspace_id, agent_session_id, request_id, turn_id, kind, status, tool_name,
input_json, output_json, metadata_json, created_at_unix_ms, updated_at_unix_ms
FROM workspace_agent_interactions;
DROP TABLE workspace_agent_interactions;
ALTER TABLE workspace_agent_interactions_v2 RENAME TO workspace_agent_interactions;
CREATE INDEX idx_workspace_agent_interactions_session_status
ON workspace_agent_interactions(workspace_id, agent_session_id, status);
`); err != nil {
return fmt.Errorf("migrate workspace agent interactions v2: %w", err)
}
if err := recordMigrationTx(ctx, tx, schemaMigrationWorkspaceAgentActivityInteractionsV2); err != nil {
return err
}
return tx.Commit()
}
func backfillWorkspaceAgentTurnsTx(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
INSERT OR IGNORE INTO workspace_agent_turns (
workspace_id, agent_session_id, turn_id, phase, outcome, backfilled,
started_at_unix_ms, settled_at_unix_ms, created_at_unix_ms, updated_at_unix_ms
)
SELECT
message_times.workspace_id,
message_times.agent_session_id,
message_times.turn_id,
'settled',
'completed',
1,
COALESCE(
MIN(CASE WHEN message_times.role = 'user' THEN message_times.started_at_unix_ms END),
MIN(message_times.started_at_unix_ms)
),
MAX(message_times.settled_at_unix_ms),
COALESCE(
MIN(CASE WHEN message_times.role = 'user' THEN message_times.started_at_unix_ms END),
MIN(message_times.started_at_unix_ms)
),
MAX(message_times.settled_at_unix_ms)
FROM (
SELECT
m.workspace_id,
m.agent_session_id,
TRIM(m.turn_id) AS turn_id,
m.role,
CASE
WHEN m.started_at_unix_ms > 0 THEN m.started_at_unix_ms
WHEN m.occurred_at_unix_ms > 0 THEN m.occurred_at_unix_ms
ELSE m.created_at_unix_ms
END AS started_at_unix_ms,
CASE
WHEN m.completed_at_unix_ms > 0 THEN m.completed_at_unix_ms
WHEN m.occurred_at_unix_ms > 0 THEN m.occurred_at_unix_ms
WHEN m.updated_at_unix_ms > 0 THEN m.updated_at_unix_ms
ELSE m.created_at_unix_ms
END AS settled_at_unix_ms
FROM workspace_agent_messages m
INNER JOIN workspace_agent_sessions s
ON s.workspace_id = m.workspace_id
AND s.agent_session_id = m.agent_session_id
WHERE TRIM(m.turn_id) != ''
AND m.deleted_at_unix_ms = 0
AND s.deleted_at_unix_ms = 0
) AS message_times
GROUP BY message_times.workspace_id, message_times.agent_session_id, message_times.turn_id
`); err != nil {
return fmt.Errorf("backfill workspace agent turns: %w", err)
}
// Claimed legacy databases may still carry a pre-activity sessions table
// without a status column; those sessions have no terminal status to
// inherit, so the outcome repair is skipped.
hasStatus, err := hasColumnTx(ctx, tx, "workspace_agent_sessions", "status")
if err != nil {
return err
}
if !hasStatus {
return nil
}
// The newest backfilled turn of a failed/canceled session inherits the
// session outcome; ties on settled_at fall back to lexically greatest
// turn id for determinism.
if _, err := tx.ExecContext(ctx, `
UPDATE workspace_agent_turns
SET outcome = (
SELECT CASE s.status WHEN 'failed' THEN 'failed' ELSE 'canceled' END
FROM workspace_agent_sessions s
WHERE s.workspace_id = workspace_agent_turns.workspace_id
AND s.agent_session_id = workspace_agent_turns.agent_session_id
)
WHERE backfilled = 1
AND EXISTS (
SELECT 1
FROM workspace_agent_sessions s
WHERE s.workspace_id = workspace_agent_turns.workspace_id
AND s.agent_session_id = workspace_agent_turns.agent_session_id
AND s.status IN ('failed', 'canceled')
)
AND NOT EXISTS (
SELECT 1
FROM workspace_agent_turns newer
WHERE newer.workspace_id = workspace_agent_turns.workspace_id
AND newer.agent_session_id = workspace_agent_turns.agent_session_id
AND newer.backfilled = 1
AND (
newer.settled_at_unix_ms > workspace_agent_turns.settled_at_unix_ms
OR (
newer.settled_at_unix_ms = workspace_agent_turns.settled_at_unix_ms
AND newer.turn_id > workspace_agent_turns.turn_id
)
)
)
`); err != nil {
return fmt.Errorf("repair backfilled workspace agent turn outcomes: %w", err)
}
return nil
}
func hasColumnTx(ctx context.Context, tx *sql.Tx, tableName string, columnName string) (bool, error) {
rows, err := tx.QueryContext(ctx, `SELECT name FROM pragma_table_info(?)`, tableName)
if err != nil {
return false, fmt.Errorf("inspect table %s columns: %w", tableName, err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, fmt.Errorf("scan table %s column: %w", tableName, err)
}
if name == columnName {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("inspect table %s columns: %w", tableName, err)
}
return false, nil
}
// applyWorkspaceAgentActivityMessagesV2 rebuilds workspace_agent_messages so
// message ownership is an explicit choice (protocol v2 rule eight): turn_id
// is either a non-empty turn reference or NULL for session-level messages.
// Historical empty strings are normalized to NULL — "attribution unknown" is
// honestly expressed as session-level instead of faked.
func (s *Store) applyWorkspaceAgentActivityMessagesV2(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityMessagesV2)
if err != nil {
return err
}
if applied {
return nil
}
if err := s.ensureWorkspaceAgentMessageSemanticsColumn(ctx); err != nil {
return err
}
return s.rebuildWorkspaceAgentMessagesV2(ctx)
}
// rebuildWorkspaceAgentMessagesV2 performs the table rebuild on a dedicated
// connection and releases it before returning, so the migration ledger write
// that follows can obtain a pool connection even with a single-connection
// pool.
func (s *Store) rebuildWorkspaceAgentMessagesV2(ctx context.Context) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return fmt.Errorf("open connection for workspace agent messages v2: %w", err)
}
defer conn.Close()
// The rebuild follows the documented SQLite table-rebuild procedure:
// disable FK enforcement on this connection, rebuild inside one
// transaction, re-enable enforcement.
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF;`); err != nil {
return fmt.Errorf("disable foreign keys for workspace agent messages v2: %w", err)
}
defer func() {
_, _ = conn.ExecContext(ctx, `PRAGMA foreign_keys = ON;`)
}()
return runInConnTx(ctx, conn, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
CREATE TABLE workspace_agent_messages_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
message_id TEXT NOT NULL CHECK (length(message_id) > 0),
version INTEGER NOT NULL CHECK (version > 0),
turn_id TEXT CHECK (turn_id IS NULL OR length(turn_id) > 0),
role TEXT NOT NULL CHECK (length(role) > 0),
kind TEXT NOT NULL CHECK (length(kind) > 0),
status TEXT NOT NULL DEFAULT '',
semantics_json TEXT NOT NULL DEFAULT 'null' CHECK (json_valid(semantics_json)),
payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(payload_json) AND json_type(payload_json) = 'object'),
occurred_at_unix_ms INTEGER NOT NULL DEFAULT 0 CHECK (occurred_at_unix_ms >= 0),
started_at_unix_ms INTEGER NOT NULL DEFAULT 0 CHECK (started_at_unix_ms >= 0),
completed_at_unix_ms INTEGER NOT NULL DEFAULT 0 CHECK (completed_at_unix_ms >= 0),
deleted_at_unix_ms INTEGER NOT NULL DEFAULT 0 CHECK (deleted_at_unix_ms >= 0),
created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms >= 0),
updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= 0),
UNIQUE (workspace_id, agent_session_id, message_id),
FOREIGN KEY (workspace_id, agent_session_id)
REFERENCES workspace_agent_sessions(workspace_id, agent_session_id)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, agent_session_id, turn_id)
REFERENCES workspace_agent_turns(workspace_id, agent_session_id, turn_id)
);
`); err != nil {
return fmt.Errorf("create workspace agent messages v2 table: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO workspace_agent_messages_v2 (
id, workspace_id, agent_session_id, message_id, version, turn_id, role, kind, status,
semantics_json, payload_json, occurred_at_unix_ms, started_at_unix_ms, completed_at_unix_ms,
deleted_at_unix_ms, created_at_unix_ms, updated_at_unix_ms
)
SELECT
id, workspace_id, agent_session_id, message_id, version,
CASE
WHEN NULLIF(TRIM(turn_id), '') IS NULL THEN NULL
WHEN EXISTS (
SELECT 1
FROM workspace_agent_turns turn_parent
WHERE turn_parent.workspace_id = workspace_agent_messages.workspace_id
AND turn_parent.agent_session_id = workspace_agent_messages.agent_session_id
AND turn_parent.turn_id = TRIM(workspace_agent_messages.turn_id)
) THEN TRIM(turn_id)
ELSE NULL
END,
role, kind, status,
semantics_json, payload_json, occurred_at_unix_ms, started_at_unix_ms, completed_at_unix_ms,
deleted_at_unix_ms, created_at_unix_ms, updated_at_unix_ms
FROM workspace_agent_messages
`); err != nil {
return fmt.Errorf("copy workspace agent messages into v2 table: %w", err)
}
if _, err := tx.ExecContext(ctx, `DROP TABLE workspace_agent_messages;`); err != nil {
return fmt.Errorf("drop legacy workspace agent messages table: %w", err)
}
if _, err := tx.ExecContext(ctx, `ALTER TABLE workspace_agent_messages_v2 RENAME TO workspace_agent_messages;`); err != nil {
return fmt.Errorf("rename workspace agent messages v2 table: %w", err)
}
if _, err := tx.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_workspace_agent_messages_session_version
ON workspace_agent_messages(workspace_id, agent_session_id, deleted_at_unix_ms, version);
CREATE INDEX IF NOT EXISTS idx_workspace_agent_messages_session_display
ON workspace_agent_messages(workspace_id, agent_session_id, deleted_at_unix_ms, id);
`); err != nil {
return fmt.Errorf("recreate workspace agent message indexes: %w", err)
}
return recordMigrationTx(ctx, tx, schemaMigrationWorkspaceAgentActivityMessagesV2)
})
}
// applyWorkspaceAgentActivityTurnIntegrityV1 repairs child references written
// by the original messages-v2 rebuild and adds the index used by conversation
// rail latest-turn ordering.
func (s *Store) applyWorkspaceAgentActivityTurnIntegrityV1(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityTurnIntegrityV1)
if err != nil || applied {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin workspace agent activity turn integrity v1: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if _, err := tx.ExecContext(ctx, `
UPDATE workspace_agent_messages
SET turn_id = NULL
WHERE turn_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_agent_turns turn_parent
WHERE turn_parent.workspace_id = workspace_agent_messages.workspace_id
AND turn_parent.agent_session_id = workspace_agent_messages.agent_session_id
AND turn_parent.turn_id = workspace_agent_messages.turn_id
);
CREATE INDEX IF NOT EXISTS idx_workspace_agent_turns_session_latest
ON workspace_agent_turns(
workspace_id,
agent_session_id,
updated_at_unix_ms DESC,
created_at_unix_ms DESC,
started_at_unix_ms DESC,
turn_id DESC
);
`); err != nil {
return fmt.Errorf("apply workspace agent activity turn integrity v1: %w", err)
}
if err := recordMigrationTx(ctx, tx, schemaMigrationWorkspaceAgentActivityTurnIntegrityV1); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit workspace agent activity turn integrity v1: %w", err)
}
committed = true
return nil
}
func runInConnTx(ctx context.Context, conn *sql.Conn, fn func(*sql.Tx) error) error {
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin workspace agent messages v2 rebuild: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit workspace agent messages v2 rebuild: %w", err)
}
committed = true
return nil
}