@stackbone/cli 0.5.4 → 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.
@@ -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
+ );