@stackbone/cli 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +10 -9
- package/main.js +52942 -35578
- package/migrations/0073_model_selections.sql +38 -0
- package/migrations/0074_runs_cancelled_status.sql +18 -0
- package/migrations/0075_runs_trigger_ref.sql +41 -0
- package/migrations/0076_event_journal.sql +150 -0
- package/migrations/0077_runs_event_seq.sql +28 -0
- package/migrations/0078_run_steps_event_seq.sql +31 -0
- package/migrations/0079_approvals_event_seq.sql +32 -0
- package/migrations/0080_run_steps_model_call_tokens_idx.sql +15 -0
- package/migrations/0081_runs_world_closed_at.sql +27 -0
- package/migrations/0082_insights_widget.sql +42 -0
- package/migrations/0083_insights_dashboard.sql +64 -0
- package/migrations/0084_insights_team_overview.sql +33 -0
- package/migrations/0085_insights_widget_updated_idx.sql +10 -0
- package/migrations/0086_insights_now_metrics_idx.sql +29 -0
- package/migrations/0087_auto_map_model_to_model_selection.sql +28 -0
- package/migrations/0088_recurring_job_pauses.sql +27 -0
- package/migrations/0089_eval_run_status_reason.sql +25 -0
- package/package.json +1 -1
- package/stackbone-cli-0.6.0.tgz +0 -0
- package/stackbone-cli-0.5.3.tgz +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- 0073_model_selections.sql — the model an operator picked for one key of one
|
|
2
|
+
-- deep agent or one workflow (PLA-1599, ADR 2026-09-17 D1).
|
|
3
|
+
--
|
|
4
|
+
-- A MODEL SELECTION is one model id per (owner, key), stored per box. Staging
|
|
5
|
+
-- and production can run different models, so there is no per-organization or
|
|
6
|
+
-- per-template row.
|
|
7
|
+
--
|
|
8
|
+
-- NO VERSIONS, unlike `prompts` (0047/0048). A prompt is text someone writes and
|
|
9
|
+
-- reviews; a selection is one id. A write replaces the row and a reset deletes
|
|
10
|
+
-- it, so "no row" means "no pick" and the code default applies. The activity
|
|
11
|
+
-- log records who changed it and when (`model_selection.update` /
|
|
12
|
+
-- `model_selection.delete`), which is the history this table does not keep.
|
|
13
|
+
--
|
|
14
|
+
-- Identity is (owner_kind, owner_name, key), the vocabulary 0047 uses for a
|
|
15
|
+
-- prompt's owner, and for the same reason: two workflows may each declare a
|
|
16
|
+
-- `chat` key and run different models under it.
|
|
17
|
+
--
|
|
18
|
+
-- Deliberately NO CHECK on `owner_kind`, following 0047: a box running an older
|
|
19
|
+
-- build must be able to STORE a kind it cannot resolve. An unrecognised kind
|
|
20
|
+
-- never matches a read, which is the safe failure. `owner_name` is a free string
|
|
21
|
+
-- for the same reason as 0047's.
|
|
22
|
+
--
|
|
23
|
+
-- No catalogue check on `model_id` either. The model provider may be unreachable
|
|
24
|
+
-- while an operator edits, and a wrong id fails at run time with the provider's
|
|
25
|
+
-- own message (ADR D4).
|
|
26
|
+
--
|
|
27
|
+
-- `updated_by` is the acting user's id as the box reads it (a verified identity,
|
|
28
|
+
-- or the control-plane proxy's actor header): free text and nullable, like
|
|
29
|
+
-- `workspace_settings.updated_by` (0029). A `stackbone dev` write names nobody.
|
|
30
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.model_selections (
|
|
31
|
+
owner_kind text NOT NULL,
|
|
32
|
+
owner_name text NOT NULL,
|
|
33
|
+
key text NOT NULL,
|
|
34
|
+
model_id text NOT NULL,
|
|
35
|
+
updated_by text,
|
|
36
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
37
|
+
PRIMARY KEY (owner_kind, owner_name, key)
|
|
38
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- 0074_runs_cancelled_status.sql — a status of its own for a run an operator
|
|
2
|
+
-- cancelled (PLA-1716, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- Until now the cancel wrote `interrupted`, the status a run parked on a human
|
|
5
|
+
-- decision also carries, so the row could not say which of the two it was: the
|
|
6
|
+
-- Runs screen showed both the same, the delete policy had to refuse both, and
|
|
7
|
+
-- the reconcile sweep reopened a cancelled workflow run to `running` because it
|
|
8
|
+
-- read as a pause. `cancelled` is final — nothing that happens after it reopens
|
|
9
|
+
-- or completes the run — and the box store enforces that with a guard on every
|
|
10
|
+
-- close.
|
|
11
|
+
--
|
|
12
|
+
-- Widen `runs_status_check` to match `runStatusSchema`, the same way 0023 added
|
|
13
|
+
-- `waiting`. No trigger change: `notify_run_event` (0001) already emits
|
|
14
|
+
-- `run.finished` with the new status when a cancel stamps `finished_at`.
|
|
15
|
+
ALTER TABLE stackbone_platform.runs DROP CONSTRAINT IF EXISTS runs_status_check;
|
|
16
|
+
ALTER TABLE stackbone_platform.runs
|
|
17
|
+
ADD CONSTRAINT runs_status_check
|
|
18
|
+
CHECK (status IN ('running', 'done', 'failed', 'interrupted', 'waiting', 'cancelled'));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- 0075_runs_trigger_ref.sql — every workflow start gate names itself (PLA-1717,
|
|
2
|
+
-- PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- Until now the projector stamped every workflow run `workflow`, whether an
|
|
5
|
+
-- operator started it, a cron tick fired it, or a Connect link delivered the
|
|
6
|
+
-- event behind it: the row is written from the start unit every gate funnels
|
|
7
|
+
-- through, and nothing told that unit which gate called it. Each gate now passes
|
|
8
|
+
-- its trigger explicitly and the projector writes it as given. The trigger is
|
|
9
|
+
-- never guessed from the workflow's name.
|
|
10
|
+
--
|
|
11
|
+
-- Two new values, so the CHECK is recreated with the WHOLE list (a recreated
|
|
12
|
+
-- named CHECK replaces the old one wholesale; see 0038):
|
|
13
|
+
--
|
|
14
|
+
-- - `platform`: a run the box started as its own errand, not the operator's
|
|
15
|
+
-- traffic: rag-ingest, the Auto-map suggestion, an eval judge. The same
|
|
16
|
+
-- starts that already skip the operator's guardrails (`platformInvoked`).
|
|
17
|
+
-- - `unknown`: a start no gate stamped. The honest default, where `workflow`
|
|
18
|
+
-- used to be a guess.
|
|
19
|
+
--
|
|
20
|
+
-- `trigger_ref` says WHICH source started the run when the trigger alone does
|
|
21
|
+
-- not: the `trigger_subscriptions.id` of the Connect link for `event`, NULL for
|
|
22
|
+
-- every other gate. Written once at the start; a resume keeps it (the box store
|
|
23
|
+
-- coalesces it exactly like `trigger`).
|
|
24
|
+
--
|
|
25
|
+
-- NULLABLE, NO DEFAULT, NO BACKFILL. Existing rows keep their `workflow` and a
|
|
26
|
+
-- NULL ref: nothing on them says which gate started them.
|
|
27
|
+
--
|
|
28
|
+
-- NO FOREIGN KEY. A run outlives the link that started it: an operator deleting
|
|
29
|
+
-- a link must neither delete the run history nor be refused because runs exist.
|
|
30
|
+
-- The read-time link join (`trigger_deliveries`) stays the source for the link's
|
|
31
|
+
-- connector and trigger names.
|
|
32
|
+
--
|
|
33
|
+
-- NO INDEX. Nothing filters on it yet; an index would be a write cost with no
|
|
34
|
+
-- reader.
|
|
35
|
+
ALTER TABLE stackbone_platform.runs DROP CONSTRAINT IF EXISTS runs_trigger_check;
|
|
36
|
+
ALTER TABLE stackbone_platform.runs
|
|
37
|
+
ADD CONSTRAINT runs_trigger_check
|
|
38
|
+
CHECK (trigger IN ('manual', 'webhook', 'cron', 'event', 'job', 'workflow', 'chat', 'eval', 'platform', 'unknown'));
|
|
39
|
+
|
|
40
|
+
ALTER TABLE stackbone_platform.runs
|
|
41
|
+
ADD COLUMN IF NOT EXISTS trigger_ref text;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
-- 0076_event_journal.sql — the box's event journal (PLA-1718, PRD PLA-1685).
|
|
2
|
+
--
|
|
3
|
+
-- An append-only record of the box's business facts, in a schema of its own:
|
|
4
|
+
--
|
|
5
|
+
-- stores → stackbone_events.outbox → relay → stackbone_events.events
|
|
6
|
+
--
|
|
7
|
+
-- A store writes the domain row and its outbox row in ONE transaction, so both
|
|
8
|
+
-- land or neither does. The relay (`@stackbone/event-journal`) moves committed
|
|
9
|
+
-- outbox rows into `events` once a second and gives each a `position`. Readers
|
|
10
|
+
-- (the Insights engine, later a rollup or an exporter) keep a cursor on
|
|
11
|
+
-- `position`. Nothing here leaves the box.
|
|
12
|
+
--
|
|
13
|
+
-- The second schema these migrations create, and the box's third:
|
|
14
|
+
-- `stackbone_checkpoints` is created by the deep-agent checkpointer at boot. A
|
|
15
|
+
-- DBA who runs with STACKBONE_SKIP_MIGRATIONS=1 applies this file by hand like
|
|
16
|
+
-- every other one in this directory; everything in it is schema-qualified.
|
|
17
|
+
|
|
18
|
+
CREATE SCHEMA IF NOT EXISTS stackbone_events;
|
|
19
|
+
|
|
20
|
+
-- event_id: a name-based uuid computed from the event name and its natural key
|
|
21
|
+
-- (the row's id, plus its event_seq when the row can pass through the same
|
|
22
|
+
-- state twice). The same fact written twice gets the same id, and the relay
|
|
23
|
+
-- drops the second copy. Built on md5 so no extension is needed; the version
|
|
24
|
+
-- nibble says 3 (MD5 name-based) and the variant is the RFC one, so any uuid
|
|
25
|
+
-- parser reads it. U+001F separates the two parts so ('a.b', 'c') and
|
|
26
|
+
-- ('a.bc', '') never collide.
|
|
27
|
+
CREATE OR REPLACE FUNCTION stackbone_events.event_uuid(event_name text, natural_key text)
|
|
28
|
+
RETURNS uuid
|
|
29
|
+
LANGUAGE sql
|
|
30
|
+
IMMUTABLE STRICT PARALLEL SAFE
|
|
31
|
+
AS $$
|
|
32
|
+
SELECT (substr(h, 1, 12) || '3' || substr(h, 14, 3)
|
|
33
|
+
|| substr('89ab89ab89ab89ab', strpos('0123456789abcdef', substr(h, 17, 1)), 1)
|
|
34
|
+
|| substr(h, 18))::uuid
|
|
35
|
+
FROM (SELECT md5(event_name || chr(31) || natural_key) AS h) AS digest
|
|
36
|
+
$$;
|
|
37
|
+
|
|
38
|
+
-- The outbox. Its insert rides the domain write, so nothing in it may fail on
|
|
39
|
+
-- data: every envelope column is text, props is jsonb, and there is no CHECK and
|
|
40
|
+
-- no cast. The relay casts on the way out, and a row that does not cast goes to
|
|
41
|
+
-- dead_letters instead of blocking the rest. `id` is the order the relay moves
|
|
42
|
+
-- rows in; `enqueued_at` is what the relay's once-a-minute log line reports the
|
|
43
|
+
-- age of.
|
|
44
|
+
CREATE TABLE IF NOT EXISTS stackbone_events.outbox (
|
|
45
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
46
|
+
enqueued_at timestamptz NOT NULL DEFAULT now(),
|
|
47
|
+
event_id text,
|
|
48
|
+
name text,
|
|
49
|
+
version text,
|
|
50
|
+
occurred_at text,
|
|
51
|
+
recorded_at text,
|
|
52
|
+
run_id text,
|
|
53
|
+
correlation_id text,
|
|
54
|
+
causation_id text,
|
|
55
|
+
aggregate text,
|
|
56
|
+
event_seq text,
|
|
57
|
+
entity text,
|
|
58
|
+
entity_kind text,
|
|
59
|
+
trigger text,
|
|
60
|
+
actor text,
|
|
61
|
+
channel text,
|
|
62
|
+
props jsonb
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- The journal. `position` orders one box's journal and means nothing outside
|
|
66
|
+
-- it. CACHE 1 on purpose: a sequence that caches hands each backend a private
|
|
67
|
+
-- range, so a relay on a fresh connection could take numbers BELOW rows another
|
|
68
|
+
-- backend already committed, and a reader walking `position` would skip them.
|
|
69
|
+
-- The envelope's run dimensions are columns, so no query needs a join.
|
|
70
|
+
-- `source_id` is not a column: it is one value per box and lives in `meta`.
|
|
71
|
+
CREATE TABLE IF NOT EXISTS stackbone_events.events (
|
|
72
|
+
position bigint GENERATED ALWAYS AS IDENTITY (CACHE 1) PRIMARY KEY,
|
|
73
|
+
event_id uuid NOT NULL,
|
|
74
|
+
name text NOT NULL,
|
|
75
|
+
version integer NOT NULL CHECK (version > 0),
|
|
76
|
+
occurred_at timestamptz NOT NULL,
|
|
77
|
+
recorded_at timestamptz NOT NULL,
|
|
78
|
+
run_id text,
|
|
79
|
+
correlation_id text,
|
|
80
|
+
causation_id text,
|
|
81
|
+
aggregate text,
|
|
82
|
+
event_seq integer CHECK (event_seq >= 0),
|
|
83
|
+
entity text,
|
|
84
|
+
entity_kind text,
|
|
85
|
+
trigger text,
|
|
86
|
+
actor text,
|
|
87
|
+
channel text,
|
|
88
|
+
props jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(props) = 'object'),
|
|
89
|
+
CONSTRAINT events_event_id_key UNIQUE (event_id)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE INDEX IF NOT EXISTS events_name_occurred_at_idx
|
|
93
|
+
ON stackbone_events.events (name, occurred_at);
|
|
94
|
+
|
|
95
|
+
-- The rows the relay could not move: the outbox row as it was written, and the
|
|
96
|
+
-- error Postgres gave for it. Nothing deletes these, the retention included.
|
|
97
|
+
CREATE TABLE IF NOT EXISTS stackbone_events.dead_letters (
|
|
98
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
99
|
+
outbox_id bigint NOT NULL,
|
|
100
|
+
payload jsonb NOT NULL,
|
|
101
|
+
error text NOT NULL,
|
|
102
|
+
failed_at timestamptz NOT NULL DEFAULT now()
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- One row per box: the source every event in this journal belongs to. The box
|
|
106
|
+
-- writes it on its first boot — the installation id it resolved, `local-dev`
|
|
107
|
+
-- under `stackbone dev` — and never changes it.
|
|
108
|
+
CREATE TABLE IF NOT EXISTS stackbone_events.meta (
|
|
109
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
110
|
+
source_id text NOT NULL,
|
|
111
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
-- Append-only. The relay is the journal's ONLY writer and the retention job its
|
|
115
|
+
-- only deleter; each names itself for its own transaction with
|
|
116
|
+
-- `set_config('stackbone_events.writer', 'relay' | 'retention', true)`, and this
|
|
117
|
+
-- statement trigger refuses everything else, UPDATE always. A guard against a
|
|
118
|
+
-- code path that writes the journal by mistake, not a security boundary: the
|
|
119
|
+
-- roles that can reach this table at all are the box's owner and, read-only,
|
|
120
|
+
-- stackbone_viewer.
|
|
121
|
+
CREATE OR REPLACE FUNCTION stackbone_events.events_refuse_foreign_write() RETURNS trigger
|
|
122
|
+
LANGUAGE plpgsql AS $$
|
|
123
|
+
DECLARE
|
|
124
|
+
writer text := coalesce(current_setting('stackbone_events.writer', true), '');
|
|
125
|
+
BEGIN
|
|
126
|
+
IF TG_OP = 'INSERT' AND writer = 'relay' THEN
|
|
127
|
+
RETURN NULL;
|
|
128
|
+
END IF;
|
|
129
|
+
IF TG_OP IN ('DELETE', 'TRUNCATE') AND writer = 'retention' THEN
|
|
130
|
+
RETURN NULL;
|
|
131
|
+
END IF;
|
|
132
|
+
RAISE EXCEPTION 'stackbone_events.events is append-only: % refused', TG_OP
|
|
133
|
+
USING ERRCODE = 'restrict_violation',
|
|
134
|
+
HINT = 'Only the event relay inserts and only the retention job deletes.';
|
|
135
|
+
END;
|
|
136
|
+
$$;
|
|
137
|
+
|
|
138
|
+
DROP TRIGGER IF EXISTS events_refuse_foreign_write ON stackbone_events.events;
|
|
139
|
+
CREATE TRIGGER events_refuse_foreign_write
|
|
140
|
+
BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON stackbone_events.events
|
|
141
|
+
FOR EACH STATEMENT EXECUTE FUNCTION stackbone_events.events_refuse_foreign_write();
|
|
142
|
+
|
|
143
|
+
-- stackbone_viewer reads the journal and its meta, and nothing else here. Table
|
|
144
|
+
-- by table and with NO default privileges on the schema, unlike 0005's grants on
|
|
145
|
+
-- `public` and `stackbone_platform`: a table added here later stays closed to the
|
|
146
|
+
-- viewer until a migration opens it. The REVOKE is explicit so a cluster-wide
|
|
147
|
+
-- default privilege a DBA set by hand cannot open the outbox either.
|
|
148
|
+
GRANT USAGE ON SCHEMA stackbone_events TO stackbone_viewer;
|
|
149
|
+
GRANT SELECT ON stackbone_events.events, stackbone_events.meta TO stackbone_viewer;
|
|
150
|
+
REVOKE ALL ON stackbone_events.outbox, stackbone_events.dead_letters FROM stackbone_viewer;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- 0077_runs_event_seq.sql — the two columns a run needs to journal its start and
|
|
2
|
+
-- its closes (PLA-1721, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- `event_seq`: how many times this row has closed since it was inserted. 0 when
|
|
5
|
+
-- the row is written, so `run.started` carries 0; each close that changes the
|
|
6
|
+
-- status adds 1, and `run.finished` carries the new value. A close on a row whose
|
|
7
|
+
-- `event_seq` is already above 0 is a second close (the reconciler failed the
|
|
8
|
+
-- run, then it completed), journaled with `reclose = true`. Together with the
|
|
9
|
+
-- row's `created_at` it keys the event: a run id deleted and written again starts
|
|
10
|
+
-- back at 0, and `created_at` tells the two rows apart.
|
|
11
|
+
--
|
|
12
|
+
-- `waited_for_approval`: whether the run ever parked on a human decision
|
|
13
|
+
-- (`interrupted`). Sticky: set by the pause and never cleared by the resume, so
|
|
14
|
+
-- `run.finished` can separate runs that waited for a person from runs that did
|
|
15
|
+
-- not.
|
|
16
|
+
--
|
|
17
|
+
-- NOT NULL with a constant default: Postgres 11+ stores the default once in the
|
|
18
|
+
-- catalog, so neither ADD COLUMN rewrites `runs`. Existing rows read 0 and false:
|
|
19
|
+
-- nothing journaled their past closes, and nothing recorded their pauses.
|
|
20
|
+
--
|
|
21
|
+
-- No index and no CHECK. Nothing filters on either; both are written by the box
|
|
22
|
+
-- store alone. No trigger change: `notify_run_event` (0001) keys on `started_at`
|
|
23
|
+
-- and `finished_at` only.
|
|
24
|
+
ALTER TABLE stackbone_platform.runs
|
|
25
|
+
ADD COLUMN IF NOT EXISTS event_seq integer NOT NULL DEFAULT 0;
|
|
26
|
+
|
|
27
|
+
ALTER TABLE stackbone_platform.runs
|
|
28
|
+
ADD COLUMN IF NOT EXISTS waited_for_approval boolean NOT NULL DEFAULT false;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- 0078_run_steps_event_seq.sql — the two columns a run step needs to journal its
|
|
2
|
+
-- end (PLA-1726, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- A step's events (`workflow_step.finished`, `model_call.finished`,
|
|
5
|
+
-- `connector_call.finished`, `tool_call.finished`) ride the upsert that writes
|
|
6
|
+
-- the step's row, and the upsert itself decides whether the write is an end:
|
|
7
|
+
--
|
|
8
|
+
-- `event_status`: the final status (`completed`, `failed`, `cancelled`) the row's
|
|
9
|
+
-- last event reported. NULL while the row is open: a write that re-arms the row
|
|
10
|
+
-- (a World retry of a step whose tool calls are keyed by position) clears it, so
|
|
11
|
+
-- the next end journals again. It is NOT `status`: the run's final sweep closes a
|
|
12
|
+
-- row left `running` without journaling anything, and the real outcome that
|
|
13
|
+
-- arrives later still has to journal.
|
|
14
|
+
--
|
|
15
|
+
-- `event_seq`: how many step events the row has journaled. The upsert adds 1 when
|
|
16
|
+
-- the row reaches a final status that differs from `event_status`, and the event
|
|
17
|
+
-- carries the new value, so the same end written twice journals once and each
|
|
18
|
+
-- real end is its own event.
|
|
19
|
+
--
|
|
20
|
+
-- NOT NULL with a constant default: Postgres 11+ stores the default once in the
|
|
21
|
+
-- catalog, so neither ADD COLUMN rewrites `run_steps`. Existing rows read 0 and
|
|
22
|
+
-- NULL: nothing journaled their past ends.
|
|
23
|
+
--
|
|
24
|
+
-- No index and no CHECK. Nothing filters on either; both are written by the box
|
|
25
|
+
-- store alone. No trigger change: `notify_run_step_event` (0018) keys on
|
|
26
|
+
-- `completed_at` only.
|
|
27
|
+
ALTER TABLE stackbone_platform.run_steps
|
|
28
|
+
ADD COLUMN IF NOT EXISTS event_seq integer NOT NULL DEFAULT 0;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE stackbone_platform.run_steps
|
|
31
|
+
ADD COLUMN IF NOT EXISTS event_status text;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- 0079_approvals_event_seq.sql — the two columns an approval needs to journal its
|
|
2
|
+
-- request and its end (PLA-1727, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- The box approvals store is the one writer that journals approval events
|
|
5
|
+
-- (`approval.created`, `approval.decided`, `approval.timed_out`), in the
|
|
6
|
+
-- transaction of the write each one describes. The SDK's agent-local INSERT
|
|
7
|
+
-- still writes this table directly and journals nothing.
|
|
8
|
+
--
|
|
9
|
+
-- `event_seq`: how many approval events the row has journaled. Each event adds 1
|
|
10
|
+
-- and carries the new value, so a row re-armed by a key a new request reused
|
|
11
|
+
-- keys its second request, decision or timeout apart from the first. 0 on a row
|
|
12
|
+
-- the box store never journaled.
|
|
13
|
+
--
|
|
14
|
+
-- `requested_at`: when the box store journaled the request that is open now: set
|
|
15
|
+
-- by the INSERT and by every re-arm. A re-arm clears `decided_at`, and
|
|
16
|
+
-- `created_at` names the first request only, so the time to a decision counts
|
|
17
|
+
-- from this column. NULL on a row whose request the box never saw (the SDK wrote
|
|
18
|
+
-- it), and its end reports `request_observed = false`.
|
|
19
|
+
--
|
|
20
|
+
-- `event_seq` is NOT NULL with a constant default and `requested_at` is nullable
|
|
21
|
+
-- with none: Postgres 11+ rewrites nothing for either ADD COLUMN, and every
|
|
22
|
+
-- existing row reads 0 and NULL, since nothing journaled its request.
|
|
23
|
+
--
|
|
24
|
+
-- No index and no CHECK: nothing filters on either, and only the box store
|
|
25
|
+
-- writes them. No trigger change: `notify_approval_event` (0002) keys on the
|
|
26
|
+
-- INSERT, `decided_at` and `status` only, so the live `approval.created` still
|
|
27
|
+
-- fires on the INSERT alone and a re-arm sends none.
|
|
28
|
+
ALTER TABLE stackbone_platform.approvals
|
|
29
|
+
ADD COLUMN IF NOT EXISTS event_seq integer NOT NULL DEFAULT 0;
|
|
30
|
+
|
|
31
|
+
ALTER TABLE stackbone_platform.approvals
|
|
32
|
+
ADD COLUMN IF NOT EXISTS requested_at timestamptz;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- 0080_run_steps_model_call_tokens_idx.sql — the index a run's token totals are
|
|
2
|
+
-- read through (PLA-1715, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- A run's tokens are the sums of its `model_call` rows, read at every Runs,
|
|
5
|
+
-- run detail, Sessions and Evals query (`runs/run-token-totals.ts` in the box
|
|
6
|
+
-- runtime), and `model_call.finished` journals the same rows. The only index on
|
|
7
|
+
-- `run_id` so far is `(run_id, started_at)`, which finds a run's steps of every
|
|
8
|
+
-- type and then fetches each row to read its type and counters.
|
|
9
|
+
--
|
|
10
|
+
-- Partial on `type = 'model_call'`, so it holds only the rows the fragment sums,
|
|
11
|
+
-- and covering (`INCLUDE`) the four counters, so the sum is an index-only scan.
|
|
12
|
+
CREATE INDEX IF NOT EXISTS run_steps_model_call_tokens_idx
|
|
13
|
+
ON stackbone_platform.run_steps (run_id)
|
|
14
|
+
INCLUDE (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
|
15
|
+
WHERE type = 'model_call';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- 0081_runs_world_closed_at.sql — when the World run behind a cancelled row was
|
|
2
|
+
-- confirmed ended (PLA-1716, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- A cancel writes `runs.status = 'cancelled'` at once, but the World workflow
|
|
5
|
+
-- behind the row keeps running: the SDK has no cancel signal yet. A `serial`
|
|
6
|
+
-- workflow's lock belongs to that World run, so it may only be freed once the
|
|
7
|
+
-- World run really ends, and the reconciliation sweep is the one place that
|
|
8
|
+
-- looks. The sweep lists open rows only (`running`, `interrupted`), so it never
|
|
9
|
+
-- revisited a cancelled row and the lock stayed held until the process restarted,
|
|
10
|
+
-- with every queued trigger stuck `waiting` behind it.
|
|
11
|
+
--
|
|
12
|
+
-- `world_closed_at` is the sweep's bookmark for a cancelled row: NULL while the
|
|
13
|
+
-- World run behind it may still be alive, so the sweep keeps listing the row; set
|
|
14
|
+
-- once a pass saw that World run completed, failed, cancelled or gone, AFTER that
|
|
15
|
+
-- pass freed the serial lock. The row's status never changes and nothing else
|
|
16
|
+
-- reads it.
|
|
17
|
+
--
|
|
18
|
+
-- NULLABLE, NO DEFAULT, NO BACKFILL. A row cancelled before this migration reads
|
|
19
|
+
-- NULL and is swept once more; the release is a no-op when this process does not
|
|
20
|
+
-- hold its lock, and the pass then sets the column.
|
|
21
|
+
--
|
|
22
|
+
-- NO INDEX. The sweep query (`listOpenWorkflowRunTraceIds` in the box runtime)
|
|
23
|
+
-- already reads `runs` without an index on `status`; adding the cancelled rows to
|
|
24
|
+
-- its predicate does not change that plan, and a partial index on only that half
|
|
25
|
+
-- of an OR would not be used for the whole predicate.
|
|
26
|
+
ALTER TABLE stackbone_platform.runs
|
|
27
|
+
ADD COLUMN IF NOT EXISTS world_closed_at timestamptz;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- 0082_insights_widget.sql — the widgets saved on this box (PLA-1734, PRD
|
|
2
|
+
-- PLA-1684). A widget is a saved query with its visualisation: a named,
|
|
3
|
+
-- versioned document Studio's Widget Builder edits and a dashboard places.
|
|
4
|
+
--
|
|
5
|
+
-- Numbered against this feature branch, whose last migration is 0081; `next`
|
|
6
|
+
-- and `main` stop at 0072. The runner keys `schema_migrations` on the four-digit
|
|
7
|
+
-- prefix alone, so `scripts/check-migration-numbers.mjs` is the pre-merge gate:
|
|
8
|
+
-- renumber against the tip you merge into.
|
|
9
|
+
--
|
|
10
|
+
-- Stored per box, like runs and approvals: every reader of one installation sees
|
|
11
|
+
-- the same library in any browser, and two installations never share one. The
|
|
12
|
+
-- control plane stores nothing about widgets.
|
|
13
|
+
--
|
|
14
|
+
-- `definition` is the whole widget document (display, query, presentation), as
|
|
15
|
+
-- `@stackbone/insights-contract` validates it. Its shape version travels inside
|
|
16
|
+
-- it (`definition.schemaVersion`), so there is no column for it and no upgrade on
|
|
17
|
+
-- read: no older shape exists yet.
|
|
18
|
+
--
|
|
19
|
+
-- `revision` starts at 1 and every update bumps it. An update names the revision
|
|
20
|
+
-- it was based on, and the box writes only when that is still the stored one:
|
|
21
|
+
-- the second of two concurrent saves gets `revision_conflict`, never a silent
|
|
22
|
+
-- overwrite.
|
|
23
|
+
--
|
|
24
|
+
-- `copied_from` names the widget this one was duplicated from. NO foreign key:
|
|
25
|
+
-- the origin may be deleted, and the copy keeps saying where it came from.
|
|
26
|
+
--
|
|
27
|
+
-- `created_by` / `updated_by` are the acting user's id as the box reads it (a
|
|
28
|
+
-- verified identity, or the control-plane proxy's actor header): free text and
|
|
29
|
+
-- nullable, like `model_selections.updated_by` (0073). A `stackbone dev` write
|
|
30
|
+
-- names nobody.
|
|
31
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.insights_widget (
|
|
32
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
33
|
+
name text NOT NULL,
|
|
34
|
+
description text NOT NULL DEFAULT '',
|
|
35
|
+
definition jsonb NOT NULL,
|
|
36
|
+
revision integer NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
|
37
|
+
copied_from uuid,
|
|
38
|
+
created_by text,
|
|
39
|
+
updated_by text,
|
|
40
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
41
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
42
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- 0083_insights_dashboard.sql — the dashboards saved on this box and the widget
|
|
2
|
+
-- each of their placements references (PLA-1735, PRD PLA-1684). A dashboard is
|
|
3
|
+
-- a saved composition of widgets placed in a slot: a named, versioned document
|
|
4
|
+
-- Studio's Dashboard Builder edits.
|
|
5
|
+
--
|
|
6
|
+
-- Numbered against this feature branch, whose last migration is 0082; `next`
|
|
7
|
+
-- and `main` stop at 0072. The runner keys `schema_migrations` on the four-digit
|
|
8
|
+
-- prefix alone, so `scripts/check-migration-numbers.mjs` is the pre-merge gate:
|
|
9
|
+
-- renumber against the tip you merge into.
|
|
10
|
+
--
|
|
11
|
+
-- `insights_dashboard` mirrors `insights_widget` (0082): `definition` is the
|
|
12
|
+
-- whole composition (placements, filters, required filters) as
|
|
13
|
+
-- `@stackbone/insights-contract` validates it, its shape version inside it;
|
|
14
|
+
-- `revision` is the counter an update must name; `copied_from` carries no
|
|
15
|
+
-- foreign key, so a copy keeps naming an origin that was deleted. `slot` is the
|
|
16
|
+
-- place in Studio the dashboard lives in (`DASHBOARD_SLOTS`), and it never
|
|
17
|
+
-- changes once the row exists. The list of one slot reads newest first through
|
|
18
|
+
-- `insights_dashboard_slot_updated_idx`.
|
|
19
|
+
--
|
|
20
|
+
-- `insights_dashboard_widget` holds one row per placement: which widget it
|
|
21
|
+
-- places. The layout stays in `definition`; these rows copy its widget ids and
|
|
22
|
+
-- the box rewrites them in the SAME transaction as every save. They exist for
|
|
23
|
+
-- integrity and for the usage lookup, not as a second copy of the layout:
|
|
24
|
+
--
|
|
25
|
+
-- - `insights_dashboard_widget_widget_fk` is `ON DELETE RESTRICT`, the first
|
|
26
|
+
-- in this migration set. Deleting a widget a dashboard places fails with
|
|
27
|
+
-- SQLSTATE 23503 under that name, and the box answers `widget_in_use`. The
|
|
28
|
+
-- database decides, so a dashboard save racing the delete cannot slip a
|
|
29
|
+
-- reference past a check the box made first.
|
|
30
|
+
-- - `insights_dashboard_widget_dashboard_fk` is `ON DELETE CASCADE`: deleting
|
|
31
|
+
-- a dashboard frees every widget it placed.
|
|
32
|
+
-- - The key is the placement, so one widget can sit on a dashboard twice.
|
|
33
|
+
-- - `insights_dashboard_widget_widget_idx` serves "which dashboards place this
|
|
34
|
+
-- widget", and the RESTRICT check the delete runs.
|
|
35
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.insights_dashboard (
|
|
36
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
37
|
+
slot text NOT NULL,
|
|
38
|
+
name text NOT NULL,
|
|
39
|
+
description text NOT NULL DEFAULT '',
|
|
40
|
+
definition jsonb NOT NULL,
|
|
41
|
+
revision integer NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
|
42
|
+
copied_from uuid,
|
|
43
|
+
created_by text,
|
|
44
|
+
updated_by text,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS insights_dashboard_slot_updated_idx
|
|
50
|
+
ON stackbone_platform.insights_dashboard (slot, updated_at);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.insights_dashboard_widget (
|
|
53
|
+
dashboard_id uuid NOT NULL,
|
|
54
|
+
placement_id text NOT NULL,
|
|
55
|
+
widget_id uuid NOT NULL,
|
|
56
|
+
PRIMARY KEY (dashboard_id, placement_id),
|
|
57
|
+
CONSTRAINT insights_dashboard_widget_dashboard_fk FOREIGN KEY (dashboard_id)
|
|
58
|
+
REFERENCES stackbone_platform.insights_dashboard (id) ON DELETE CASCADE,
|
|
59
|
+
CONSTRAINT insights_dashboard_widget_widget_fk FOREIGN KEY (widget_id)
|
|
60
|
+
REFERENCES stackbone_platform.insights_widget (id) ON DELETE RESTRICT
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS insights_dashboard_widget_widget_idx
|
|
64
|
+
ON stackbone_platform.insights_dashboard_widget (widget_id);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- 0084_insights_team_overview.sql — which saved dashboards each Studio team's
|
|
2
|
+
-- Overview shows, in order (PLA-1736, PRD PLA-1684). The storage PLA-1683's
|
|
3
|
+
-- Overview reads; the screen that edits it is not part of this migration.
|
|
4
|
+
--
|
|
5
|
+
-- Numbered against this feature branch, whose last migration is 0083; `next`
|
|
6
|
+
-- and `main` stop at 0072. The runner keys `schema_migrations` on the four-digit
|
|
7
|
+
-- prefix alone, so `scripts/check-migration-numbers.mjs` is the pre-merge gate:
|
|
8
|
+
-- renumber against the tip you merge into.
|
|
9
|
+
--
|
|
10
|
+
-- One row per dashboard a team shows. `team` is a Studio team id
|
|
11
|
+
-- (`STUDIO_TEAM_IDS` in `@stackbone/validators`); the box refuses any other, and
|
|
12
|
+
-- the column carries no CHECK so a new team needs no migration. The box replaces
|
|
13
|
+
-- a team's rows whole, in one transaction, so a reorder never leaves half a list.
|
|
14
|
+
--
|
|
15
|
+
-- - `PRIMARY KEY (team, dashboard_id)`: a team shows a dashboard once.
|
|
16
|
+
-- - `insights_team_overview_position_key`: one dashboard per position of a
|
|
17
|
+
-- team. Positions start at 0.
|
|
18
|
+
-- - `insights_team_overview_dashboard_fk` is `ON DELETE CASCADE`: deleting a
|
|
19
|
+
-- dashboard takes it off every team's Overview. On insert it is also how the
|
|
20
|
+
-- box learns a listed dashboard does not exist (SQLSTATE 23503 under that
|
|
21
|
+
-- name), which it answers as `unknown_dashboard`.
|
|
22
|
+
--
|
|
23
|
+
-- No index on `dashboard_id` alone: four teams' lists is a table the cascade
|
|
24
|
+
-- reads whole faster than it would read an index.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.insights_team_overview (
|
|
26
|
+
team text NOT NULL,
|
|
27
|
+
dashboard_id uuid NOT NULL,
|
|
28
|
+
position integer NOT NULL CHECK (position >= 0),
|
|
29
|
+
PRIMARY KEY (team, dashboard_id),
|
|
30
|
+
CONSTRAINT insights_team_overview_position_key UNIQUE (team, position),
|
|
31
|
+
CONSTRAINT insights_team_overview_dashboard_fk FOREIGN KEY (dashboard_id)
|
|
32
|
+
REFERENCES stackbone_platform.insights_dashboard (id) ON DELETE CASCADE
|
|
33
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- 0085_insights_widget_updated_idx.sql — the index the widget library is read
|
|
2
|
+
-- through (PLA-1684).
|
|
3
|
+
--
|
|
4
|
+
-- `GET /api/insights/widgets` answers every saved widget, most recently updated
|
|
5
|
+
-- first (`ORDER BY updated_at DESC, created_at DESC` in the box's Insights
|
|
6
|
+
-- store), and 0082 created the table with its primary key only, so every read
|
|
7
|
+
-- of the library sorted the whole table. The index carries that order, both
|
|
8
|
+
-- columns, so the read walks it instead.
|
|
9
|
+
CREATE INDEX IF NOT EXISTS insights_widget_updated_idx
|
|
10
|
+
ON stackbone_platform.insights_widget (updated_at DESC, created_at DESC);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- 0086_insights_now_metrics_idx.sql — the indexes the Insights "now" metrics
|
|
2
|
+
-- are read through (PLA-1725, PRD PLA-1685).
|
|
3
|
+
--
|
|
4
|
+
-- Three metrics read the current state of a domain table instead of the event
|
|
5
|
+
-- journal, on every query, never from a cache (the box runtime's
|
|
6
|
+
-- `insights/engine/catalog/now-metrics.ts`):
|
|
7
|
+
--
|
|
8
|
+
-- - `approvals_pending`: approvals in `pending`, by `topic` and whether the
|
|
9
|
+
-- sweep will time them out (`timeout_at` and `fallback`);
|
|
10
|
+
-- - `runs_active`: runs `running` or `interrupted`, by status and workflow or
|
|
11
|
+
-- agent (`entity_name`). `runs` had no index on `status` at all;
|
|
12
|
+
-- - `outputs_waiting`: parked outputs not settled yet, by `workflow_name`,
|
|
13
|
+
-- `kind` and `reason`.
|
|
14
|
+
--
|
|
15
|
+
-- Each index is partial on the metric's predicate, spelled as the metric spells
|
|
16
|
+
-- it so the planner proves the index covers the read, and holds the columns its
|
|
17
|
+
-- properties read, so a count by any of them is an index-only scan over the
|
|
18
|
+
-- rows the metric counts and no others.
|
|
19
|
+
CREATE INDEX IF NOT EXISTS approvals_pending_now_idx
|
|
20
|
+
ON stackbone_platform.approvals (topic, timeout_at, fallback)
|
|
21
|
+
WHERE status = 'pending';
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS runs_active_now_idx
|
|
24
|
+
ON stackbone_platform.runs (entity_name, status)
|
|
25
|
+
WHERE status IN ('running', 'interrupted');
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS parked_file_outputs_waiting_now_idx
|
|
28
|
+
ON stackbone_platform.parked_file_outputs (workflow_name, kind, reason)
|
|
29
|
+
WHERE settled_at IS NULL;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- 0087_auto_map_model_to_model_selection.sql — Auto-map's model moves from the
|
|
2
|
+
-- `autoMapModel` workspace setting onto its catalog entry (PLA-1745).
|
|
3
|
+
--
|
|
4
|
+
-- The default `mapping-suggest` workflow now reads its model with
|
|
5
|
+
-- `stackbone.models.use('model')`, so the pick lives where the entry's Models
|
|
6
|
+
-- block edits it: the (workflow, mapping-suggest, model) row of
|
|
7
|
+
-- `model_selections` (0073). A box that already saved `autoMapModel` keeps
|
|
8
|
+
-- running that model: the value is COPIED once, here, rather than read as a
|
|
9
|
+
-- fallback, because a fallback would keep a value in force that no screen shows.
|
|
10
|
+
--
|
|
11
|
+
-- A box that already holds a selection under that key keeps it: the operator
|
|
12
|
+
-- picked it on the entry, which is the newer of the two editors. `ON CONFLICT DO
|
|
13
|
+
-- NOTHING` is that rule.
|
|
14
|
+
--
|
|
15
|
+
-- The setting is then removed from the payload in every case, copied or not, so
|
|
16
|
+
-- nothing is left for a stale reader to find. An empty string or a JSON null
|
|
17
|
+
-- names no model and copies nothing.
|
|
18
|
+
INSERT INTO stackbone_platform.model_selections (owner_kind, owner_name, key, model_id)
|
|
19
|
+
SELECT 'workflow', 'mapping-suggest', 'model', payload ->> 'autoMapModel'
|
|
20
|
+
FROM stackbone_platform.workspace_settings
|
|
21
|
+
WHERE id = 1
|
|
22
|
+
AND coalesce(payload ->> 'autoMapModel', '') <> ''
|
|
23
|
+
ON CONFLICT (owner_kind, owner_name, key) DO NOTHING;
|
|
24
|
+
|
|
25
|
+
UPDATE stackbone_platform.workspace_settings
|
|
26
|
+
SET payload = payload - 'autoMapModel'
|
|
27
|
+
WHERE id = 1
|
|
28
|
+
AND payload ? 'autoMapModel';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- 0088_recurring_job_pauses.sql — the recurring jobs an operator paused from
|
|
2
|
+
-- Studio (PLA-1842).
|
|
3
|
+
--
|
|
4
|
+
-- A PAUSE is one row per pause key. The key names a GROUP of recurring jobs that
|
|
5
|
+
-- stop and start together: a workflow pauses as `workflow:<name>`, which covers
|
|
6
|
+
-- every cron it declares (`decl:<name>#<n>`) and the one it registers at run time
|
|
7
|
+
-- (`dyn:<name>`). Keying on the scheduler id instead would not survive a
|
|
8
|
+
-- redeploy: a declared id is the cron's POSITION in `schedules`, so reordering
|
|
9
|
+
-- the list would move the pause onto another cron.
|
|
10
|
+
--
|
|
11
|
+
-- The box reads every row at boot and arms nothing a row names, so a pause holds
|
|
12
|
+
-- across restarts and redeploys. Resuming deletes the row: "no row" means "not
|
|
13
|
+
-- paused", and the activity log (`recurring_job.pause` / `recurring_job.resume`)
|
|
14
|
+
-- is the history this table does not keep.
|
|
15
|
+
--
|
|
16
|
+
-- No CHECK on the key's shape, for the reason 0073 gives for `owner_kind`: a box
|
|
17
|
+
-- running an older build must still be able to STORE a key it cannot resolve. A
|
|
18
|
+
-- key no declared job carries pauses nothing, which is the safe failure.
|
|
19
|
+
--
|
|
20
|
+
-- `paused_by` is the acting user's id as the box reads it, free text and
|
|
21
|
+
-- nullable like `model_selections.updated_by` (0073). A `stackbone dev` write
|
|
22
|
+
-- names nobody.
|
|
23
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.recurring_job_pauses (
|
|
24
|
+
pause_key text PRIMARY KEY,
|
|
25
|
+
paused_by text,
|
|
26
|
+
paused_at timestamptz NOT NULL DEFAULT now()
|
|
27
|
+
);
|