feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
-- Durable project-scoped operational tasks, immutable artifacts and approvals.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_tasks (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
create_request_id uuid NOT NULL,
|
||||
task_type text NOT NULL CHECK (task_type IN
|
||||
('scheduled_flow','external_work_order','event_investigation')),
|
||||
workflow text NOT NULL CHECK (length(workflow) > 0),
|
||||
status text NOT NULL CHECK (status IN
|
||||
('queued','running','waiting_human','completed','failed','cancelled')),
|
||||
priority text NOT NULL CHECK (priority IN ('normal','urgent')),
|
||||
revision bigint NOT NULL CHECK (revision >= 1),
|
||||
execution_generation bigint NOT NULL CHECK (execution_generation >= 1),
|
||||
execution_request_id uuid NOT NULL,
|
||||
agent_session_id text,
|
||||
agent_lease_expires_at timestamptz,
|
||||
current_plan_revision bigint,
|
||||
current_report_revision bigint,
|
||||
scheduled_for timestamptz,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id, task_id),
|
||||
UNIQUE (project_id, create_request_id),
|
||||
UNIQUE (project_id, execution_request_id),
|
||||
CHECK ((jsonb_typeof(document)='object'
|
||||
AND document->>'task_id'=task_id::text
|
||||
AND document->>'task_type'=task_type
|
||||
AND document->>'workflow'=workflow
|
||||
AND document->>'status'=status
|
||||
AND (document->>'revision')::bigint=revision) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS operational_tasks_status_idx
|
||||
ON workbench.operational_tasks(project_id,status,updated_at DESC,task_id);
|
||||
CREATE INDEX IF NOT EXISTS operational_tasks_lease_idx
|
||||
ON workbench.operational_tasks(project_id,agent_lease_expires_at)
|
||||
WHERE status='running' AND agent_lease_expires_at IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS operational_tasks_external_source_idx
|
||||
ON workbench.operational_tasks(
|
||||
project_id,
|
||||
(document#>>'{source,kind}'),
|
||||
(document#>>'{source,external_id}')
|
||||
) WHERE document#>>'{source,external_id}' IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_requests (
|
||||
project_id uuid NOT NULL,
|
||||
scope text NOT NULL CHECK (length(scope) > 0),
|
||||
request_id uuid NOT NULL,
|
||||
request_digest text NOT NULL CHECK (length(request_digest) > 0),
|
||||
response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (project_id,scope,request_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_evidence (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
evidence_id text NOT NULL CHECK (length(evidence_id) > 0),
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,evidence_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_plans (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
revision bigint NOT NULL CHECK (revision >= 1),
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,revision),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_reports (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
revision bigint NOT NULL CHECK (revision >= 1),
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,revision),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_actions (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
action_id uuid NOT NULL,
|
||||
version bigint NOT NULL CHECK (version >= 1),
|
||||
approval_status text NOT NULL CHECK (approval_status IN
|
||||
('pending','approved','rejected')),
|
||||
execution_status text NOT NULL CHECK (execution_status IN
|
||||
('not_submitted','submitted','succeeded','failed','unsupported','unknown')),
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,action_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE,
|
||||
CHECK ((document->>'action_id'=action_id::text
|
||||
AND (document->>'version')::bigint=version
|
||||
AND document->>'approval_status'=approval_status
|
||||
AND document->>'execution_status'=execution_status) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_action_revisions (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
action_id uuid NOT NULL,
|
||||
version bigint NOT NULL CHECK (version >= 1),
|
||||
document jsonb NOT NULL,
|
||||
recorded_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,action_id,version),
|
||||
FOREIGN KEY (project_id,task_id,action_id)
|
||||
REFERENCES workbench.operational_task_actions(project_id,task_id,action_id)
|
||||
ON DELETE CASCADE,
|
||||
CHECK ((document->>'action_id'=action_id::text
|
||||
AND (document->>'version')::bigint=version) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_external_feedback (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
source_event_id text NOT NULL CHECK (length(source_event_id) > 0),
|
||||
source_revision bigint NOT NULL CHECK (source_revision >= 1),
|
||||
document jsonb NOT NULL,
|
||||
accepted_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,source_event_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_notifications (
|
||||
project_id uuid NOT NULL,
|
||||
notification_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id,notification_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS operational_task_notifications_unread_idx
|
||||
ON workbench.operational_task_notifications(project_id,created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_notification_reads (
|
||||
project_id uuid NOT NULL,
|
||||
notification_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
version bigint NOT NULL CHECK (version >= 2),
|
||||
read_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,notification_id,user_id),
|
||||
FOREIGN KEY (project_id,notification_id)
|
||||
REFERENCES workbench.operational_task_notifications(project_id,notification_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_schedules (
|
||||
project_id uuid NOT NULL,
|
||||
schedule_id uuid NOT NULL,
|
||||
create_request_id uuid NOT NULL,
|
||||
schedule_key text NOT NULL CHECK (length(schedule_key) > 0),
|
||||
next_run_at timestamptz NOT NULL,
|
||||
interval_seconds integer NOT NULL CHECK (interval_seconds >= 60),
|
||||
enabled boolean NOT NULL,
|
||||
version bigint NOT NULL CHECK (version >= 1),
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,schedule_id),
|
||||
UNIQUE (project_id,create_request_id),
|
||||
UNIQUE (project_id,schedule_key),
|
||||
CHECK ((document->>'schedule_id'=schedule_id::text
|
||||
AND document->>'schedule_key'=schedule_key
|
||||
AND (document->>'version')::bigint=version) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_schedule_occurrences (
|
||||
project_id uuid NOT NULL,
|
||||
schedule_id uuid NOT NULL,
|
||||
due_at timestamptz NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,schedule_id,due_at),
|
||||
UNIQUE (project_id,task_id),
|
||||
FOREIGN KEY (project_id,schedule_id)
|
||||
REFERENCES workbench.operational_task_schedules(project_id,schedule_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Durable workbench event log and independent Agent execution runs.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.domain_event_log (
|
||||
event_seq bigserial PRIMARY KEY,
|
||||
project_id uuid NOT NULL,
|
||||
event_id uuid NOT NULL,
|
||||
topic text NOT NULL CHECK (topic IN (
|
||||
'operational.task.changed',
|
||||
'operational.run.queued',
|
||||
'operational.run.changed',
|
||||
'operational.report.published',
|
||||
'operational.action.changed',
|
||||
'operational.notification.changed',
|
||||
'workbench.alert.changed',
|
||||
'workbench.work_order.changed',
|
||||
'analysis.run.changed'
|
||||
)),
|
||||
aggregate_type text NOT NULL CHECK (length(aggregate_type) > 0),
|
||||
aggregate_id text NOT NULL CHECK (length(aggregate_id) > 0),
|
||||
aggregate_revision bigint NOT NULL CHECK (aggregate_revision >= 1),
|
||||
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (project_id,event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS domain_event_log_project_cursor_idx
|
||||
ON workbench.domain_event_log(project_id,event_seq);
|
||||
CREATE INDEX IF NOT EXISTS domain_event_log_retention_idx
|
||||
ON workbench.domain_event_log(occurred_at,event_seq);
|
||||
|
||||
ALTER TABLE IF EXISTS analysis.runs
|
||||
ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1
|
||||
CHECK (revision >= 1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_runs (
|
||||
project_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
generation bigint NOT NULL CHECK (generation >= 1),
|
||||
execution_request_id uuid NOT NULL,
|
||||
status text NOT NULL CHECK (status IN (
|
||||
'queued','claimed','running','succeeded','failed','timed_out','cancelled'
|
||||
)),
|
||||
revision bigint NOT NULL CHECK (revision >= 1),
|
||||
agent_session_id text,
|
||||
lease_expires_at timestamptz,
|
||||
heartbeat_at timestamptz,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
failure_code text,
|
||||
failure_detail text,
|
||||
dispatch_attempts integer NOT NULL DEFAULT 0 CHECK (dispatch_attempts >= 0),
|
||||
next_dispatch_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,run_id),
|
||||
UNIQUE (project_id,task_id,generation),
|
||||
UNIQUE (project_id,execution_request_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS operational_task_runs_one_active_idx
|
||||
ON workbench.operational_task_runs(project_id,task_id)
|
||||
WHERE status IN ('queued','claimed','running');
|
||||
CREATE INDEX IF NOT EXISTS operational_task_runs_queue_idx
|
||||
ON workbench.operational_task_runs(
|
||||
project_id,next_dispatch_at,created_at,run_id
|
||||
) WHERE status='queued';
|
||||
CREATE INDEX IF NOT EXISTS operational_task_runs_lease_idx
|
||||
ON workbench.operational_task_runs(project_id,lease_expires_at,run_id)
|
||||
WHERE status IN ('claimed','running') AND lease_expires_at IS NOT NULL;
|
||||
|
||||
ALTER TABLE workbench.operational_tasks
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_status text;
|
||||
|
||||
UPDATE workbench.operational_tasks
|
||||
SET lifecycle_status = CASE status
|
||||
WHEN 'waiting_human' THEN 'waiting_human'
|
||||
WHEN 'completed' THEN 'completed'
|
||||
WHEN 'failed' THEN 'failed'
|
||||
WHEN 'cancelled' THEN 'cancelled'
|
||||
ELSE 'open'
|
||||
END
|
||||
WHERE lifecycle_status IS NULL;
|
||||
|
||||
UPDATE workbench.operational_tasks
|
||||
SET document = document || jsonb_build_object('lifecycle_status', lifecycle_status)
|
||||
WHERE document->>'lifecycle_status' IS DISTINCT FROM lifecycle_status;
|
||||
|
||||
ALTER TABLE workbench.operational_tasks
|
||||
ALTER COLUMN lifecycle_status SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid='workbench.operational_tasks'::regclass
|
||||
AND conname='operational_tasks_lifecycle_status_check'
|
||||
) THEN
|
||||
ALTER TABLE workbench.operational_tasks
|
||||
ADD CONSTRAINT operational_tasks_lifecycle_status_check
|
||||
CHECK (lifecycle_status IN
|
||||
('open','waiting_human','completed','failed','cancelled'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Deterministically preserve the currently active or last known execution as
|
||||
-- generation history. uuid values are derived without requiring an extension.
|
||||
INSERT INTO workbench.operational_task_runs (
|
||||
project_id,run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,created_at,updated_at
|
||||
)
|
||||
SELECT
|
||||
project_id,
|
||||
(substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),1,8)
|
||||
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),9,4)
|
||||
|| '-5' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),14,3)
|
||||
|| '-a' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),18,3)
|
||||
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),21,12))::uuid,
|
||||
task_id,
|
||||
execution_generation,
|
||||
execution_request_id,
|
||||
CASE status
|
||||
WHEN 'queued' THEN 'queued'
|
||||
WHEN 'running' THEN 'running'
|
||||
WHEN 'waiting_human' THEN 'succeeded'
|
||||
WHEN 'completed' THEN 'succeeded'
|
||||
WHEN 'failed' THEN 'failed'
|
||||
WHEN 'cancelled' THEN 'cancelled'
|
||||
END,
|
||||
GREATEST(revision,1),
|
||||
agent_session_id,
|
||||
agent_lease_expires_at,
|
||||
CASE WHEN status='running' THEN updated_at ELSE NULL END,
|
||||
CASE WHEN status IN ('running','waiting_human','completed','failed','cancelled')
|
||||
THEN created_at ELSE NULL END,
|
||||
CASE WHEN status IN ('waiting_human','completed','failed','cancelled')
|
||||
THEN updated_at ELSE NULL END,
|
||||
CASE WHEN status='failed' THEN 'MIGRATED_FAILURE' ELSE NULL END,
|
||||
NULL,
|
||||
0,
|
||||
created_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM workbench.operational_tasks
|
||||
ON CONFLICT (project_id,task_id,generation) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Distinguish non-interactive OAuth identities from human product users.
|
||||
-- This migration is intentionally re-runnable.
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_role_check
|
||||
CHECK (role IN ('admin', 'service', 'user'));
|
||||
Reference in New Issue
Block a user