feat(workbench): add dynamic task approval lifecycle

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent db092b0f5e
commit ad813a7c16
35 changed files with 2956 additions and 564 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
-- Read-only work-order projection synchronized by an external work-order source.
-- Historical work-order projection. Do not reapply after
-- 021_retire_workbench_work_orders.sql has retired this table.
BEGIN;
@@ -0,0 +1,56 @@
-- Task-scoped, reviewable data access requests for dynamic investigations.
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
BEGIN;
SET LOCAL lock_timeout = '5s';
DO $$
BEGIN
IF current_database() <> 'tjwater_v2' THEN
RAISE EXCEPTION 'Operational access-request migration is restricted to tjwater_v2';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS workbench.operational_task_access_requests (
project_id uuid NOT NULL,
task_id uuid NOT NULL,
access_request_id uuid NOT NULL,
node_id text NOT NULL CHECK (length(node_id) > 0),
status text NOT NULL CHECK (status IN ('pending','approved','rejected')),
version bigint NOT NULL CHECK (version >= 1),
document jsonb NOT NULL,
requested_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (project_id,task_id,access_request_id),
FOREIGN KEY (project_id,task_id)
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE,
CHECK ((document->>'access_request_id'=access_request_id::text
AND document->>'status'=status
AND (document->>'version')::bigint=version) IS TRUE)
);
CREATE INDEX IF NOT EXISTS operational_task_access_pending_idx
ON workbench.operational_task_access_requests(project_id,status,requested_at)
WHERE status='pending';
COMMENT ON TABLE workbench.operational_task_access_requests IS
'动态调查中的任务级数据访问申请;与动作审批分离,保存申请范围及人工审核结果。';
COMMENT ON COLUMN workbench.operational_task_access_requests.project_id IS
'所属项目标识;当前仍要求一个项目独占一个 BizDB。';
COMMENT ON COLUMN workbench.operational_task_access_requests.task_id IS
'所属工况任务标识。';
COMMENT ON COLUMN workbench.operational_task_access_requests.access_request_id IS
'访问申请的稳定标识,同时用于计划审核节点引用。';
COMMENT ON COLUMN workbench.operational_task_access_requests.node_id IS
'发起申请的执行计划节点标识。';
COMMENT ON COLUMN workbench.operational_task_access_requests.status IS
'人工审核状态:pending、approved 或 rejected。';
COMMENT ON COLUMN workbench.operational_task_access_requests.version IS
'用于乐观并发控制的申请修订号。';
COMMENT ON COLUMN workbench.operational_task_access_requests.document IS
'经过接口模型校验的完整访问申请文档。';
COMMENT ON COLUMN workbench.operational_task_access_requests.requested_at IS
'Agent 发起访问申请的时间。';
COMMENT ON COLUMN workbench.operational_task_access_requests.updated_at IS
'申请最近一次状态变更时间。';
COMMIT;
@@ -0,0 +1,55 @@
-- Retire the legacy demo-order projection only after its three rows have
-- moved to active peripheral orders and active operational task projections.
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
BEGIN;
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
legacy_count integer;
BEGIN
IF current_database() <> 'tjwater_v2' THEN
RAISE EXCEPTION 'Legacy work-order retirement is restricted to tjwater_v2';
END IF;
IF to_regclass('workbench.work_orders') IS NULL THEN
RETURN;
END IF;
LOCK TABLE workbench.work_orders IN ACCESS EXCLUSIVE MODE;
SELECT count(*) INTO legacy_count FROM workbench.work_orders;
IF legacy_count <> 3 OR EXISTS (
SELECT 1
FROM workbench.work_orders AS legacy
WHERE legacy.project_id <> '324ff7ee-c2d4-5184-a84b-48fb14a2933e'::uuid
OR legacy.work_order_id NOT IN (
'mock-work-order-incident-valve-dispatch',
'mock-work-order-pump-boundary-reply',
'mock-work-order-valve-operation-execution'
)
OR legacy.document->>'migrated_to_peripheral_sample' IS NULL
OR NOT EXISTS (
SELECT 1 FROM peripheral_sample.work_orders AS source_order
WHERE source_order.project_id = legacy.project_id
AND source_order.work_order_id::text =
legacy.document->>'migrated_to_peripheral_sample'
AND source_order.deleted_at IS NULL
)
OR NOT EXISTS (
SELECT 1 FROM workbench.operational_tasks AS task
WHERE task.project_id = legacy.project_id
AND task.task_type = 'external_work_order'
AND task.document#>>'{source,external_id}' =
'peripheral_sample:' ||
(legacy.document->>'migrated_to_peripheral_sample')
AND task.document#>>'{source,deleted_at}' IS NULL
)
) THEN
RAISE EXCEPTION 'Legacy work-order retirement preconditions failed';
END IF;
DROP TABLE workbench.work_orders;
END $$;
COMMIT;
@@ -0,0 +1,225 @@
-- Backfill actionable notification lifecycle state and business-object links.
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
BEGIN;
SET LOCAL lock_timeout = '5s';
DO $$
BEGIN
IF current_database() <> 'tjwater_v2' THEN
RAISE EXCEPTION 'Notification lifecycle migration is restricted to tjwater_v2';
END IF;
END $$;
LOCK TABLE workbench.operational_task_notifications IN SHARE ROW EXCLUSIVE MODE;
CREATE TEMP TABLE notification_lifecycle_projects ON COMMIT DROP AS
SELECT project_id, count(*)::bigint AS notification_count
FROM workbench.operational_task_notifications
GROUP BY project_id;
WITH mapped AS (
SELECT
notification.project_id,
notification.notification_id,
action.action_id,
action.approval_status,
action.execution_status
FROM workbench.operational_task_notifications AS notification
LEFT JOIN LATERAL (
SELECT candidate.action_id, candidate.approval_status, candidate.execution_status
FROM workbench.operational_task_actions AS candidate
WHERE candidate.project_id = notification.project_id
AND candidate.task_id = notification.task_id
AND 1 = (
SELECT count(*)
FROM workbench.operational_task_actions AS sibling
WHERE sibling.project_id = notification.project_id
AND sibling.task_id = notification.task_id
)
LIMIT 1
) AS action ON true
WHERE notification.document->>'kind' IN (
'action_pending', 'action_approval_pending', 'external_coordination_failed'
)
AND NOT (notification.document ? 'lifecycle_status')
)
UPDATE workbench.operational_task_notifications AS notification
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
'lifecycle_status', CASE
WHEN mapped.action_id IS NULL THEN 'active'
WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
AND mapped.approval_status = 'pending' THEN 'active'
WHEN notification.document->>'kind' = 'external_coordination_failed'
AND mapped.execution_status IN ('failed', 'unsupported', 'unknown') THEN 'active'
ELSE 'resolved'
END,
'subject_type', CASE WHEN mapped.action_id IS NULL THEN 'task' ELSE 'action' END,
'subject_id', COALESCE(mapped.action_id::text, notification.task_id::text),
'resolved_at', CASE
WHEN (notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
AND mapped.approval_status <> 'pending')
OR (notification.document->>'kind' = 'external_coordination_failed'
AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown'))
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
ELSE NULL
END,
'resolution_reason', CASE
WHEN notification.document->>'resolution_reason' IS NOT NULL
THEN notification.document->>'resolution_reason'
WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
AND mapped.approval_status <> 'pending' THEN 'migration_action_decided'
WHEN notification.document->>'kind' = 'external_coordination_failed'
AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown')
THEN 'migration_external_coordination_recovered'
ELSE NULL
END
))
FROM mapped
WHERE notification.project_id = mapped.project_id
AND notification.notification_id = mapped.notification_id;
DO $block$
BEGIN
IF to_regclass('workbench.operational_task_access_requests') IS NOT NULL THEN
EXECUTE $update$
WITH mapped AS (
SELECT
notification.project_id,
notification.notification_id,
access_request.access_request_id,
access_request.status
FROM workbench.operational_task_notifications AS notification
LEFT JOIN LATERAL (
SELECT candidate.access_request_id, candidate.status
FROM workbench.operational_task_access_requests AS candidate
WHERE candidate.project_id = notification.project_id
AND candidate.task_id = notification.task_id
AND 1 = (
SELECT count(*)
FROM workbench.operational_task_access_requests AS sibling
WHERE sibling.project_id = notification.project_id
AND sibling.task_id = notification.task_id
)
LIMIT 1
) AS access_request ON true
WHERE notification.document->>'kind' = 'access_approval_pending'
AND NOT (notification.document ? 'lifecycle_status')
)
UPDATE workbench.operational_task_notifications AS notification
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
'lifecycle_status', CASE
WHEN mapped.access_request_id IS NULL OR mapped.status = 'pending'
THEN 'active' ELSE 'resolved'
END,
'subject_type', CASE
WHEN mapped.access_request_id IS NULL THEN 'task'
ELSE 'access_request'
END,
'subject_id', COALESCE(
mapped.access_request_id::text,
notification.task_id::text
),
'resolved_at', CASE
WHEN mapped.access_request_id IS NOT NULL AND mapped.status <> 'pending'
THEN COALESCE(
notification.document->'resolved_at',
to_jsonb(CURRENT_TIMESTAMP)
)
ELSE NULL
END,
'resolution_reason', CASE
WHEN notification.document->>'resolution_reason' IS NOT NULL
THEN notification.document->>'resolution_reason'
WHEN mapped.status <> 'pending' THEN 'migration_access_request_decided'
ELSE NULL
END
))
FROM mapped
WHERE notification.project_id = mapped.project_id
AND notification.notification_id = mapped.notification_id
$update$;
END IF;
END $block$;
UPDATE workbench.operational_task_notifications AS notification
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
'lifecycle_status', CASE
WHEN notification.document->>'kind' = 'task_failed'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'failed'
THEN 'active'
WHEN notification.document->>'kind' = 'human_input_required'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'waiting_human'
THEN 'active'
WHEN notification.document->>'kind' IN ('task_failed', 'human_input_required')
THEN 'resolved'
ELSE COALESCE(notification.document->>'lifecycle_status', 'active')
END,
'subject_type', COALESCE(notification.document->>'subject_type', 'task'),
'subject_id', COALESCE(notification.document->>'subject_id', notification.task_id::text),
'resolved_at', CASE
WHEN notification.document->>'kind' = 'task_failed'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed'
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
WHEN notification.document->>'kind' = 'human_input_required'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human'
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
ELSE notification.document->'resolved_at'
END,
'resolution_reason', CASE
WHEN notification.document->>'kind' = 'task_failed'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed'
THEN 'migration_task_recovered'
WHEN notification.document->>'kind' = 'human_input_required'
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human'
THEN 'migration_human_input_completed'
ELSE notification.document->>'resolution_reason'
END
))
FROM workbench.operational_tasks AS task
WHERE notification.project_id = task.project_id
AND notification.task_id = task.task_id
AND notification.document->>'kind' NOT IN (
'action_pending', 'action_approval_pending',
'external_coordination_failed', 'access_approval_pending'
)
AND NOT (notification.document ? 'lifecycle_status');
WITH inserted_events AS (
INSERT INTO workbench.domain_event_log (
project_id,
event_id,
topic,
aggregate_type,
aggregate_id,
aggregate_revision,
metadata
)
SELECT
project_id,
(
substr(md5(project_id::text || ':notification-lifecycle-v1'), 1, 8)
|| '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 9, 4)
|| '-5' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 14, 3)
|| '-a' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 18, 3)
|| '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 21, 12)
)::uuid,
'operational.notification.changed',
'operational_notification_batch',
project_id::text,
1,
jsonb_build_object(
'change', 'lifecycle_backfilled',
'notification_count', notification_count
)
FROM notification_lifecycle_projects
ON CONFLICT (project_id, event_id) DO NOTHING
RETURNING project_id, event_seq
)
SELECT pg_notify('workbench_events', project_id::text || ':' || event_seq::text)
FROM inserted_events;
COMMENT ON COLUMN workbench.operational_task_notifications.document IS
'通知内容及 active/resolved 生命周期;用户 unread/read 状态独立存于 notification_reads。';
COMMIT;