fix(chat): add message scope columns and write-path resolution
Persist workflow_instance_id, stage_instance_id, scope_kind on chat messages and task runs; resolve scope from live instance for chat and document tasks so refresh can apply consistent stage authorization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- 消息工作流作用域:支持刷新后阶段授权与历史兼容
|
||||
|
||||
ALTER TABLE chat_messages
|
||||
ADD COLUMN IF NOT EXISTS workflow_instance_id BIGINT REFERENCES project_workflow_instances(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS stage_instance_id BIGINT REFERENCES project_stage_instances(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS stage_key TEXT,
|
||||
ADD COLUMN IF NOT EXISTS scope_kind TEXT NOT NULL DEFAULT 'legacy_unscoped';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_scope
|
||||
ON chat_messages (session_id, scope_kind)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_stage_key
|
||||
ON chat_messages (session_id, stage_key)
|
||||
WHERE deleted_at IS NULL AND stage_key IS NOT NULL;
|
||||
|
||||
ALTER TABLE task_runs
|
||||
ADD COLUMN IF NOT EXISTS workflow_instance_id BIGINT REFERENCES project_workflow_instances(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS stage_instance_id BIGINT REFERENCES project_stage_instances(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS workflow_stage_key TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE task_runs
|
||||
DROP COLUMN IF EXISTS workflow_stage_key,
|
||||
DROP COLUMN IF EXISTS stage_instance_id,
|
||||
DROP COLUMN IF EXISTS workflow_instance_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_chat_messages_stage_key;
|
||||
DROP INDEX IF EXISTS idx_chat_messages_session_scope;
|
||||
|
||||
ALTER TABLE chat_messages
|
||||
DROP COLUMN IF EXISTS scope_kind,
|
||||
DROP COLUMN IF EXISTS stage_key,
|
||||
DROP COLUMN IF EXISTS stage_instance_id,
|
||||
DROP COLUMN IF EXISTS workflow_instance_id;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- +goose Up
|
||||
-- 受控回填历史消息作用域(零正文删除)
|
||||
|
||||
-- 1. 任务消息:通过 task_runs 唯一匹配回填 workflow_stage
|
||||
UPDATE chat_messages cm
|
||||
SET
|
||||
scope_kind = 'workflow_stage',
|
||||
workflow_instance_id = tr.workflow_instance_id,
|
||||
stage_instance_id = tr.stage_instance_id,
|
||||
stage_key = tr.workflow_stage_key,
|
||||
metadata = cm.metadata || jsonb_build_object(
|
||||
'scope_kind', 'workflow_stage',
|
||||
'workflow_instance_id', tr.workflow_instance_id,
|
||||
'stage_instance_id', tr.stage_instance_id,
|
||||
'stage_key', tr.workflow_stage_key
|
||||
)
|
||||
FROM task_runs tr
|
||||
WHERE cm.scope_kind = 'legacy_unscoped'
|
||||
AND cm.deleted_at IS NULL
|
||||
AND cm.metadata->>'task_key' IS NOT NULL
|
||||
AND cm.metadata->>'task_key' = tr.task_key
|
||||
AND cm.session_id = tr.session_id
|
||||
AND tr.workflow_instance_id IS NOT NULL
|
||||
AND tr.stage_instance_id IS NOT NULL
|
||||
AND tr.workflow_stage_key IS NOT NULL;
|
||||
|
||||
-- 2. 激活前 owner 私聊:项目有 live 实例且消息创建于激活之前
|
||||
UPDATE chat_messages cm
|
||||
SET
|
||||
scope_kind = 'pre_activation',
|
||||
workflow_instance_id = pwi.id,
|
||||
metadata = cm.metadata || jsonb_build_object(
|
||||
'scope_kind', 'pre_activation',
|
||||
'workflow_instance_id', pwi.id
|
||||
)
|
||||
FROM chat_sessions cs
|
||||
JOIN projects p ON p.id = cs.project_id
|
||||
JOIN project_workflow_instances pwi ON pwi.project_id = p.id AND pwi.status = 'active'
|
||||
WHERE cm.session_id = cs.session_id
|
||||
AND cm.scope_kind = 'legacy_unscoped'
|
||||
AND cm.deleted_at IS NULL
|
||||
AND cm.metadata->>'task_key' IS NULL
|
||||
AND pwi.activated_at IS NOT NULL
|
||||
AND cm.created_at < pwi.activated_at
|
||||
AND cs.user_id IS NOT NULL
|
||||
AND cm.user_id = cs.user_id;
|
||||
|
||||
-- 3. 已有 metadata.stage_key 但缺 scope_kind 的普通聊天(live 实例存在)
|
||||
UPDATE chat_messages
|
||||
SET
|
||||
scope_kind = 'workflow_stage',
|
||||
stage_key = chat_messages.metadata->>'stage_key',
|
||||
workflow_instance_id = src.workflow_instance_id,
|
||||
stage_instance_id = src.stage_instance_id,
|
||||
metadata = chat_messages.metadata || jsonb_build_object(
|
||||
'scope_kind', 'workflow_stage',
|
||||
'workflow_instance_id', src.workflow_instance_id,
|
||||
'stage_instance_id', src.stage_instance_id
|
||||
)
|
||||
FROM (
|
||||
SELECT cm.id AS message_id, pwi.id AS workflow_instance_id, psi.id AS stage_instance_id
|
||||
FROM chat_messages cm
|
||||
INNER JOIN chat_sessions cs ON cm.session_id = cs.session_id
|
||||
INNER JOIN project_workflow_instances pwi ON pwi.project_id = cs.project_id AND pwi.status = 'active'
|
||||
INNER JOIN project_stage_instances psi ON psi.workflow_instance_id = pwi.id
|
||||
AND psi.stage_key = cm.metadata->>'stage_key'
|
||||
WHERE cm.scope_kind = 'legacy_unscoped'
|
||||
AND cm.deleted_at IS NULL
|
||||
AND cm.metadata->>'stage_key' IS NOT NULL
|
||||
AND cm.metadata->>'stage_key' <> ''
|
||||
AND cm.metadata->>'task_key' IS NULL
|
||||
) AS src
|
||||
WHERE chat_messages.id = src.message_id;
|
||||
|
||||
-- +goose Down
|
||||
-- 回填不可逆;Down 仅重置为 legacy_unscoped(不含正文删除)
|
||||
UPDATE chat_messages
|
||||
SET scope_kind = 'legacy_unscoped'
|
||||
WHERE scope_kind IN ('workflow_stage', 'pre_activation');
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/replyexport"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/stream"
|
||||
@@ -44,6 +45,7 @@ type Session struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
MessageScope *projectinstance.MessageScope `json:"-"`
|
||||
UserCtx *coordinator.UserProjectContext `json:"user_ctx"`
|
||||
Recognizer intent.Recognizer `json:"-"`
|
||||
Coordinator *coordinator.CoordinatorEngine `json:"-"`
|
||||
@@ -269,9 +271,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
if s.ProjectID > 0 {
|
||||
userMeta["project_id"] = s.ProjectID
|
||||
}
|
||||
if s.ActiveStageKey != "" {
|
||||
userMeta["stage_key"] = s.ActiveStageKey
|
||||
}
|
||||
s.applyMessageScopeMeta(userMeta)
|
||||
if clientTurnID != "" {
|
||||
userMeta["client_turn_id"] = clientTurnID
|
||||
userMeta["client_id"] = clientTurnID + ":user"
|
||||
@@ -433,9 +433,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
if s.ProjectID > 0 {
|
||||
asstMeta["project_id"] = s.ProjectID
|
||||
}
|
||||
if s.ActiveStageKey != "" {
|
||||
asstMeta["stage_key"] = s.ActiveStageKey
|
||||
}
|
||||
s.applyMessageScopeMeta(asstMeta)
|
||||
if recRes.TopIntent != nil {
|
||||
asstMeta["turn_intent"] = string(recRes.TopIntent.Intent)
|
||||
}
|
||||
@@ -531,3 +529,16 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
PendingExpectation: s.UserCtx.PendingExpectation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Session) applyMessageScopeMeta(meta map[string]any) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
if s.MessageScope != nil {
|
||||
projectinstance.ApplyScopeToMetadata(meta, s.MessageScope)
|
||||
return
|
||||
}
|
||||
if s.ActiveStageKey != "" {
|
||||
meta["stage_key"] = s.ActiveStageKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -118,8 +117,9 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery)
|
||||
}
|
||||
|
||||
// 倒序取最多 MaxMsgPerDay 条
|
||||
scoped := m.hasMessageScopeColumns(ctx)
|
||||
msgRows, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+m.chatMessageSelectColumns(ctx)+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
@@ -129,18 +129,7 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery)
|
||||
return nil, fmt.Errorf("query messages for day %s failed: %w", dayStr, err)
|
||||
}
|
||||
|
||||
var msgsDesc []ChatMessageRecord
|
||||
for msgRows.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := msgRows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
msgsDesc = append(msgsDesc, r)
|
||||
}
|
||||
}
|
||||
msgsDesc, _ := appendChatMessagesFromRows(msgRows, scoped)
|
||||
msgRows.Close()
|
||||
|
||||
// 内存反转为正序 ASC
|
||||
@@ -343,6 +332,8 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
|
||||
|
||||
var msgs []ChatMessageRecord
|
||||
var hasMoreBefore, hasMoreAfter bool
|
||||
scoped := m.hasMessageScopeColumns(ctx)
|
||||
cols := m.chatMessageSelectColumns(ctx)
|
||||
|
||||
if anchorID > 0 {
|
||||
// 锚点上下文:前后各取 limit/2 条
|
||||
@@ -353,25 +344,14 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
|
||||
|
||||
// before or equal to anchor
|
||||
rowsBefore, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+cols+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND id <= $3 AND deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT $4
|
||||
`, sessionID, dayKey, anchorID, half)
|
||||
if err == nil {
|
||||
var beforeDesc []ChatMessageRecord
|
||||
for rowsBefore.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := rowsBefore.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
beforeDesc = append(beforeDesc, r)
|
||||
}
|
||||
}
|
||||
beforeDesc, _ := appendChatMessagesFromRows(rowsBefore, scoped)
|
||||
rowsBefore.Close()
|
||||
for i := len(beforeDesc) - 1; i >= 0; i-- {
|
||||
msgs = append(msgs, beforeDesc[i])
|
||||
@@ -380,47 +360,27 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
|
||||
|
||||
// after anchor
|
||||
rowsAfter, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+cols+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT $4
|
||||
`, sessionID, dayKey, anchorID, half)
|
||||
if err == nil {
|
||||
for rowsAfter.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := rowsAfter.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
msgs = append(msgs, r)
|
||||
}
|
||||
}
|
||||
afterMsgs, _ := appendChatMessagesFromRows(rowsAfter, scoped)
|
||||
rowsAfter.Close()
|
||||
msgs = append(msgs, afterMsgs...)
|
||||
}
|
||||
} else if beforeID > 0 {
|
||||
rows, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+cols+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND id < $3 AND deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT $4
|
||||
`, sessionID, dayKey, beforeID, limit)
|
||||
if err == nil {
|
||||
var desc []ChatMessageRecord
|
||||
for rows.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
desc = append(desc, r)
|
||||
}
|
||||
}
|
||||
desc, _ := appendChatMessagesFromRows(rows, scoped)
|
||||
rows.Close()
|
||||
for i := len(desc) - 1; i >= 0; i-- {
|
||||
msgs = append(msgs, desc[i])
|
||||
@@ -428,48 +388,30 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
|
||||
}
|
||||
} else if afterID > 0 {
|
||||
rows, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+cols+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT $4
|
||||
`, sessionID, dayKey, afterID, limit)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
msgs = append(msgs, r)
|
||||
}
|
||||
}
|
||||
afterMsgs, _ := appendChatMessagesFromRows(rows, scoped)
|
||||
rows.Close()
|
||||
msgs = append(msgs, afterMsgs...)
|
||||
}
|
||||
} else {
|
||||
// 默认从当日第一条起正序 limit 条
|
||||
rows, err := m.pool.Query(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
SELECT `+cols+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT $3
|
||||
`, sessionID, dayKey, limit)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
msgs = append(msgs, r)
|
||||
}
|
||||
}
|
||||
defaultMsgs, _ := appendChatMessagesFromRows(rows, scoped)
|
||||
rows.Close()
|
||||
msgs = append(msgs, defaultMsgs...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-14
@@ -199,12 +199,32 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
|
||||
project_id = COALESCE($3, chat_sessions.project_id)
|
||||
`, sessionID, uid, pid)
|
||||
|
||||
wfInstID, stInstID, stageKey, scopeKind := scopeFieldsFromMeta(meta)
|
||||
rec.WorkflowInstanceID = wfInstID
|
||||
rec.StageInstanceID = stInstID
|
||||
rec.StageKey = stageKey
|
||||
rec.ScopeKind = scopeKind
|
||||
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
err := m.pool.QueryRow(ctx, `
|
||||
INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, content_kind, export_policy, content_revision, content_sha256, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256, now).Scan(&messageID)
|
||||
var err error
|
||||
if m.hasMessageScopeColumns(ctx) {
|
||||
err = m.pool.QueryRow(ctx, `
|
||||
INSERT INTO chat_messages (
|
||||
session_id, user_id, role, content, intent, branch, metadata, day_key, category,
|
||||
content_kind, export_policy, content_revision, content_sha256,
|
||||
workflow_instance_id, stage_instance_id, stage_key, scope_kind, created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id
|
||||
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256,
|
||||
nullScopeInt64(wfInstID), nullScopeInt64(stInstID), nullScopeText(stageKey), scopeKind, now).Scan(&messageID)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx, `
|
||||
INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, content_kind, export_policy, content_revision, content_sha256, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256, now).Scan(&messageID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert chat message failed: %w", err)
|
||||
}
|
||||
@@ -254,20 +274,15 @@ func (m *ContextManager) GetMessageByID(ctx context.Context, messageID int64) (*
|
||||
}
|
||||
|
||||
if m.pool != nil {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
err := m.pool.QueryRow(ctx, `
|
||||
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
scoped := m.hasMessageScopeColumns(ctx)
|
||||
r, err := scanChatMessageRow(m.pool.QueryRow(ctx, `
|
||||
SELECT `+m.chatMessageSelectColumns(ctx)+`
|
||||
FROM chat_messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, messageID).Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt)
|
||||
`, messageID), scoped)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const chatMessageSelectColumnsLegacy = `
|
||||
id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata,
|
||||
COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'),
|
||||
COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
|
||||
`
|
||||
|
||||
const chatMessageSelectColumnsScoped = `
|
||||
id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata,
|
||||
COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'),
|
||||
COALESCE(content_revision, 1), COALESCE(content_sha256, ''),
|
||||
COALESCE(workflow_instance_id, 0), COALESCE(stage_instance_id, 0),
|
||||
COALESCE(stage_key, ''), COALESCE(scope_kind, 'legacy_unscoped'), created_at
|
||||
`
|
||||
|
||||
func (m *ContextManager) hasMessageScopeColumns(ctx context.Context) bool {
|
||||
if m == nil || m.pool == nil {
|
||||
return false
|
||||
}
|
||||
var ok bool
|
||||
_ = m.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'chat_messages' AND column_name = 'scope_kind'
|
||||
)
|
||||
`).Scan(&ok)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *ContextManager) chatMessageSelectColumns(ctx context.Context) string {
|
||||
if m.hasMessageScopeColumns(ctx) {
|
||||
return chatMessageSelectColumnsScoped
|
||||
}
|
||||
return chatMessageSelectColumnsLegacy
|
||||
}
|
||||
|
||||
func appendChatMessagesFromRows(rows pgx.Rows, scoped bool) ([]ChatMessageRecord, error) {
|
||||
var out []ChatMessageRecord
|
||||
for rows.Next() {
|
||||
rec, err := scanChatMessageRow(rows, scoped)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanChatMessageRow(row pgx.Row, scoped bool) (ChatMessageRecord, error) {
|
||||
var r ChatMessageRecord
|
||||
var metaBytes []byte
|
||||
var err error
|
||||
if scoped {
|
||||
err = row.Scan(
|
||||
&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes,
|
||||
&r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256,
|
||||
&r.WorkflowInstanceID, &r.StageInstanceID, &r.StageKey, &r.ScopeKind, &r.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
err = row.Scan(
|
||||
&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes,
|
||||
&r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256,
|
||||
&r.CreatedAt,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if len(metaBytes) > 0 {
|
||||
_ = json.Unmarshal(metaBytes, &r.Metadata)
|
||||
}
|
||||
if r.ScopeKind == "" {
|
||||
_, _, _, r.ScopeKind = scopeFieldsFromMeta(r.Metadata)
|
||||
}
|
||||
if r.StageKey == "" {
|
||||
if sk, ok := r.Metadata["stage_key"].(string); ok {
|
||||
r.StageKey = sk
|
||||
}
|
||||
}
|
||||
enrichMessageExportCapability(&r)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func nullScopeInt64(v int64) any {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullScopeText(v string) any {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func scopeFieldsFromMeta(meta map[string]any) (workflowInstanceID, stageInstanceID int64, stageKey, scopeKind string) {
|
||||
if meta == nil {
|
||||
return 0, 0, "", "legacy_unscoped"
|
||||
}
|
||||
if sk, ok := meta["scope_kind"].(string); ok && sk != "" {
|
||||
scopeKind = sk
|
||||
} else {
|
||||
scopeKind = "legacy_unscoped"
|
||||
}
|
||||
if v, ok := meta["workflow_instance_id"].(float64); ok {
|
||||
workflowInstanceID = int64(v)
|
||||
} else if v, ok := meta["workflow_instance_id"].(int64); ok {
|
||||
workflowInstanceID = v
|
||||
}
|
||||
if v, ok := meta["stage_instance_id"].(float64); ok {
|
||||
stageInstanceID = int64(v)
|
||||
} else if v, ok := meta["stage_instance_id"].(int64); ok {
|
||||
stageInstanceID = v
|
||||
}
|
||||
if sk, ok := meta["stage_key"].(string); ok {
|
||||
stageKey = sk
|
||||
}
|
||||
return workflowInstanceID, stageInstanceID, stageKey, scopeKind
|
||||
}
|
||||
|
||||
// ListSessionMessagesForAuth 列出会话消息轻量字段(用于授权统计同集)。
|
||||
func (m *ContextManager) ListSessionMessagesForAuth(ctx context.Context, sessionID string) ([]ChatMessageRecord, error) {
|
||||
if sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if m.pool == nil {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
raw := m.messages[sessionID]
|
||||
out := make([]ChatMessageRecord, 0, len(raw))
|
||||
for _, msg := range raw {
|
||||
if msg.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
scoped := m.hasMessageScopeColumns(ctx)
|
||||
rows, err := m.pool.Query(ctx, `
|
||||
SELECT `+m.chatMessageSelectColumns(ctx)+`
|
||||
FROM chat_messages
|
||||
WHERE session_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return appendChatMessagesFromRows(rows, scoped)
|
||||
}
|
||||
+21
-17
@@ -102,23 +102,27 @@ type UserSummary struct {
|
||||
|
||||
// ChatMessageRecord 结构化会话消息
|
||||
type ChatMessageRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
DayKey string `json:"day_key"`
|
||||
Category string `json:"category"`
|
||||
ContentKind string `json:"content_kind,omitempty"`
|
||||
ExportPolicy string `json:"export_policy,omitempty"`
|
||||
ContentRevision int `json:"content_revision,omitempty"`
|
||||
ContentSha256 string `json:"content_sha256,omitempty"`
|
||||
ExportCapability any `json:"export_capability,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
DayKey string `json:"day_key"`
|
||||
Category string `json:"category"`
|
||||
ContentKind string `json:"content_kind,omitempty"`
|
||||
ExportPolicy string `json:"export_policy,omitempty"`
|
||||
ContentRevision int `json:"content_revision,omitempty"`
|
||||
ContentSha256 string `json:"content_sha256,omitempty"`
|
||||
WorkflowInstanceID int64 `json:"workflow_instance_id,omitempty"`
|
||||
StageInstanceID int64 `json:"stage_instance_id,omitempty"`
|
||||
StageKey string `json:"stage_key,omitempty"`
|
||||
ScopeKind string `json:"scope_kind,omitempty"`
|
||||
ExportCapability any `json:"export_capability,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ChatDayBucket 按日消息分片桶
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeWorkflowStage = "workflow_stage"
|
||||
ScopePreActivation = "pre_activation"
|
||||
ScopeLegacyUnscoped = "legacy_unscoped"
|
||||
ScopeNonProjectPrivate = "non_project_private"
|
||||
)
|
||||
|
||||
var ErrTaskScopeNotResolved = errors.New("无法唯一解析文档任务的工作流阶段作用域")
|
||||
|
||||
// MessageScope 不可变消息工作流作用域(服务端解析,客户端不可伪造)。
|
||||
type MessageScope struct {
|
||||
ScopeKind string `json:"scope_kind"`
|
||||
WorkflowInstanceID int64 `json:"workflow_instance_id,omitempty"`
|
||||
StageInstanceID int64 `json:"stage_instance_id,omitempty"`
|
||||
StageKey string `json:"stage_key,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyScopeToMetadata 将作用域写入 metadata(冗余,便于审计与兼容)。
|
||||
func ApplyScopeToMetadata(meta map[string]any, scope *MessageScope) {
|
||||
if meta == nil || scope == nil {
|
||||
return
|
||||
}
|
||||
meta["scope_kind"] = scope.ScopeKind
|
||||
if scope.WorkflowInstanceID > 0 {
|
||||
meta["workflow_instance_id"] = scope.WorkflowInstanceID
|
||||
}
|
||||
if scope.StageInstanceID > 0 {
|
||||
meta["stage_instance_id"] = scope.StageInstanceID
|
||||
}
|
||||
if scope.StageKey != "" {
|
||||
meta["stage_key"] = scope.StageKey
|
||||
}
|
||||
}
|
||||
|
||||
// ScopeFromMetadata 从 metadata 提取作用域(历史兼容)。
|
||||
func ScopeFromMetadata(metadata map[string]any) MessageScope {
|
||||
out := MessageScope{ScopeKind: ScopeLegacyUnscoped}
|
||||
if metadata == nil {
|
||||
return out
|
||||
}
|
||||
if sk, ok := metadata["scope_kind"].(string); ok && sk != "" {
|
||||
out.ScopeKind = sk
|
||||
}
|
||||
if v, ok := metadata["workflow_instance_id"].(float64); ok && v > 0 {
|
||||
out.WorkflowInstanceID = int64(v)
|
||||
} else if v, ok := metadata["workflow_instance_id"].(int64); ok && v > 0 {
|
||||
out.WorkflowInstanceID = v
|
||||
}
|
||||
if v, ok := metadata["stage_instance_id"].(float64); ok && v > 0 {
|
||||
out.StageInstanceID = int64(v)
|
||||
} else if v, ok := metadata["stage_instance_id"].(int64); ok && v > 0 {
|
||||
out.StageInstanceID = v
|
||||
}
|
||||
out.StageKey = MessageStageKey(metadata)
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveMessageScopeForChat 解析普通聊天消息作用域。
|
||||
func (s *Service) ResolveMessageScopeForChat(ctx context.Context, projectID int64, activeStageKey string) (*MessageScope, error) {
|
||||
if projectID <= 0 {
|
||||
return &MessageScope{ScopeKind: ScopeNonProjectPrivate}, nil
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||||
}
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil || detail == nil {
|
||||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||||
}
|
||||
stageKey := activeStageKey
|
||||
if stageKey == "" {
|
||||
stageKey = detail.ActiveStageKey
|
||||
}
|
||||
if stageKey == "" {
|
||||
return &MessageScope{
|
||||
ScopeKind: ScopePreActivation,
|
||||
WorkflowInstanceID: live.ID,
|
||||
}, nil
|
||||
}
|
||||
for _, st := range detail.Stages {
|
||||
if st.StageKey == stageKey {
|
||||
return &MessageScope{
|
||||
ScopeKind: ScopeWorkflowStage,
|
||||
WorkflowInstanceID: live.ID,
|
||||
StageInstanceID: st.ID,
|
||||
StageKey: st.StageKey,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return &MessageScope{
|
||||
ScopeKind: ScopeWorkflowStage,
|
||||
WorkflowInstanceID: live.ID,
|
||||
StageKey: stageKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveMessageScopeForTask 解析文档任务消息作用域;无法唯一归属时拒绝。
|
||||
func (s *Service) ResolveMessageScopeForTask(ctx context.Context, projectID int64, taskKey string) (*MessageScope, error) {
|
||||
if projectID <= 0 || taskKey == "" {
|
||||
return nil, fmt.Errorf("%w: project_id=%d task_key=%q", ErrTaskScopeNotResolved, projectID, taskKey)
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return nil, ErrTaskScopeNotResolved
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return nil, fmt.Errorf("%w: no live instance for project %d", ErrTaskScopeNotResolved, projectID)
|
||||
}
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil || detail == nil {
|
||||
return nil, ErrTaskScopeNotResolved
|
||||
}
|
||||
var matches []MessageScope
|
||||
for _, st := range detail.Stages {
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.TaskKey == taskKey {
|
||||
matches = append(matches, MessageScope{
|
||||
ScopeKind: ScopeWorkflowStage,
|
||||
WorkflowInstanceID: live.ID,
|
||||
StageInstanceID: st.ID,
|
||||
StageKey: st.StageKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
stageKey := TaskKeyToStage(live.ConfigSnapshot, taskKey)
|
||||
if stageKey == "" {
|
||||
return nil, fmt.Errorf("%w: task_key %q not in instance snapshot", ErrTaskScopeNotResolved, taskKey)
|
||||
}
|
||||
for _, st := range detail.Stages {
|
||||
if st.StageKey == stageKey {
|
||||
return &MessageScope{
|
||||
ScopeKind: ScopeWorkflowStage,
|
||||
WorkflowInstanceID: live.ID,
|
||||
StageInstanceID: st.ID,
|
||||
StageKey: st.StageKey,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrTaskScopeNotResolved
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
first := matches[0]
|
||||
for _, m := range matches[1:] {
|
||||
if m.StageKey != first.StageKey {
|
||||
return nil, fmt.Errorf("%w: ambiguous task_key %q", ErrTaskScopeNotResolved, taskKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
scope := matches[0]
|
||||
return &scope, nil
|
||||
}
|
||||
|
||||
// MessageVisible 判断消息对 viewer 是否可见(需会话 owner 与阶段授权上下文)。
|
||||
func MessageVisible(scope MessageScope, access *StageAccess, sessionOwnerID, viewerUserID int64) bool {
|
||||
if viewerUserID <= 0 {
|
||||
return false
|
||||
}
|
||||
switch scope.ScopeKind {
|
||||
case ScopeNonProjectPrivate, ScopePreActivation:
|
||||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||||
case ScopeLegacyUnscoped:
|
||||
if access == nil || !access.Restricted {
|
||||
return sessionOwnerID <= 0 || viewerUserID == sessionOwnerID
|
||||
}
|
||||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||||
case ScopeWorkflowStage, "":
|
||||
if access == nil || !access.Restricted {
|
||||
return true
|
||||
}
|
||||
stageKey := scope.StageKey
|
||||
if stageKey == "" {
|
||||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||||
}
|
||||
return access.StageKeyAllowed(stageKey)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MessageRecordVisible 从 ChatMessageRecord 字段判定可见性。
|
||||
func MessageRecordVisible(
|
||||
scopeKind, stageKey string,
|
||||
metadata map[string]any,
|
||||
access *StageAccess,
|
||||
sessionOwnerID, viewerUserID int64,
|
||||
) bool {
|
||||
scope := MessageScope{ScopeKind: scopeKind, StageKey: stageKey}
|
||||
if scope.ScopeKind == "" {
|
||||
scope = ScopeFromMetadata(metadata)
|
||||
}
|
||||
if scope.StageKey == "" {
|
||||
scope.StageKey = MessageStageKey(metadata)
|
||||
}
|
||||
return MessageVisible(scope, access, sessionOwnerID, viewerUserID)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package projectinstance
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMessageVisible_PreActivationOwnerOnly(t *testing.T) {
|
||||
access := &StageAccess{Restricted: true, AllowedStageKeys: map[string]bool{"V0.1_triage": true}}
|
||||
scope := MessageScope{ScopeKind: ScopePreActivation}
|
||||
|
||||
if !MessageVisible(scope, access, 9, 9) {
|
||||
t.Fatal("owner should see pre_activation messages")
|
||||
}
|
||||
if MessageVisible(scope, access, 9, 99) {
|
||||
t.Fatal("non-owner must not see pre_activation messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageVisible_WorkflowStageRestricted(t *testing.T) {
|
||||
access := &StageAccess{
|
||||
Restricted: true,
|
||||
AllowedStageKeys: map[string]bool{"V0.1_triage": true},
|
||||
}
|
||||
scope := MessageScope{ScopeKind: ScopeWorkflowStage, StageKey: "V0.1_triage"}
|
||||
if !MessageVisible(scope, access, 9, 9) {
|
||||
t.Fatal("allowed stage should be visible")
|
||||
}
|
||||
scope.StageKey = "V0.5_feasibility"
|
||||
if MessageVisible(scope, access, 9, 9) {
|
||||
t.Fatal("disallowed stage must be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageVisible_LegacyUnscopedOwnerWhenRestricted(t *testing.T) {
|
||||
access := &StageAccess{Restricted: true}
|
||||
scope := MessageScope{ScopeKind: ScopeLegacyUnscoped}
|
||||
if !MessageVisible(scope, access, 9, 9) {
|
||||
t.Fatal("owner should see legacy_unscoped when restricted")
|
||||
}
|
||||
if MessageVisible(scope, access, 9, 99) {
|
||||
t.Fatal("non-owner must not see legacy_unscoped when restricted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeFromMetadata_RoundTrip(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"scope_kind": ScopeWorkflowStage,
|
||||
"workflow_instance_id": float64(89),
|
||||
"stage_instance_id": float64(201),
|
||||
"stage_key": "V0.1_triage",
|
||||
}
|
||||
scope := ScopeFromMetadata(meta)
|
||||
if scope.ScopeKind != ScopeWorkflowStage || scope.WorkflowInstanceID != 89 || scope.StageInstanceID != 201 || scope.StageKey != "V0.1_triage" {
|
||||
t.Fatalf("unexpected scope: %+v", scope)
|
||||
}
|
||||
}
|
||||
@@ -86,13 +86,15 @@ func (s *PGStore) InsertRun(ctx context.Context, rec *RunRecord) error {
|
||||
run_id, session_id, project_id, user_id, client_turn_id,
|
||||
task_key, stage, status, workspace_id, assistant_message_id,
|
||||
error_message, worker_id, lease_expires_at, lease_heartbeat_at, attempt,
|
||||
workflow_instance_id, stage_instance_id, workflow_stage_key,
|
||||
created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||
ON CONFLICT (run_id) DO NOTHING
|
||||
`,
|
||||
rec.RunID, rec.SessionID, nullInt64(rec.ProjectID), rec.UserID, rec.ClientTurnID,
|
||||
rec.TaskKey, rec.Stage, rec.Status, rec.WorkspaceID, nullInt64(rec.AssistantMsgID),
|
||||
rec.ErrorMessage, rec.WorkerID, rec.LeaseExpiresAt, leaseHeartbeat, attempt,
|
||||
nullInt64(rec.WorkflowInstanceID), nullInt64(rec.StageInstanceID), nullScopeText(rec.WorkflowStageKey),
|
||||
rec.CreatedAt, rec.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
@@ -101,13 +103,15 @@ func (s *PGStore) InsertRun(ctx context.Context, rec *RunRecord) error {
|
||||
INSERT INTO task_runs (
|
||||
run_id, session_id, project_id, user_id, client_turn_id,
|
||||
task_key, stage, status, workspace_id, assistant_message_id,
|
||||
error_message, created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
error_message, workflow_instance_id, stage_instance_id, workflow_stage_key,
|
||||
created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (run_id) DO NOTHING
|
||||
`,
|
||||
rec.RunID, rec.SessionID, nullInt64(rec.ProjectID), rec.UserID, rec.ClientTurnID,
|
||||
rec.TaskKey, rec.Stage, rec.Status, rec.WorkspaceID, nullInt64(rec.AssistantMsgID),
|
||||
rec.ErrorMessage, rec.CreatedAt, rec.UpdatedAt,
|
||||
rec.ErrorMessage, nullInt64(rec.WorkflowInstanceID), nullInt64(rec.StageInstanceID), nullScopeText(rec.WorkflowStageKey),
|
||||
rec.CreatedAt, rec.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -373,6 +377,13 @@ func nullInt64(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
func nullScopeText(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// TablesReady 检查 task_runs 表是否已迁移。
|
||||
func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool {
|
||||
if pool == nil {
|
||||
|
||||
+19
-16
@@ -25,22 +25,25 @@ type CreateRequest struct {
|
||||
|
||||
// RunRecord 运行实例状态。
|
||||
type RunRecord struct {
|
||||
RunID string
|
||||
SessionID string
|
||||
ProjectID int64
|
||||
UserID int64
|
||||
ClientTurnID string
|
||||
TaskKey string
|
||||
Stage string
|
||||
Status string // pending | running | completed | failed | cancelled | status_conflict
|
||||
WorkspaceID string
|
||||
AssistantMsgID int64
|
||||
WorkerID string
|
||||
LeaseExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ErrorMessage string
|
||||
Attempt int
|
||||
RunID string
|
||||
SessionID string
|
||||
ProjectID int64
|
||||
UserID int64
|
||||
ClientTurnID string
|
||||
TaskKey string
|
||||
Stage string
|
||||
WorkflowInstanceID int64
|
||||
StageInstanceID int64
|
||||
WorkflowStageKey string
|
||||
Status string // pending | running | completed | failed | cancelled | status_conflict
|
||||
WorkspaceID string
|
||||
AssistantMsgID int64
|
||||
WorkerID string
|
||||
LeaseExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ErrorMessage string
|
||||
Attempt int
|
||||
}
|
||||
|
||||
// StoredEvent 持久化 SSE 事件(支持重连重放)。
|
||||
|
||||
Reference in New Issue
Block a user