writbase 0.1.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.
Files changed (30) hide show
  1. package/README.md +121 -0
  2. package/dist/writbase.js +707 -0
  3. package/migrations/00001_enums.sql +7 -0
  4. package/migrations/00002_core_tables.sql +105 -0
  5. package/migrations/00003_constraints_indexes.sql +69 -0
  6. package/migrations/00004_rls_policies.sql +169 -0
  7. package/migrations/00005_seed_app_settings.sql +2 -0
  8. package/migrations/00006_rate_limit_rpc.sql +14 -0
  9. package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
  10. package/migrations/00008_indexes_and_cleanup.sql +22 -0
  11. package/migrations/00009_atomic_mutations.sql +175 -0
  12. package/migrations/00010_user_rate_limits.sql +52 -0
  13. package/migrations/00011_atomic_permission_update.sql +31 -0
  14. package/migrations/00012_pg_cron_cleanup.sql +27 -0
  15. package/migrations/00013_max_agent_keys.sql +4 -0
  16. package/migrations/00014_event_log_indexes.sql +2 -0
  17. package/migrations/00015_auth_rate_limits.sql +69 -0
  18. package/migrations/00016_request_log.sql +42 -0
  19. package/migrations/00017_task_search.sql +58 -0
  20. package/migrations/00018_inter_agent_exchange.sql +423 -0
  21. package/migrations/00019_workspaces.sql +782 -0
  22. package/migrations/00020_can_comment.sql +46 -0
  23. package/migrations/00021_webhook_delivery.sql +125 -0
  24. package/migrations/00022_task_archive.sql +380 -0
  25. package/migrations/00023_get_top_tasks.sql +21 -0
  26. package/migrations/00024_agent_key_defaults.sql +55 -0
  27. package/migrations/00025_production_automation.sql +353 -0
  28. package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
  29. package/migrations/00027_rename_agent_key_defaults.sql +51 -0
  30. package/package.json +34 -0
@@ -0,0 +1,353 @@
1
+ -- Production automation: 6 pg_cron jobs for operational hygiene
2
+ --
3
+ -- 1. Auto-archive stale completed tasks (daily)
4
+ -- 2. Deactivate unused agent keys (weekly)
5
+ -- 3. Event log rotation (daily)
6
+ -- 4. Webhook delivery retries + dead-letter cleanup (every 2 min + daily)
7
+ -- 5. Usage metrics aggregation (daily)
8
+ -- 6. ANALYZE hot tables (daily)
9
+ --
10
+ -- All jobs are gated on pg_cron availability (no-op on local dev).
11
+
12
+ -- ══════════════════════════════════════════════════════════════════════
13
+ -- 1. Auto-archive: mark completed/cancelled/failed tasks as archived
14
+ -- after 30 days of inactivity
15
+ -- ══════════════════════════════════════════════════════════════════════
16
+
17
+ CREATE OR REPLACE FUNCTION auto_archive_stale_tasks()
18
+ RETURNS integer
19
+ LANGUAGE plpgsql
20
+ SET search_path = public
21
+ AS $$
22
+ DECLARE
23
+ v_count integer;
24
+ BEGIN
25
+ UPDATE tasks
26
+ SET is_archived = true, updated_at = now()
27
+ WHERE status IN ('done', 'cancelled', 'failed')
28
+ AND is_archived = false
29
+ AND updated_at < now() - interval '30 days';
30
+
31
+ GET DIAGNOSTICS v_count = ROW_COUNT;
32
+ RETURN v_count;
33
+ END;
34
+ $$;
35
+
36
+ -- ══════════════════════════════════════════════════════════════════════
37
+ -- 2. Inactive key detection: deactivate keys unused for 90+ days
38
+ -- ══════════════════════════════════════════════════════════════════════
39
+
40
+ CREATE OR REPLACE FUNCTION deactivate_stale_agent_keys()
41
+ RETURNS integer
42
+ LANGUAGE plpgsql
43
+ SET search_path = public
44
+ AS $$
45
+ DECLARE
46
+ v_count integer;
47
+ BEGIN
48
+ UPDATE agent_keys
49
+ SET is_active = false
50
+ WHERE is_active = true
51
+ AND last_used_at IS NOT NULL
52
+ AND last_used_at < now() - interval '90 days';
53
+
54
+ GET DIAGNOSTICS v_count = ROW_COUNT;
55
+ RETURN v_count;
56
+ END;
57
+ $$;
58
+
59
+ -- ══════════════════════════════════════════════════════════════════════
60
+ -- 3. Event log rotation: delete entries older than 90 days
61
+ -- ══════════════════════════════════════════════════════════════════════
62
+
63
+ -- The event_log trigger (prevent_event_log_mutation) fires unconditionally
64
+ -- on UPDATE/DELETE — SECURITY DEFINER does NOT bypass triggers. We add a
65
+ -- session variable check so the cleanup function can bypass it safely.
66
+
67
+ CREATE OR REPLACE FUNCTION prevent_event_log_mutation() RETURNS TRIGGER AS $$
68
+ BEGIN
69
+ -- Allow cleanup jobs to bypass via session variable
70
+ IF current_setting('app.allow_event_log_cleanup', true) = 'true' THEN
71
+ RETURN OLD;
72
+ END IF;
73
+ RAISE EXCEPTION 'event_log is append-only: % not permitted', TG_OP;
74
+ END;
75
+ $$ LANGUAGE plpgsql;
76
+
77
+ CREATE OR REPLACE FUNCTION cleanup_event_log()
78
+ RETURNS integer
79
+ LANGUAGE plpgsql
80
+ SECURITY DEFINER
81
+ SET search_path = public
82
+ AS $$
83
+ DECLARE
84
+ v_count integer;
85
+ BEGIN
86
+ -- Set bypass flag for the append-only trigger
87
+ PERFORM set_config('app.allow_event_log_cleanup', 'true', true);
88
+
89
+ DELETE FROM event_log
90
+ WHERE created_at < now() - interval '90 days';
91
+
92
+ GET DIAGNOSTICS v_count = ROW_COUNT;
93
+
94
+ -- Reset bypass flag
95
+ PERFORM set_config('app.allow_event_log_cleanup', 'false', true);
96
+ RETURN v_count;
97
+ END;
98
+ $$;
99
+
100
+ -- ══════════════════════════════════════════════════════════════════════
101
+ -- 4. Webhook delivery log + retry infrastructure
102
+ -- ══════════════════════════════════════════════════════════════════════
103
+
104
+ CREATE TABLE IF NOT EXISTS webhook_delivery_log (
105
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
106
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
107
+ subscription_id uuid NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
108
+ task_id uuid NOT NULL,
109
+ event_type text NOT NULL,
110
+ payload jsonb NOT NULL,
111
+ status text NOT NULL DEFAULT 'pending'
112
+ CHECK (status IN ('pending', 'delivered', 'failed', 'dead')),
113
+ attempts integer NOT NULL DEFAULT 0,
114
+ last_attempt_at timestamptz,
115
+ next_retry_at timestamptz DEFAULT now(),
116
+ last_error text,
117
+ created_at timestamptz NOT NULL DEFAULT now()
118
+ );
119
+
120
+ CREATE INDEX idx_wdl_pending_retry
121
+ ON webhook_delivery_log (next_retry_at)
122
+ WHERE status = 'pending' OR status = 'failed';
123
+
124
+ CREATE INDEX idx_wdl_subscription
125
+ ON webhook_delivery_log (subscription_id, created_at DESC);
126
+
127
+ CREATE INDEX idx_wdl_task
128
+ ON webhook_delivery_log (task_id);
129
+
130
+ -- Retry function: exponential backoff (2^attempts minutes, max 6 attempts)
131
+ -- After 6 failed attempts → mark as 'dead'
132
+ CREATE OR REPLACE FUNCTION process_webhook_retries()
133
+ RETURNS integer
134
+ LANGUAGE plpgsql
135
+ SECURITY DEFINER
136
+ SET search_path = public
137
+ AS $$
138
+ DECLARE
139
+ v_count integer := 0;
140
+ v_dead integer := 0;
141
+ v_stuck integer := 0;
142
+ BEGIN
143
+ -- Promote stuck pending rows (attempted but no update in 10 min) to failed
144
+ UPDATE webhook_delivery_log
145
+ SET status = 'failed'
146
+ WHERE status = 'pending'
147
+ AND attempts > 0
148
+ AND last_attempt_at < now() - interval '10 minutes';
149
+ GET DIAGNOSTICS v_stuck = ROW_COUNT;
150
+
151
+ -- Mark deliveries that exceeded max attempts as dead
152
+ UPDATE webhook_delivery_log
153
+ SET status = 'dead'
154
+ WHERE status = 'failed'
155
+ AND attempts >= 6;
156
+ GET DIAGNOSTICS v_dead = ROW_COUNT;
157
+
158
+ -- Reset eligible failed deliveries back to pending for retry
159
+ UPDATE webhook_delivery_log
160
+ SET status = 'pending',
161
+ next_retry_at = now() + (power(2, attempts) || ' minutes')::interval
162
+ WHERE status = 'failed'
163
+ AND attempts < 6
164
+ AND next_retry_at <= now();
165
+ GET DIAGNOSTICS v_count = ROW_COUNT;
166
+
167
+ RETURN v_stuck + v_dead + v_count;
168
+ END;
169
+ $$;
170
+
171
+ -- Dead-letter cleanup: purge dead entries older than 30 days
172
+ CREATE OR REPLACE FUNCTION cleanup_dead_webhooks()
173
+ RETURNS integer
174
+ LANGUAGE plpgsql
175
+ SECURITY DEFINER
176
+ SET search_path = public
177
+ AS $$
178
+ DECLARE
179
+ v_dead integer;
180
+ v_delivered integer;
181
+ BEGIN
182
+ DELETE FROM webhook_delivery_log
183
+ WHERE status = 'dead'
184
+ AND created_at < now() - interval '30 days';
185
+ GET DIAGNOSTICS v_dead = ROW_COUNT;
186
+
187
+ -- Also purge delivered entries older than 7 days (no longer needed)
188
+ DELETE FROM webhook_delivery_log
189
+ WHERE status = 'delivered'
190
+ AND created_at < now() - interval '7 days';
191
+ GET DIAGNOSTICS v_delivered = ROW_COUNT;
192
+
193
+ RETURN v_dead + v_delivered;
194
+ END;
195
+ $$;
196
+
197
+ -- ══════════════════════════════════════════════════════════════════════
198
+ -- 5. Usage metrics aggregation (for billing/quota enforcement)
199
+ -- ══════════════════════════════════════════════════════════════════════
200
+
201
+ CREATE TABLE IF NOT EXISTS workspace_usage (
202
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
203
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
204
+ period_start date NOT NULL,
205
+ period_end date NOT NULL,
206
+ tasks_created integer NOT NULL DEFAULT 0,
207
+ tasks_active integer NOT NULL DEFAULT 0,
208
+ api_requests integer NOT NULL DEFAULT 0,
209
+ agent_keys_active integer NOT NULL DEFAULT 0,
210
+ computed_at timestamptz NOT NULL DEFAULT now(),
211
+ UNIQUE (workspace_id, period_start)
212
+ );
213
+
214
+ CREATE INDEX idx_workspace_usage_lookup
215
+ ON workspace_usage (workspace_id, period_start DESC);
216
+
217
+ CREATE OR REPLACE FUNCTION aggregate_workspace_usage()
218
+ RETURNS integer
219
+ LANGUAGE plpgsql
220
+ SECURITY DEFINER
221
+ SET search_path = public
222
+ AS $$
223
+ DECLARE
224
+ v_period_start date;
225
+ v_period_end date;
226
+ v_count integer;
227
+ BEGIN
228
+ -- Current calendar month
229
+ v_period_start := date_trunc('month', now())::date;
230
+ v_period_end := (date_trunc('month', now()) + interval '1 month' - interval '1 day')::date;
231
+
232
+ INSERT INTO workspace_usage (workspace_id, period_start, period_end,
233
+ tasks_created, tasks_active, api_requests, agent_keys_active, computed_at)
234
+ SELECT
235
+ w.id,
236
+ v_period_start,
237
+ v_period_end,
238
+ COALESCE(tc.cnt, 0),
239
+ COALESCE(ta.cnt, 0),
240
+ COALESCE(rl.cnt, 0),
241
+ COALESCE(ak.cnt, 0),
242
+ now()
243
+ FROM workspaces w
244
+ LEFT JOIN LATERAL (
245
+ SELECT count(*)::int AS cnt FROM tasks
246
+ WHERE workspace_id = w.id
247
+ AND created_at >= v_period_start
248
+ AND created_at < v_period_end + interval '1 day'
249
+ ) tc ON true
250
+ LEFT JOIN LATERAL (
251
+ SELECT count(*)::int AS cnt FROM tasks
252
+ WHERE workspace_id = w.id
253
+ AND is_archived = false
254
+ ) ta ON true
255
+ LEFT JOIN LATERAL (
256
+ SELECT count(*)::int AS cnt FROM request_log
257
+ WHERE workspace_id = w.id
258
+ AND created_at >= v_period_start
259
+ AND created_at < v_period_end + interval '1 day'
260
+ ) rl ON true
261
+ LEFT JOIN LATERAL (
262
+ SELECT count(*)::int AS cnt FROM agent_keys
263
+ WHERE workspace_id = w.id
264
+ AND is_active = true
265
+ ) ak ON true
266
+ ON CONFLICT (workspace_id, period_start)
267
+ DO UPDATE SET
268
+ tasks_created = EXCLUDED.tasks_created,
269
+ tasks_active = EXCLUDED.tasks_active,
270
+ api_requests = EXCLUDED.api_requests,
271
+ agent_keys_active = EXCLUDED.agent_keys_active,
272
+ computed_at = now();
273
+
274
+ GET DIAGNOSTICS v_count = ROW_COUNT;
275
+ RETURN v_count;
276
+ END;
277
+ $$;
278
+
279
+ -- ══════════════════════════════════════════════════════════════════════
280
+ -- 6. Schedule all jobs via pg_cron
281
+ -- ══════════════════════════════════════════════════════════════════════
282
+
283
+ DO $outer$
284
+ BEGIN
285
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
286
+
287
+ -- 1. Auto-archive stale tasks: daily at 02:00 UTC
288
+ PERFORM cron.schedule(
289
+ 'auto-archive-stale-tasks',
290
+ '0 2 * * *',
291
+ 'SELECT public.auto_archive_stale_tasks()'
292
+ );
293
+
294
+ -- 2. Deactivate stale agent keys: weekly on Sunday at 03:00 UTC
295
+ PERFORM cron.schedule(
296
+ 'deactivate-stale-keys',
297
+ '0 3 * * 0',
298
+ 'SELECT public.deactivate_stale_agent_keys()'
299
+ );
300
+
301
+ -- 3. Event log rotation: daily at 04:30 UTC
302
+ PERFORM cron.schedule(
303
+ 'cleanup-event-log',
304
+ '30 4 * * *',
305
+ 'SELECT public.cleanup_event_log()'
306
+ );
307
+
308
+ -- 4a. Webhook retry processing: every 2 minutes
309
+ PERFORM cron.schedule(
310
+ 'process-webhook-retries',
311
+ '*/2 * * * *',
312
+ 'SELECT public.process_webhook_retries()'
313
+ );
314
+
315
+ -- 4b. Dead-letter + delivered webhook cleanup: daily at 05:00 UTC
316
+ PERFORM cron.schedule(
317
+ 'cleanup-dead-webhooks',
318
+ '0 5 * * *',
319
+ 'SELECT public.cleanup_dead_webhooks()'
320
+ );
321
+
322
+ -- 5. Usage metrics aggregation: daily at 01:00 UTC
323
+ PERFORM cron.schedule(
324
+ 'aggregate-workspace-usage',
325
+ '0 1 * * *',
326
+ 'SELECT public.aggregate_workspace_usage()'
327
+ );
328
+
329
+ -- 6. ANALYZE hot tables: daily at 05:30 UTC
330
+ PERFORM cron.schedule(
331
+ 'analyze-hot-tables',
332
+ '30 5 * * *',
333
+ 'ANALYZE tasks; ANALYZE event_log; ANALYZE request_log; ANALYZE webhook_delivery_log'
334
+ );
335
+
336
+ ELSE
337
+ RAISE NOTICE 'pg_cron not available — skipping production automation schedules';
338
+ END IF;
339
+ END;
340
+ $outer$;
341
+
342
+ -- ══════════════════════════════════════════════════════════════════════
343
+ -- RLS for new tables
344
+ -- ══════════════════════════════════════════════════════════════════════
345
+
346
+ ALTER TABLE webhook_delivery_log ENABLE ROW LEVEL SECURITY;
347
+ ALTER TABLE workspace_usage ENABLE ROW LEVEL SECURITY;
348
+
349
+ CREATE POLICY workspace_isolation ON webhook_delivery_log
350
+ FOR ALL USING (workspace_id IN (SELECT get_user_workspace_ids()));
351
+
352
+ CREATE POLICY workspace_isolation ON workspace_usage
353
+ FOR ALL USING (workspace_id IN (SELECT get_user_workspace_ids()));
@@ -0,0 +1,19 @@
1
+ -- Fix get_top_tasks: exclude non-actionable statuses (done, cancelled, failed, blocked) by default.
2
+ -- When p_status is explicitly set, filter to that exact status (including non-actionable ones).
3
+
4
+ CREATE OR REPLACE FUNCTION get_top_tasks(
5
+ p_workspace_id uuid, p_project_id uuid,
6
+ p_department_id uuid DEFAULT NULL,
7
+ p_status text DEFAULT NULL,
8
+ p_limit int DEFAULT 10
9
+ ) RETURNS SETOF tasks LANGUAGE sql STABLE AS $$
10
+ SELECT * FROM tasks
11
+ WHERE workspace_id = p_workspace_id
12
+ AND project_id = p_project_id
13
+ AND is_archived = false
14
+ AND (p_department_id IS NULL OR department_id = p_department_id)
15
+ AND (p_status IS NOT NULL AND status = p_status::status
16
+ OR p_status IS NULL AND status NOT IN ('done', 'cancelled', 'failed', 'blocked'))
17
+ ORDER BY priority DESC, created_at ASC
18
+ LIMIT LEAST(p_limit, 25);
19
+ $$;
@@ -0,0 +1,51 @@
1
+ -- Rename default_project_id/default_department_id to project_id/department_id
2
+ -- These aren't "defaults" — they're the agent's home project/department.
3
+
4
+ ALTER TABLE agent_keys RENAME COLUMN default_project_id TO project_id;
5
+ ALTER TABLE agent_keys RENAME COLUMN default_department_id TO department_id;
6
+
7
+ -- Rename the CHECK constraint
8
+ ALTER TABLE agent_keys DROP CONSTRAINT agent_keys_default_dept_requires_project;
9
+ ALTER TABLE agent_keys ADD CONSTRAINT agent_keys_dept_requires_project
10
+ CHECK (department_id IS NULL OR project_id IS NOT NULL);
11
+
12
+ -- Recreate trigger function with updated column names
13
+ CREATE OR REPLACE FUNCTION check_workspace_consistency()
14
+ RETURNS trigger LANGUAGE plpgsql AS $$
15
+ BEGIN
16
+ IF TG_TABLE_NAME = 'agent_permissions' THEN
17
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
18
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
19
+ END IF;
20
+ IF NEW.department_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM departments WHERE id = NEW.department_id AND workspace_id = NEW.workspace_id) THEN
21
+ RAISE EXCEPTION 'cross-workspace reference: department % not in workspace %', NEW.department_id, NEW.workspace_id;
22
+ END IF;
23
+ END IF;
24
+
25
+ IF TG_TABLE_NAME = 'tasks' THEN
26
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
27
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
28
+ END IF;
29
+ IF NEW.assigned_to_agent_key_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = NEW.assigned_to_agent_key_id AND workspace_id = NEW.workspace_id) THEN
30
+ RAISE EXCEPTION 'cross-workspace reference: assignee % not in workspace %', NEW.assigned_to_agent_key_id, NEW.workspace_id;
31
+ END IF;
32
+ END IF;
33
+
34
+ IF TG_TABLE_NAME = 'webhook_subscriptions' THEN
35
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
36
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
37
+ END IF;
38
+ END IF;
39
+
40
+ IF TG_TABLE_NAME = 'agent_keys' THEN
41
+ IF NEW.project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
42
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
43
+ END IF;
44
+ IF NEW.department_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM departments WHERE id = NEW.department_id AND workspace_id = NEW.workspace_id) THEN
45
+ RAISE EXCEPTION 'cross-workspace reference: department % not in workspace %', NEW.department_id, NEW.workspace_id;
46
+ END IF;
47
+ END IF;
48
+
49
+ RETURN NEW;
50
+ END;
51
+ $$;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "writbase",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "WritBase CLI — self-hosted operator toolkit for agent-first task management",
6
+ "bin": {
7
+ "writbase": "./dist/writbase.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "migrations/"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup src/bin/writbase.ts --format esm --target node18",
15
+ "postbuild": "rm -rf ./migrations && cp -r ../supabase/migrations ./migrations",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@inquirer/prompts": "^7.0.0",
23
+ "@supabase/supabase-js": "^2.49.1",
24
+ "commander": "^13.1.0",
25
+ "dotenv": "^16.4.7",
26
+ "nanospinner": "^1.2.2",
27
+ "picocolors": "^1.1.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsup": "^8.4.0",
32
+ "typescript": "^5.7.0"
33
+ }
34
+ }