feat(agent): 支持持久化 /btw 旁路提问

This commit is contained in:
ji ruo committed 2026-09-10 21:42:25 +08:00
1 parent 8dae851b9b
commit 555539bc8e
34 files changed
+3744 -314

No files matched your search

+5
View File
@@ -377,6 +377,11 @@ func (s *ExplorationStore) StopIntentWithReason(id int64, reason, origin string)
WHERE id=$1 AND exploration_id=$2`, id, s.expID, string(newPayload)); err != nil {
return 0, err
}
// Soft deletion retains the main intent audit trail, but side conversations
// are removed atomically with the stopped state. Delayed snapshots reject it.
if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE intent_id=$1`, id); err != nil {
return 0, err
}
if origin == "" {
origin = "user"
+39
View File
@@ -1069,3 +1069,42 @@ CREATE TABLE IF NOT EXISTS server_logs (
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC);
-- Independent /btw history and the latest provider-ready main checkpoint.
CREATE TABLE IF NOT EXISTS side_question_sessions (
session_key TEXT PRIMARY KEY,
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE,
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
exploration_id BIGINT REFERENCES explorations(id) ON DELETE CASCADE,
intent_id BIGINT REFERENCES exploration_nodes(id) ON DELETE CASCADE,
run_id BIGINT NOT NULL,
version BIGINT NOT NULL,
snapshot JSONB NOT NULL,
generation BIGINT NOT NULL DEFAULT 0,
CHECK ((conversation_id IS NOT NULL AND task_id IS NULL AND exploration_id IS NULL AND intent_id IS NULL)
OR (conversation_id IS NULL AND task_id IS NOT NULL AND exploration_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_side_sessions_conv ON side_question_sessions(conversation_id);
CREATE INDEX IF NOT EXISTS idx_side_sessions_task ON side_question_sessions(task_id);
CREATE INDEX IF NOT EXISTS idx_side_sessions_exp ON side_question_sessions(exploration_id);
CREATE INDEX IF NOT EXISTS idx_side_sessions_intent ON side_question_sessions(intent_id);
CREATE TABLE IF NOT EXISTS side_question_requests (
id TEXT PRIMARY KEY,
ordinal BIGSERIAL UNIQUE,
session_key TEXT NOT NULL REFERENCES side_question_sessions(session_key) ON DELETE CASCADE,
generation BIGINT NOT NULL,
client_id TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('running','completed','failed','cancelled','interrupted')),
error TEXT NOT NULL DEFAULT '',
model JSONB NOT NULL,
snapshot_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sequence BIGINT NOT NULL DEFAULT 0,
usage JSONB NOT NULL DEFAULT '{}',
UNIQUE(session_key,generation,client_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_side_request_running ON side_question_requests(session_key) WHERE status='running';
CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(session_key,ordinal DESC);
+232
View File
@@ -0,0 +1,232 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/Autumn-27/artex/sidequestion"
"github.com/google/uuid"
)
var ErrSideBusy = errors.New("当前会话已有旁路问题正在回答")
var ErrSideParentGone = errors.New("旁路父会话已删除或归档")
// Lock the real parent before the side session, also covering soft task/intent
// deletion. A delayed checkpoint cannot recreate data after archive cleanup.
func lockSideParent(ctx context.Context, tx *sql.Tx, p sidequestion.Parent) error {
var id int64
var err error
if p.ConversationID > 0 {
err = tx.QueryRowContext(ctx, `SELECT id FROM conversations WHERE id=$1 FOR SHARE`, p.ConversationID).Scan(&id)
} else {
err = tx.QueryRowContext(ctx, `SELECT id FROM tasks WHERE id=$1 AND exploration_id=$2 AND deleted_at IS NULL AND archived_at IS NULL FOR SHARE`, p.TaskID, p.ExplorationID).Scan(&id)
if err == nil && p.IntentID > 0 {
err = tx.QueryRowContext(ctx, `SELECT id FROM exploration_nodes WHERE id=$1 AND exploration_id=$2 AND kind='intent' AND state<>'stopped' FOR SHARE`, p.IntentID, p.ExplorationID).Scan(&id)
}
}
if errors.Is(err, sql.ErrNoRows) {
return ErrSideParentGone
}
return err
}
func nullableSideID(id int64) any {
if id == 0 {
return nil
}
return id
}
func (d *DB) SaveSideSnapshot(ctx context.Context, s sidequestion.Snapshot) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockSideParent(ctx, tx, s.Parent); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO side_question_sessions(session_key,conversation_id,task_id,exploration_id,intent_id,run_id,version,snapshot)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(session_key) DO UPDATE SET run_id=EXCLUDED.run_id,version=EXCLUDED.version,snapshot=EXCLUDED.snapshot
WHERE (side_question_sessions.run_id,side_question_sessions.version)<(EXCLUDED.run_id,EXCLUDED.version)`,
s.Parent.Key(), nullableSideID(s.Parent.ConversationID), nullableSideID(s.Parent.TaskID), nullableSideID(s.Parent.ExplorationID), nullableSideID(s.Parent.IntentID), s.RunID, s.Version, string(b))
if err != nil {
return err
}
return tx.Commit()
}
func (d *DB) SideSnapshot(ctx context.Context, key string) (*sidequestion.Snapshot, error) {
var b []byte
err := d.QueryRowContext(ctx, `SELECT snapshot FROM side_question_sessions WHERE session_key=$1`, key).Scan(&b)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
var s sidequestion.Snapshot
err = json.Unmarshal(b, &s)
return &s, err
}
const sideCols = `id,ordinal,session_key,generation,client_id,question,answer,status,error,model,snapshot_at,created_at,sequence,usage`
func (d *DB) ExistingSideRequest(ctx context.Context, key, client string) (*sidequestion.Exchange, error) {
e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND client_id=$2 AND generation=(SELECT generation FROM side_question_sessions WHERE session_key=$1)`, key, client))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &e, err
}
func scanSide(row interface{ Scan(...any) error }) (sidequestion.Exchange, error) {
var e sidequestion.Exchange
var model, usage []byte
err := row.Scan(&e.ID, &e.Ordinal, &e.SessionKey, &e.Generation, &e.ClientID, &e.Question, &e.Answer, &e.Status, &e.Error, &model, &e.SnapshotAt, &e.CreatedAt, &e.Sequence, &usage)
if err != nil {
return e, err
}
if err = json.Unmarshal(model, &e.Model); err != nil {
return e, err
}
err = json.Unmarshal(usage, &e.Usage)
return e, err
}
func (d *DB) SideRequest(ctx context.Context, id string) (*sidequestion.Exchange, error) {
e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE id=$1`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &e, err
}
func (d *DB) CurrentSideRequest(ctx context.Context, key string) (*sidequestion.Exchange, error) {
e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND status='running'`, key))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &e, err
}
func (d *DB) SideHistory(ctx context.Context, key string, before int64, limit int) ([]sidequestion.Exchange, error) {
rows, err := d.QueryContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND ($2::bigint=0 OR ordinal<$2) ORDER BY ordinal DESC LIMIT $3`, key, before, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []sidequestion.Exchange{}
for rows.Next() {
e, err := scanSide(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) SideReplay(ctx context.Context, key string) ([]sidequestion.Exchange, error) {
rows, err := d.QueryContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND status='completed' ORDER BY ordinal DESC LIMIT 20`, key)
if err != nil {
return nil, err
}
defer rows.Close()
out := []sidequestion.Exchange{}
for rows.Next() {
e, err := scanSide(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
func (d *DB) StartSideRequest(ctx context.Context, s sidequestion.Snapshot, clientID, question string) (*sidequestion.Exchange, bool, error) {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return nil, false, err
}
defer tx.Rollback()
if err = lockSideParent(ctx, tx, s.Parent); err != nil {
return nil, false, err
}
var generation int64
if err = tx.QueryRowContext(ctx, `SELECT generation FROM side_question_sessions WHERE session_key=$1 FOR UPDATE`, s.Parent.Key()).Scan(&generation); err != nil {
return nil, false, err
}
e, err := scanSide(tx.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND generation=$2 AND client_id=$3`, s.Parent.Key(), generation, clientID))
if err == nil {
if e.Question != question {
return nil, false, fmt.Errorf("同一请求 ID 不能用于不同问题")
}
return &e, false, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, false, err
}
var busy bool
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM side_question_requests WHERE session_key=$1 AND status='running')`, s.Parent.Key()).Scan(&busy); err != nil {
return nil, false, err
}
if busy {
return nil, false, ErrSideBusy
}
model, err := json.Marshal(s.Model)
if err != nil {
return nil, false, err
}
e, err = scanSide(tx.QueryRowContext(ctx, `INSERT INTO side_question_requests(id,session_key,generation,client_id,question,status,model,snapshot_at) VALUES($1,$2,$3,$4,$5,'running',$6,$7) RETURNING `+sideCols, uuid.NewString(), s.Parent.Key(), generation, clientID, question, string(model), s.CapturedAt))
if err != nil {
return nil, false, err
}
return &e, true, tx.Commit()
}
// Conditional updates cannot resurrect deleted history or overwrite a terminal
// cancellation with a late provider callback.
func (d *DB) UpdateSideRequest(ctx context.Context, e sidequestion.Exchange) (bool, error) {
usage, err := json.Marshal(e.Usage)
if err != nil {
return false, err
}
r, err := d.ExecContext(ctx, `UPDATE side_question_requests r SET answer=$2,status=$3,error=$4,sequence=$5,usage=$6
WHERE r.id=$1 AND r.status='running' AND r.sequence<$5 AND EXISTS(SELECT 1 FROM side_question_sessions s WHERE s.session_key=r.session_key AND s.generation=r.generation)`, e.ID, e.Answer, e.Status, e.Error, e.Sequence, string(usage))
if err != nil {
return false, err
}
n, err := r.RowsAffected()
return n == 1, err
}
func (d *DB) ClearSideHistory(ctx context.Context, key string) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE side_question_sessions SET generation=generation+1 WHERE session_key=$1`, key); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM side_question_requests WHERE session_key=$1`, key); err != nil {
return err
}
return tx.Commit()
}
func (d *DB) InterruptSideRequests(ctx context.Context) error {
_, err := d.ExecContext(ctx, `UPDATE side_question_requests SET status='interrupted',error='服务重启,回答已中断',sequence=sequence+1 WHERE status='running'`)
return err
}
+270
View File
@@ -0,0 +1,270 @@
package db
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/Autumn-27/artex/sidequestion"
"github.com/Autumn-27/norma/llm"
)
func sideFixture(t *testing.T) (*DB, sidequestion.Snapshot) {
t.Helper()
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
c, err := d.CreateConversation("mainagent", "side persistence", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteConversation(c.ID) })
s := sidequestion.Snapshot{Parent: sidequestion.Parent{ConversationID: c.ID}, RunID: 1, Version: 1, CapturedAt: time.Now().UTC(), Model: sidequestion.Model{Model: "fixture"}, Request: llm.CompletionRequest{Messages: []llm.Message{llm.UserText("main-only")}}}
if err = d.SaveSideSnapshot(t.Context(), s); err != nil {
t.Fatal(err)
}
return d, s
}
func TestSideHistoryIdempotencyPagingAndRecovery(t *testing.T) {
d, s := sideFixture(t)
ctx := t.Context()
first, created, err := d.StartSideRequest(ctx, s, "request-0", "question-0")
if err != nil || !created {
t.Fatalf("start %v %v", created, err)
}
again, created, err := d.StartSideRequest(ctx, s, "request-0", "question-0")
if err != nil || created || again.ID != first.ID {
t.Fatalf("dedup %v %v", created, err)
}
if _, _, err = d.StartSideRequest(ctx, s, "request-0", "different"); err == nil {
t.Fatal("conflicting duplicate accepted")
}
if _, _, err = d.StartSideRequest(ctx, s, "request-1", "question-1"); !errors.Is(err, ErrSideBusy) {
t.Fatalf("busy %v", err)
}
for i := 0; i < 24; i++ {
e := first
if i > 0 {
e, _, err = d.StartSideRequest(ctx, s, fmt.Sprintf("request-%d", i), fmt.Sprintf("question-%d", i))
if err != nil {
t.Fatal(err)
}
}
e.Answer = fmt.Sprintf("answer-%d", i)
e.Sequence = 1
e.Status = "completed"
if ok, err := d.UpdateSideRequest(ctx, *e); err != nil || !ok {
t.Fatalf("finish %v %v", ok, err)
}
}
page, err := d.SideHistory(ctx, s.Parent.Key(), 0, 20)
if err != nil || len(page) != 20 {
t.Fatalf("page %d %v", len(page), err)
}
tail, err := d.SideHistory(ctx, s.Parent.Key(), page[19].Ordinal, 20)
if err != nil || len(tail) != 4 || tail[0].Ordinal >= page[19].Ordinal {
t.Fatalf("tail %+v %v", tail, err)
}
replay, err := d.SideReplay(ctx, s.Parent.Key())
if err != nil || len(replay) != 20 || replay[0].Question != "question-4" || replay[19].Question != "question-23" {
t.Fatalf("replay %+v %v", replay, err)
}
e, _, err := d.StartSideRequest(ctx, s, "unfinished", "partial question")
if err != nil {
t.Fatal(err)
}
e.Answer = "saved partial"
e.Sequence = 1
e.Usage.InputTokens = 17
if _, err = d.UpdateSideRequest(ctx, *e); err != nil {
t.Fatal(err)
}
if err = d.InterruptSideRequests(ctx); err != nil {
t.Fatal(err)
}
got, err := d.SideRequest(ctx, e.ID)
if err != nil || got.Status != "interrupted" || got.Answer != "saved partial" || got.Usage.InputTokens != 17 {
t.Fatalf("recovery %+v %v", got, err)
}
saved, err := d.SideSnapshot(ctx, s.Parent.Key())
if err != nil || saved.Request.Messages[0].Text() != "main-only" {
t.Fatalf("snapshot %+v %v", saved, err)
}
if _, _, err = d.StartSideRequest(ctx, *saved, "after-restart", "continue"); err != nil {
t.Fatal(err)
}
}
func TestSideClearLateWritersAndDeletedParent(t *testing.T) {
d, s := sideFixture(t)
ctx := t.Context()
for i := 0; i < 10; i++ {
e, _, err := d.StartSideRequest(ctx, s, "same-client", "question")
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if err := d.ClearSideHistory(ctx, s.Parent.Key()); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
copy := *e
copy.Sequence = 1
copy.Answer = "late"
copy.Status = "completed"
if _, err := d.UpdateSideRequest(ctx, copy); err != nil {
t.Error(err)
}
}()
wg.Wait()
if row, err := d.SideRequest(ctx, e.ID); err != nil || row != nil {
t.Fatalf("cleared answer resurrected: %+v %v", row, err)
}
e.Sequence = 2
e.Status = "completed"
if ok, err := d.UpdateSideRequest(ctx, *e); err != nil || ok {
t.Fatalf("late update %v %v", ok, err)
}
}
s.Version = 3
if err := d.SaveSideSnapshot(ctx, s); err != nil {
t.Fatal(err)
}
s.Version = 2
if err := d.SaveSideSnapshot(ctx, s); err != nil {
t.Fatal(err)
}
got, err := d.SideSnapshot(ctx, s.Parent.Key())
if err != nil || got.Version != 3 {
t.Fatalf("older version won: %+v %v", got, err)
}
if err = d.DeleteConversation(s.Parent.ConversationID); err != nil {
t.Fatal(err)
}
if err = d.SaveSideSnapshot(ctx, s); !errors.Is(err, ErrSideParentGone) {
t.Fatalf("deleted parent restored: %v", err)
}
if got, err = d.SideSnapshot(ctx, s.Parent.Key()); err != nil || got != nil {
t.Fatalf("delete cascade: %+v %v", got, err)
}
}
func TestSideTaskArchiveVersions(t *testing.T) {
for _, version := range []int{1, 2, 3} {
t.Run(fmt.Sprint(version), func(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.EnsureLLMRecordsTable(); err != nil {
t.Fatal(err)
}
if err := d.EnsureLLMUsageTable(); err != nil {
t.Fatal(err)
}
task, err := d.CreateTask("btw archive", "restore context", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = d.Exec(`DELETE FROM task_archives WHERE task_id=$1`, task.ID)
_ = d.DeleteTask(task.ID)
}()
iid, err := d.Exploration(task.ExplorationID).AddNode(KindIntent, map[string]any{"summary": "worker"}, 1, "paused", "planner", nil)
if err != nil {
t.Fatal(err)
}
var snapshots []sidequestion.Snapshot
for _, intent := range []int64{0, iid} {
s := sidequestion.Snapshot{Parent: sidequestion.Parent{TaskID: task.ID, ExplorationID: task.ExplorationID, IntentID: intent}, RunID: 1, Version: 2, CapturedAt: time.Now().UTC(), Request: llm.CompletionRequest{Messages: []llm.Message{llm.UserText("archived main context")}}}
if err = d.SaveSideSnapshot(t.Context(), s); err != nil {
t.Fatal(err)
}
e, _, err := d.StartSideRequest(t.Context(), s, "client", "archive question")
if err != nil {
t.Fatal(err)
}
e.Answer = "archive answer"
e.Sequence = 1
e.Status = "completed"
if _, err = d.UpdateSideRequest(t.Context(), *e); err != nil {
t.Fatal(err)
}
snapshots = append(snapshots, s)
}
if err := d.SetPaused(task.ID, true); err != nil {
t.Fatal(err)
}
job, err := d.QueueTaskArchive(task.ID)
if err != nil {
t.Fatal(err)
}
if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil {
t.Fatal(err)
}
archive, err := d.SnapshotTaskArchive(task.ID)
if err != nil {
t.Fatal(err)
}
if archive.FormatVersion != 3 || archive.DataCounts["side_question_sessions"] != 2 || archive.DataCounts["side_question_requests"] != 2 {
t.Fatalf("missing side archive: %+v", archive.DataCounts)
}
if err = d.CompleteTaskArchive(job.ID, archive, "/tmp/side-fixture.tar.zst", "fixture", 1, 1); err != nil {
t.Fatal(err)
}
if err = d.SaveSideSnapshot(t.Context(), snapshots[0]); !errors.Is(err, ErrSideParentGone) {
t.Fatalf("late archived snapshot: %v", err)
}
if got, err := d.SideSnapshot(t.Context(), snapshots[0].Parent.Key()); err != nil || got != nil {
t.Fatalf("archive retained hot snapshot %+v %v", got, err)
}
if _, err = d.QueueTaskArchiveRestore(job.ID); err != nil {
t.Fatal(err)
}
if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil {
t.Fatal(err)
}
archive.FormatVersion = version
if version < 3 {
delete(archive.Tables, "side_question_sessions")
delete(archive.Tables, "side_question_requests")
delete(archive.DataCounts, "side_question_sessions")
delete(archive.DataCounts, "side_question_requests")
}
if _, err = d.RestoreTaskArchive(job.ID, archive, 0); err != nil {
t.Fatal(err)
}
for _, s := range snapshots {
got, err := d.SideSnapshot(context.Background(), s.Parent.Key())
if err != nil {
t.Fatal(err)
}
if version < 3 {
if got != nil {
t.Fatal("legacy archive fabricated snapshot")
}
} else {
if got == nil || got.Request.Messages[0].Text() != "archived main context" {
t.Fatalf("restored snapshot: %+v", got)
}
history, err := d.SideHistory(t.Context(), s.Parent.Key(), 0, 20)
if err != nil || len(history) != 1 || history[0].Answer != "archive answer" {
t.Fatalf("restored history %+v %v", history, err)
}
}
}
})
}
}
+3 -1
View File
@@ -16,7 +16,7 @@ import (
)
const (
TaskArchiveFormatVersion = 2
TaskArchiveFormatVersion = 3
TaskArchiveLegacyFormatVersion = 1
TaskArchiveLLMRecordsPath = "database/llm_records.ndjson"
)
@@ -592,6 +592,8 @@ func (d *DB) snapshotTaskArchive(taskID int64, llmRecords io.Writer) (*TaskArchi
{"skill_usage", `SELECT * FROM skill_usage WHERE task_id=$1 OR exploration_id=$2 ORDER BY id`, []any{taskID, expID}},
{"tool_usage", `SELECT * FROM tool_usage WHERE task_id=$1 OR exploration_id=$2 ORDER BY id`, []any{taskID, expID}},
{"intercept_pending", `SELECT * FROM intercept_pending WHERE COALESCE(task_id,'')=$1 ORDER BY id`, []any{strconv.FormatInt(taskID, 10)}},
{"side_question_sessions", `SELECT * FROM side_question_sessions WHERE task_id=$1 ORDER BY session_key`, []any{taskID}},
{"side_question_requests", `SELECT r.* FROM side_question_requests r JOIN side_question_sessions s ON s.session_key=r.session_key WHERE s.task_id=$1 ORDER BY r.ordinal`, []any{taskID}},
{"assets", `SELECT asset.* FROM assets asset WHERE asset.id IN (` + archiveAssetIDsQuery() + `) ORDER BY asset.id`, []any{taskID, expID}},
}
counts := make(map[string]int64, len(queries))
+9
View File
@@ -78,6 +78,9 @@ WHERE relation.source_task_id=$1 LIMIT 1`, taskID).Scan(&dependent)
return err
}
}
if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE task_id=$1`, taskID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID); err != nil {
return err
}
@@ -245,6 +248,11 @@ WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID
} else {
warnings = append(warnings, warning...)
}
for _, table := range []string{"side_question_sessions", "side_question_requests"} {
if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil {
return nil, fmt.Errorf("restore %s: %w", table, err)
}
}
if warning, err := restoreTaskScopes(tx, remappedTables["task_scope"]); err != nil {
return nil, err
} else {
@@ -351,6 +359,7 @@ func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error {
"exploration_nodes": true, "exploration_edges": true, "exploration_anchors": true,
"task_constraints": true, "activity": true, "task_asset_links": true, "findings": true,
"llm_records": true, "llm_usage": true, "skill_usage": true, "tool_usage": true,
"side_question_sessions": true, "side_question_requests": true,
}
if !allowed[table] {
return fmt.Errorf("archive restore table %q is not allowed", table)