diff --git a/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/host.sql b/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/host.sql new file mode 100644 index 00000000..fab18cb4 --- /dev/null +++ b/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/host.sql @@ -0,0 +1,768 @@ +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 18.4 +-- Dumped by pg_dump version 18.4 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: approvals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.approvals ( + id text NOT NULL, + dot_id text NOT NULL, + task_id text, + tool text NOT NULL, + permission text NOT NULL, + arguments jsonb NOT NULL, + reason text NOT NULL, + status text NOT NULL, + note text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + resolved_at timestamp with time zone, + CONSTRAINT approvals_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'approved'::text, 'rejected'::text, 'expired'::text]))) +); + + +-- +-- Name: channel_bindings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_bindings ( + id text NOT NULL, + dot_id text NOT NULL, + kind text NOT NULL, + enabled boolean DEFAULT true NOT NULL, + settings jsonb NOT NULL, + status text NOT NULL, + status_detail text, + account text, + event_cursor bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_bindings_event_cursor_check CHECK ((event_cursor >= 0)), + CONSTRAINT channel_bindings_kind_check CHECK ((kind = ANY (ARRAY['telegram'::text, 'whatsapp'::text]))), + CONSTRAINT channel_bindings_status_check CHECK ((status = ANY (ARRAY['connecting'::text, 'connected'::text, 'needs_relink'::text, 'error'::text]))) +); + + +-- +-- Name: channel_pairings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_pairings ( + binding_id text NOT NULL, + code_hash text NOT NULL, + expires_at timestamp with time zone NOT NULL, + consumed_at timestamp with time zone +); + + +-- +-- Name: channel_peers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_peers ( + binding_id text NOT NULL, + peer_id text NOT NULL, + chat_id text NOT NULL, + role text NOT NULL, + label text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_peers_role_check CHECK ((role = ANY (ARRAY['owner'::text, 'user'::text]))) +); + + +-- +-- Name: channel_prompts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_prompts ( + binding_id text NOT NULL, + approval_id text NOT NULL, + chat_id text NOT NULL, + ref text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: computers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.computers ( + dot_id text NOT NULL, + vm_name text NOT NULL, + guest_port integer, + pid integer, + state text NOT NULL, + golden_image text, + runtime_image text, + token_enc bytea NOT NULL, + event_cursor bigint DEFAULT 0 NOT NULL, + last_active_at timestamp with time zone, + last_error text, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + next_automation_at timestamp with time zone, + stop_reason text, + CONSTRAINT computers_guest_port_check CHECK (((guest_port >= 1) AND (guest_port <= 65535))), + CONSTRAINT computers_pid_check CHECK ((pid > 0)), + CONSTRAINT computers_state_check CHECK ((state = ANY (ARRAY['PROVISIONING'::text, 'STARTING'::text, 'RUNNING'::text, 'IDLE'::text, 'STOPPING'::text, 'STOPPED'::text, 'ERROR'::text, 'DELETING'::text]))), + CONSTRAINT computers_stop_reason_check CHECK ((stop_reason = ANY (ARRAY['idle'::text, 'user'::text, 'exited'::text]))) +); + + +-- +-- Name: dots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dots ( + id text NOT NULL, + name text NOT NULL, + config jsonb NOT NULL, + status text NOT NULL, + error text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + config_version integer DEFAULT 1 NOT NULL, + CONSTRAINT dots_status_check CHECK ((status = ANY (ARRAY['CREATING'::text, 'READY'::text, 'IDLE'::text, 'RUNNING'::text, 'WAITING_APPROVAL'::text, 'ERROR'::text, 'DISABLED'::text]))) +); + + +-- +-- Name: events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.events ( + id bigint NOT NULL, + dot_id text NOT NULL, + type text NOT NULL, + data jsonb NOT NULL, + source text NOT NULL, + guest_seq bigint, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT events_check CHECK (((source = 'guest'::text) = (guest_seq IS NOT NULL))), + CONSTRAINT events_source_check CHECK ((source = ANY (ARRAY['host'::text, 'guest'::text]))) +); + + +-- +-- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id; + + +-- +-- Name: inbound_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.inbound_events ( + seq bigint NOT NULL, + id text NOT NULL, + dot_id text NOT NULL, + type text NOT NULL, + data jsonb NOT NULL, + ts text NOT NULL, + task_id text, + run_id text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + sent_at timestamp with time zone, + delivered_at timestamp with time zone, + dropped_at timestamp with time zone, + drop_reason text, + failures integer DEFAULT 0 NOT NULL, + last_error text, + retry_at timestamp with time zone, + CONSTRAINT inbound_events_type_check CHECK ((type = ANY (ARRAY['user.message'::text, 'task.created'::text, 'approval.received'::text, 'system.event'::text]))) +); + + +-- +-- Name: inbound_events_seq_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.inbound_events_seq_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: inbound_events_seq_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.inbound_events_seq_seq OWNED BY public.inbound_events.seq; + + +-- +-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.schema_migrations ( + version text NOT NULL, + applied_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: secrets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.secrets ( + scope text NOT NULL, + name text NOT NULL, + value_enc bytea NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: task_runs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.task_runs ( + id text NOT NULL, + task_id text NOT NULL, + started_at timestamp with time zone DEFAULT now() NOT NULL, + delivered_at timestamp with time zone, + finished_at timestamp with time zone, + outcome text +); + + +-- +-- Name: tasks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tasks ( + id text NOT NULL, + dot_id text NOT NULL, + description text NOT NULL, + priority integer DEFAULT 0 NOT NULL, + status text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + scheduled_at timestamp with time zone, + started_at timestamp with time zone, + finished_at timestamp with time zone, + summary text, + error text, + spent_usd double precision DEFAULT 0 NOT NULL, + CONSTRAINT tasks_spent_usd_check CHECK ((spent_usd >= (0)::double precision)), + CONSTRAINT tasks_status_check CHECK ((status = ANY (ARRAY['PENDING'::text, 'RUNNING'::text, 'WAITING_APPROVAL'::text, 'COMPLETED'::text, 'FAILED'::text, 'CANCELLED'::text]))) +); + + +-- +-- Name: events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass); + + +-- +-- Name: inbound_events seq; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_events ALTER COLUMN seq SET DEFAULT nextval('public.inbound_events_seq_seq'::regclass); + + +-- +-- Data for Name: approvals; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.approvals VALUES ('apr_01m4ceepvn47ypbkcpd5cymdey', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task_01m4ceeptfsmdeddevy4770sbk', 'browser_identity_delete', 'browser.identity.delete', '{"identity_id": "shop-abc123"}', 'the tool needs approval', 'approved', 'fine', '2026-10-08 00:26:18.038234+00', '2026-10-08 00:26:18.071102+00'); +INSERT INTO public.approvals VALUES ('apr_01m4ceepzq74326e3e6vrheqvj', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task_01m4ceepz48cetk8s7k7zwkst5', 'browser_identity_delete', 'browser.identity.delete', '{"identity_id": "shop-abc123"}', 'the tool needs approval', 'pending', NULL, '2026-10-08 00:26:18.153524+00', NULL); + + +-- +-- Data for Name: channel_bindings; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.channel_bindings VALUES ('chb_upgrade', 'dot_01m4ceepkrt609r0tde6hm21hb', 'telegram', true, '{"approvals": true, "notify_tasks": true, "show_arguments": false}', 'connecting', NULL, 'fare_watch_bot', 0, '2026-10-08 00:26:18.181416+00'); + + +-- +-- Data for Name: channel_pairings; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_peers; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.channel_peers VALUES ('chb_upgrade', '4242', '4242', 'owner', 'Federico', '2026-10-08 00:26:18.187194+00'); + + +-- +-- Data for Name: channel_prompts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: computers; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.computers VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'invisible-dot-dot_01m4ceepkrt609r0tde6hm21hb', 47000, 9000, 'RUNNING', 'images/golden-test.qcow2', 'images/runtime-test.iso', '\xd8f1c54703e75012cfac1404e670ebfdb057db12ea08404cd0e3b7367c02826ead80d0e36069ab2c190ee026c9ef5059590e1c32c10d787db872b944b7da832b635e4b9117ced9', 16, '2026-10-07 09:00:00+00', NULL, '2026-10-08 00:26:18.181416+00', '2026-10-09 00:23:53.167+00', NULL); +INSERT INTO public.computers VALUES ('dot_01m4ceeq1d5fgv96gmxs71ht30', 'invisible-dot-dot_01m4ceeq1d5fgv96gmxs71ht30', NULL, NULL, 'STOPPED', 'images/golden-test.qcow2', 'images/runtime-test.iso', '\x292befaaec6513a1a36ee6a6efa250c5eaba66e80bc9c2e7bca66bf26f3ece3c52f63b6c00d9eaec7a44d828176b1994b779f5cc635738a95529d12917459e3b65ed4a130f74d4', 1, '2026-10-07 09:00:00+00', NULL, '2026-10-08 00:26:18.281007+00', NULL, 'user'); + + +-- +-- Data for Name: dots; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.dots VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'fare-watch', '{"name": "fare-watch", "model": {"id": "z-ai/glm-5.3-flash", "provider": "openrouter"}, "limits": {"context_tokens": 32000, "max_steps_per_task": 60, "max_cost_per_task_usd": 1}, "models": {}, "computer": {"cpu": 2, "disk": "40gb", "memory": "4gb", "idle_timeout": "15m"}, "permissions": {"files.write": "ask"}}', 'WAITING_APPROVAL', NULL, '2026-10-08 00:26:17.77945+00', '2026-10-08 00:26:18.145257+00', 3); +INSERT INTO public.dots VALUES ('dot_01m4ceeq1d5fgv96gmxs71ht30', 'minimal-dot', '{"name": "minimal-dot", "model": {"id": "z-ai/glm-5.3-flash", "provider": "openrouter"}, "limits": {"context_tokens": 32000, "max_steps_per_task": 60, "max_cost_per_task_usd": 1}, "models": {}, "computer": {"cpu": 2, "disk": "40gb", "memory": "4gb", "idle_timeout": "15m"}, "permissions": {"files.write": "ask"}}', 'IDLE', NULL, '2026-10-08 00:26:18.190622+00', '2026-10-08 00:26:18.281007+00', 1); + + +-- +-- Data for Name: events; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.events VALUES (1, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.created', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:17.77945+00'); +INSERT INTO public.events VALUES (2, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "PROVISIONING"}', 'host', NULL, '2026-10-08 00:26:17.77945+00'); +INSERT INTO public.events VALUES (3, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "STARTING"}', 'host', NULL, '2026-10-08 00:26:17.815323+00'); +INSERT INTO public.events VALUES (4, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "RUNNING"}', 'host', NULL, '2026-10-08 00:26:17.823904+00'); +INSERT INTO public.events VALUES (5, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.started', '{"guest_port": 47000, "runtime_image": "images/runtime-test.iso"}', 'host', NULL, '2026-10-08 00:26:17.823904+00'); +INSERT INTO public.events VALUES (6, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.started', '{"guest_ts": "2026-10-08T00:26:17.640Z", "guest_event_id": "evt_01m4ceepn88vyrgn8zp99tx0zz"}', 'guest', 1, '2026-10-08 00:26:17.862351+00'); +INSERT INTO public.events VALUES (7, 'dot_01m4ceepkrt609r0tde6hm21hb', 'user.message', '{"text": "Remember: my favourite colour is teal.", "message_id": "msg_01m4ceepq4w4wdyp8tvy7v060r"}', 'host', NULL, '2026-10-08 00:26:17.880657+00'); +INSERT INTO public.events VALUES (8, 'dot_01m4ceepkrt609r0tde6hm21hb', 'message.assistant', '{"text": "echo: Remember: my favourite colour is teal.", "guest_ts": "2026-10-08T00:26:17.720Z", "in_reply_to": "msg_01m4ceepq4w4wdyp8tvy7v060r", "guest_event_id": "evt_01m4ceepqrxs5240rvb8cmrtee"}', 'guest', 2, '2026-10-08 00:26:17.899743+00'); +INSERT INTO public.events VALUES (9, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "priority": 0, "description": "List the workspace."}', 'host', NULL, '2026-10-08 00:26:17.910436+00'); +INSERT INTO public.events VALUES (10, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "THINKING", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3b18qmjz7x3acqxr7"}', 'guest', 3, '2026-10-08 00:26:17.940112+00'); +INSERT INTO public.events VALUES (11, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps31c9j93jpd8nb3gz9"}', 'guest', 4, '2026-10-08 00:26:17.948021+00'); +INSERT INTO public.events VALUES (12, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.completed', '{"summary": "done: List the workspace.", "task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3hawexcrf77xqs9wq"}', 'guest', 5, '2026-10-08 00:26:17.956503+00'); +INSERT INTO public.events VALUES (13, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "IDLE", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3parat5eey42mqgsj"}', 'guest', 6, '2026-10-08 00:26:17.966985+00'); +INSERT INTO public.events VALUES (14, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "priority": 0, "description": "Delete the old identity."}', 'host', NULL, '2026-10-08 00:26:17.981828+00'); +INSERT INTO public.events VALUES (15, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.845Z", "guest_event_id": "evt_01m4ceepvn3vzncer00rmzep10"}', 'guest', 7, '2026-10-08 00:26:18.018234+00'); +INSERT INTO public.events VALUES (16, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "WAITING_APPROVAL", "guest_ts": "2026-10-08T00:26:17.845Z", "guest_event_id": "evt_01m4ceepvnc1nwxjjf3q3w032b"}', 'guest', 8, '2026-10-08 00:26:18.029602+00'); +INSERT INTO public.events VALUES (17, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.requested', '{"tool": "browser_identity_delete", "reason": "the tool needs approval", "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.845Z", "arguments": {"identity_id": "shop-abc123"}, "permission": "browser.identity.delete", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey", "guest_event_id": "evt_01m4ceepvnz1rg79k8y3p2hh5g"}', 'guest', 9, '2026-10-08 00:26:18.038234+00'); +INSERT INTO public.events VALUES (18, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.resolved', '{"note": "fine", "always": true, "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "decision": "approve", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey"}', 'host', NULL, '2026-10-08 00:26:18.071102+00'); +INSERT INTO public.events VALUES (19, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.updated', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:18.071102+00'); +INSERT INTO public.events VALUES (20, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "EXECUTING", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy4fbsgfqd96zgj6t4v"}', 'guest', 10, '2026-10-08 00:26:18.091455+00'); +INSERT INTO public.events VALUES (21, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.completed', '{"summary": "approval approved", "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy4ea0fjr9b6g6kpk29"}', 'guest', 11, '2026-10-08 00:26:18.100452+00'); +INSERT INTO public.events VALUES (22, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "IDLE", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy48vx98jjv03q60ajm"}', 'guest', 12, '2026-10-08 00:26:18.107693+00'); +INSERT INTO public.events VALUES (23, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.updated', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:18.111019+00'); +INSERT INTO public.events VALUES (24, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "priority": 0, "description": "Delete the shop identity."}', 'host', NULL, '2026-10-08 00:26:18.121808+00'); +INSERT INTO public.events VALUES (25, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "guest_ts": "2026-10-08T00:26:17.975Z", "guest_event_id": "evt_01m4ceepzqq05k889g7kweerrc"}', 'guest', 13, '2026-10-08 00:26:18.139836+00'); +INSERT INTO public.events VALUES (26, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "WAITING_APPROVAL", "guest_ts": "2026-10-08T00:26:17.975Z", "guest_event_id": "evt_01m4ceepzqxj1pzcgx1zba0mcr"}', 'guest', 14, '2026-10-08 00:26:18.145257+00'); +INSERT INTO public.events VALUES (27, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.requested', '{"tool": "browser_identity_delete", "reason": "the tool needs approval", "task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "guest_ts": "2026-10-08T00:26:17.975Z", "arguments": {"identity_id": "shop-abc123"}, "permission": "browser.identity.delete", "approval_id": "apr_01m4ceepzq74326e3e6vrheqvj", "guest_event_id": "evt_01m4ceepzq7pb07ekz37zhskey"}', 'guest', 15, '2026-10-08 00:26:18.153524+00'); +INSERT INTO public.events VALUES (28, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeq0s2zdfycr7vwnwy9s8", "priority": 0, "description": "Check the fares again."}', 'host', NULL, '2026-10-08 00:26:18.171462+00'); +INSERT INTO public.events VALUES (29, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.cancelled', '{"task_id": "task_01m4ceeq0s2zdfycr7vwnwy9s8"}', 'host', NULL, '2026-10-08 00:26:18.176182+00'); +INSERT INTO public.events VALUES (30, 'dot_01m4ceepkrt609r0tde6hm21hb', 'automation.next_run', '{"guest_ts": "2026-10-08T00:26:18.019Z", "guest_event_id": "evt_01m4ceeq13g8b426wcn3432mbv", "next_run_at_ms": 1791505433167}', 'guest', 16, '2026-10-08 00:26:18.181416+00'); +INSERT INTO public.events VALUES (31, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'dot.created', '{"name": "minimal-dot"}', 'host', NULL, '2026-10-08 00:26:18.190622+00'); +INSERT INTO public.events VALUES (32, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "PROVISIONING"}', 'host', NULL, '2026-10-08 00:26:18.190622+00'); +INSERT INTO public.events VALUES (33, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STARTING"}', 'host', NULL, '2026-10-08 00:26:18.203391+00'); +INSERT INTO public.events VALUES (34, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "RUNNING"}', 'host', NULL, '2026-10-08 00:26:18.208314+00'); +INSERT INTO public.events VALUES (35, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.started', '{"guest_port": 47001, "runtime_image": "images/runtime-test.iso"}', 'host', NULL, '2026-10-08 00:26:18.208314+00'); +INSERT INTO public.events VALUES (36, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'agent.started', '{"guest_ts": "2026-10-08T00:26:18.048Z", "guest_event_id": "evt_01m4ceeq206y59sjefjm80tgar"}', 'guest', 1, '2026-10-08 00:26:18.237511+00'); +INSERT INTO public.events VALUES (37, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STOPPING"}', 'host', NULL, '2026-10-08 00:26:18.260481+00'); +INSERT INTO public.events VALUES (38, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STOPPED"}', 'host', NULL, '2026-10-08 00:26:18.281007+00'); +INSERT INTO public.events VALUES (39, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.stopped', '{"forced": false, "reason": "user"}', 'host', NULL, '2026-10-08 00:26:18.281007+00'); + + +-- +-- Data for Name: inbound_events; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.inbound_events VALUES (1, 'msg_01m4ceepq4w4wdyp8tvy7v060r', 'dot_01m4ceepkrt609r0tde6hm21hb', 'user.message', '{"text": "Remember: my favourite colour is teal."}', '2026-10-07T09:00:00.000Z', NULL, NULL, '2026-10-08 00:26:17.880657+00', '2026-10-08 00:26:17.892461+00', '2026-10-08 00:26:17.899719+00', NULL, NULL, 0, NULL, NULL); +INSERT INTO public.inbound_events VALUES (2, 'evt_01m4ceeprmq0dfftt3ejd0v7qc', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "priority": 0, "description": "List the workspace."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceepr4pcpyejkrwmwszmvy', 'run_01m4ceeprj3zwtyjmrn8wpefzz', '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.933733+00', '2026-10-08 00:26:17.940076+00', NULL, NULL, 0, NULL, NULL); +INSERT INTO public.inbound_events VALUES (3, 'evt_01m4ceepv0asgrb9zz7j54fjsp', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "priority": 0, "description": "Delete the old identity."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceeptfsmdeddevy4770sbk', 'run_01m4ceeptymc59t9c34rmqzq8v', '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.010516+00', '2026-10-08 00:26:18.018374+00', NULL, NULL, 0, NULL, NULL); +INSERT INTO public.inbound_events VALUES (4, 'evt_01m4ceepxe33m1qnqvsfbzvkvc', 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.received', '{"note": "fine", "decision": "approve", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey"}', '2026-10-07T09:00:00.000Z', NULL, NULL, '2026-10-08 00:26:18.071102+00', '2026-10-08 00:26:18.087155+00', '2026-10-08 00:26:18.091459+00', NULL, NULL, 0, NULL, NULL); +INSERT INTO public.inbound_events VALUES (5, 'evt_01m4ceepzer7yzzxfn4t1hwway', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "priority": 0, "description": "Delete the shop identity."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceepz48cetk8s7k7zwkst5', 'run_01m4ceepzd75vf6eyq6s83tvxf', '2026-10-08 00:26:18.126436+00', '2026-10-08 00:26:18.135244+00', '2026-10-08 00:26:18.139817+00', NULL, NULL, 0, NULL, NULL); + + +-- +-- Data for Name: schema_migrations; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.schema_migrations VALUES ('0001_initial', '2026-10-08 00:26:17.623866+00'); +INSERT INTO public.schema_migrations VALUES ('0002_inbound_events', '2026-10-08 00:26:17.666723+00'); +INSERT INTO public.schema_migrations VALUES ('0003_task_spend', '2026-10-08 00:26:17.680464+00'); +INSERT INTO public.schema_migrations VALUES ('0004_channels', '2026-10-08 00:26:17.688232+00'); +INSERT INTO public.schema_migrations VALUES ('0005_channel_prompts', '2026-10-08 00:26:17.707123+00'); +INSERT INTO public.schema_migrations VALUES ('0006_events_task', '2026-10-08 00:26:17.718165+00'); +INSERT INTO public.schema_migrations VALUES ('0007_dot_config_version', '2026-10-08 00:26:17.724644+00'); +INSERT INTO public.schema_migrations VALUES ('0008_computer_next_automation', '2026-10-08 00:26:17.730628+00'); +INSERT INTO public.schema_migrations VALUES ('0009_dot_keeps_its_memory', '2026-10-08 00:26:17.737384+00'); +INSERT INTO public.schema_migrations VALUES ('0010_dot_has_no_goal', '2026-10-08 00:26:17.745491+00'); +INSERT INTO public.schema_migrations VALUES ('0011_dot_has_no_browser_settings', '2026-10-08 00:26:17.753326+00'); + + +-- +-- Data for Name: secrets; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.secrets VALUES ('global', 'openrouter_api_key', '\x83ecec4851c227f6a0848b8123934629df0b90a2a63abc4b38d243360f1593e3604f9658463bada064baf61238436101953919de8d0551c0946b3f7cb6adbefbbf3da3', '2026-10-08 00:26:17.762727+00'); +INSERT INTO public.secrets VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'telegram_bot_token', '\xcb8b7dc5772170ab2190e83956526daf3705cd174d2c1d80cffa2d1dcd5709736fbde9a872341e083d0586749322d18eeede5662b8b012c46505bd92aba13e6a8417a8', '2026-10-08 00:26:18.184962+00'); + + +-- +-- Data for Name: task_runs; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.task_runs VALUES ('run_01m4ceeprj3zwtyjmrn8wpefzz', 'task_01m4ceepr4pcpyejkrwmwszmvy', '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.942498+00', '2026-10-08 00:26:17.956503+00', 'completed'); +INSERT INTO public.task_runs VALUES ('run_01m4ceeptymc59t9c34rmqzq8v', 'task_01m4ceeptfsmdeddevy4770sbk', '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.023517+00', '2026-10-08 00:26:18.100452+00', 'completed'); +INSERT INTO public.task_runs VALUES ('run_01m4ceepzd75vf6eyq6s83tvxf', 'task_01m4ceepz48cetk8s7k7zwkst5', '2026-10-08 00:26:18.126436+00', '2026-10-08 00:26:18.14187+00', NULL, NULL); + + +-- +-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.tasks VALUES ('task_01m4ceepr4pcpyejkrwmwszmvy', 'dot_01m4ceepkrt609r0tde6hm21hb', 'List the workspace.', 0, 'COMPLETED', '2026-10-08 00:26:17.910436+00', NULL, '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.956503+00', 'done: List the workspace.', NULL, 0); +INSERT INTO public.tasks VALUES ('task_01m4ceeptfsmdeddevy4770sbk', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Delete the old identity.', 0, 'COMPLETED', '2026-10-08 00:26:17.981828+00', NULL, '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.100452+00', 'approval approved', NULL, 0); +INSERT INTO public.tasks VALUES ('task_01m4ceepz48cetk8s7k7zwkst5', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Delete the shop identity.', 0, 'WAITING_APPROVAL', '2026-10-08 00:26:18.121808+00', NULL, '2026-10-08 00:26:18.126436+00', NULL, NULL, NULL, 0); +INSERT INTO public.tasks VALUES ('task_01m4ceeq0s2zdfycr7vwnwy9s8', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Check the fares again.', 0, 'CANCELLED', '2026-10-08 00:26:18.171462+00', NULL, NULL, '2026-10-08 00:26:18.176182+00', NULL, 'cancelled by the user', 0); + + +-- +-- Name: events_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.events_id_seq', 39, true); + + +-- +-- Name: inbound_events_seq_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.inbound_events_seq_seq', 5, true); + + +-- +-- Name: approvals approvals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.approvals + ADD CONSTRAINT approvals_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_bindings channel_bindings_dot_id_kind_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_bindings + ADD CONSTRAINT channel_bindings_dot_id_kind_key UNIQUE (dot_id, kind); + + +-- +-- Name: channel_bindings channel_bindings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_bindings + ADD CONSTRAINT channel_bindings_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_pairings channel_pairings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_pairings + ADD CONSTRAINT channel_pairings_pkey PRIMARY KEY (binding_id, code_hash); + + +-- +-- Name: channel_peers channel_peers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_peers + ADD CONSTRAINT channel_peers_pkey PRIMARY KEY (binding_id, peer_id); + + +-- +-- Name: channel_prompts channel_prompts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_prompts + ADD CONSTRAINT channel_prompts_pkey PRIMARY KEY (binding_id, approval_id, chat_id); + + +-- +-- Name: computers computers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.computers + ADD CONSTRAINT computers_pkey PRIMARY KEY (dot_id); + + +-- +-- Name: dots dots_name_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dots + ADD CONSTRAINT dots_name_key UNIQUE (name); + + +-- +-- Name: dots dots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dots + ADD CONSTRAINT dots_pkey PRIMARY KEY (id); + + +-- +-- Name: events events_dot_id_guest_seq_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events + ADD CONSTRAINT events_dot_id_guest_seq_key UNIQUE (dot_id, guest_seq); + + +-- +-- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events + ADD CONSTRAINT events_pkey PRIMARY KEY (id); + + +-- +-- Name: inbound_events inbound_events_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_events + ADD CONSTRAINT inbound_events_id_key UNIQUE (id); + + +-- +-- Name: inbound_events inbound_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_events + ADD CONSTRAINT inbound_events_pkey PRIMARY KEY (seq); + + +-- +-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); + + +-- +-- Name: secrets secrets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.secrets + ADD CONSTRAINT secrets_pkey PRIMARY KEY (scope, name); + + +-- +-- Name: task_runs task_runs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.task_runs + ADD CONSTRAINT task_runs_pkey PRIMARY KEY (id); + + +-- +-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); + + +-- +-- Name: approvals_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX approvals_status_idx ON public.approvals USING btree (status, created_at); + + +-- +-- Name: channel_bindings_account_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX channel_bindings_account_key ON public.channel_bindings USING btree (account) WHERE ((kind = 'telegram'::text) AND (account IS NOT NULL)); + + +-- +-- Name: channel_peers_chat_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_peers_chat_idx ON public.channel_peers USING btree (binding_id, chat_id); + + +-- +-- Name: channel_prompts_approval_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_prompts_approval_idx ON public.channel_prompts USING btree (approval_id); + + +-- +-- Name: events_dot_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX events_dot_idx ON public.events USING btree (dot_id, id); + + +-- +-- Name: events_task_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX events_task_idx ON public.events USING btree (dot_id, ((data ->> 'task_id'::text)), id) WHERE ((data ->> 'task_id'::text) IS NOT NULL); + + +-- +-- Name: events_user_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX events_user_message_idx ON public.events USING btree (dot_id, ((data ->> 'message_id'::text))) WHERE (type = 'user.message'::text); + + +-- +-- Name: events_user_message_origin_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX events_user_message_origin_key ON public.events USING btree (dot_id, (((data -> 'origin'::text) ->> 'binding_id'::text)), (((data -> 'origin'::text) ->> 'external_id'::text))) WHERE ((type = 'user.message'::text) AND ((data -> 'origin'::text) IS NOT NULL)); + + +-- +-- Name: inbound_events_pending_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX inbound_events_pending_idx ON public.inbound_events USING btree (dot_id, seq) WHERE ((delivered_at IS NULL) AND (dropped_at IS NULL)); + + +-- +-- Name: task_runs_task_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX task_runs_task_idx ON public.task_runs USING btree (task_id); + + +-- +-- Name: tasks_dot_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX tasks_dot_status_idx ON public.tasks USING btree (dot_id, status); + + +-- +-- Name: tasks_pending_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX tasks_pending_idx ON public.tasks USING btree (priority DESC, created_at, id) WHERE (status = 'PENDING'::text); + + +-- +-- Name: approvals approvals_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.approvals + ADD CONSTRAINT approvals_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE; + + +-- +-- Name: channel_bindings channel_bindings_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_bindings + ADD CONSTRAINT channel_bindings_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE; + + +-- +-- Name: channel_pairings channel_pairings_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_pairings + ADD CONSTRAINT channel_pairings_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE; + + +-- +-- Name: channel_peers channel_peers_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_peers + ADD CONSTRAINT channel_peers_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE; + + +-- +-- Name: channel_prompts channel_prompts_approval_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_prompts + ADD CONSTRAINT channel_prompts_approval_id_fkey FOREIGN KEY (approval_id) REFERENCES public.approvals(id) ON DELETE CASCADE; + + +-- +-- Name: channel_prompts channel_prompts_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_prompts + ADD CONSTRAINT channel_prompts_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE; + + +-- +-- Name: computers computers_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.computers + ADD CONSTRAINT computers_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE; + + +-- +-- Name: inbound_events inbound_events_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_events + ADD CONSTRAINT inbound_events_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE; + + +-- +-- Name: task_runs task_runs_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.task_runs + ADD CONSTRAINT task_runs_task_id_fkey FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON DELETE CASCADE; + + +-- +-- Name: tasks tasks_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + + diff --git a/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/master.key b/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/master.key new file mode 100644 index 00000000..a59389d8 --- /dev/null +++ b/apps/scheduler/test/fixtures/upgrade/golden-b326f2a1d4fe/master.key @@ -0,0 +1 @@ +f38457d1f14a7ea3ee7119f7dc463369ae540f0ba0036e1001a40dba8a997e56 diff --git a/apps/scheduler/test/fixtures/upgrade/make-host-state.ts b/apps/scheduler/test/fixtures/upgrade/make-host-state.ts new file mode 100644 index 00000000..84088a85 --- /dev/null +++ b/apps/scheduler/test/fixtures/upgrade/make-host-state.ts @@ -0,0 +1,111 @@ +/** + * Writes the control plane's database as a release leaves it, for test/upgrade.test.ts. + * + * Run it from the root of a checkout of the release (a worktree of its tag), against a PostgreSQL server + * (it makes a database of its own there and prints its name), then dump that database, leaving out psql's + * meta-commands and the dump's reset of search_path, which would outlive the load on PGlite: + * + * DATABASE_URL=postgres://... npx tsx fixtures/upgrade//master.key + * pg_dump --inserts --no-owner --no-privileges | grep -v -e '^\\' -e "set_config('search_path'" \ + * > fixtures/upgrade//host.sql + * + * It drives that release's scheduler, with a fake computer, through what a person's install holds: two + * Dots (one stopped), a chat answered, a completed task, a task waiting for an approval, an approval + * answered with "always", a cancelled task, an automation, a Telegram binding with its token, and the + * OpenRouter key. The master key it writes is a test key: it only opens the secrets of the dump. + */ +import { randomBytes } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { Database } from "@invisible-dots/database"; +import { createScratchPostgres } from "@invisible-dots/database/testing"; +import { Scheduler } from "../../../src/index.js"; +import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../../../src/testing.js"; + +const [keyFile] = process.argv.slice(2); +if (!keyFile) throw new Error("usage: make-host-state.ts "); +const masterKey = randomBytes(32); +const { url } = await createScratchPostgres(); +const db = await Database.open({ target: { kind: "pg", url }, masterKey, poolSize: 5 }); +await db.migrate(); +const driver = new FakeDriver(); +const scheduler = new Scheduler({ + db, + driver, + clock: new ManualClock(new Date("2026-10-07T09:00:00.000Z")), + lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 }, + dispatcher: { retryDelayMs: 0, maxDeliveryAttempts: 2 }, +}); +await scheduler.setOpenRouterKey("sk-or-v1-upgrade-fixture-not-a-real-key"); + +const yaml = (name: string) => + `name: ${name}\nmodel:\n provider: openrouter\n id: z-ai/glm-5.3-flash\ncomputer:\n idle_timeout: 15m\npermissions:\n files.write: ask\n`; +const ready = async (name: string) => { + const dot = await scheduler.createDot(yaml(name)); + await waitUntilSettledReady(scheduler, driver, dot.id, name); + return dot; +}; + +const watcher = await ready("fare-watch"); +const guest = driver.guestOf(watcher.id); + +// A chat answered, and a completed task. +await scheduler.sendMessage(watcher.id, "Remember: my favourite colour is teal."); +const done = await scheduler.createTask(watcher.id, { description: "List the workspace." }); +await waitFor(async () => (await db.tasks.get(done.id))?.status === "COMPLETED", "task completed"); + +// An approval answered with "always", and a task that waits for an approval when the release is upgraded. +const original = guest.onInbound; +guest.onInbound = (event, g) => { + if (event.type !== "task.created") return original(event, g); + g.emit("task.started", { task_id: event.data.task_id }); + g.requestApproval(event.data.task_id); +}; +const allowed = await scheduler.createTask(watcher.id, { description: "Delete the old identity." }); +const always = await waitFor(async () => (await scheduler.listApprovals("pending")).find((a) => a.task_id === allowed.id), "first approval"); +await scheduler.resolveApproval(always.id, "approve", { always: true, note: "fine" }); +await waitFor(async () => (await db.tasks.get(allowed.id))?.status === "COMPLETED", "approved task completed"); +await scheduler.updateDot(watcher.id, yaml("fare-watch")); +const waiting = await scheduler.createTask(watcher.id, { description: "Delete the shop identity." }); +await waitFor(async () => (await db.tasks.get(waiting.id))?.status === "WAITING_APPROVAL", "task waiting"); + +// A cancelled task, queued behind the waiting one. +const cancelled = await scheduler.createTask(watcher.id, { description: "Check the fares again." }); +await scheduler.cancelTask(cancelled.id); + +// An automation the Dot reported. +guest.putAutomation({ + id: "job_water", + name: "water the plants", + enabled: true, + schedule: { kind: "every", every_ms: 86_400_000 }, + message: "Water the plants", + next_run_at_ms: 1_791_505_433_167, + last_run_at_ms: null, + last_status: null, + last_error: null, + delete_after_run: false, + created_at_ms: 1_791_419_033_167, +}); + +// A Telegram binding with its token and a paired owner. +const binding = await db.channels.createBinding({ + id: "chb_upgrade", + dotId: watcher.id, + kind: "telegram", + settings: { approvals: true, notify_tasks: true, show_arguments: false }, + eventCursor: 0, + account: "fare_watch_bot", +}); +await db.secrets.put(watcher.id, "telegram_bot_token", "123456:upgrade-fixture-not-a-real-token"); +await db.channels.upsertPeer({ bindingId: binding.id, peerId: "4242", chatId: "4242", role: "owner", label: "Federico" }); + +// A second Dot, stopped. +const minimal = await ready("minimal-dot"); +await scheduler.stopComputer(minimal.id); +await waitFor(async () => (await db.computers.get(minimal.id))?.state === "STOPPED", "computer stopped"); + +await scheduler.settle(); +await scheduler.close(); +await db.close(); +await writeFile(keyFile, masterKey.toString("hex") + "\n"); +console.log(new URL(url).pathname.slice(1)); diff --git a/apps/scheduler/test/upgrade.test.ts b/apps/scheduler/test/upgrade.test.ts new file mode 100644 index 00000000..ceb465ae --- /dev/null +++ b/apps/scheduler/test/upgrade.test.ts @@ -0,0 +1,102 @@ +/** + * A release's database under this control plane: what a person's install holds must open, migrate and + * go on working after an upgrade. Each directory of test/fixtures/upgrade holds the database one release + * left (host.sql, made by make-host-state.ts from that release's checkout) and the test master key its + * secrets were sealed with. + */ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Database, loadMigrations, OPENROUTER_KEY_NAME, PgliteDb } from "@invisible-dots/database"; +import { createScratchPostgres, testAdapters } from "@invisible-dots/database/testing"; +import pg from "pg"; +import { afterEach, describe, expect, it } from "vitest"; +import { Scheduler } from "../src/index.js"; +import { FakeDriver, ManualClock } from "../src/testing.js"; + +const FIXTURES = fileURLToPath(new URL("./fixtures/upgrade/", import.meta.url)); +const RELEASES = readdirSync(FIXTURES, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); + +describe.each(testAdapters())("a release's database under this control plane (%s)", (kind) => { + const cleanup: (() => Promise)[] = []; + afterEach(async () => { + for (const step of cleanup.splice(0).reverse()) await step(); + }); + + /** The release's database loaded as it was left, then opened and migrated by this build. */ + async function upgraded(release: string): Promise { + const dump = readFileSync(join(FIXTURES, release, "host.sql"), "utf8"); + const masterKey = Buffer.from(readFileSync(join(FIXTURES, release, "master.key"), "utf8").trim(), "hex"); + let db: Database; + if (kind === "pglite") { + const dataDir = mkdtempSync(join(tmpdir(), "idots-upgrade-")); + cleanup.push(async () => rmSync(dataDir, { recursive: true, force: true })); + const loader = await PgliteDb.open(dataDir); + await loader.exec(dump); + await loader.close(); + db = await Database.open({ target: { kind: "pglite", dataDir }, masterKey }); + } else { + const { url, drop } = await createScratchPostgres(); + cleanup.push(drop); + const loader = new pg.Client({ connectionString: url }); + await loader.connect(); + await loader.query(dump); + await loader.end(); + db = await Database.open({ target: { kind: "pg", url }, masterKey, poolSize: 3 }); + } + cleanup.push(() => db.close()); + await db.migrate(); + return db; + } + + it("has a release to upgrade from", () => { + expect(RELEASES.length).toBeGreaterThan(0); + }); + + it.each(RELEASES)("%s: migrates to every migration of this build, and its Dots, configs, tasks and secrets read back", async (release) => { + const db = await upgraded(release); + const applied = (await db.query<{ version: string }>("SELECT version FROM schema_migrations ORDER BY version")).rows.map((r) => r.version); + expect(applied).toEqual((await loadMigrations()).map((m) => m.version)); + + const dots = await db.dots.list(); + expect(dots.map((d) => d.name).sort()).toEqual(["fare-watch", "minimal-dot"]); + const watcher = dots.find((d) => d.name === "fare-watch")!; + expect(watcher.config.model.id).toBe("z-ai/glm-5.3-flash"); + expect(watcher.config.permissions?.["files.write"]).toBe("ask"); + expect((await db.computers.get(dots.find((d) => d.name === "minimal-dot")!.id))?.state).toBe("STOPPED"); + + const tasks = await db.tasks.listByDot(watcher.id, { limit: 50 }); + expect(tasks.map((t) => t.status).sort()).toEqual(["CANCELLED", "COMPLETED", "COMPLETED", "WAITING_APPROVAL"]); + + // The secrets open with the key they were sealed with. + expect(await db.secrets.get("global", OPENROUTER_KEY_NAME)).toBe("sk-or-v1-upgrade-fixture-not-a-real-key"); + expect(await db.secrets.get(watcher.id, "telegram_bot_token")).toBe("123456:upgrade-fixture-not-a-real-token"); + const [binding] = await db.channels.listBindings(watcher.id); + expect(binding).toMatchObject({ kind: "telegram", account: "fare_watch_bot" }); + expect((await db.channels.peers(binding!.id)).map((p) => [p.peer_id, p.role])).toEqual([["4242", "owner"]]); + }); + + it.each(RELEASES)("%s: the approval it left pending is answered, new work is queued, and new events follow the old ones", async (release) => { + const db = await upgraded(release); + const scheduler = new Scheduler({ db, driver: new FakeDriver(), clock: new ManualClock(new Date("2026-11-01T09:00:00.000Z")) }); + cleanup.push(() => scheduler.close()); + const watcher = await scheduler.requireDot("fare-watch"); + const lastEvent = (await db.query<{ id: string }>("SELECT max(id)::text AS id FROM events")).rows[0]!.id; + + const [pending] = await scheduler.listApprovals("pending"); + expect(pending).toMatchObject({ dot_id: watcher.id, status: "pending", tool: "browser_identity_delete" }); + const resolved = await scheduler.resolveApproval(pending!.id, "approve", { note: "after the upgrade" }); + expect(resolved.status).toBe("approved"); + const queued = (await db.query<{ type: string; data: { approval_id?: string } }>("SELECT type, data FROM inbound_events WHERE dot_id = $1 AND sent_at IS NULL", [watcher.id])).rows; + expect(queued.some((row) => row.type === "approval.received" && row.data.approval_id === pending!.id)).toBe(true); + + const task = await scheduler.createTask("fare-watch", { description: "Check the fares after the upgrade." }); + expect(task.status).toBe("PENDING"); + const conversation = await scheduler.conversation("fare-watch"); + expect(JSON.stringify(conversation)).toContain("Remember: my favourite colour is teal."); + + const events = await scheduler.listEvents(watcher.id, { after: Number(lastEvent) }); + expect(events.map((e) => e.type)).toEqual(expect.arrayContaining(["approval.resolved", "task.created"])); + }); +});