@stackbone/cli 0.3.3 → 0.3.5
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 +161 -0
- package/README.md +18 -18
- package/main.js +51517 -45762
- package/migrations/0047_prompts_owner.sql +99 -0
- package/migrations/0048_prompt_publish.sql +58 -0
- package/migrations/0049_run_steps_annotations.sql +46 -0
- package/migrations/0050_run_steps_model_provider.sql +30 -0
- package/migrations/0051_logs.sql +137 -0
- package/migrations/0052_telemetry_export_state.sql +43 -0
- package/migrations/0053_runs_exported_at.sql +51 -0
- package/migrations/0054_runs_trace_parent.sql +35 -0
- package/migrations/0055_audit_log.sql +105 -0
- package/package.json +3 -1
- package/runtime-deps/@stackbone/workflow-world/index.js +73 -73
- package/stackbone-cli-0.3.5.tgz +0 -0
- package/stackbone-cli-0.3.3.tgz +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- 0047_prompts_owner.sql — a prompt belongs to ONE workflow or ONE deep agent,
|
|
2
|
+
-- not to the whole installation (STA-398).
|
|
3
|
+
--
|
|
4
|
+
-- 0013 made `key` a workspace-wide PRIMARY KEY. That reads fine with three
|
|
5
|
+
-- prompts and breaks the moment two entries want the same obvious name: the
|
|
6
|
+
-- sign-up workflow's `welcome_email` and the reactivation workflow's
|
|
7
|
+
-- `welcome_email` are different letters, and under the old key the second one
|
|
8
|
+
-- could not be written at all — `prompts_already_exists`, with the operator's
|
|
9
|
+
-- only way out being to invent `welcome_email_2`. Identity becomes the PAIR
|
|
10
|
+
-- (owner, key), where the owner is `(owner_kind, owner_name)`.
|
|
11
|
+
--
|
|
12
|
+
-- The vocabulary is the one the box already uses for the same idea. A guardrail
|
|
13
|
+
-- attachment (0036) is scoped to an `agent` or a `workflow` unit by NAME, and a
|
|
14
|
+
-- run (0037) records its `entity_kind` for the same reason: a deep agent and a
|
|
15
|
+
-- workflow may legitimately share a name, so the kind is part of the identity.
|
|
16
|
+
-- Prompts drop 0036's third value — there is no workspace-wide prompt, because
|
|
17
|
+
-- a prompt with no owner is exactly the thing this migration exists to remove.
|
|
18
|
+
--
|
|
19
|
+
-- Deliberately NO CHECK constraint on `owner_kind`, following 0036 and 0037: a
|
|
20
|
+
-- box running an older build must be able to STORE a kind it cannot resolve
|
|
21
|
+
-- rather than reject the write and lose the operator's text. An unrecognised
|
|
22
|
+
-- kind simply never matches a read, which is the safe failure.
|
|
23
|
+
--
|
|
24
|
+
-- `owner_name` is an unconstrained free string like 0036's `unit_id`, NOT the
|
|
25
|
+
-- lowercase slug `key` is validated against on the wire: a workflow named
|
|
26
|
+
-- `sendWelcomeEmail` is a legitimate name the guardrails surface already
|
|
27
|
+
-- accepts for the identical concept, and a stricter column here would refuse to
|
|
28
|
+
-- store a prompt for a workflow that exists.
|
|
29
|
+
--
|
|
30
|
+
-- DROP + CREATE rather than a widening ALTER, on purpose. There is no deployed
|
|
31
|
+
-- installation to preserve, and the alternative — adding two NOT NULL columns to
|
|
32
|
+
-- rows that carry no owner — would need an invented default owner for the
|
|
33
|
+
-- backfill, which is a guess about whose prompt each row was. Rebuilding the two
|
|
34
|
+
-- tables keeps the schema honest instead of encoding that guess.
|
|
35
|
+
|
|
36
|
+
DROP TABLE IF EXISTS stackbone_platform.prompt_versions;
|
|
37
|
+
DROP TABLE IF EXISTS stackbone_platform.prompts;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE stackbone_platform.prompts (
|
|
40
|
+
-- The owner's kind: `agent` (a deep agent) or `workflow`. See above for why
|
|
41
|
+
-- there is no CHECK and no `workspace` member.
|
|
42
|
+
owner_kind text NOT NULL,
|
|
43
|
+
-- The owner's NAME, as the bundle declares it.
|
|
44
|
+
owner_name text NOT NULL,
|
|
45
|
+
key text NOT NULL,
|
|
46
|
+
name text NOT NULL,
|
|
47
|
+
description text,
|
|
48
|
+
-- The version number considered live. Always points at an existing row in
|
|
49
|
+
-- `prompt_versions` for this (owner, key).
|
|
50
|
+
current_version integer NOT NULL CHECK (current_version >= 1),
|
|
51
|
+
metadata jsonb,
|
|
52
|
+
created_by_email text,
|
|
53
|
+
updated_by_email text,
|
|
54
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
55
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
56
|
+
-- Soft delete. Active prompts have `deleted_at IS NULL`; reads filter on it
|
|
57
|
+
-- so the rows survive for the retention window.
|
|
58
|
+
deleted_at timestamptz,
|
|
59
|
+
PRIMARY KEY (owner_kind, owner_name, key)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE stackbone_platform.prompt_versions (
|
|
63
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
64
|
+
-- All three identity columns, not a widened `prompt_key`: the parent's key
|
|
65
|
+
-- is composite now, so a composite FK needs the child to carry every part of
|
|
66
|
+
-- it. Without them a version row could not say which owner's chain it is in.
|
|
67
|
+
owner_kind text NOT NULL,
|
|
68
|
+
owner_name text NOT NULL,
|
|
69
|
+
prompt_key text NOT NULL,
|
|
70
|
+
version integer NOT NULL CHECK (version >= 1),
|
|
71
|
+
content text NOT NULL,
|
|
72
|
+
-- Pre-computed list of the `{{vars}}` referenced by `content`, so Studio's
|
|
73
|
+
-- diff view can highlight added/removed variables without re-parsing.
|
|
74
|
+
vars_extracted jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
75
|
+
-- Source version when this row was created by a rollback/promote; NULL for
|
|
76
|
+
-- a regular edit.
|
|
77
|
+
rolled_back_from integer,
|
|
78
|
+
created_by_email text,
|
|
79
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
80
|
+
FOREIGN KEY (owner_kind, owner_name, prompt_key)
|
|
81
|
+
REFERENCES stackbone_platform.prompts (owner_kind, owner_name, key)
|
|
82
|
+
ON DELETE CASCADE
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- One version number per (owner, key) — the race backstop behind the
|
|
86
|
+
-- `MAX(version) + 1` the application assigns under a per-prompt advisory lock.
|
|
87
|
+
-- The lock key folds the owner in for the same reason this index leads with it.
|
|
88
|
+
CREATE UNIQUE INDEX IF NOT EXISTS prompt_versions_owner_key_version_idx
|
|
89
|
+
ON stackbone_platform.prompt_versions (owner_kind, owner_name, prompt_key, version);
|
|
90
|
+
|
|
91
|
+
-- Drives the history list (newest first) for one owner's key.
|
|
92
|
+
CREATE INDEX IF NOT EXISTS prompt_versions_owner_key_version_desc_idx
|
|
93
|
+
ON stackbone_platform.prompt_versions (owner_kind, owner_name, prompt_key, version DESC);
|
|
94
|
+
|
|
95
|
+
-- The list endpoint orders active prompts by owner, then key, so a screen shows
|
|
96
|
+
-- each owner's prompts together.
|
|
97
|
+
CREATE INDEX IF NOT EXISTS prompts_active_owner_key_idx
|
|
98
|
+
ON stackbone_platform.prompts (owner_kind, owner_name, key)
|
|
99
|
+
WHERE deleted_at IS NULL;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- 0048_prompt_publish.sql — writing a prompt and publishing it are two steps
|
|
2
|
+
-- (STA-400).
|
|
3
|
+
--
|
|
4
|
+
-- Until now the version that ran was always the highest one that existed: the
|
|
5
|
+
-- head's pointer only ever moved forward (`GREATEST(current_version, $n)`), so
|
|
6
|
+
-- saving an edit put it straight in front of the agent, and going back to an
|
|
7
|
+
-- earlier wording had to COPY that wording into a brand-new version number. An
|
|
8
|
+
-- operator debugging a run then saw `v7` in the logs and `v3`'s text in the
|
|
9
|
+
-- prompt, with nothing on screen tying the two together.
|
|
10
|
+
--
|
|
11
|
+
-- The head now carries two numbers instead of one:
|
|
12
|
+
--
|
|
13
|
+
-- * `latest_version` — the newest version anyone has WRITTEN. This is the
|
|
14
|
+
-- old `current_version`, renamed to say what it always
|
|
15
|
+
-- actually held.
|
|
16
|
+
-- * `published_version` — the one that RUNS. Nullable, because "nothing is
|
|
17
|
+
-- published" is a real state: a prompt whose pointer
|
|
18
|
+
-- has been cleared reads as having no content, which
|
|
19
|
+
-- is what makes deleting it (and its versions)
|
|
20
|
+
-- possible at all.
|
|
21
|
+
--
|
|
22
|
+
-- Publishing moves `published_version` onto a version that already exists. It
|
|
23
|
+
-- never inserts, so the number an operator picked is the number the logs show.
|
|
24
|
+
-- The history stays append-only for EDITS — every save is still an immutable new
|
|
25
|
+
-- row under the unique `(owner, key, version)` index from 0047.
|
|
26
|
+
--
|
|
27
|
+
-- `rolled_back_from` goes with the copy it recorded. It existed to stamp "this
|
|
28
|
+
-- row was born from v3" on the duplicate a rollback inserted; publishing inserts
|
|
29
|
+
-- nothing, so there is no row left to stamp and the column would be NULL on
|
|
30
|
+
-- every future version. Dropped rather than left as a field that can only ever
|
|
31
|
+
-- answer "no".
|
|
32
|
+
--
|
|
33
|
+
-- Both numbers are deliberately left WITHOUT a foreign key onto
|
|
34
|
+
-- `prompt_versions`. A composite FK would force the create path to insert the
|
|
35
|
+
-- head, then the version, then update the head — three statements where two do,
|
|
36
|
+
-- for an invariant the routes already enforce (a publish validates its target
|
|
37
|
+
-- first, and the published version cannot be deleted).
|
|
38
|
+
|
|
39
|
+
ALTER TABLE stackbone_platform.prompts
|
|
40
|
+
RENAME COLUMN current_version TO latest_version;
|
|
41
|
+
|
|
42
|
+
-- Nullable now: deleting a prompt's last draft leaves a head with no versions at
|
|
43
|
+
-- all, which reads as no content rather than as a broken row.
|
|
44
|
+
ALTER TABLE stackbone_platform.prompts
|
|
45
|
+
ALTER COLUMN latest_version DROP NOT NULL;
|
|
46
|
+
|
|
47
|
+
ALTER TABLE stackbone_platform.prompts
|
|
48
|
+
ADD COLUMN IF NOT EXISTS published_version integer CHECK (published_version >= 1);
|
|
49
|
+
|
|
50
|
+
-- Everything written before this migration ran under "the newest version is the
|
|
51
|
+
-- live one", so that is exactly what was published. Without the backfill every
|
|
52
|
+
-- existing prompt would go dark on deploy.
|
|
53
|
+
UPDATE stackbone_platform.prompts
|
|
54
|
+
SET published_version = latest_version
|
|
55
|
+
WHERE published_version IS NULL;
|
|
56
|
+
|
|
57
|
+
ALTER TABLE stackbone_platform.prompt_versions
|
|
58
|
+
DROP COLUMN IF EXISTS rolled_back_from;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- 0049_run_steps_annotations.sql — give a `run_steps` row somewhere to carry what
|
|
2
|
+
-- the SYSTEM observed about the step, next to (never inside) what the creator's
|
|
3
|
+
-- code passed through it. First tenant: which prompt, whose, and at what version
|
|
4
|
+
-- governed the step (STA-404).
|
|
5
|
+
--
|
|
6
|
+
-- WHY A NEW COLUMN AT ALL. `run_steps` has exactly three JSON columns — `input`,
|
|
7
|
+
-- `output` and `error` — and all three hold the CREATOR's payload: the arguments
|
|
8
|
+
-- a tool was called with, what it answered, how it broke. Folding an annotation
|
|
9
|
+
-- into any of them would mix two things that are read for different reasons and
|
|
10
|
+
-- have different lifetimes, and would show up in the trace viewer's Inspector as
|
|
11
|
+
-- if the creator had returned it. The only other free slot in the schema is
|
|
12
|
+
-- `runs.metadata`, which hangs off the RUN and not the step (and is already taken
|
|
13
|
+
-- by the eval seal), so it cannot answer "what ran in THIS step".
|
|
14
|
+
--
|
|
15
|
+
-- WHY `annotations` AND NOT `prompt_uses`. The next per-step annotation — a
|
|
16
|
+
-- guardrail's verdict fold, a retry note, whatever it turns out to be — should
|
|
17
|
+
-- not need a fourth ALTER on this table. So the column is one jsonb OBJECT whose
|
|
18
|
+
-- top-level keys name the annotation kinds, and this feature owns exactly one of
|
|
19
|
+
-- them:
|
|
20
|
+
--
|
|
21
|
+
-- {"prompts": [{"ownerKind": "workflow", "ownerName": "qualify-lead",
|
|
22
|
+
-- "key": "welcome", "version": 3, "reason": null}]}
|
|
23
|
+
--
|
|
24
|
+
-- `reason` is null on a healthy read and names the degradation otherwise
|
|
25
|
+
-- (`not_found` / `not_published` / `deleted` / `missing_var` / `no_owner` /
|
|
26
|
+
-- `unavailable`); `version` is null exactly when `reason` is not. The wire shape
|
|
27
|
+
-- is `runStepPromptUseSchema` in `@stackbone/validators`, the one schema the box,
|
|
28
|
+
-- the emulator and Studio all read.
|
|
29
|
+
--
|
|
30
|
+
-- NULLABLE, no default, no backfill. Every row already written keeps reading back
|
|
31
|
+
-- with the column NULL, and the read path maps NULL to "this step read no
|
|
32
|
+
-- prompts" — so a run that used none looks exactly as it did before this
|
|
33
|
+
-- migration. There is nothing deployed to migrate, but a `DEFAULT '{}'` would
|
|
34
|
+
-- still have rewritten every existing row for no gain.
|
|
35
|
+
--
|
|
36
|
+
-- No index: this is read only alongside the row it annotates (the step listing
|
|
37
|
+
-- already selects by `run_id`), never searched across runs.
|
|
38
|
+
--
|
|
39
|
+
-- The pg_notify trigger (`notify_run_step_event`) streams the row's identity and
|
|
40
|
+
-- status, not its columns, so no trigger change is needed.
|
|
41
|
+
|
|
42
|
+
ALTER TABLE stackbone_platform.run_steps
|
|
43
|
+
ADD COLUMN IF NOT EXISTS annotations jsonb;
|
|
44
|
+
|
|
45
|
+
COMMENT ON COLUMN stackbone_platform.run_steps.annotations IS
|
|
46
|
+
'System observations about this step, keyed by annotation kind. `prompts` holds the prompt reads (STA-404). Never the creator payload — that is input/output/error.';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 0050_run_steps_model_provider.sql — give the model its own home on a run step.
|
|
2
|
+
--
|
|
3
|
+
-- Until now the model id had nowhere to live, so it shared the `name` column with
|
|
4
|
+
-- whatever else each writer put there: a direct `client.ai` call stores the model
|
|
5
|
+
-- id, a deep-agent invocation stores the AGENT's name. Both read back as "name",
|
|
6
|
+
-- and nothing on the row says which of the two it is — so the trace viewer could
|
|
7
|
+
-- only print `name` and hope.
|
|
8
|
+
--
|
|
9
|
+
-- Two nullable text columns fix that. `name` keeps meaning whatever each writer
|
|
10
|
+
-- already means by it; it simply stops being where a reader looks for the model.
|
|
11
|
+
--
|
|
12
|
+
-- `provider` is DERIVED from the model id's prefix, never guessed. The ids the box
|
|
13
|
+
-- speaks are OpenRouter-style `vendor/model`, so `openai/gpt-4o-mini` yields
|
|
14
|
+
-- `openai`, and an id with no prefix leaves the column NULL rather than inventing
|
|
15
|
+
-- one. `openrouter` is never stored: it is the gateway that routed the call, not
|
|
16
|
+
-- the provider that served it.
|
|
17
|
+
--
|
|
18
|
+
-- Both columns are nullable with no default and no CHECK, because every row
|
|
19
|
+
-- written before this migration has neither and must stay readable: the trace
|
|
20
|
+
-- viewer falls back to `name` when `model` is null.
|
|
21
|
+
--
|
|
22
|
+
-- Viewer posture is UNCHANGED. 0005 grants SELECT on the TABLE
|
|
23
|
+
-- (`GRANT SELECT ON ALL TABLES IN SCHEMA stackbone_platform`), which covers every
|
|
24
|
+
-- column the table ever grows, and no column-level revoke exists to undo it. So a
|
|
25
|
+
-- new column needs no grant of its own — the DB Explorer sees these two the moment
|
|
26
|
+
-- they exist.
|
|
27
|
+
|
|
28
|
+
ALTER TABLE stackbone_platform.run_steps
|
|
29
|
+
ADD COLUMN IF NOT EXISTS model text,
|
|
30
|
+
ADD COLUMN IF NOT EXISTS provider text;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
-- 0051_logs.sql — the box's OWN log table (PLA-741, parent PLA-231).
|
|
2
|
+
--
|
|
3
|
+
-- Numbered 0051 against `main`, whose last migration is 0050. The runner keys
|
|
4
|
+
-- `schema_migrations` on the four-digit prefix alone, so two branches that both
|
|
5
|
+
-- take the next free number do not merely collide untidily: the loser applies its
|
|
6
|
+
-- DDL and then dies on the journal's primary key, and the retry reports a
|
|
7
|
+
-- checksum mismatch that points the operator at the wrong problem.
|
|
8
|
+
-- `scripts/check-migration-numbers.mjs` (PLA-735) is the pre-merge gate for that;
|
|
9
|
+
-- renumber against the tip you are merging into, not the one you branched from.
|
|
10
|
+
-- The 0046 → 0050 gap is legal — the migrator applies a late lower number on its
|
|
11
|
+
-- next run, which is why the unmerged 0047-0049 of another branch are harmless.
|
|
12
|
+
--
|
|
13
|
+
-- ONE PLAIN TABLE, not a partitioned one. Retention here is a DELETE by age, and
|
|
14
|
+
-- a box's log volume is bounded by the pipe's own four brakes (a per-line size
|
|
15
|
+
-- cap, a per-run line cap, a per-box daily cap — `log-pipe.ts`). Partitioning
|
|
16
|
+
-- buys a `DROP PARTITION` instead of a batched `DELETE`, at the price of a
|
|
17
|
+
-- partition maintenance job that has to run BEFORE the writes it serves; a box
|
|
18
|
+
-- whose maintenance job missed a tick would start rejecting log lines, which is
|
|
19
|
+
-- exactly the failure mode this feature exists to avoid. Every read and write
|
|
20
|
+
-- goes through the log-domain port (`logs-store.ts`), so moving to partitions
|
|
21
|
+
-- later is a change behind that seam with no data migration and no caller edit.
|
|
22
|
+
--
|
|
23
|
+
-- NO WORKSPACE COLUMN. The box has no honest source for one: it is a single
|
|
24
|
+
-- installation, and the only tenant identifier it ever sees arrives per request
|
|
25
|
+
-- on a JWT claim, not on the boot line it is writing at the time. One box is one
|
|
26
|
+
-- installation, so the scope of every row is the database it lives in. A column
|
|
27
|
+
-- filled with a constant would invite a reader to filter on it.
|
|
28
|
+
--
|
|
29
|
+
-- ts — when the line was written, from the pipe's clock. The
|
|
30
|
+
-- retention sweep and every read order by it.
|
|
31
|
+
-- level — Pino's NUMERIC level (10 trace … 60 fatal), matching the
|
|
32
|
+
-- record the pipe builds and the tail's `>=` filter. A text
|
|
33
|
+
-- level would make "warn and above" a CASE expression.
|
|
34
|
+
-- stream — which of the process's two streams the line was written to.
|
|
35
|
+
-- message — the line itself, already scrubbed and already capped by the
|
|
36
|
+
-- pipe. The DDL imposes no length: the cap is a policy the box
|
|
37
|
+
-- owns, and a column limit would reject rather than truncate.
|
|
38
|
+
-- run_id — the run the line belongs to. NULL for a boot line, a
|
|
39
|
+
-- lifecycle line, a recurring job's line.
|
|
40
|
+
--
|
|
41
|
+
-- DELIBERATELY NOT A FOREIGN KEY to `runs`, unlike `run_steps`.
|
|
42
|
+
-- Two ordinary sequences break under one: the first lines of a
|
|
43
|
+
-- run are written before its `runs` row is inserted, and a chat
|
|
44
|
+
-- turn whose start FAILED goes on logging with no run row that
|
|
45
|
+
-- will ever exist. An FK turns both into a rejected INSERT —
|
|
46
|
+
-- losing exactly the lines an operator opens the screen to
|
|
47
|
+
-- read. It would also chain this table into the runs table's
|
|
48
|
+
-- own retention, which is a separate policy.
|
|
49
|
+
-- step_id — the ENGINE's external step id, not a projected
|
|
50
|
+
-- `run_steps.id` uuid. Free text, minted outside Postgres.
|
|
51
|
+
-- invocation_id — the World run id of a workflow invocation (the `trace_id` the
|
|
52
|
+
-- pipe stamps). Also free text and also not a key.
|
|
53
|
+
-- fields — the producer's own structured fields, minus the canonical
|
|
54
|
+
-- ones this table has columns for. JSONB rather than JSON so a
|
|
55
|
+
-- later slice can index a hot key without a rewrite.
|
|
56
|
+
-- truncated — the pipe hit its per-line cap and stored the head. A flag
|
|
57
|
+
-- rather than a silent short message: a reader has to be able
|
|
58
|
+
-- to tell a short line from a cut one.
|
|
59
|
+
-- masked_count — how many spans the scrubber removed. A COUNT, never the
|
|
60
|
+
-- values: it lets a reader tell "nothing here was secret" from
|
|
61
|
+
-- "three things were taken out of this".
|
|
62
|
+
-- subject_id — RESERVED for the compliance slice (PLA-718), which needs a
|
|
63
|
+
-- data-subject handle to answer an erasure request. Written by
|
|
64
|
+
-- nobody today. It ships now because adding a column to a table
|
|
65
|
+
-- holding a month of log rows is a rewrite of the whole heap,
|
|
66
|
+
-- and this is the cheapest moment it will ever be.
|
|
67
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.logs (
|
|
68
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
69
|
+
ts timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
level integer NOT NULL,
|
|
71
|
+
stream text NOT NULL DEFAULT 'stdout',
|
|
72
|
+
message text NOT NULL,
|
|
73
|
+
run_id uuid,
|
|
74
|
+
step_id text,
|
|
75
|
+
invocation_id text,
|
|
76
|
+
fields jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
77
|
+
truncated boolean NOT NULL DEFAULT false,
|
|
78
|
+
masked_count integer NOT NULL DEFAULT 0,
|
|
79
|
+
subject_id text
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- TWO indexes, and the argument for stopping there is as load-bearing as the
|
|
83
|
+
-- argument for these two. Every index is paid for on EVERY insert, and this is
|
|
84
|
+
-- the highest-insert table the box has.
|
|
85
|
+
--
|
|
86
|
+
-- (run_id, ts) — the run-scoped read: "this run's lines, oldest first".
|
|
87
|
+
-- (ts) — the retention sweep's own scan, and the global tail's paging.
|
|
88
|
+
--
|
|
89
|
+
-- NOT indexed, on purpose:
|
|
90
|
+
-- level — six distinct values over millions of rows. The planner would
|
|
91
|
+
-- ignore it for anything but `fatal`, and a scan is cheaper.
|
|
92
|
+
-- message — no trigram index. Substring search is a BOUNDED scan (the reader
|
|
93
|
+
-- always carries a time window), and a GIN trigram index over a
|
|
94
|
+
-- month of log text costs a large fraction of the table it
|
|
95
|
+
-- indexes — on the customer's own disk, for a search nobody runs
|
|
96
|
+
-- on a cold box. The search slice that needs it can add it.
|
|
97
|
+
-- ts (BRIN) — a BRIN index wins on an append-only table whose physical order
|
|
98
|
+
-- matches its timestamps. This table is bulk-DELETEd hourly and
|
|
99
|
+
-- its freed space is reused out of order, so the correlation the
|
|
100
|
+
-- BRIN depends on decays exactly as the table ages.
|
|
101
|
+
CREATE INDEX IF NOT EXISTS logs_run_id_ts_idx ON stackbone_platform.logs (run_id, ts);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS logs_ts_idx ON stackbone_platform.logs (ts);
|
|
103
|
+
|
|
104
|
+
-- PER-TABLE AUTOVACUUM. The cluster default waits for dead tuples to reach 20% of
|
|
105
|
+
-- the table before it even considers a vacuum. This is the one table the box
|
|
106
|
+
-- bulk-deletes on a schedule, so on the defaults an hourly retention pass would
|
|
107
|
+
-- leave its dead rows in place for hours, the heap would keep growing past what
|
|
108
|
+
-- the operator's budget setting measures, and the visibility map would go stale
|
|
109
|
+
-- under the very index the retention scan uses.
|
|
110
|
+
--
|
|
111
|
+
-- 1% plus a floor of 5 000 rows: the fraction keeps a big table vacuumed
|
|
112
|
+
-- promptly, and the explicit threshold stops a nearly-empty table from vacuuming
|
|
113
|
+
-- itself on every few writes.
|
|
114
|
+
ALTER TABLE stackbone_platform.logs SET (
|
|
115
|
+
autovacuum_vacuum_scale_factor = 0.01,
|
|
116
|
+
autovacuum_vacuum_threshold = 5000
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
-- The read-only DB-Explorer role may not read log lines. Same reasoning as
|
|
120
|
+
-- 0011_secrets_revoke_viewer.sql, and the same mechanism: 0005's blanket
|
|
121
|
+
-- `ALTER DEFAULT PRIVILEGES … GRANT SELECT` already fires for every table created
|
|
122
|
+
-- after it, so a table-level REVOKE is the only thing that strips the grant this
|
|
123
|
+
-- CREATE just received.
|
|
124
|
+
--
|
|
125
|
+
-- Logs are the widest surface a credential can escape through. They are scrubbed
|
|
126
|
+
-- on the way in (`log-scrubber.ts`) with both credential SHAPES and the box's own
|
|
127
|
+
-- secret VALUES, but scrubbing is a best effort over free text and this is not:
|
|
128
|
+
-- a `viewer` seat is read-only precisely because it is handed out widely.
|
|
129
|
+
REVOKE ALL ON stackbone_platform.logs FROM stackbone_viewer;
|
|
130
|
+
|
|
131
|
+
-- NO TRIGGERS. Every other table in this schema that Studio watches carries a row
|
|
132
|
+
-- trigger emitting `pg_notify` on the platform channel. Not this one: the box
|
|
133
|
+
-- logs its own boot, its own recurring jobs and every line of every run through
|
|
134
|
+
-- the same pipe, so a per-row NOTIFY here would flood a channel the bridge shares
|
|
135
|
+
-- with the run/step/session frames it exists to deliver — and a NOTIFY payload is
|
|
136
|
+
-- capped at 8 000 bytes, which a log line can exceed on its own. The live tail is
|
|
137
|
+
-- fed from the pipe IN MEMORY, before the row is ever written.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- 0052_telemetry_export_state.sql — what the OTLP export has LOST (PLA-744).
|
|
2
|
+
--
|
|
3
|
+
-- Numbered 0052 against `main`, whose last migration is 0051. The runner keys
|
|
4
|
+
-- `schema_migrations` on the four-digit prefix alone, so two branches that both
|
|
5
|
+
-- take the next free number half-apply and then report a checksum mismatch that
|
|
6
|
+
-- points at the wrong problem. `scripts/check-migration-numbers.mjs` is the
|
|
7
|
+
-- pre-merge gate; renumber against the tip you merge into, not the one you
|
|
8
|
+
-- branched from.
|
|
9
|
+
--
|
|
10
|
+
-- WHY A TABLE AND NOT A COUNTER IN MEMORY. The export is fail-open: a collector
|
|
11
|
+
-- that refuses the connection costs the box its telemetry and nothing else, and
|
|
12
|
+
-- the run goes on. That is the right trade and it is also how an integration
|
|
13
|
+
-- goes quietly dead — a box that has exported nothing since a certificate
|
|
14
|
+
-- expired looks exactly like a box nobody sends much traffic to. The count is
|
|
15
|
+
-- the only thing that tells them apart, and it is worth nothing if it resets
|
|
16
|
+
-- every time the container restarts, which is precisely when an operator looks.
|
|
17
|
+
--
|
|
18
|
+
-- ONE ROW, like `runtime_identity` (0033) and `runtime_control_plane_identity`
|
|
19
|
+
-- (0046): this describes THE runtime's export, not a collection of anything.
|
|
20
|
+
-- The writer upserts it, so the row's absence simply means nothing has been lost
|
|
21
|
+
-- yet, and the read defaults to zero.
|
|
22
|
+
--
|
|
23
|
+
-- failure_count — records that never reached the collector, over this box's
|
|
24
|
+
-- life: a batch a 4xx refused, a batch whose connection was
|
|
25
|
+
-- refused, the records a `partialSuccess` rejected, and the
|
|
26
|
+
-- oldest records the bounded queue dropped. RECORDS, not
|
|
27
|
+
-- batches — "we lost 40 000 lines" and "we lost 80 sends"
|
|
28
|
+
-- are different sentences and only the first is actionable.
|
|
29
|
+
-- `bigint` because a box exporting into a black hole for a
|
|
30
|
+
-- week passes 2^31.
|
|
31
|
+
-- last_failure_at — when the counter last moved. A large count that stopped
|
|
32
|
+
-- moving yesterday is a fixed outage; the same count still
|
|
33
|
+
-- moving is an open one.
|
|
34
|
+
--
|
|
35
|
+
-- NO ENDPOINT COLUMN. Where the box exports to is deployment configuration read
|
|
36
|
+
-- from `OTEL_EXPORTER_OTLP_ENDPOINT`, never a stored setting: a row Studio could
|
|
37
|
+
-- write would let a seat redirect the operator's telemetry. The settings screen
|
|
38
|
+
-- reads the endpoint's presence from the environment and the count from here.
|
|
39
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.telemetry_export_state (
|
|
40
|
+
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
|
41
|
+
failure_count bigint NOT NULL DEFAULT 0,
|
|
42
|
+
last_failure_at timestamptz
|
|
43
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- 0053_runs_exported_at.sql — HOW FAR the OTLP trace export has got on each run
|
|
2
|
+
-- (PLA-746).
|
|
3
|
+
--
|
|
4
|
+
-- Numbered 0053 against `main`, whose last migration is 0050; this branch adds
|
|
5
|
+
-- 0051 and 0052 ahead of it. The runner keys `schema_migrations` on the
|
|
6
|
+
-- four-digit prefix alone, so two branches that both take the next free number
|
|
7
|
+
-- half-apply and then report a checksum mismatch pointing at the wrong problem.
|
|
8
|
+
-- `scripts/check-migration-numbers.mjs` is the pre-merge gate; renumber against
|
|
9
|
+
-- the tip you merge into, not the one you branched from.
|
|
10
|
+
--
|
|
11
|
+
-- WHY A COLUMN AND NOT A QUEUE. `run.finished` (0001's row trigger) travels over
|
|
12
|
+
-- LISTEN/NOTIFY, which reaches a connected listener and nobody else. A deploy
|
|
13
|
+
-- that recycles the container loses the trace of every run closing around the
|
|
14
|
+
-- restart, and a box that dies mid-turn loses it forever. This column is the one
|
|
15
|
+
-- durable fact the sweep needs: the unexported rows ARE the retry queue, so a
|
|
16
|
+
-- second queue would only be a second thing to lose.
|
|
17
|
+
--
|
|
18
|
+
-- A COMPARABLE WATERMARK, NOT A FLAG. `exported_at` holds the `finished_at`
|
|
19
|
+
-- whose trace actually left the box, so the sweep asks
|
|
20
|
+
--
|
|
21
|
+
-- finished_at IS NOT NULL AND (exported_at IS NULL OR finished_at > exported_at)
|
|
22
|
+
--
|
|
23
|
+
-- and not "exported_at IS NULL". A run CAN close twice: the chat reconciler marks
|
|
24
|
+
-- a dead `running` turn `failed`, and the live turn it raced then lands its own
|
|
25
|
+
-- `complete()` — an UPDATE with no status guard, over a row whose `finished_at`
|
|
26
|
+
-- is already set, which fires NO second `run.finished` (the trigger only notifies
|
|
27
|
+
-- on the NULL → NOT NULL transition). Under a nullity check the operator's APM
|
|
28
|
+
-- would say `failed` forever while Studio says done. Equality is the "already
|
|
29
|
+
-- shipped" case and must NOT re-export, which is why the writer stamps the
|
|
30
|
+
-- observed `finished_at` rather than `now()`: a value taken from the clock could
|
|
31
|
+
-- land ahead of a close that happened while the trace was in flight, and that
|
|
32
|
+
-- close would then never be exported at all.
|
|
33
|
+
--
|
|
34
|
+
-- NULLABLE, NO DEFAULT, NO BACKFILL. A NULL means "never exported", and on the
|
|
35
|
+
-- first boot with OTLP enabled that is every run the box has ever recorded. The
|
|
36
|
+
-- sweep does NOT send them: it cuts by the operator's log-retention window, so
|
|
37
|
+
-- turning the export on is not an accidental replay of the box's whole history.
|
|
38
|
+
-- A historical backfill is an explicit operator action, never a side effect.
|
|
39
|
+
ALTER TABLE stackbone_platform.runs
|
|
40
|
+
ADD COLUMN IF NOT EXISTS exported_at timestamptz;
|
|
41
|
+
|
|
42
|
+
-- The sweep's only query, as an index. PARTIAL on the same predicate the sweep
|
|
43
|
+
-- uses, so a box whose export is keeping up scans an index holding almost
|
|
44
|
+
-- nothing — the steady state is "every finished run is exported", and an index
|
|
45
|
+
-- over the whole table would grow with the journal forever. The predicate names
|
|
46
|
+
-- only columns (no `now()`), which is what makes it legal in an index at all;
|
|
47
|
+
-- the age cutoffs are applied on top of it, at read time.
|
|
48
|
+
CREATE INDEX IF NOT EXISTS runs_export_pending_idx
|
|
49
|
+
ON stackbone_platform.runs (finished_at)
|
|
50
|
+
WHERE finished_at IS NOT NULL
|
|
51
|
+
AND (exported_at IS NULL OR finished_at > exported_at);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- 0054_runs_trace_parent.sql — the INBOUND trace a run continues (PLA-747).
|
|
2
|
+
--
|
|
3
|
+
-- Numbered 0054 against `main`, whose last migration is 0050; this branch adds
|
|
4
|
+
-- 0051, 0052 and 0053 ahead of it. The runner keys `schema_migrations` on the
|
|
5
|
+
-- four-digit prefix alone, so two branches that both take the next free number
|
|
6
|
+
-- half-apply and then report a checksum mismatch pointing at the wrong problem.
|
|
7
|
+
-- `scripts/check-migration-numbers.mjs` (PLA-735) is the pre-merge gate;
|
|
8
|
+
-- renumber against the tip you merge into, not the one you branched from.
|
|
9
|
+
--
|
|
10
|
+
-- NOT `trace_id`. The column beside this one holds the WORLD runId — the box's
|
|
11
|
+
-- own join key between a projected `runs` row and the workflow engine's run.
|
|
12
|
+
-- `trace_parent` is the opposite direction: the W3C `traceparent` header a
|
|
13
|
+
-- CALLER sent, naming a trace that was opened outside this box entirely. A
|
|
14
|
+
-- reader who conflates the two searches the wrong id in the wrong system, so the
|
|
15
|
+
-- names are deliberately not variations of each other.
|
|
16
|
+
--
|
|
17
|
+
-- HONOURED MEANS STORED. The header arrives on one HTTP request — `POST
|
|
18
|
+
-- /api/workflows/:name/start`, the only door that can carry it today (the
|
|
19
|
+
-- connector intake is polling and there is no inbound webhook receiver) — and
|
|
20
|
+
-- the spans that need it leave minutes later, from a sweep with no request in
|
|
21
|
+
-- scope. Memory would lose it on the next deploy; the row is the only place it
|
|
22
|
+
-- survives to the export.
|
|
23
|
+
--
|
|
24
|
+
-- NULLABLE, NO DEFAULT, NO BACKFILL, NO CHECK. A NULL means "nobody sent one",
|
|
25
|
+
-- which is what almost every run is. No CHECK for the same reason 0037 has none:
|
|
26
|
+
-- the grammar is validated in the box before the write, and an older box must be
|
|
27
|
+
-- able to STORE a value a newer build validated by a rule this one does not know.
|
|
28
|
+
-- The read side re-parses it anyway (`otlp/traceparent.ts`), so a value that
|
|
29
|
+
-- somehow got in malformed is ignored at export rather than breaking it.
|
|
30
|
+
--
|
|
31
|
+
-- NO INDEX. Nothing searches by it: every read is by the run's own primary key —
|
|
32
|
+
-- the trace exporter reads the run it is exporting, the log exporter reads the
|
|
33
|
+
-- run a log line names. An index would be a write cost with no reader.
|
|
34
|
+
ALTER TABLE stackbone_platform.runs
|
|
35
|
+
ADD COLUMN IF NOT EXISTS trace_parent text;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
-- 0055_audit_log.sql — the BOX's half of the organization activity log
|
|
2
|
+
-- (PLA-898). One row per human mutation the box itself carried out: an approval
|
|
3
|
+
-- decided, the creator's config saved or rolled back, a workspace setting
|
|
4
|
+
-- changed.
|
|
5
|
+
--
|
|
6
|
+
-- Numbered 0055 against `next`, whose last migration is 0054. The runner keys
|
|
7
|
+
-- `schema_migrations` on the four-digit prefix alone, so two branches that both
|
|
8
|
+
-- take the next free number half-apply and then report a checksum mismatch
|
|
9
|
+
-- pointing at the wrong problem. `scripts/check-migration-numbers.mjs` is the
|
|
10
|
+
-- pre-merge gate; renumber against the tip you merge into.
|
|
11
|
+
--
|
|
12
|
+
-- THE SHAPE IS NOT THIS FILE'S TO CHOOSE. Nine of the ten columns below are the
|
|
13
|
+
-- shared ones `auditColumns()` (`@stackbone/audit-core`) builds, the same set the
|
|
14
|
+
-- control plane's `public.audit_log` is grown from, so the federated reader that
|
|
15
|
+
-- merges the two timelines never has to translate between dialects.
|
|
16
|
+
-- `libs/shared/box-schema/src/lib/schema/audit-log.spec.ts` is what holds the
|
|
17
|
+
-- Drizzle definition, this DDL and that builder to one shape.
|
|
18
|
+
--
|
|
19
|
+
-- What only the box adds is `agent_id`: everything a box records happened while
|
|
20
|
+
-- it was serving one agent, and the merged timeline's per-agent filter is matched
|
|
21
|
+
-- on it. The control plane's table has no such column — nothing it records
|
|
22
|
+
-- belongs to an agent at all.
|
|
23
|
+
--
|
|
24
|
+
-- NO FOREIGN KEYS. `organization_id` and `actor_user_id` are the control plane's
|
|
25
|
+
-- ids, and neither an `organization` nor a `user` table exists inside a box, so
|
|
26
|
+
-- there is nothing here for them to reference. `actor_user_id` is NULLABLE, and
|
|
27
|
+
-- that is the point rather than a concession: a `stackbone dev` save happens with
|
|
28
|
+
-- nobody signed in, and the honest row for it names no actor instead of failing
|
|
29
|
+
-- the save or borrowing somebody's name. `organization_id` stays NOT NULL — a box
|
|
30
|
+
-- nobody has registered records the nil uuid, which says "this box answers for no
|
|
31
|
+
-- organization yet" and never claims a tenant it does not have.
|
|
32
|
+
CREATE TABLE IF NOT EXISTS stackbone_platform.audit_log (
|
|
33
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
34
|
+
organization_id uuid NOT NULL,
|
|
35
|
+
actor_user_id uuid,
|
|
36
|
+
agent_id text,
|
|
37
|
+
action text NOT NULL,
|
|
38
|
+
subject_type text NOT NULL,
|
|
39
|
+
subject_id uuid,
|
|
40
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
41
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
-- The four indexes, three of them declared by `auditIndexes()` so both planes
|
|
46
|
+
-- carry the same set under the same names. A name is unique per SCHEMA in
|
|
47
|
+
-- Postgres — the control plane's table is in `public` and this one in
|
|
48
|
+
-- `stackbone_platform` — so the repeated names read as "the same index" rather
|
|
49
|
+
-- than collide. Twenty-four months of human actions is a small table, but the
|
|
50
|
+
-- filters the contract promises should not start life as sequential scans.
|
|
51
|
+
CREATE INDEX IF NOT EXISTS audit_log_organization_idx
|
|
52
|
+
ON stackbone_platform.audit_log (organization_id, created_at);
|
|
53
|
+
--> statement-breakpoint
|
|
54
|
+
CREATE INDEX IF NOT EXISTS audit_log_subject_idx
|
|
55
|
+
ON stackbone_platform.audit_log (subject_type, subject_id);
|
|
56
|
+
--> statement-breakpoint
|
|
57
|
+
CREATE INDEX IF NOT EXISTS audit_log_actor_idx
|
|
58
|
+
ON stackbone_platform.audit_log (organization_id, actor_user_id, created_at);
|
|
59
|
+
--> statement-breakpoint
|
|
60
|
+
-- The box's own: "everything this agent did", newest first.
|
|
61
|
+
CREATE INDEX IF NOT EXISTS audit_log_agent_idx
|
|
62
|
+
ON stackbone_platform.audit_log (agent_id, created_at);
|
|
63
|
+
--> statement-breakpoint
|
|
64
|
+
|
|
65
|
+
-- THE TRAIL IS APPEND-ONLY, AND THE DATABASE IS WHAT SAYS SO.
|
|
66
|
+
--
|
|
67
|
+
-- The table has exactly one writer (the box audit recorder, inside the
|
|
68
|
+
-- transaction the mutation it describes already holds) and no updater or deleter
|
|
69
|
+
-- at all. That is a property of today's code, not a guarantee: any future
|
|
70
|
+
-- handler, migration or psql session inside the customer's own infrastructure
|
|
71
|
+
-- could rewrite history, and a trail that can be edited by whoever is being
|
|
72
|
+
-- audited is not evidence. So the rule lives here, below every application, where
|
|
73
|
+
-- no code path can forget it.
|
|
74
|
+
--
|
|
75
|
+
-- UNCONDITIONAL, unlike the control plane's twin (`db-core`'s 0023), which has to
|
|
76
|
+
-- let one DELETE through: there, `audit_log.organization_id` is ON DELETE CASCADE
|
|
77
|
+
-- and erasing an organization would otherwise be refused by its own children.
|
|
78
|
+
-- Nothing here carries a foreign key, so there is no cascade to admit and no
|
|
79
|
+
-- carve-out to write. Adding one "for parity" would open the only hole this
|
|
80
|
+
-- trigger exists to close.
|
|
81
|
+
--
|
|
82
|
+
-- No retention exception. The age-window purge, when it arrives, amends THIS
|
|
83
|
+
-- function (`CREATE OR REPLACE FUNCTION`) rather than adding a second, competing
|
|
84
|
+
-- guard. Nothing here is a partial version of it: there is no age test and no
|
|
85
|
+
-- column test to mistake for one.
|
|
86
|
+
--
|
|
87
|
+
-- TRUNCATE is NOT covered, and cannot be by a row trigger — it bypasses them
|
|
88
|
+
-- entirely. Blocking it needs a BEFORE TRUNCATE statement trigger or, better,
|
|
89
|
+
-- revoking the privilege from the application role, and it would break the test
|
|
90
|
+
-- fixtures that reset the schema with `TRUNCATE ... CASCADE`. Out of scope here.
|
|
91
|
+
CREATE OR REPLACE FUNCTION stackbone_platform.audit_log_refuse_mutation() RETURNS trigger
|
|
92
|
+
LANGUAGE plpgsql AS $fn$
|
|
93
|
+
BEGIN
|
|
94
|
+
RAISE EXCEPTION 'audit_log rows are immutable — % refused', TG_OP
|
|
95
|
+
USING ERRCODE = 'restrict_violation';
|
|
96
|
+
END;
|
|
97
|
+
$fn$;
|
|
98
|
+
--> statement-breakpoint
|
|
99
|
+
-- One trigger over both verbs so there is a single object to find and amend.
|
|
100
|
+
-- BEFORE, so the refusal happens before any row is touched.
|
|
101
|
+
DROP TRIGGER IF EXISTS audit_log_refuse_mutation_bud ON stackbone_platform.audit_log;
|
|
102
|
+
--> statement-breakpoint
|
|
103
|
+
CREATE TRIGGER audit_log_refuse_mutation_bud
|
|
104
|
+
BEFORE UPDATE OR DELETE ON stackbone_platform.audit_log
|
|
105
|
+
FOR EACH ROW EXECUTE FUNCTION stackbone_platform.audit_log_refuse_mutation();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackbone/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"@bufbuild/protobuf": "^2.12.0",
|
|
19
19
|
"@clack/prompts": "^1.2.0",
|
|
20
20
|
"@hono/node-server": "^2.0.0",
|
|
21
|
+
"@hono/zod-openapi": "^1.6.1",
|
|
22
|
+
"@scalar/hono-api-reference": "^0.11.16",
|
|
21
23
|
"ajv": "^8.20.0",
|
|
22
24
|
"ajv-formats": "^3.0.1",
|
|
23
25
|
"bullmq": "^5.76.6",
|