mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.
Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
its request row) and target_gate_exemption_log. All DDL is idempotent; no
CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
find-or-create of a request, and ResolveTargetRequest, which settles the
row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
target requests in the delete transaction. DeleteConversation and
DeleteConversations revoke the pins of the removed conversations in the
same transaction as the delete.
Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
through create and patch; exposed in the task DTO. A retest conversation
takes the flag of the finding's task, never its pins. A spawned task starts
with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
TargetScope now carries the Interceptor that owns the gate.
Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
channel, poll, context and wait bound. A plain request is never settled by
a call that leaves. A request merged with an ask is bound to its creating
call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
address for every human decision, classic asks included. On a target
request "allowed" pins the displayed addresses; that is the only writer of
pins.
- A request raised while the global egress proxy is set says the approval is
name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
/api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
unicast, never loopback, link-local or metadata, unspecified or multicast.
806 lines
29 KiB
Go
806 lines
29 KiB
Go
package db
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CompleteTaskArchive performs the hot-store compaction only after the external
|
|
// package has been fully written and checksummed. The task/exploration rows remain
|
|
// as minimal ID stubs; all heavyweight task-owned rows move into the package.
|
|
func (d *DB) CompleteTaskArchive(
|
|
archiveID int64,
|
|
snapshot *TaskArchiveSnapshot,
|
|
archivePath, sha256 string,
|
|
originalSize, compressedSize int64,
|
|
) error {
|
|
if snapshot == nil || snapshot.FormatVersion != TaskArchiveFormatVersion {
|
|
return ErrTaskArchiveFormatMismatch
|
|
}
|
|
countsRaw, err := json.Marshal(snapshot.DataCounts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
statsRaw, err := json.Marshal(snapshot.AggregateStats)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
if err := coordinateWithSchemaMigration(tx); err != nil {
|
|
return err
|
|
}
|
|
var taskID, expID int64
|
|
var state string
|
|
if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state
|
|
FROM task_archives archive JOIN tasks task ON task.id=archive.task_id
|
|
WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID, &state); err != nil {
|
|
return err
|
|
}
|
|
if state != Archiving || taskID != snapshot.TaskID || expID != snapshot.ExplorationID {
|
|
return fmt.Errorf("%w: archive snapshot identity/state mismatch", ErrTaskArchiveState)
|
|
}
|
|
var dependent int64
|
|
err = tx.QueryRow(`SELECT child.id FROM task_relations relation
|
|
JOIN tasks child ON child.id=relation.task_id AND child.deleted_at IS NULL
|
|
WHERE relation.source_task_id=$1 LIMIT 1`, taskID).Scan(&dependent)
|
|
if err == nil {
|
|
return fmt.Errorf("%w: task %d", ErrTaskArchiveDependent, dependent)
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
// Prevent task/asset ownership from changing while exclusivity is rechecked.
|
|
if _, err := tx.Exec(`LOCK TABLE assets, exploration_anchors IN SHARE ROW EXCLUSIVE MODE`); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM intercept_pending WHERE COALESCE(task_id,'')=$1`, strconv.FormatInt(taskID, 10)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM llm_records WHERE COALESCE(task_id,'')=$1`, strconv.FormatInt(taskID, 10)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM llm_usage WHERE COALESCE(task_id,'')=$1 OR exploration_id=$2`, strconv.FormatInt(taskID, 10), expID); err != nil {
|
|
return err
|
|
}
|
|
for _, table := range []string{"skill_usage", "tool_usage"} {
|
|
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE task_id=$1 OR exploration_id=$2`, taskID, expID); err != nil {
|
|
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
|
|
}
|
|
for _, statement := range []struct {
|
|
query string
|
|
args []any
|
|
}{
|
|
{`DELETE FROM task_relations WHERE task_id=$1 OR source_task_id=$1`, []any{taskID}},
|
|
{`DELETE FROM task_asset_links WHERE task_id=$1`, []any{taskID}},
|
|
{`DELETE FROM task_llm_profiles WHERE task_id=$1`, []any{taskID}},
|
|
{`DELETE FROM task_scope WHERE task_id=$1`, []any{taskID}},
|
|
{`DELETE FROM task_constraints WHERE exploration_id=$1`, []any{expID}},
|
|
{`DELETE FROM activity WHERE exploration_id=$1`, []any{expID}},
|
|
{`DELETE FROM exploration_nodes WHERE exploration_id=$1`, []any{expID}},
|
|
} {
|
|
if _, err := tx.Exec(statement.query, statement.args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.Exec(`UPDATE assets SET task_ids=array_remove(task_ids,$1) WHERE $1=ANY(task_ids)`, taskID); err != nil {
|
|
return err
|
|
}
|
|
if len(snapshot.ExclusiveAssetIDs) > 0 {
|
|
if _, err := tx.Exec(`DELETE FROM assets asset
|
|
WHERE asset.id=ANY($2::bigint[]) AND asset.company_id IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM tasks task WHERE task.id<>$1 AND task.deleted_at IS NULL AND task.id=ANY(asset.task_ids))
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM exploration_anchors anchor JOIN exploration_nodes node ON node.id=anchor.node_id
|
|
JOIN tasks task ON task.exploration_id=node.exploration_id
|
|
WHERE anchor.asset_id=asset.id AND task.id<>$1 AND task.deleted_at IS NULL
|
|
)`, taskID, snapshot.ExclusiveAssetIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.Exec(`UPDATE explorations SET description='',goal='',status='open' WHERE id=$1`, expID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`UPDATE tasks SET
|
|
name='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
|
|
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
|
|
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,pinned_at=NULL,
|
|
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
|
|
WHERE id=$1`, taskID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`UPDATE task_archives SET
|
|
state=$2,phase='ready',progress=100,error='',archive_path=$3,sha256=$4,
|
|
original_size=$5,compressed_size=$6,data_counts=$7,aggregate_stats=$8,
|
|
format_version=$9,archived_at=now(),warnings='[]'
|
|
WHERE id=$1`, archiveID, ArchiveReady, archivePath, sha256, originalSize, compressedSize,
|
|
string(countsRaw), string(statsRaw), snapshot.FormatVersion); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RestoreTaskArchive restores PostgreSQL rows from a verified manifest. It is
|
|
// idempotent for accounting/traffic retry scenarios and returns non-fatal
|
|
// warnings for global objects that intentionally are not recreated.
|
|
func (d *DB) RestoreTaskArchive(archiveID int64, snapshot *TaskArchiveSnapshot, remainingTimeoutSeconds int64) ([]string, error) {
|
|
return d.restoreTaskArchive(archiveID, snapshot, remainingTimeoutSeconds, nil)
|
|
}
|
|
|
|
// RestoreTaskArchiveWithLLMRecords restores a v2 package whose heavyweight LLM
|
|
// record history is stored as a sequence of JSON objects outside manifest.json.
|
|
func (d *DB) RestoreTaskArchiveWithLLMRecords(
|
|
archiveID int64,
|
|
snapshot *TaskArchiveSnapshot,
|
|
remainingTimeoutSeconds int64,
|
|
llmRecords io.Reader,
|
|
) ([]string, error) {
|
|
if llmRecords == nil {
|
|
return nil, errors.New("nil streamed LLM record reader")
|
|
}
|
|
return d.restoreTaskArchive(archiveID, snapshot, remainingTimeoutSeconds, llmRecords)
|
|
}
|
|
|
|
func (d *DB) restoreTaskArchive(
|
|
archiveID int64,
|
|
snapshot *TaskArchiveSnapshot,
|
|
remainingTimeoutSeconds int64,
|
|
llmRecords io.Reader,
|
|
) ([]string, error) {
|
|
if snapshot == nil || !IsTaskArchiveFormatSupported(snapshot.FormatVersion) {
|
|
return nil, ErrTaskArchiveFormatMismatch
|
|
}
|
|
streamedLLMRecords := snapshot.StreamedTables["llm_records"]
|
|
if (len(snapshot.StreamedTables) > 0 && snapshot.FormatVersion < 2) ||
|
|
len(snapshot.StreamedTables) > 1 ||
|
|
(len(snapshot.StreamedTables) == 1 && streamedLLMRecords != TaskArchiveLLMRecordsPath) {
|
|
return nil, fmt.Errorf("%w: unsupported streamed table metadata", ErrTaskArchiveFormatMismatch)
|
|
}
|
|
if streamedLLMRecords != "" && llmRecords == nil {
|
|
return nil, fmt.Errorf("%w: streamed LLM records are missing", ErrTaskArchiveFormatMismatch)
|
|
}
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
if err := coordinateWithSchemaMigration(tx); err != nil {
|
|
return nil, err
|
|
}
|
|
var taskID, expID int64
|
|
var state string
|
|
if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state
|
|
FROM task_archives archive JOIN tasks task ON task.id=archive.task_id
|
|
WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID, &state); err != nil {
|
|
return nil, err
|
|
}
|
|
if state != Restoring || taskID != snapshot.TaskID || expID != snapshot.ExplorationID {
|
|
return nil, fmt.Errorf("%w: restore snapshot identity/state mismatch", ErrTaskArchiveState)
|
|
}
|
|
warnings := []string{}
|
|
assetMap, assetWarnings, err := restoreArchiveAssets(tx, taskID, snapshot.Tables["assets"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
warnings = append(warnings, assetWarnings...)
|
|
|
|
// The task row exists as an archived stub. Restore its global references only
|
|
// when the current instance still owns them; never recreate categories/profiles.
|
|
taskRows, err := decodeArchiveRows(snapshot.Tables["tasks"])
|
|
if err != nil || len(taskRows) != 1 {
|
|
return nil, fmt.Errorf("restore task row: expected one row: %w", err)
|
|
}
|
|
taskRow := taskRows[0]
|
|
categoryID, _ := jsonInt64(taskRow["category_id"])
|
|
if categoryID > 0 && !rowExists(tx, "task_categories", categoryID) {
|
|
taskRow["category_id"] = nil
|
|
warnings = append(warnings, fmt.Sprintf("task category %d has been deleted; restored as uncategorized", categoryID))
|
|
}
|
|
companyID, _ := jsonInt64(taskRow["company_id"])
|
|
if companyID > 0 && !rowExists(tx, "companies", companyID) {
|
|
taskRow["company_id"] = nil
|
|
warnings = append(warnings, fmt.Sprintf("task company %d has been deleted; company association skipped", companyID))
|
|
}
|
|
for _, key := range []string{"llm_profile_id", "active_llm_profile_id"} {
|
|
profileID, _ := jsonInt64(taskRow[key])
|
|
if profileID > 0 && !rowExists(tx, "llm_profiles", profileID) {
|
|
taskRow[key] = nil
|
|
warnings = append(warnings, fmt.Sprintf("LLM profile %d has been deleted; removed from the task configuration", profileID))
|
|
}
|
|
}
|
|
if err := restoreExplorationStub(tx, snapshot.Tables["explorations"], expID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := restoreTaskStub(tx, taskRow, taskID, remainingTimeoutSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
remappedTables, err := remapArchiveAssetReferences(snapshot.Tables, assetMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remappedTables["findings"], err = normalizeArchivedFindingVersions(remappedTables["findings"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Insert graph rows in foreign-key order. The archived stub has no graph rows,
|
|
// so an ID conflict signals external corruption and must stop the restore.
|
|
for _, table := range []string{"exploration_nodes", "exploration_edges", "exploration_anchors", "task_constraints", "activity"} {
|
|
if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil {
|
|
return nil, fmt.Errorf("restore %s: %w", table, err)
|
|
}
|
|
}
|
|
if warning, err := restoreTaskRelations(tx, taskID, remappedTables["task_relations"]); err != nil {
|
|
return nil, err
|
|
} 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 {
|
|
warnings = append(warnings, warning...)
|
|
}
|
|
if warning, err := restoreTaskLLMProfiles(tx, remappedTables["task_llm_profiles"]); err != nil {
|
|
return nil, err
|
|
} else {
|
|
warnings = append(warnings, warning...)
|
|
}
|
|
for _, table := range []string{"task_asset_links", "findings"} {
|
|
if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil {
|
|
return nil, fmt.Errorf("restore %s: %w", table, err)
|
|
}
|
|
}
|
|
if err := restoreFindingTrafficTx(tx, snapshot); err != nil {
|
|
return nil, fmt.Errorf("restore finding traffic: %w", err)
|
|
}
|
|
if streamedLLMRecords != "" {
|
|
count, err := insertArchiveJSONSequenceRows(tx, "llm_records", llmRecords)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("restore llm_records: %w", err)
|
|
}
|
|
if expected := snapshot.DataCounts["llm_records"]; count != expected {
|
|
return nil, fmt.Errorf("restore llm_records: row count %d does not match manifest %d", count, expected)
|
|
}
|
|
} else if err := insertArchiveRows(tx, "llm_records", remappedTables["llm_records"]); err != nil {
|
|
return nil, fmt.Errorf("restore llm_records: %w", err)
|
|
}
|
|
for _, table := range []string{"llm_usage", "skill_usage", "tool_usage"} {
|
|
if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil {
|
|
return nil, fmt.Errorf("restore %s: %w", table, err)
|
|
}
|
|
}
|
|
if err := restoreInterceptRows(tx, remappedTables["intercept_pending"]); err != nil {
|
|
return nil, err
|
|
}
|
|
warningsRaw, _ := json.Marshal(warnings)
|
|
if _, err := tx.Exec(`UPDATE task_archives SET warnings=$2,phase='database_restored',progress=85,error='' WHERE id=$1`, archiveID, string(warningsRaw)); err != nil {
|
|
return nil, err
|
|
}
|
|
return warnings, tx.Commit()
|
|
}
|
|
|
|
func restoreExplorationStub(tx *sql.Tx, raw json.RawMessage, expID int64) error {
|
|
_, err := tx.Exec(`UPDATE explorations current SET
|
|
description=archived.description,goal=archived.goal,status=archived.status,
|
|
created_at=archived.created_at,updated_at=archived.updated_at
|
|
FROM json_populate_record(NULL::explorations,$2::json) archived
|
|
WHERE current.id=$1 AND archived.id=$1`, expID, string(firstArchiveRow(raw)))
|
|
return err
|
|
}
|
|
|
|
func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) error {
|
|
raw, err := json.Marshal(row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(`UPDATE tasks current SET
|
|
name=archived.name,category_id=archived.category_id,description=archived.description,goal=archived.goal,
|
|
status=archived.status,paused=archived.paused,queued=false,queued_at=NULL,queue_mode='',
|
|
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
|
|
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
|
|
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
|
|
coverage_enabled=archived.coverage_enabled,pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
|
|
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
|
|
ELSE archived.deadline_at END,
|
|
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
|
|
created_at=archived.created_at,updated_at=archived.updated_at
|
|
FROM json_populate_record(NULL::tasks,$2::json) archived
|
|
WHERE current.id=$1 AND archived.id=$1`, taskID, string(raw), remaining)
|
|
return err
|
|
}
|
|
|
|
func firstArchiveRow(raw json.RawMessage) json.RawMessage {
|
|
var rows []json.RawMessage
|
|
if json.Unmarshal(raw, &rows) != nil || len(rows) == 0 {
|
|
return json.RawMessage("{}")
|
|
}
|
|
return rows[0]
|
|
}
|
|
|
|
func decodeArchiveRows(raw json.RawMessage) ([]map[string]any, error) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var rows []map[string]any
|
|
if err := decoder.Decode(&rows); err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func rowExists(tx *sql.Tx, table string, id int64) bool {
|
|
if table != "task_categories" && table != "llm_profiles" && table != "companies" && table != "tasks" && table != "assets" {
|
|
return false
|
|
}
|
|
var exists bool
|
|
_ = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+table+` WHERE id=$1)`, id).Scan(&exists)
|
|
return exists
|
|
}
|
|
|
|
func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error {
|
|
// json_populate_recordset inserts NULL for absent columns, bypassing SQL
|
|
// defaults. Preserve compatibility with v3 archives predating side memory.
|
|
if (table == "side_question_sessions" || table == "side_question_requests") && len(raw) > 0 {
|
|
var rows []map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &rows); err != nil {
|
|
return err
|
|
}
|
|
field := "memory"
|
|
if table == "side_question_requests" {
|
|
field = "context_info"
|
|
}
|
|
for _, row := range rows {
|
|
if len(row[field]) == 0 || string(row[field]) == "null" {
|
|
row[field] = json.RawMessage(`{}`)
|
|
}
|
|
}
|
|
var err error
|
|
raw, err = json.Marshal(rows)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
allowed := map[string]bool{
|
|
"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)
|
|
}
|
|
if rawRowCount(raw) == 0 {
|
|
return nil
|
|
}
|
|
_, err := tx.Exec(`INSERT INTO `+table+` SELECT * FROM json_populate_recordset(NULL::`+table+`,$1::json)`, string(raw))
|
|
return err
|
|
}
|
|
|
|
func insertArchiveJSONSequenceRows(tx *sql.Tx, table string, reader io.Reader) (int64, error) {
|
|
if table != "llm_records" {
|
|
return 0, fmt.Errorf("archive streamed restore table %q is not allowed", table)
|
|
}
|
|
statement, err := tx.Prepare(`INSERT INTO llm_records SELECT * FROM json_populate_record(NULL::llm_records,$1::json)`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer statement.Close()
|
|
decoder := json.NewDecoder(reader)
|
|
var count int64
|
|
for {
|
|
var raw json.RawMessage
|
|
if err := decoder.Decode(&raw); errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
return 0, err
|
|
}
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) == 0 || trimmed[0] != '{' {
|
|
return 0, errors.New("streamed archive row must be a JSON object")
|
|
}
|
|
if _, err := statement.Exec(string(trimmed)); err != nil {
|
|
return 0, err
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func restoreArchiveAssets(tx *sql.Tx, taskID int64, raw json.RawMessage) (map[int64]int64, []string, error) {
|
|
rows, err := decodeArchiveRows(raw)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
mapping := make(map[int64]int64, len(rows))
|
|
warnings := []string{}
|
|
for _, row := range rows {
|
|
oldID, ok := jsonInt64(row["id"])
|
|
if !ok || oldID <= 0 {
|
|
return nil, nil, errors.New("archived asset has invalid id")
|
|
}
|
|
if companyID, ok := jsonInt64(row["company_id"]); ok && companyID > 0 && !rowExists(tx, "companies", companyID) {
|
|
row["company_id"] = nil
|
|
row["company_source"] = "explicit"
|
|
warnings = append(warnings, fmt.Sprintf("asset %d's company %d has been deleted; restored as unaffiliated", oldID, companyID))
|
|
}
|
|
if existing, found, err := findArchiveAssetNaturalID(tx, row); err != nil {
|
|
return nil, nil, err
|
|
} else if found {
|
|
mapping[oldID] = existing
|
|
if _, err := tx.Exec(`UPDATE assets SET task_ids=CASE WHEN $1=ANY(task_ids) THEN task_ids ELSE array_append(task_ids,$1) END WHERE id=$2`, taskID, existing); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
continue
|
|
}
|
|
candidate := oldID
|
|
if rowExists(tx, "assets", oldID) {
|
|
if err := tx.QueryRow(`SELECT nextval(pg_get_serial_sequence('assets','id'))`).Scan(&candidate); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
row["id"] = candidate
|
|
warnings = append(warnings, fmt.Sprintf("asset ID %d is already taken; restored as %d", oldID, candidate))
|
|
}
|
|
row["task_ids"] = mergeJSONTaskID(row["task_ids"], taskID)
|
|
assetRaw, _ := json.Marshal(row)
|
|
res, err := tx.Exec(`INSERT INTO assets SELECT * FROM json_populate_record(NULL::assets,$1::json) ON CONFLICT DO NOTHING`, string(assetRaw))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
inserted, _ := res.RowsAffected()
|
|
if inserted == 0 {
|
|
existing, found, findErr := findArchiveAssetNaturalID(tx, row)
|
|
if findErr != nil || !found {
|
|
return nil, nil, errors.Join(findErr, fmt.Errorf("could not restore asset %d", oldID))
|
|
}
|
|
candidate = existing
|
|
if _, err := tx.Exec(`UPDATE assets SET task_ids=CASE WHEN $1=ANY(task_ids) THEN task_ids ELSE array_append(task_ids,$1) END WHERE id=$2`, taskID, existing); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
mapping[oldID] = candidate
|
|
}
|
|
return mapping, warnings, nil
|
|
}
|
|
|
|
func mergeJSONTaskID(value any, taskID int64) []int64 {
|
|
out := []int64{}
|
|
seen := map[int64]bool{}
|
|
if values, ok := value.([]any); ok {
|
|
for _, item := range values {
|
|
if id, ok := jsonInt64(item); ok && id > 0 && !seen[id] {
|
|
seen[id] = true
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
}
|
|
if !seen[taskID] {
|
|
out = append(out, taskID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findArchiveAssetNaturalID(tx *sql.Tx, row map[string]any) (int64, bool, error) {
|
|
typeName, _ := row["type"].(string)
|
|
stringValue := func(key string) string {
|
|
value, _ := row[key].(string)
|
|
return value
|
|
}
|
|
var query string
|
|
var args []any
|
|
switch typeName {
|
|
case "root_domain":
|
|
query, args = `SELECT id FROM assets WHERE type='root_domain' AND domain=$1`, []any{stringValue("domain")}
|
|
case "ip":
|
|
query, args = `SELECT id FROM assets WHERE type='ip' AND ip=$1`, []any{stringValue("ip")}
|
|
case "subdomain":
|
|
query, args = `SELECT id FROM assets WHERE type='subdomain' AND domain=$1 AND COALESCE(record_type,'')=$2`, []any{stringValue("domain"), stringValue("record_type")}
|
|
case "app":
|
|
if bundle := stringValue("bundle_id"); bundle != "" {
|
|
query, args = `SELECT id FROM assets WHERE type='app' AND bundle_id=$1`, []any{bundle}
|
|
} else {
|
|
query, args = `SELECT id FROM assets WHERE type='app' AND bundle_id IS NULL AND app_name=$1`, []any{stringValue("app_name")}
|
|
}
|
|
case "service":
|
|
if stringValue("service_type") == "http" {
|
|
query, args = `SELECT id FROM assets WHERE type='service' AND service_type='http' AND url=$1`, []any{stringValue("url")}
|
|
} else {
|
|
port, _ := jsonInt64(row["port"])
|
|
query, args = `SELECT id FROM assets WHERE type='service' AND service_type='other' AND COALESCE(domain,'')=$1 AND COALESCE(ip,'')=$2 AND port=$3 AND service_name=$4`, []any{stringValue("domain"), stringValue("ip"), port, stringValue("service_name")}
|
|
}
|
|
case "endpoint":
|
|
query, args = `SELECT id FROM assets WHERE type='endpoint' AND url=$1 AND method=$2`, []any{stringValue("url"), stringValue("method")}
|
|
default:
|
|
return 0, false, fmt.Errorf("unsupported archived asset type %q", typeName)
|
|
}
|
|
var id int64
|
|
err := tx.QueryRow(query, args...).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, false, nil
|
|
}
|
|
return id, err == nil, err
|
|
}
|
|
|
|
func remapArchiveAssetReferences(tables map[string]json.RawMessage, mapping map[int64]int64) (map[string]json.RawMessage, error) {
|
|
out := make(map[string]json.RawMessage, len(tables))
|
|
for name, raw := range tables {
|
|
out[name] = raw
|
|
}
|
|
for _, table := range []string{"exploration_anchors", "task_asset_links"} {
|
|
rows, err := decodeArchiveRows(tables[table])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
if old, ok := jsonInt64(row["asset_id"]); ok {
|
|
if replacement, exists := mapping[old]; exists {
|
|
row["asset_id"] = replacement
|
|
}
|
|
}
|
|
}
|
|
out[table], _ = json.Marshal(rows)
|
|
}
|
|
for _, table := range []string{"exploration_nodes", "findings"} {
|
|
rows, err := decodeArchiveRows(tables[table])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
key := "asset_ids"
|
|
container := row
|
|
if table == "exploration_nodes" {
|
|
payload, ok := row["payload"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
container = payload
|
|
}
|
|
if values, ok := container[key].([]any); ok {
|
|
for i, value := range values {
|
|
if old, ok := jsonInt64(value); ok {
|
|
if replacement, exists := mapping[old]; exists {
|
|
values[i] = replacement
|
|
}
|
|
}
|
|
}
|
|
container[key] = values
|
|
}
|
|
}
|
|
out[table], _ = json.Marshal(rows)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func restoreTaskRelations(tx *sql.Tx, taskID int64, raw json.RawMessage) ([]string, error) {
|
|
rows, err := decodeArchiveRows(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
warnings := []string{}
|
|
for _, row := range rows {
|
|
sourceID, ok := jsonInt64(row["source_task_id"])
|
|
if !ok || !liveTaskExists(tx, sourceID) {
|
|
warnings = append(warnings, fmt.Sprintf("source task %d is unavailable; inheritance skipped", sourceID))
|
|
continue
|
|
}
|
|
created, _ := row["created_at"].(string)
|
|
if _, err := tx.Exec(`INSERT INTO task_relations(task_id,source_task_id,created_at) VALUES($1,$2,COALESCE($3::timestamptz,now())) ON CONFLICT DO NOTHING`, taskID, sourceID, nilIfEmptyString(created)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return warnings, nil
|
|
}
|
|
|
|
func restoreTaskScopes(tx *sql.Tx, raw json.RawMessage) ([]string, error) {
|
|
rows, err := decodeArchiveRows(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
warnings := []string{}
|
|
kept := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
if companyID, ok := jsonInt64(row["company_id"]); ok && companyID > 0 && !rowExists(tx, "companies", companyID) {
|
|
warnings = append(warnings, fmt.Sprintf("company %d has been deleted; associated scope skipped", companyID))
|
|
continue
|
|
}
|
|
kept = append(kept, row)
|
|
}
|
|
if len(kept) == 0 {
|
|
return warnings, nil
|
|
}
|
|
encoded, _ := json.Marshal(kept)
|
|
_, err = tx.Exec(`INSERT INTO task_scope SELECT * FROM json_populate_recordset(NULL::task_scope,$1::json) ON CONFLICT DO NOTHING`, string(encoded))
|
|
return warnings, err
|
|
}
|
|
|
|
func restoreTaskLLMProfiles(tx *sql.Tx, raw json.RawMessage) ([]string, error) {
|
|
rows, err := decodeArchiveRows(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
warnings := []string{}
|
|
position := 0
|
|
for _, row := range rows {
|
|
profileID, ok := jsonInt64(row["profile_id"])
|
|
if !ok || !rowExists(tx, "llm_profiles", profileID) {
|
|
warnings = append(warnings, fmt.Sprintf("LLM profile %d has been deleted; chain entry skipped", profileID))
|
|
continue
|
|
}
|
|
taskID, _ := jsonInt64(row["task_id"])
|
|
status, _ := row["status"].(string)
|
|
lastError, _ := row["last_error"].(string)
|
|
exhaustedAt, _ := row["exhausted_at"].(string)
|
|
createdAt, _ := row["created_at"].(string)
|
|
updatedAt, _ := row["updated_at"].(string)
|
|
if _, err := tx.Exec(`INSERT INTO task_llm_profiles(task_id,profile_id,position,status,last_error,exhausted_at,created_at,updated_at)
|
|
VALUES($1,$2,$3,$4,NULLIF($5,''),$6::timestamptz,COALESCE($7::timestamptz,now()),COALESCE($8::timestamptz,now()))
|
|
ON CONFLICT DO NOTHING`, taskID, profileID, position, status, lastError,
|
|
nilIfEmptyString(exhaustedAt), nilIfEmptyString(createdAt), nilIfEmptyString(updatedAt)); err != nil {
|
|
return nil, err
|
|
}
|
|
position++
|
|
}
|
|
return warnings, nil
|
|
}
|
|
|
|
func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
|
rows, err := decodeArchiveRows(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, row := range rows {
|
|
// Archives predating approval snapshots lack this NOT NULL column.
|
|
if source, _ := row["decision_source"].(string); source == "" {
|
|
source = "unknown"
|
|
if row["rule_id"] != nil {
|
|
source = "rule"
|
|
} else if reason, _ := row["reason"].(string); isModelReason(reason) {
|
|
source = "model"
|
|
}
|
|
row["decision_source"] = source
|
|
}
|
|
// Archives predating decided_by lack this NOT NULL column too.
|
|
if row["decided_by"] == nil {
|
|
row["decided_by"] = ""
|
|
}
|
|
if status, _ := row["status"].(string); status == "pending" {
|
|
row["status"] = "timeout"
|
|
row["decided_by"] = "system"
|
|
row["reason"] = "approval timed out while the task was archived"
|
|
row["decided_at"] = time.Now().UTC()
|
|
if audit, ok := row["audit"].(map[string]any); ok {
|
|
audit["effective_action"] = "deny"
|
|
audit["decision_reason"] = "approval timed out while the task was archived"
|
|
audit["execution_status"] = "not_executed"
|
|
}
|
|
} else if audit, ok := row["audit"].(map[string]any); ok && audit["execution_status"] == "awaiting_result" {
|
|
// An archived run cannot resume its former result callback.
|
|
audit["execution_status"] = "unknown"
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
encoded, _ := json.Marshal(rows)
|
|
_, err = tx.Exec(`INSERT INTO intercept_pending SELECT * FROM json_populate_recordset(NULL::intercept_pending,$1::json)`, string(encoded))
|
|
return err
|
|
}
|
|
|
|
func liveTaskExists(tx *sql.Tx, id int64) bool {
|
|
var exists bool
|
|
_ = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, id).Scan(&exists)
|
|
return exists
|
|
}
|
|
|
|
func nilIfEmptyString(value string) any {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
// CompleteTaskArchiveRestore removes compact metadata after every external
|
|
// component has been verified and the package has been consumed.
|
|
func (d *DB) CompleteTaskArchiveRestore(archiveID int64) error {
|
|
res, err := d.Exec(`DELETE FROM task_archives archive USING tasks task
|
|
WHERE archive.id=$1 AND archive.task_id=task.id AND task.deleted_at IS NULL AND task.archived_at IS NULL`, archiveID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n != 1 {
|
|
return ErrTaskArchiveState
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTaskArchiveStub permanently removes the cold task after its package has
|
|
// been staged for deletion. Dependency protection is rechecked transactionally.
|
|
func (d *DB) DeleteTaskArchiveStub(archiveID int64) error {
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
if err := coordinateWithSchemaMigration(tx); err != nil {
|
|
return err
|
|
}
|
|
var taskID, expID int64
|
|
var state string
|
|
if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state
|
|
FROM task_archives archive JOIN tasks task ON task.id=archive.task_id
|
|
WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID, &state); err != nil {
|
|
return err
|
|
}
|
|
if state != Deleting {
|
|
return ErrTaskArchiveState
|
|
}
|
|
var dependent int64
|
|
err = tx.QueryRow(`SELECT task_id FROM task_archives WHERE id<>$1 AND $2=ANY(source_task_ids) LIMIT 1`, archiveID, taskID).Scan(&dependent)
|
|
if err == nil {
|
|
return fmt.Errorf("%w: task %d", ErrTaskArchiveDeleteBlocked, dependent)
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
// As in DeleteTaskCascadePrepared: pins stay as revoked history.
|
|
if err := revokeTaskPins(tx, taskID); err != nil {
|
|
return err
|
|
}
|
|
if err := settleTaskTargetRequests(tx, taskID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1 AND archived_at IS NOT NULL`, taskID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM explorations WHERE id=$1`, expID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ArchivedAggregateStats returns compact summaries used by global dashboards so
|
|
// cold data does not disappear from historical totals.
|
|
func (d *DB) ArchivedAggregateStats() ([]json.RawMessage, error) {
|
|
rows, err := d.Query(`SELECT archive.aggregate_stats
|
|
FROM task_archives archive
|
|
JOIN tasks task ON task.id=archive.task_id
|
|
WHERE task.archived_at IS NOT NULL`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []json.RawMessage
|
|
for rows.Next() {
|
|
var raw json.RawMessage
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, raw)
|
|
}
|
|
return out, rows.Err()
|
|
}
|