chore: Add dev tooling to seed an instance and inspect its activity feed (#37645)

This commit is contained in:
Arvin A
2026-09-04 09:49:31 +00:00
committed by GitHub
parent a3a8863626
commit ac432dd66a
10 changed files with 2142 additions and 29 deletions
+20
View File
@@ -106,6 +106,26 @@ When changing directories, use `pushd` to navigate into the directory and
`popd` to return to the previous directory. When in doubt, use `pwd` to check
your current directory.
### Seeding a local instance
An empty instance is a bad place to test anything that reads a user's work.
These commands fill one. They are dev tooling on the private root package, so
they never reach a user.
```bash
N8N_API_KEY=<jwt> pnpm seed:preference # 10 workflows in one house style, plus history
N8N_API_KEY=<jwt> pnpm seed:account # ~500 varied workflows across 30 projects
pnpm inspect:activity # read-only activity_event viewer on 127.0.0.1
```
Both seed profiles delete their own prior output, so a re-run replaces it.
That clear step deletes anything named `[seed]` and any empty team project,
whoever made them, so do not point either at a shared instance. The viewer is
unauthenticated and serves the whole table: keep it on loopback.
See [scripts/instance-seeding/AGENTS.md](scripts/instance-seeding/AGENTS.md) for
profiles, tokens, determinism, and the other commands.
### Code Quality
- `pnpm lint` - Lint code
- `pnpm typecheck` - Run type checks
+7 -1
View File
@@ -41,7 +41,13 @@
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs",
"grind": "node scripts/grind.mjs",
"nathan": "node scripts/nathan.mjs",
"inspect:activity": "node scripts/instance-seeding/inspectActivity.mjs",
"seed:account": "node scripts/instance-seeding/seedInstance.mjs",
"seed:history": "node scripts/instance-seeding/seedHistory.mjs",
"seed:preference": "cross-env PROFILE=preference pnpm seed:account && pnpm seed:history",
"seed:account:check": "node scripts/instance-seeding/checkPreferenceProfile.mjs",
"seed:test": "node --test scripts/instance-seeding/*.test.mjs",
"nathan": "node scripts/nathan.mjs",
"agent:setup": "node scripts/agent-setup.mjs",
"format:check": "turbo run format:check",
"lint": "turbo run lint",
+156
View File
@@ -1,5 +1,161 @@
# Seed n8n instance
Four dev-tooling scripts, none of them product code:
| Script | Command | What it does |
| --- | --- | --- |
| `seedInstance.mjs` | `pnpm seed:account` | Builds the estate via the public API. Two profiles. |
| `seedHistory.mjs` | `pnpm seed:history` | Executions, assistant threads, activity entries via SQLite. |
| `inspectActivity.mjs` | `pnpm inspect:activity` | Read-only viewer of `activity_event`. |
| `checkPreferenceProfile.mjs` | `pnpm seed:account:check` | Checks top-level parameter names against node definitions. |
`pnpm seed:preference` chains the first two. That is the single command that leaves
a usable instance.
## Two profiles
`PROFILE` picks the shape of the estate. They exist for opposite reasons.
**`estate`** (default) is ~500 workflows across 30 projects, varied and random, built
to stress the workflow dependency graph. The rest of this document describes it.
**`preference`** is 10 hand-written workflows in one house style, so an agent can be
asked "what does this org normally do?" and be graded on the answer. Diversity is the
bug here, not the feature.
```sh
N8N_API_KEY=… PROFILE=preference pnpm seed:account
```
The ten live in `preference-profile.mjs` and are written out, not generated. At n=10
generation buys nothing, and a generated workflow whose name does not match its nodes
teaches the reader something false.
### The house style
Five rules, no exceptions. Each is a place n8n offers a real choice and this estate
always makes the same one, because a rule with no alternative is a constraint rather
than a preference.
| Rule | What it rejects |
| --- | --- |
| OpenAI `gpt-4o-mini` chat model (5/5 AI workflows) | Anthropic, Gemini, Mistral, Ollama |
| Linear for tracking (6/6 tracker workflows) | Jira, GitHub Issues, Asana, Trello |
| Slack for notifications (10/10 workflows) | Discord, Teams, Telegram, email |
| Cron expressions on every schedule | the `interval` rule form |
| Every workflow ends writing to `automation_runs` | no audit trail |
### Determinism
Every random choice goes through one seeded PRNG, and seeded runs pin the clock too.
`PROFILE=preference` sets `SEED=1`, so two runs give byte-identical estates. That is
what lets an A/B eval compare two arms against one instance. Set `SEED` explicitly for
the estate profile.
### Credentials
`preference` builds credentials from the developer's own tokens:
| Env var | Credential |
| --- | --- |
| `SEED_OPENAI_API_KEY` | `openAiApi` |
| `SEED_LINEAR_API_KEY` | `linearApi` |
| `SEED_SLACK_TOKEN` | `slackApi` |
| `SEED_GMAIL_OAUTH` | `gmailOAuth2` |
| `SEED_ENRICHMENT_TOKEN` | `httpHeaderAuth` |
| `SEED_LINEAR_TEAM_ID` | Linear team the issue nodes target |
A missing token is not an error. It gives a placeholder named `(seed, fake key)`, so
the workflows stay wired and openable but visibly not runnable. Supply the token later
and re-run: the credential is upgraded in place and keeps its id, so nodes keep
pointing at it.
Two things make that work. The upgrade is a `PATCH`, because the public API has no
`PUT` for credentials and its 405 fails quietly enough to look like success. And the
`preference` profile skips `clearSeeded()`, which deletes credentials and would change
every id on every run; it removes only the workflows.
Secrets go through the public API, so n8n does the encryption and this tooling never
touches the instance key. Token lengths are logged, never values.
## `seedHistory.mjs`
Writes what the public API cannot: executions have no create route, threads and
activity entries have none at all. Run it after `seed:account`.
A live instance is fine. SQLite serialises writers, so n8n's inserts queue behind the
script. Prefer an idle one: an instance actively executing workflows can hold the write
lock long enough to fail.
A default run gives 175 executions over 14 days, 10 threads with 22 messages, and 35
activity entries. `[seed] Invoice Dunning` fails its three most recent runs, so a "what
broke?" probe has a definite answer.
### The window ends at the current time
n8n prunes on age: threads after 30 days, executions past `EXECUTIONS_DATA_MAX_AGE`
(336 hours). A fixed past date puts the fortnight beyond both cutoffs and the next
startup deletes it. This was observed, not predicted: a hardcoded clock lost all 10
threads on the first restart.
Determinism survives. The seeded PRNG still decides which workflow fails, on which
run, at which node, and every message body. Only absolute timestamps move. `HISTORY_NOW`
pins the window if you accept the pruning.
### Errors match their workflow
An error names a node, and that node must exist in the workflow the error is attached
to and say something a node of that type could say. One shared error message produced
self-contradicting records, such as a Gmail auth failure on a workflow with no Gmail
node. `NODE_FAILURES` maps node type to a plausible message.
### Clearing
Clear and rewrite are one transaction, so a failed insert cannot leave the previous
history deleted and nothing in its place. Threads and activity are cleared by project
id, not workflow id: re-seeding replaces the workflows with fresh ids, which orphans
rows keyed on the old ones and then collides on the seeded primary keys. Executions
cascade when their workflow is deleted.
## `inspectActivity.mjs`
```sh
pnpm inspect:activity # ~/.n8n/database.sqlite
DB_SQLITE_DATABASE=/path/to/database.sqlite pnpm inspect:activity
PORT=5700 pnpm inspect:activity
```
Every column of `activity_event`, paginated, sortable on any column, with a free-text
filter spanning all columns including the JSON `data` blob.
Read-only three times over, since a later edit can undo any one layer alone:
1. The connection is opened `readOnly`, so SQLite refuses writes.
2. Every statement is a SELECT.
3. The sort column comes from an allowlist derived from the table, because a column
name cannot be a bound parameter.
Non-GET methods return 405. A request whose `Host` is not loopback returns 403, which
closes DNS rebinding. Nothing else authenticates.
**A debug surface.** Unauthenticated, serving the whole table including who did what in
which project. Loopback only. Do not tunnel or port-forward it.
No dependencies: `node:sqlite` and `node:http` are built into Node 24.
## Note on `activity_event`
`activityEventCategories` is `['workflow', 'credential']`. Executions are deliberately
absent, because `execution_entity` already indexes `(workflowId, status, id)` for the
read a feed wants, so a row per run would duplicate it and pay an insert on the
execution hot path. A reader queries that table instead. The execution and activity
phases of the seed are therefore independent, with no ordering between them.
---
# The estate profile
`seedInstance.mjs` fills a local n8n instance with a realistic-looking spread of
projects, workflows, credentials, and data tables via the **public API**. The
resulting dependency graph is designed to render like a real org's automation
@@ -0,0 +1,99 @@
#!/usr/bin/env node
// Check the top-level parameter names the preference workflows emit against the names
// their node declares. Catches a silent failure: n8n accepts a misspelled parameter,
// stores it, and renders an empty field without erroring.
//
// Walks `node.parameters` one level deep. Nested shapes (`filters.conditions[]`,
// `columns.value`, `additionalFields`) and values are not checked, so a green run
// means nothing is misspelled at the top level, not that the parameters are correct.
//
// Some nodes declare parameters outside their own directory, via a spread or an
// exported constant. Those sources need listing in SOURCES or the check false-fails.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { preferenceWorkflows } from './preference-profile.mjs';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const SOURCES = {
'n8n-nodes-base.scheduleTrigger': ['packages/nodes-base/nodes/Schedule'],
'n8n-nodes-base.webhook': ['packages/nodes-base/nodes/Webhook'],
'n8n-nodes-base.if': ['packages/nodes-base/nodes/If'],
'n8n-nodes-base.set': ['packages/nodes-base/nodes/Set'],
'n8n-nodes-base.httpRequest': ['packages/nodes-base/nodes/HttpRequest'],
'n8n-nodes-base.slack': ['packages/nodes-base/nodes/Slack'],
'n8n-nodes-base.linear': ['packages/nodes-base/nodes/Linear'],
'n8n-nodes-base.gmail': ['packages/nodes-base/nodes/Google/Gmail'],
'n8n-nodes-base.dataTable': ['packages/nodes-base/nodes/DataTable'],
'@n8n/n8n-nodes-langchain.agent': [
'packages/@n8n/nodes-langchain/nodes/agents/Agent',
// `promptType` and friends are spread in from here.
'packages/@n8n/nodes-langchain/utils/descriptions.ts',
],
'@n8n/n8n-nodes-langchain.lmChatOpenAi': [
'packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi',
],
};
/** Every `.ts` file under `target`, which may itself be a file. */
function typeScriptFiles(target) {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) throw new Error(`SOURCES points at a missing path: ${target}`);
if (stat.isFile()) return target.endsWith('.ts') ? [target] : [];
return readdirSync(target, { withFileTypes: true }).flatMap((entry) => {
const child = path.join(target, entry.name);
if (entry.isDirectory()) return typeScriptFiles(child);
return entry.isFile() && child.endsWith('.ts') ? [child] : [];
});
}
// Read the files in Node rather than shelling out to grep. A missing path now throws
// instead of arriving as an empty result set, and there is no dependency on which
// grep the platform provides or on its exit codes.
function declaredNames(dirs) {
const out = new Set();
for (const dir of dirs) {
for (const file of typeScriptFiles(path.join(REPO, dir))) {
const txt = readFileSync(file, 'utf8');
for (const m of txt.matchAll(/name: '([A-Za-z0-9_]+)'/g)) out.add(m[1]);
for (const m of txt.matchAll(/_FIELD = '([A-Za-z0-9_]+)'/g)) out.add(m[1]);
}
}
return out;
}
const cache = new Map();
const problems = [];
let checked = 0;
for (const wf of preferenceWorkflows({ customer_accounts: 'dt1', automation_runs: 'dt2' })) {
for (const node of wf.nodes) {
const dirs = SOURCES[node.type];
if (!dirs) {
problems.push(`${wf.name} / ${node.name}: node type ${node.type} is not in SOURCES`);
continue;
}
if (!cache.has(node.type)) cache.set(node.type, declaredNames(dirs));
const declared = cache.get(node.type);
for (const key of Object.keys(node.parameters ?? {})) {
checked++;
if (!declared.has(key)) {
problems.push(`${wf.name} / ${node.name} (${node.type}): parameter '${key}' not declared`);
}
}
}
}
console.log(
`Checked ${checked} parameters across ${preferenceWorkflows({ customer_accounts: 'a', automation_runs: 'b' }).length} workflows.`,
);
if (problems.length === 0) {
console.log('All parameter names are declared by their node.');
process.exit(0);
}
console.error(`\n${problems.length} problem(s):`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
@@ -0,0 +1,249 @@
#!/usr/bin/env node
// A one-page read-only viewer for the `activity_event` table.
//
// A debug surface: unauthenticated, and it serves the whole table including who did
// what in which project. Binds loopback only. Do not tunnel or port-forward it.
//
// Read-only three times over, since a later edit can undo any one layer alone:
// the connection is opened `readOnly`, every statement is a SELECT, and the sort
// column comes from an allowlist because a column name cannot be bound.
import { DatabaseSync } from 'node:sqlite';
import { realpathSync } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
// Mirrors `getN8nFolder()` in @n8n/config: `.n8n` sits inside N8N_USER_FOLDER.
const DB_PATH =
process.env.DB_SQLITE_DATABASE ??
path.join(process.env.N8N_USER_FOLDER ?? os.homedir(), '.n8n', 'database.sqlite');
const PORT = Number(process.env.PORT) || 5699;
const HOST = '127.0.0.1';
const PAGE_SIZES = [25, 50, 100, 250];
// Assigned by `open()`, not at import. Opening the database and binding a port are
// side effects, and a module that does them on import cannot be tested or reused.
let db;
let COLUMNS = [];
let SORTABLE = new Set();
function open(dbPath) {
try {
db = new DatabaseSync(dbPath, { readOnly: true });
} catch (e) {
console.error(`Cannot open ${dbPath} read-only: ${e.message}`);
process.exit(1);
}
const tableExists = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='activity_event'")
.get();
if (!tableExists) {
console.error(`No activity_event table in ${dbPath}.`);
console.error('The table ships with the activity-event migration; this instance predates it.');
process.exit(1);
}
// Derived from the table, so a new column becomes sortable without a code change.
COLUMNS = db
.prepare("SELECT name FROM pragma_table_info('activity_event')")
.all()
.map((r) => r.name);
SORTABLE = new Set(COLUMNS);
}
// Match the whole authority, not a prefix of it. Slicing up to the first `]` accepts
// `[::1]evil.com`, and splitting on `:` accepts `127.0.0.1.evil.com`, so both let a
// rebinding host through. Anchored patterns with an optional numeric port do not.
export function isLoopbackHost(rawHost) {
const host = rawHost ?? '';
const bracketed = /^\[([0-9a-fA-F:]+)\](?::\d+)?$/.exec(host);
if (bracketed) return ['::1', '0:0:0:0:0:0:0:1'].includes(bracketed[1].toLowerCase());
const plain = /^([0-9a-zA-Z.-]+)(?::\d+)?$/.exec(host);
return plain !== null && ['127.0.0.1', 'localhost'].includes(plain[1].toLowerCase());
}
const esc = (v) =>
v === null || v === undefined
? ''
: String(v).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
);
function query({ q, sort, dir, limit, offset }) {
// Spans every column including the JSON `data` blob, so a run id or a node name
// finds its entry without the reader knowing which field holds it.
// `%` and `_` are LIKE wildcards; unescaped, either matches every row.
const escaped = q.replace(/[\\%_]/g, (c) => `\\${c}`);
const where = q
? `WHERE ${COLUMNS.map((c) => `IFNULL("${c}", '') LIKE ? ESCAPE '\\'`).join(' OR ')}`
: '';
const params = q ? COLUMNS.map(() => `%${escaped}%`) : [];
const total = db.prepare(`SELECT COUNT(*) AS n FROM activity_event ${where}`).get(...params).n;
// `sort` and `dir` are allowlisted above; everything else is bound.
const rows = db
.prepare(`SELECT * FROM activity_event ${where} ORDER BY "${sort}" ${dir} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
return { total, rows };
}
function page({ q, sort, dir, limit, offset, total, rows }) {
const link = (over) => {
const p = new URLSearchParams({
q,
sort,
dir,
limit: String(limit),
offset: String(offset),
...over,
});
if (!p.get('q')) p.delete('q');
return `/?${p}`;
};
const header = COLUMNS.map((c) => {
const active = c === sort;
const nextDir = active && dir === 'DESC' ? 'asc' : 'desc';
const arrow = active ? (dir === 'DESC' ? ' ▾' : ' ▴') : '';
return `<th><a href="${esc(link({ sort: c, dir: nextDir, offset: '0' }))}"${active ? ' class="on"' : ''}>${esc(c)}${arrow}</a></th>`;
}).join('');
// One line per row, full value on hover. A wrapped cell turns 25 rows into six
// screens, and this is read by scanning down it.
const body =
rows.length === 0
? `<tr><td colspan="${COLUMNS.length}" class="empty">No rows${q ? ` matching “${esc(q)}”` : ''}.</td></tr>`
: rows
.map(
(r) =>
`<tr>${COLUMNS.map((c) => {
const v = r[c];
const s = v === null || v === undefined ? '' : String(v);
const cls = ` class="c-${c}"`;
// `title` only where it adds something the cell cannot show.
const t = s.length > 20 ? ` title="${esc(s)}"` : '';
return `<td${cls}${t}>${esc(s)}</td>`;
}).join('')}</tr>`,
)
.join('');
const from = total === 0 ? 0 : offset + 1;
const to = Math.min(offset + limit, total);
const prev = Math.max(0, offset - limit);
const next = offset + limit;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>activity_event · ${total} rows</title>
<style>
:root { color-scheme: light dark; --line: #8883; }
body { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; padding: 16px 20px; }
h1 { font-size: 15px; margin: 0 0 2px; font-weight: 600; }
.sub { opacity: .65; margin-bottom: 14px; }
.bar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
input[type=search] { font: inherit; padding: 5px 8px; min-width: 280px; border: 1px solid var(--line); border-radius: 5px; background: transparent; color: inherit; }
button, select { font: inherit; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; background: transparent; color: inherit; cursor: pointer; }
table { border-collapse: collapse; width: 100%; table-layout: fixed; }
th, td { border-bottom: 1px solid var(--line); padding: 5px 8px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
th { position: sticky; top: 0; background: Canvas; font-weight: 600; }
/* Fixed layout so the wide id columns cannot starve the ones being scanned. */
.c-id, th:nth-child(1) { width: 3.5rem; }
.c-category, th:nth-child(2) { width: 6rem; }
.c-action, th:nth-child(3) { width: 6rem; }
.c-typeVersion, th:nth-child(4) { width: 3.5rem; }
.c-userId, th:nth-child(5) { width: 8%; }
.c-projectId, th:nth-child(6) { width: 8%; }
.c-resourceType, th:nth-child(7) { width: 6rem; }
.c-resourceId, th:nth-child(8) { width: 8%; }
.c-resourceName, th:nth-child(9) { width: 22%; }
.c-data, th:nth-child(10) { width: 14%; }
.c-createdAt, th:nth-child(11) { width: 13.5rem; }
td[title] { cursor: help; }
th a { color: inherit; text-decoration: none; }
th a.on { text-decoration: underline; }
tr:hover td { background: #8881; }
.empty { opacity: .6; text-align: center; padding: 28px; }
.pager { display: flex; gap: 10px; align-items: center; margin-top: 12px; }
.pager a { color: inherit; }
.pager .off { opacity: .35; pointer-events: none; }
.warn { margin-top: 18px; opacity: .6; font-size: 12px; }
</style></head><body>
<h1>activity_event</h1>
<div class="sub">${esc(DB_PATH)} · read-only · ${total} row${total === 1 ? '' : 's'}</div>
<form class="bar" method="get" action="/">
<input type="search" name="q" value="${esc(q)}" placeholder="filter any column, including the JSON data blob" autofocus>
<input type="hidden" name="sort" value="${esc(sort)}">
<input type="hidden" name="dir" value="${esc(dir.toLowerCase())}">
<select name="limit">${PAGE_SIZES.map((n) => `<option value="${n}"${n === limit ? ' selected' : ''}>${n} / page</option>`).join('')}</select>
<button type="submit">Filter</button>
${q ? '<a href="/">clear</a>' : ''}
</form>
<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>
<div class="pager">
<a class="${offset === 0 ? 'off' : ''}" href="${esc(link({ offset: '0' }))}">« first</a>
<a class="${offset === 0 ? 'off' : ''}" href="${esc(link({ offset: String(prev) }))}">‹ prev</a>
<span>${from}–${to} of ${total}</span>
<a class="${next >= total ? 'off' : ''}" href="${esc(link({ offset: String(next) }))}">next ›</a>
</div>
<div class="warn">Debug surface: unauthenticated, serves the whole table. Loopback only: do not tunnel or port-forward.</div>
</body></html>`;
}
const server = http.createServer((req, res) => {
// The socket is loopback, but a hostname resolving to 127.0.0.1 would still be
// served. Requiring a loopback Host closes that. Nothing else authenticates.
if (!isLoopbackHost(req.headers.host)) {
res.writeHead(403, { 'content-type': 'text/plain' });
return res.end('Loopback host required.\n');
}
if (req.method !== 'GET') {
res.writeHead(405, { 'content-type': 'text/plain', allow: 'GET' });
return res.end('Read-only.\n');
}
const url = new URL(req.url, `http://${HOST}:${PORT}`);
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
return res.end();
}
if (url.pathname !== '/') {
res.writeHead(404, { 'content-type': 'text/plain' });
return res.end('Not found.\n');
}
const q = (url.searchParams.get('q') ?? '').slice(0, 200);
const askedSort = url.searchParams.get('sort') ?? 'id';
const sort = SORTABLE.has(askedSort) ? askedSort : 'id';
const dir = (url.searchParams.get('dir') ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
const askedLimit = Number(url.searchParams.get('limit'));
const limit = PAGE_SIZES.includes(askedLimit) ? askedLimit : 50;
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
try {
const { total, rows } = query({ q, sort, dir, limit, offset });
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'x-frame-options': 'DENY',
});
res.end(page({ q, sort, dir, limit, offset, total, rows }));
} catch (e) {
res.writeHead(500, { 'content-type': 'text/plain' });
res.end(`Query failed: ${e.message}\n`);
}
});
// Only serve when run as a script. Importing this module gives you `isLoopbackHost`
// and the server without either touching the database or taking the port.
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
open(DB_PATH);
server.listen(PORT, HOST, () => {
console.log(`activity_event viewer http://${HOST}:${PORT}`);
console.log(`database ${DB_PATH} (read-only)`);
console.log(`columns ${COLUMNS.join(', ')}`);
console.log('Loopback only, unauthenticated. Ctrl-C to stop.');
});
}
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import http from 'node:http';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { after, before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { isLoopbackHost } from './inspectActivity.mjs';
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'inspectActivity.mjs');
const PORT = 5788;
const BASE = `http://127.0.0.1:${PORT}`;
/** A database with the columns the viewer reads, and two rows to render. */
function seedDb() {
const file = join(mkdtempSync(join(tmpdir(), 'inspect-activity-')), 'database.sqlite');
const db = new DatabaseSync(file);
db.exec(`CREATE TABLE activity_event (
id integer PRIMARY KEY NOT NULL,
category varchar(32) NOT NULL,
action varchar(64) NOT NULL,
typeVersion integer NOT NULL DEFAULT 1,
userId varchar,
projectId varchar(36),
resourceType varchar(32),
resourceId varchar(36),
resourceName text,
data text,
createdAt datetime(3) NOT NULL
)`);
const insert = db.prepare(`INSERT INTO activity_event
(category, action, userId, projectId, resourceType, resourceId, resourceName, data, createdAt)
VALUES (?, ?, 'u1', 'p1', ?, ?, ?, ?, ?)`);
insert.run(
'workflow',
'created',
'workflow',
'w1',
'[seed] Alpha',
'{"nodeCount":6}',
'2026-01-01 00:00:00.000',
);
insert.run(
'credential',
'created',
'credential',
'c1',
'[seed] 100% Cover',
null,
'2026-01-02 00:00:00.000',
);
db.close();
return file;
}
/**
* `fetch` treats Host as a forbidden header and drops an override silently, which
* makes the loopback guard untestable through it. node:http sends what it is given.
*/
function request(requestPath, { method = 'GET', headers = {} } = {}) {
return new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port: PORT, path: requestPath, method, headers },
(res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
},
);
req.on('error', reject);
req.end();
});
}
const get = (requestPath, headers = {}) => request(requestPath, { headers });
describe('isLoopbackHost', () => {
it('accepts loopback authorities, with or without a port', () => {
for (const host of [
'127.0.0.1',
'127.0.0.1:5699',
'localhost',
'LOCALHOST:80',
'[::1]',
'[::1]:5699',
'[0:0:0:0:0:0:0:1]',
]) {
assert.equal(isLoopbackHost(host), true, `should accept ${host}`);
}
});
// A prefix match on the bracket accepted `[::1]evil`, so the guard passed a
// rebinding host straight through to the handler.
it('rejects hosts that only start with a loopback authority', () => {
for (const host of [
'[::1]evil',
'[::1]evil.com:80',
'[::1]@evil.com',
'127.0.0.1.evil.com',
'[email protected]',
'localhost.evil.com',
]) {
assert.equal(isLoopbackHost(host), false, `should reject ${host}`);
}
});
it('rejects anything else', () => {
for (const host of ['evil.com', '[::2]', '[::1]:abc', 'localhost:1:2', '', undefined]) {
assert.equal(isLoopbackHost(host), false, `should reject ${JSON.stringify(host)}`);
}
});
});
describe('inspectActivity server', () => {
let child;
let dbFile;
before(async () => {
dbFile = seedDb();
child = spawn(process.execPath, [SCRIPT], {
env: { ...process.env, DB_SQLITE_DATABASE: dbFile, PORT: String(PORT) },
stdio: 'ignore',
});
for (let i = 0; i < 60; i++) {
try {
await get('/');
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error('viewer did not start');
});
after(() => child?.kill());
it('serves the table on a loopback host', async () => {
const { status, body } = await get('/');
assert.equal(status, 200);
assert.match(body, /activity_event/);
assert.match(body, /2 rows/);
assert.match(body, /\[seed\] Alpha/);
});
it('rejects a non-loopback Host with 403', async () => {
for (const host of ['evil.example.com', '[::1]evil.com']) {
const { status } = await get('/', { host });
assert.equal(status, 403, `expected 403 for Host: ${host}`);
}
});
it('rejects every non-GET method with 405', async () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
const { status, headers } = await request('/', { method });
assert.equal(status, 405);
assert.equal(headers.allow, 'GET');
}
});
it('opens the database read-only', () => {
const ro = new DatabaseSync(dbFile, { readOnly: true });
assert.throws(() => ro.exec('DELETE FROM activity_event'), /readonly/i);
ro.close();
});
it('filters on the JSON data column', async () => {
const { body } = await get('/?q=nodeCount');
assert.match(body, /1 rows?/);
assert.match(body, /\[seed\] Alpha/);
});
it('treats LIKE wildcards in the filter as literals', async () => {
// `%` unescaped matches every row. Only the row containing it should match.
const { body } = await get('/?q=%25');
assert.match(body, /1 rows?/);
assert.match(body, /100% Cover/);
});
it('falls back to a safe sort column instead of interpolating one', async () => {
const { status } = await get('/?sort=id%3BDROP+TABLE+activity_event--');
assert.equal(status, 200);
const check = new DatabaseSync(dbFile, { readOnly: true });
assert.equal(check.prepare('SELECT COUNT(*) AS n FROM activity_event').get().n, 2);
check.close();
});
it('404s an unknown path', async () => {
assert.equal((await get('/secrets')).status, 404);
});
});
@@ -0,0 +1,512 @@
// Ten hand-written workflows that share one house style.
//
// Written out rather than generated. The point is that "what does this org normally
// do?" has a checkable answer, which needs the answer to be a fact about the estate
// and not an artifact of sampling. Names describe what the nodes do, so a reader
// does not learn something false from the title.
// Five rules, no exceptions: at n=10 an exception is indistinguishable from noise.
// Each rule is a place n8n offers a real choice. A rule with no alternative is a
// constraint, not a preference, and detecting it proves nothing.
export const HOUSE_STYLE = {
// Alternatives: Anthropic, Gemini, Mistral, Ollama, Azure OpenAI.
chatModel: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
chatModelName: 'gpt-4o-mini',
// Alternatives: Jira, GitHub Issues, Asana, Trello, ClickUp.
tracker: 'n8n-nodes-base.linear',
// Alternatives: Discord, Teams, Telegram, email.
notifier: 'n8n-nodes-base.slack',
// Alternatives: the `interval` rule form, which the estate profile uses.
scheduleStyle: 'cronExpression',
// Every workflow's last node writes one row to `automation_runs`.
auditTable: 'automation_runs',
};
export const PREFERENCE_PROJECT = 'Automation Platform';
// Read and written by the workflows, not decoration. Three read `customer_accounts`;
// all ten write `automation_runs`, which puts the audit convention in the data too.
export const PREFERENCE_DATA_TABLES = [
{
name: 'customer_accounts',
columns: [
{ name: 'account_id', type: 'string' },
{ name: 'company', type: 'string' },
{ name: 'plan', type: 'string' },
{ name: 'health_score', type: 'number' },
{ name: 'renewal_date', type: 'string' },
{ name: 'invoice_overdue_days', type: 'number' },
],
// Tabular, in column order, because six objects with six keys each is 48 lines
// of the same shape and harder to scan for the values that matter.
rows: [
['acc_1001', 'Northwind Trading', 'enterprise', 82, '2026-03-01', 0],
['acc_1002', 'Contoso Logistics', 'growth', 41, '2026-02-14', 12],
['acc_1003', 'Fabrikam Health', 'enterprise', 67, '2026-05-20', 0],
['acc_1004', 'Tailspin Media', 'starter', 29, '2026-01-30', 34],
['acc_1005', 'Proseware Analytics', 'growth', 74, '2026-04-11', 0],
['acc_1006', 'Litware Robotics', 'enterprise', 55, '2026-02-28', 7],
].map(([account_id, company, plan, health_score, renewal_date, invoice_overdue_days]) => ({
account_id,
company,
plan,
health_score,
renewal_date,
invoice_overdue_days,
})),
},
{
name: 'automation_runs',
columns: [
{ name: 'workflow', type: 'string' },
{ name: 'outcome', type: 'string' },
{ name: 'detail', type: 'string' },
{ name: 'ran_at', type: 'string' },
],
rows: [],
},
];
// `env` names the variable that supplies a real token. Without it the seeder writes a
// labelled placeholder, so the workflow stays wired and openable but is not runnable.
export const PREFERENCE_CREDENTIALS = [
['openai', 'openAiApi', 'OpenAI', 'SEED_OPENAI_API_KEY', 'apiKey'],
['linear', 'linearApi', 'Linear', 'SEED_LINEAR_API_KEY', 'apiKey'],
['slack', 'slackApi', 'Slack', 'SEED_SLACK_TOKEN', 'accessToken'],
['gmail', 'gmailOAuth2', 'Gmail', 'SEED_GMAIL_OAUTH', 'oauthTokenData'],
['http', 'httpHeaderAuth', 'Enrichment API', 'SEED_ENRICHMENT_TOKEN', 'value'],
].map(([key, type, label, env, field]) => ({
key,
type,
name: `${label} (${PREFERENCE_PROJECT})`,
env,
field,
}));
const LINEAR_TEAM_ID = process.env.SEED_LINEAR_TEAM_ID || 'seed-placeholder-team-id';
// Small helpers only. Each workflow spells out its own graph, so "is this a real
// workflow?" is answerable from the recipe without chasing factories.
let x = 0;
const at = (row = 0) => [240 + x++ * 220, 300 + row * 160];
const resetPos = () => {
x = 0;
};
const scheduleTrigger = (cron) => ({
parameters: { rule: { interval: [{ field: 'cronExpression', expression: cron }] } },
id: undefined,
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.4,
position: at(),
});
const webhookTrigger = (path) => ({
parameters: { httpMethod: 'POST', path, responseMode: 'lastNode', options: {} },
id: undefined,
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: at(),
});
const chatModel = () => ({
parameters: {
model: { __rl: true, value: HOUSE_STYLE.chatModelName, mode: 'list' },
options: {},
},
id: undefined,
name: 'OpenAI Chat Model',
type: HOUSE_STYLE.chatModel,
typeVersion: 1.3,
position: [0, 0], // repositioned by the caller, sub-nodes sit below their root
credentials: { openai: true },
});
const agent = (name, prompt) => ({
parameters: {
promptType: 'define',
text: prompt,
options: {},
},
id: undefined,
name,
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3.1,
position: at(),
});
const linearCreate = (name, title, description) => ({
parameters: {
resource: 'issue',
operation: 'create',
teamId: LINEAR_TEAM_ID,
title,
additionalFields: { description },
},
id: undefined,
name,
type: HOUSE_STYLE.tracker,
typeVersion: 1.1,
position: at(),
credentials: { linear: true },
});
const linearGetAll = (name) => ({
parameters: { resource: 'issue', operation: 'getAll', returnAll: false, limit: 50 },
id: undefined,
name,
type: HOUSE_STYLE.tracker,
typeVersion: 1.1,
position: at(),
credentials: { linear: true },
});
const slackPost = (name, channel, text) => ({
parameters: {
resource: 'message',
operation: 'post',
select: 'channel',
channelId: { __rl: true, value: channel, mode: 'name' },
text,
otherOptions: {},
},
id: undefined,
name,
type: HOUSE_STYLE.notifier,
typeVersion: 2.7,
position: at(),
credentials: { slack: true },
});
const dataTableGet = (name, tableId, filters) => ({
parameters: {
resource: 'row',
operation: 'get',
dataTableId: { __rl: true, value: tableId, mode: 'id' },
filters: filters ?? { conditions: [] },
options: {},
},
id: undefined,
name,
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: at(),
});
const auditRow = (tableId, workflowName, detail) => ({
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: { __rl: true, value: tableId, mode: 'id' },
columns: {
mappingMode: 'defineBelow',
value: {
workflow: workflowName,
outcome: 'success',
detail,
ran_at: '={{ $now.toISO() }}',
},
},
options: {},
},
id: undefined,
name: 'Record Run',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: at(),
});
const ifNode = (name, leftValue, operator, rightValue) => ({
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 },
conditions: [
{
id: 'cond-1',
leftValue,
rightValue,
operator,
},
],
combinator: 'and',
},
options: {},
},
id: undefined,
name,
type: 'n8n-nodes-base.if',
typeVersion: 2.3,
position: at(),
});
const numberGt = { type: 'number', operation: 'gt' };
const stringEquals = { type: 'string', operation: 'equals' };
// --- the ten workflows -------------------------------------------------------
// `tables` maps a data-table name to the id the seeder created, so the recipes can
// reference real tables rather than placeholders.
export function preferenceWorkflows(tables) {
const accounts = tables.customer_accounts;
const runs = tables.automation_runs;
/** Chain nodes left to right on `main`, and hang the chat model off its agent. */
const build = (name, nodes, { modelFor } = {}) => {
const connections = {};
const main = nodes.filter((n) => n.name !== 'OpenAI Chat Model');
for (let i = 0; i < main.length - 1; i++) {
connections[main[i].name] = {
main: [[{ node: main[i + 1].name, type: 'main', index: 0 }]],
};
}
if (modelFor) {
const model = nodes.find((n) => n.name === 'OpenAI Chat Model');
const root = main.find((n) => n.name === modelFor);
model.position = [root.position[0], root.position[1] + 200];
connections['OpenAI Chat Model'] = {
ai_languageModel: [[{ node: modelFor, type: 'ai_languageModel', index: 0 }]],
};
}
resetPos();
return { name, nodes, connections };
};
return [
build(
'Support Inbox Triage',
[
scheduleTrigger('*/15 * * * *'),
{
parameters: {
resource: 'message',
operation: 'getAll',
returnAll: false,
limit: 25,
filters: { readStatus: 'unread' },
},
id: undefined,
name: 'Fetch Unread Support Mail',
type: 'n8n-nodes-base.gmail',
typeVersion: 2.2,
position: at(),
credentials: { gmail: true },
},
agent(
'Classify Ticket',
'Read the support email below and reply with one line: "<severity> | <one-sentence summary>". Severity must be one of P1, P2, P3.\n\nSubject: {{ $json.subject }}\nBody: {{ $json.snippet }}',
),
chatModel(),
linearCreate(
'Open Support Issue',
'={{ $json.output.split("|")[1].trim() }}',
'=Raised by Support Inbox Triage.\n\nSeverity: {{ $json.output.split("|")[0].trim() }}',
),
slackPost(
'Notify Support Channel',
'#support-triage',
'=New ticket triaged: {{ $json.output }}',
),
auditRow(runs, 'Support Inbox Triage', '=Triaged {{ $json.title }}'),
],
{ modelFor: 'Classify Ticket' },
),
build(
'Churn Risk Digest',
[
scheduleTrigger('0 8 * * 1-5'),
dataTableGet('Read Accounts', accounts, {
conditions: [{ keyName: 'health_score', condition: 'lt', keyValue: 50 }],
}),
agent(
'Summarise Churn Risk',
'These accounts have a health score below 50. Write a three-bullet summary for the customer success team, naming the accounts and the single clearest risk for each.\n\n{{ JSON.stringify($json) }}',
),
chatModel(),
slackPost('Post Digest', '#cs-alerts', '=Daily churn risk digest:\n{{ $json.output }}'),
auditRow(runs, 'Churn Risk Digest', 'Digest posted to #cs-alerts'),
],
{ modelFor: 'Summarise Churn Risk' },
),
build(
'Incident Intake',
[
webhookTrigger('incident-intake'),
agent(
'Assess Severity',
'An incident was reported. Reply with only P1, P2 or P3.\n\nTitle: {{ $json.body.title }}\nDescription: {{ $json.body.description }}',
),
chatModel(),
ifNode('Is P1?', '={{ $json.output.trim() }}', stringEquals, 'P1'),
linearCreate(
'Raise Incident',
'=[{{ $json.output.trim() }}] {{ $json.body.title }}',
'={{ $json.body.description }}',
),
slackPost('Page On-Call', '#incidents', '=P1 incident raised: {{ $json.title }}'),
auditRow(runs, 'Incident Intake', '=Incident {{ $json.title }} recorded'),
],
{ modelFor: 'Assess Severity' },
),
build('Lead Enrichment', [
webhookTrigger('lead-capture'),
{
parameters: {
method: 'GET',
url: '=https://api.clearbit.com/v2/companies/find?domain={{ $json.body.domain }}',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
options: {},
},
id: undefined,
name: 'Enrich Company',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: at(),
credentials: { http: true },
},
slackPost(
'Notify Sales',
'#sales-inbound',
'=New enriched lead: {{ $json.name }} ({{ $json.metrics.employees }} employees)',
),
auditRow(runs, 'Lead Enrichment', '=Enriched {{ $json.name }}'),
]),
build(
'Weekly Engineering Digest',
[
scheduleTrigger('0 9 * * 1'),
linearGetAll('Fetch Open Issues'),
agent(
'Write Digest',
'Summarise this week of Linear issues for an engineering all-hands. Group by theme, keep it under 150 words.\n\n{{ JSON.stringify($json) }}',
),
chatModel(),
slackPost('Post To Engineering', '#engineering', '=Weekly digest:\n{{ $json.output }}'),
auditRow(runs, 'Weekly Engineering Digest', 'Digest posted to #engineering'),
],
{ modelFor: 'Write Digest' },
),
build('Invoice Dunning', [
scheduleTrigger('0 10 * * *'),
dataTableGet('Read Overdue Accounts', accounts, {
conditions: [{ keyName: 'invoice_overdue_days', condition: 'gt', keyValue: 5 }],
}),
ifNode('Overdue Past Grace?', '={{ $json.invoice_overdue_days }}', numberGt, 30),
{
parameters: {
resource: 'message',
operation: 'send',
sendTo: '=billing@{{ $json.company.toLowerCase().replaceAll(" ", "") }}.com',
subject: '=Invoice overdue: {{ $json.invoice_overdue_days }} days',
message:
'=Hello {{ $json.company }},\n\nOur records show an invoice outstanding for {{ $json.invoice_overdue_days }} days. Please let us know if you need a copy.\n\nBilling team',
options: {},
},
id: undefined,
name: 'Send Dunning Email',
type: 'n8n-nodes-base.gmail',
typeVersion: 2.2,
position: at(),
credentials: { gmail: true },
},
slackPost('Flag To Finance', '#finance-ops', '=Dunning email sent to {{ $json.company }}'),
auditRow(runs, 'Invoice Dunning', '=Chased {{ $json.company }}'),
]),
build(
'Product Feedback Classifier',
[
webhookTrigger('product-feedback'),
agent(
'Categorise Feedback',
'Classify this product feedback into exactly one of: bug, feature-request, praise, confusion. Reply with the label only.\n\n{{ $json.body.message }}',
),
chatModel(),
linearCreate(
'Log Feedback',
'=[{{ $json.output.trim() }}] {{ $json.body.message.slice(0, 60) }}',
'={{ $json.body.message }}\n\nFrom: {{ $json.body.email }}',
),
slackPost(
'Share With Product',
'#product-feedback',
'=New {{ $json.output.trim() }} logged: {{ $json.title }}',
),
auditRow(runs, 'Product Feedback Classifier', '=Classified as {{ $json.output.trim() }}'),
],
{ modelFor: 'Categorise Feedback' },
),
build('Onboarding Kickoff', [
webhookTrigger('onboarding-start'),
linearCreate(
'Create Onboarding Issue',
'=Onboard {{ $json.body.company }}',
'=New {{ $json.body.plan }} customer.\n\nPrimary contact: {{ $json.body.contact_email }}\nKickoff call: {{ $json.body.kickoff_date }}',
),
slackPost(
'Announce New Customer',
'#customer-success',
'=Onboarding started for {{ $json.body.company }} ({{ $json.body.plan }})',
),
auditRow(runs, 'Onboarding Kickoff', '=Onboarding opened for {{ $json.body.company }}'),
]),
build('Deploy Announcer', [
webhookTrigger('deploy-complete'),
{
parameters: {
assignments: {
assignments: [
{ id: 'a1', name: 'release', value: '={{ $json.body.tag }}', type: 'string' },
{ id: 'a2', name: 'author', value: '={{ $json.body.author }}', type: 'string' },
{
id: 'a3',
name: 'services',
value: '={{ $json.body.services.join(", ") }}',
type: 'string',
},
],
},
options: {},
},
id: undefined,
name: 'Format Release Note',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: at(),
},
slackPost(
'Announce Release',
'#releases',
'=Deployed {{ $json.release }} by {{ $json.author }}. Services: {{ $json.services }}',
),
auditRow(runs, 'Deploy Announcer', '=Announced {{ $json.release }}'),
]),
build('Stale Issue Sweeper', [
scheduleTrigger('0 7 * * 3'),
linearGetAll('Fetch Issues'),
ifNode(
'Untouched Over 30 Days?',
'={{ $now.diff($fromISO($json.updatedAt), "days").days }}',
numberGt,
30,
),
slackPost(
'Nudge Owners',
'#engineering',
'=Stale issue needs an update: {{ $json.title }} (last touched {{ $json.updatedAt }})',
),
auditRow(runs, 'Stale Issue Sweeper', '=Flagged {{ $json.title }}'),
]),
];
}
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env node
// Write a fortnight of history for a seeded instance: executions, assistant threads,
// and activity entries. Writes SQLite directly, because the public API has no create
// route for any of them, and because backdating needs `startedAt` set by hand.
//
// Run after `seed:account`. A live instance is fine, but prefer an idle one: SQLite
// serialises writers, so a busy instance can hold the write lock long enough to fail.
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import os from 'node:os';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// `flatted` is how n8n serialises run data. The format has cycles, so plain JSON
// would not round-trip. Resolved from the cli package rather than vendored.
const { stringify } = require(
require.resolve('flatted', { paths: [path.join(REPO, 'packages/cli')] }),
);
// Mirrors `getN8nFolder()` in @n8n/config: the `.n8n` directory sits *inside*
// N8N_USER_FOLDER, so joining the db name straight onto that variable looks one
// directory too high.
const DB_PATH =
process.env.DB_SQLITE_DATABASE ??
path.join(process.env.N8N_USER_FOLDER ?? os.homedir(), '.n8n', 'database.sqlite');
const SEED_PREFIX = '[seed] ';
// Threads have no name to prefix, so they carry this in `metadata` instead. It is how
// the clear step tells its own rows from a developer's real assistant threads.
const THREAD_MARKER = '"seeded":true';
const DAYS = Number(process.env.HISTORY_DAYS) || 14;
// Fixed seed and fixed clock, for the same reason the estate is fixed: an A/B eval
// compares two arms against one instance, which requires the instance not to move.
function makeRandom(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = makeRandom(Number(process.env.SEED) || 1);
// The window has to end at the current time. n8n prunes threads after 30 days and
// executions past EXECUTIONS_DATA_MAX_AGE, so a fixed past date gets the whole
// fortnight deleted on the next start. Only timestamps move; the PRNG still decides
// what happens. HISTORY_NOW pins the window if you accept the pruning.
const NOW = process.env.HISTORY_NOW ? Date.parse(process.env.HISTORY_NOW) : Date.now();
const iso = (ms) => new Date(ms).toISOString().replace('T', ' ').replace('Z', '');
const pick = (arr) => arr[Math.floor(random() * arr.length)];
const uuid = () => {
// Deterministic v4-shaped id. Not cryptographic; these are fixture ids.
const hex = '0123456789abcdef';
let s = '';
for (let i = 0; i < 32; i++) s += hex[Math.floor(random() * 16)];
return `${s.slice(0, 8)}-${s.slice(8, 12)}-4${s.slice(13, 16)}-a${s.slice(17, 20)}-${s.slice(20, 32)}`;
};
// One workflow fails its last run and two earlier ones, so "what broke?" has an
// answer. A uniformly green history has none.
const FAILING_WORKFLOW = 'Invoice Dunning';
const FAILURE = {
node: 'Send Dunning Email',
message: 'Forbidden - perhaps check your credentials?',
description: 'The Gmail credential has no valid token. Reconnect the account.',
};
// An error must name a node that exists in its own workflow and say something that
// node could say. One shared message produces records that contradict themselves.
const NODE_FAILURES = {
'n8n-nodes-base.gmail': {
message: 'Forbidden - perhaps check your credentials?',
description: 'The Gmail credential has no valid token. Reconnect the account.',
},
'n8n-nodes-base.slack': {
message: 'Bad request - please check your parameters',
description: 'channel_not_found: the bot is not a member of that channel.',
},
'n8n-nodes-base.linear': {
message: 'Authorization failed - please check your credentials',
description: 'The Linear API key was rejected.',
},
'n8n-nodes-base.httpRequest': {
message: 'The service refused the connection - perhaps it is offline',
description: 'connect ETIMEDOUT: the enrichment API did not respond.',
},
'n8n-nodes-base.dataTable': {
message: 'Bad request - please check your parameters',
description: 'The referenced column does not exist on this data table.',
},
'@n8n/n8n-nodes-langchain.agent': {
message: 'Bad request - please check your parameters',
description: 'The model returned no output for this prompt.',
},
};
const GENERIC_FAILURE = {
message: 'The service was not able to process your request',
description: 'The node returned an unexpected response.',
};
/** Pick a node in this workflow that can plausibly fail, and an error to match. */
function failureFor(wf, isDesignated) {
const candidates = wf.nodes.filter((n) => !n.type.includes('lmChat') && NODE_FAILURES[n.type]);
if (isDesignated) {
const node = wf.nodes.find((n) => n.name === FAILURE.node);
if (node) return { node: FAILURE.node, ...FAILURE };
}
if (candidates.length === 0) return null;
const node = pick(candidates);
return { node: node.name, ...(NODE_FAILURES[node.type] ?? GENERIC_FAILURE) };
}
function runData(wf, failure) {
const nodes = wf.nodes.filter((n) => !n.type.includes('lmChat'));
const runData = {};
for (const n of nodes) {
// Stop before the failing node: a run that broke never produced output past it.
if (failure && n.name === failure.node) break;
runData[n.name] = [
{
hints: [],
startTime: NOW,
executionTime: 40 + Math.floor(random() * 400),
source: [null],
executionStatus: 'success',
data: { main: [[{ json: { seeded: true }, pairedItem: { item: 0 } }]] },
},
];
}
const result = { resultData: { runData, lastNodeExecuted: Object.keys(runData).pop() } };
if (failure) {
result.resultData.error = {
level: 'warning',
tags: { packageName: 'nodes-base' },
message: failure.message,
description: failure.description,
node: { name: failure.node },
name: 'NodeApiError',
};
result.resultData.lastNodeExecuted = failure.node;
}
return result;
}
function main() {
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA foreign_keys = ON');
const workflows = db
.prepare('SELECT id, name, nodes FROM workflow_entity WHERE name LIKE ? ORDER BY name')
.all(`${SEED_PREFIX}%`)
.map((w) => ({ ...w, nodes: JSON.parse(w.nodes) }));
if (workflows.length === 0) {
console.error(`No ${SEED_PREFIX} workflows in ${DB_PATH}. Run seed:account first.`);
process.exit(1);
}
const project = db
.prepare("SELECT id FROM project WHERE name LIKE ? AND type = 'team' LIMIT 1")
.get(`${SEED_PREFIX}%`);
const user = db.prepare('SELECT id FROM user ORDER BY createdAt LIMIT 1').get();
if (!project || !user) {
console.error('Could not find the seeded project or an owner user.');
process.exit(1);
}
console.log(`Database: ${DB_PATH}`);
console.log(`Workflows: ${workflows.length}, project ${project.id}`);
// Clear and rewrite in one transaction. Split apart, a failed insert rolls back the
// new history while the deletes stay committed, leaving nothing behind.
// Outside the transaction block so the summary after the catch can read them.
let execCount = 0;
let failCount = 0;
let threadCount = 0;
let msgCount = 0;
let actCount = 0;
db.exec('BEGIN');
try {
const wfIds = workflows.map((w) => w.id);
const placeholders = wfIds.map(() => '?').join(',');
const priorExec = db
.prepare(`SELECT id FROM execution_entity WHERE workflowId IN (${placeholders})`)
.all(...wfIds)
.map((r) => r.id);
if (priorExec.length > 0) {
const p = priorExec.map(() => '?').join(',');
db.prepare(`DELETE FROM execution_data WHERE executionId IN (${p})`).run(...priorExec);
db.prepare(`DELETE FROM execution_entity WHERE id IN (${p})`).run(...priorExec);
}
// Matched on this script's own marker, not on workflow id and not on project
// alone. Workflow id fails because re-seeding replaces the workflows with fresh
// ids, which orphans the old rows and then collides on the seeded primary keys.
// Project alone fails the other way: a developer who opens the assistant on a
// seeded workflow, or whose instance writes real activity in that project, would
// have that work deleted by the next run. Executions need neither, because
// deleting a workflow cascades them.
const priorThreads = db
.prepare(`SELECT id FROM instance_ai_threads WHERE projectId = ? AND metadata LIKE ?`)
.all(project.id, `%${THREAD_MARKER}%`)
.map((r) => r.id);
if (priorThreads.length > 0) {
const p = priorThreads.map(() => '?').join(',');
db.prepare(`DELETE FROM instance_ai_messages WHERE threadId IN (${p})`).run(...priorThreads);
db.prepare(`DELETE FROM instance_ai_threads WHERE id IN (${p})`).run(...priorThreads);
}
// `[` is not a LIKE wildcard in SQLite, so the prefix matches literally.
const clearedActivity = db
.prepare('DELETE FROM activity_event WHERE projectId = ? AND resourceName LIKE ?')
.run(project.id, `${SEED_PREFIX}%`);
console.log(
`Cleared prior: ${priorExec.length} executions, ${priorThreads.length} threads, ${clearedActivity.changes} activity entries`,
);
const insExec = db.prepare(`
INSERT INTO execution_entity
(workflowId, finished, mode, startedAt, stoppedAt, status, createdAt,
storedAt, jsonSizeBytes, binaryDataSizeBytes, usedPrivateCredentials)
VALUES (?, ?, ?, ?, ?, ?, ?, 'db', ?, 0, 0)
`);
const insExecData = db.prepare(
'INSERT INTO execution_data (executionId, workflowData, data) VALUES (?, ?, ?)',
);
const insThread = db.prepare(`
INSERT INTO instance_ai_threads (id, resourceId, projectId, title, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insMessage = db.prepare(`
INSERT INTO instance_ai_messages (id, threadId, content, role, type, resourceId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insActivity = db.prepare(`
INSERT INTO activity_event
(category, action, typeVersion, userId, projectId, resourceType, resourceId, resourceName, data, createdAt)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`);
for (const wf of workflows) {
const short = wf.name.replace(SEED_PREFIX, '');
const isFailing = short === FAILING_WORKFLOW;
// Scheduled workflows run often, webhook-driven ones sporadically.
const scheduled = wf.nodes.some((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
const runs = scheduled ? DAYS * 2 : Math.ceil(DAYS / 2);
for (let i = 0; i < runs; i++) {
// Newest run is i = 0, walking backwards across the window.
const ageMs = (i * DAYS * 86400e3) / runs + Math.floor(random() * 3600e3);
const startedAt = NOW - ageMs;
const duration = 400 + Math.floor(random() * 4000);
// The failing workflow breaks on its newest run and twice more; everything
// else fails rarely, so the estate looks used rather than synthetic.
const shouldFail = isFailing ? i === 0 || i === 3 || i === 7 : random() < 0.04;
const failure = shouldFail ? failureFor(wf, isFailing) : null;
const failed = failure !== null;
const status = failed ? 'error' : 'success';
const info = insExec.run(
wf.id,
failed ? 0 : 1,
scheduled ? 'trigger' : 'webhook',
iso(startedAt),
iso(startedAt + duration),
status,
iso(startedAt),
0,
);
const data = runData(wf, failure);
const serialised = stringify(data);
insExecData.run(
Number(info.lastInsertRowid),
JSON.stringify({
id: wf.id,
name: wf.name,
nodes: wf.nodes,
connections: {},
active: true,
settings: { executionOrder: 'v1' },
}),
serialised,
);
execCount++;
if (failed) failCount++;
}
// One thread for each workflow, phrased as a developer talking about that
// workflow, so a "what was I working on?" probe has something to find.
const threadId = uuid();
const threadAt = NOW - Math.floor(random() * DAYS * 86400e3);
insThread.run(
threadId,
wf.id,
project.id,
`Working on ${short}`,
`{${THREAD_MARKER}}`,
iso(threadAt),
iso(threadAt + 600e3),
);
threadCount++;
const turns = isFailing
? [
['user', `${short} stopped working. What is going on?`],
[
'assistant',
`The last three runs of ${short} failed at **${FAILURE.node}** with "${FAILURE.message}". ${FAILURE.description}`,
],
['user', 'Can you keep the rest of the flow running while I fix the credential?'],
[
'assistant',
'You can set "Continue on Fail" on that node, so the Slack notification and the audit row still run.',
],
]
: [
['user', `Add a step to ${short} that records the run in a data table.`],
[
'assistant',
`Added a Data Table insert named "Record Run" at the end of ${short}, writing to \`automation_runs\`, matching the other workflows here.`,
],
];
for (const [role, content] of turns) {
insMessage.run(
uuid(),
threadId,
content,
role,
'message',
wf.id,
iso(threadAt),
iso(threadAt),
);
msgCount++;
}
// Activity entries. Only `workflow` and `credential` categories exist:
// executions are deliberately not in the vocabulary, because
// `execution_entity` already indexes the read a feed wants.
const createdAt = NOW - DAYS * 86400e3 - Math.floor(random() * 7 * 86400e3);
insActivity.run(
'workflow',
'created',
user.id,
project.id,
'workflow',
wf.id,
wf.name,
JSON.stringify({ nodeCount: wf.nodes.length }),
iso(createdAt),
);
actCount++;
const savedAt = NOW - Math.floor(random() * DAYS * 86400e3);
insActivity.run(
'workflow',
'saved',
user.id,
project.id,
'workflow',
wf.id,
wf.name,
JSON.stringify({ nodeCount: wf.nodes.length }),
iso(savedAt),
);
actCount++;
insActivity.run(
'workflow',
'published',
user.id,
project.id,
'workflow',
wf.id,
wf.name,
JSON.stringify({ nodeCount: wf.nodes.length }),
iso(savedAt + 60e3),
);
actCount++;
}
// Credential activity, so the feed is not workflow-only.
for (const cred of db
.prepare('SELECT id, name FROM credentials_entity WHERE name LIKE ?')
.all(`${SEED_PREFIX}%`)) {
insActivity.run(
'credential',
'created',
user.id,
project.id,
'credential',
cred.id,
cred.name,
null,
iso(NOW - DAYS * 86400e3 - 86400e3),
);
actCount++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
console.error('Rolled back:', e.message);
process.exit(1);
}
console.log(`Executions: ${execCount} (${failCount} failed)`);
console.log(`AI threads: ${threadCount}, messages: ${msgCount}`);
console.log(`Activity entries: ${actCount}`);
console.log(`Newest failure: "${SEED_PREFIX}${FAILING_WORKFLOW}" at ${FAILURE.node}`);
db.close();
}
main();
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'seedHistory.mjs');
const SEED_PREFIX = '[seed] ';
// Only the tables and columns seedHistory reads or writes, with the foreign keys it
// relies on. It sets `PRAGMA foreign_keys = ON`, so the referenced tables have to
// exist and executions have to cascade from their workflow.
const SCHEMA = `
CREATE TABLE user (id varchar PRIMARY KEY, createdAt datetime(3) NOT NULL);
CREATE TABLE project (id varchar(36) PRIMARY KEY, name varchar(255) NOT NULL, type varchar(36) NOT NULL);
CREATE TABLE workflow_entity (id varchar(36) PRIMARY KEY, name varchar(128) NOT NULL, nodes text);
CREATE TABLE credentials_entity (id varchar(36) PRIMARY KEY, name varchar(128) NOT NULL);
CREATE TABLE execution_entity (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
workflowId varchar(36) NOT NULL,
finished boolean NOT NULL,
mode varchar NOT NULL,
startedAt datetime, stoppedAt datetime,
status varchar NOT NULL,
createdAt datetime(3) NOT NULL,
storedAt varchar(2) NOT NULL DEFAULT ('db'),
jsonSizeBytes bigint NOT NULL DEFAULT (0),
binaryDataSizeBytes bigint NOT NULL DEFAULT (0),
usedPrivateCredentials boolean NOT NULL DEFAULT FALSE,
FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON DELETE CASCADE
);
CREATE TABLE execution_data (
executionId int PRIMARY KEY NOT NULL,
workflowData text NOT NULL,
data text NOT NULL,
FOREIGN KEY (executionId) REFERENCES execution_entity (id) ON DELETE CASCADE
);
CREATE TABLE instance_ai_threads (
id varchar PRIMARY KEY NOT NULL,
resourceId varchar(255) NOT NULL,
projectId varchar(36) NOT NULL,
title text NOT NULL DEFAULT (''),
metadata text,
createdAt datetime(3) NOT NULL, updatedAt datetime(3) NOT NULL,
FOREIGN KEY (projectId) REFERENCES project (id) ON DELETE CASCADE
);
CREATE TABLE instance_ai_messages (
id varchar(36) PRIMARY KEY NOT NULL,
threadId varchar NOT NULL,
content text NOT NULL, role varchar(16) NOT NULL, type varchar(32),
resourceId varchar(255),
createdAt datetime(3) NOT NULL, updatedAt datetime(3) NOT NULL,
FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON DELETE CASCADE
);
CREATE TABLE activity_event (
id integer PRIMARY KEY NOT NULL,
category varchar(32) NOT NULL, action varchar(64) NOT NULL,
typeVersion integer NOT NULL DEFAULT (1),
userId varchar, projectId varchar(36),
resourceType varchar(32), resourceId varchar(36), resourceName text, data text,
createdAt datetime(3) NOT NULL,
FOREIGN KEY (userId) REFERENCES user (id) ON DELETE SET NULL,
FOREIGN KEY (projectId) REFERENCES project (id) ON DELETE CASCADE
);
`;
const NODES = JSON.stringify([
{
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.4,
parameters: {},
},
{ name: 'Notify', type: 'n8n-nodes-base.slack', typeVersion: 2.7, parameters: {} },
]);
/**
* A database holding a seeded project with two workflows, plus rows the seeder must
* not touch: unmarked threads and activity inside the seeded project, and a marked
* thread belonging to a different project.
*/
function fixture() {
const file = join(mkdtempSync(join(tmpdir(), 'seed-history-')), 'database.sqlite');
const db = new DatabaseSync(file);
db.exec(SCHEMA);
const now = '2026-01-01 00:00:00.000';
db.exec(`
INSERT INTO user (id, createdAt) VALUES ('u1', '${now}');
INSERT INTO project (id, name, type) VALUES
('p-seed', '${SEED_PREFIX}Automation Platform', 'team'),
('p-other', 'Someone Else', 'team');
INSERT INTO workflow_entity (id, name, nodes) VALUES
('wf1', '${SEED_PREFIX}Alpha', '${NODES}'),
('wf2', '${SEED_PREFIX}Beta', '${NODES}');
INSERT INTO credentials_entity (id, name) VALUES ('c1', '${SEED_PREFIX}OpenAI');
`);
const thread = db.prepare(
'INSERT INTO instance_ai_threads (id, resourceId, projectId, title, metadata, createdAt, updatedAt) VALUES (?,?,?,?,?,?,?)',
);
// A developer's own conversation on a seeded workflow: same project, no marker.
thread.run('t-real', 'wf1', 'p-seed', 'My real conversation', '{"seeded":false}', now, now);
// Metadata is nullable, so the filter has to cope with NULL rather than throw.
thread.run('t-null', 'wf2', 'p-seed', 'No metadata at all', null, now, now);
// Marked, but in another project. Scoping must keep the seeder out of it.
thread.run('t-other', 'wf9', 'p-other', 'Marked elsewhere', '{"seeded":true}', now, now);
db.prepare(
'INSERT INTO instance_ai_messages (id, threadId, content, role, type, resourceId, createdAt, updatedAt) VALUES (?,?,?,?,?,?,?,?)',
).run('m-real', 't-real', 'do not delete me', 'user', 'message', 'wf1', now, now);
const activity = db.prepare(
'INSERT INTO activity_event (category, action, userId, projectId, resourceType, resourceId, resourceName, data, createdAt) VALUES (?,?,?,?,?,?,?,?,?)',
);
// Real activity in the seeded project: the relay would write rows like this.
activity.run(
'workflow',
'saved',
'u1',
'p-seed',
'workflow',
'wf-real',
'My Real Workflow',
null,
now,
);
// A null resourceName must not be swept up by a LIKE on that column.
activity.run('workflow', 'saved', 'u1', 'p-seed', 'workflow', 'wf-anon', null, null, now);
db.close();
return file;
}
const run = (dbFile) =>
execFileSync(process.execPath, [SCRIPT], {
env: { ...process.env, DB_SQLITE_DATABASE: dbFile, HISTORY_DAYS: '2' },
encoding: 'utf8',
});
function counts(dbFile) {
const db = new DatabaseSync(dbFile, { readOnly: true });
const one = (sql) => db.prepare(sql).get().n;
const out = {
executions: one('SELECT COUNT(*) AS n FROM execution_entity'),
executionData: one('SELECT COUNT(*) AS n FROM execution_data'),
threads: one('SELECT COUNT(*) AS n FROM instance_ai_threads'),
messages: one('SELECT COUNT(*) AS n FROM instance_ai_messages'),
activity: one('SELECT COUNT(*) AS n FROM activity_event'),
seededThreads: one(
`SELECT COUNT(*) AS n FROM instance_ai_threads WHERE projectId = 'p-seed' AND metadata LIKE '%"seeded":true%'`,
),
seededActivity: one(
`SELECT COUNT(*) AS n FROM activity_event WHERE projectId = 'p-seed' AND resourceName LIKE '[seed] %'`,
),
};
const ids = (sql) =>
db
.prepare(sql)
.all()
.map((r) => r.id);
out.threadIds = ids('SELECT id FROM instance_ai_threads ORDER BY id');
db.close();
return out;
}
const survivors = (dbFile) => {
const db = new DatabaseSync(dbFile, { readOnly: true });
const has = (sql, ...p) => db.prepare(sql).all(...p).length > 0;
const out = {
realThread: has(`SELECT 1 FROM instance_ai_threads WHERE id = 't-real'`),
nullMetadataThread: has(`SELECT 1 FROM instance_ai_threads WHERE id = 't-null'`),
otherProjectThread: has(`SELECT 1 FROM instance_ai_threads WHERE id = 't-other'`),
realMessage: has(`SELECT 1 FROM instance_ai_messages WHERE id = 'm-real'`),
realActivity: has(`SELECT 1 FROM activity_event WHERE resourceName = 'My Real Workflow'`),
anonActivity: has(`SELECT 1 FROM activity_event WHERE resourceId = 'wf-anon'`),
};
db.close();
return out;
};
describe('seedHistory cleanup', () => {
it('writes history for the seeded workflows', () => {
const dbFile = fixture();
run(dbFile);
const c = counts(dbFile);
assert.equal(c.seededThreads, 2, 'one marked thread per seeded workflow');
assert.ok(c.executions > 0, 'executions written');
assert.equal(c.executionData, c.executions, 'every execution has its data row');
assert.ok(c.seededActivity > 0, 'activity written');
});
it('leaves rows it did not create alone', () => {
const dbFile = fixture();
run(dbFile);
assert.deepEqual(survivors(dbFile), {
realThread: true,
nullMetadataThread: true,
otherProjectThread: true,
realMessage: true,
realActivity: true,
anonActivity: true,
});
});
// Re-running used to stack a second fortnight on the first, and later to collide
// on primary keys generated from the fixed seed.
it('replaces its own history instead of accumulating', () => {
const dbFile = fixture();
run(dbFile);
const first = counts(dbFile);
run(dbFile);
const second = counts(dbFile);
run(dbFile);
const third = counts(dbFile);
assert.deepEqual(second, first, 'second run matches the first');
assert.deepEqual(third, first, 'third run matches the first');
});
it('keeps the untouched rows across repeated runs', () => {
const dbFile = fixture();
run(dbFile);
run(dbFile);
const s = survivors(dbFile);
assert.equal(s.realThread, true);
assert.equal(s.realMessage, true);
assert.equal(s.otherProjectThread, true);
assert.equal(s.realActivity, true);
});
// `seed:account` deletes and recreates the workflows, so their ids change between
// history runs. Clearing by workflow id therefore matched nothing on the second
// run, orphaning the first run's rows, and the ids generated from the fixed seed
// then collided on insert.
it('survives the seeded workflows being replaced with new ids', () => {
const dbFile = fixture();
run(dbFile);
const before = counts(dbFile);
const db = new DatabaseSync(dbFile);
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
INSERT INTO workflow_entity (id, name, nodes)
SELECT 'new-' || id, name, nodes FROM workflow_entity WHERE name LIKE '[seed] %';
DELETE FROM workflow_entity WHERE id IN ('wf1', 'wf2');
`);
db.close();
const out = run(dbFile);
const after = counts(dbFile);
assert.equal(after.seededThreads, before.seededThreads, 'threads replaced, not duplicated');
assert.equal(after.seededActivity, before.seededActivity, 'activity replaced, not duplicated');
assert.equal(after.executionData, after.executions, 'no orphaned execution data');
assert.doesNotMatch(out, /UNIQUE constraint|Rolled back/);
assert.equal(survivors(dbFile).realThread, true, 'the real thread still survives');
});
it('reports what it cleared on the second run', () => {
const dbFile = fixture();
run(dbFile);
const out = run(dbFile);
assert.match(out, /Cleared prior: \d+ executions, 2 threads, \d+ activity entries/);
});
it('exits with a message when nothing is seeded', () => {
const file = join(mkdtempSync(join(tmpdir(), 'seed-history-empty-')), 'database.sqlite');
const db = new DatabaseSync(file);
db.exec(SCHEMA);
db.close();
assert.throws(() => run(file), /Run seed:account first/);
});
});
+211 -28
View File
@@ -7,6 +7,13 @@
// data-tables, whose names disallow brackets) so the script can clear its
// own output before reseeding without touching pre-existing data.
import {
PREFERENCE_CREDENTIALS,
PREFERENCE_DATA_TABLES,
PREFERENCE_PROJECT,
preferenceWorkflows,
} from './preference-profile.mjs';
const BASE = process.env.N8N_BASE_URL ?? 'http://localhost:5678';
const KEY = process.env.N8N_API_KEY;
if (!KEY) {
@@ -19,6 +26,24 @@ const PERSONAL_WORKFLOWS = Number(process.env.WF_MULTIPLIER) || 50;
const SEED_PREFIX = '[seed] ';
const SEED_DT_PREFIX = 'seed_';
// Set it and the run is reproducible. `preference` defaults it: a profile meant for
// A/B evaluation is useless if the estate moves between runs.
const SEED_ENV = process.env.SEED;
// `estate` (default): ~500 varied workflows, built to stress the dependency graph.
// `preference`: ten hand-written workflows in one house style, small enough to fit an
// agent's context. Diversity is the bug in that profile, not the feature.
const PROFILE = (process.env.PROFILE ?? 'estate').toLowerCase();
if (!['estate', 'preference'].includes(PROFILE)) {
console.error(`PROFILE must be 'estate' or 'preference', got '${PROFILE}'`);
process.exit(1);
}
const SEED = SEED_ENV !== undefined ? Number(SEED_ENV) : PROFILE === 'preference' ? 1 : undefined;
if (SEED !== undefined && !Number.isFinite(SEED)) {
console.error(`SEED must be a number, got '${SEED_ENV}'`);
process.exit(1);
}
const CREDS_PER_PROJECT_RANGE = [1, 2];
const DATA_TABLES_PER_PROJECT_PROB = 0.55;
const SUBWORKFLOW_PROB = 0.7;
@@ -27,7 +52,7 @@ const SUBWORKFLOW_PROB = 0.7;
// community projects; small/large are minority outliers. Trenchcoats are
// kept deliberately small — they're legacy noise, not the bulk of the org.
function sampleWorkflowCount(kind /* 'community' | 'trenchcoat' */) {
const r = Math.random();
const r = random();
if (kind === 'trenchcoat') {
if (r < 0.5) return randInt([6, 12]);
if (r < 0.9) return randInt([12, 20]);
@@ -340,25 +365,45 @@ const CRED_RECIPES = [
},
];
// Every random choice goes through `random()`. With SEED set it is a seeded PRNG and
// two runs are byte-identical; without, it falls back to Math.random.
function makeRandom(seed) {
if (seed === undefined) return Math.random;
// mulberry32: small, fast, good enough for fixtures. Not for anything security-facing.
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = makeRandom(SEED);
// Seeded runs pin the clock too, or timestamps drift and the estate is only half
// reproducible. The date is arbitrary.
const NOW = SEED !== undefined ? Date.parse('2026-01-15T09:00:00.000Z') : Date.now();
function rand(len = 16) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let s = '';
for (let i = 0; i < len; i++) s += chars[Math.floor(Math.random() * chars.length)];
for (let i = 0; i < len; i++) s += chars[Math.floor(random() * chars.length)];
return s;
}
function pickWord(arr) {
return arr[Math.floor(Math.random() * arr.length)];
return arr[Math.floor(random() * arr.length)];
}
function pick(arr, n) {
const c = [...arr];
const out = [];
for (let i = 0; i < n && c.length; i++) {
out.push(c.splice(Math.floor(Math.random() * c.length), 1)[0]);
out.push(c.splice(Math.floor(random() * c.length), 1)[0]);
}
return out;
}
function randInt([lo, hi]) {
return lo + Math.floor(Math.random() * (hi - lo + 1));
return lo + Math.floor(random() * (hi - lo + 1));
}
const WORKFLOW_THEMES = [
@@ -745,12 +790,151 @@ async function clearSeeded() {
log('Clear done.');
}
// A missing token gives a placeholder that says so, keeping every node wired to
// something openable but not runnable. Supplying it later upgrades in place by id.
// Token lengths are logged, never values.
async function seedPreferenceCredentials(projectId, existingByName) {
const out = {};
for (const spec of PREFERENCE_CREDENTIALS) {
const token = process.env[spec.env];
const real = typeof token === 'string' && token.length > 0;
const placeholderName = `${SEED_PREFIX}${spec.name} (seed, fake key)`;
const realName = `${SEED_PREFIX}${spec.name}`;
const name = real ? realName : placeholderName;
const data = { [spec.field]: real ? token : `seed-placeholder-${spec.key}` };
// Upgrade in place so the id survives and the nodes keep resolving. Both names
// are tried: supplying a token drops the "(seed, fake key)" suffix, so the new
// name alone would miss the placeholder being replaced.
const prior = existingByName.get(name) ?? existingByName.get(real ? placeholderName : realName);
try {
if (prior) {
// PATCH, not PUT: there is no PUT route, and its 405 fails quietly enough
// to look like a working upgrade.
await api('PATCH', `/credentials/${prior.id}`, { name, type: spec.type, data });
out[spec.key] = { id: prior.id, name };
} else {
const created = await api('POST', '/credentials', {
name,
type: spec.type,
data,
projectId,
});
out[spec.key] = { id: created.id, name: created.name };
}
log(` ${spec.type}: ${real ? `real token, ${token.length} chars` : 'placeholder'}`);
} catch (e) {
log(` credential failed: ${spec.type}`, String(e).slice(0, 200));
}
}
return out;
}
// Ten workflows in one project, one house style. See `preference-profile.mjs`.
async function seedPreferenceProfile() {
log('Seeding preference profile at', BASE);
log(`Seed: ${SEED} (fixed — re-running produces the same estate)`);
// Not `clearSeeded()`: that deletes credentials, so their ids would change every
// run and every node pointing at them would need rewiring. Only the workflows are
// removed, since they are the one thing recreated unconditionally below.
const priorWorkflows = (await listAll('/workflows')).filter((w) =>
w.name.startsWith(SEED_PREFIX),
);
if (priorWorkflows.length > 0) {
log(`Removing ${priorWorkflows.length} prior seeded workflows...`);
for (const w of priorWorkflows) {
try {
await api('DELETE', `/workflows/${w.id}`);
} catch (e) {
log(' workflow delete failed:', w.name, String(e).slice(0, 200));
}
}
}
const projects = await listAll('/projects');
const projectName = `${SEED_PREFIX}${PREFERENCE_PROJECT}`;
let project = projects.find((p) => p.name === projectName);
if (!project) {
project = await api('POST', '/projects', { name: projectName });
log('Created project:', projectName);
} else {
log('Reusing project:', projectName);
}
log('Credentials...');
const existingCreds = new Map((await listAll('/credentials')).map((c) => [c.name, c]));
const creds = await seedPreferenceCredentials(project.id, existingCreds);
log('Data tables...');
const tables = {};
const existingDts = await listAll('/data-tables');
for (const spec of PREFERENCE_DATA_TABLES) {
const name = `${SEED_DT_PREFIX}${spec.name}`;
const prior = existingDts.find((d) => d.name === name);
if (prior) {
tables[spec.name] = prior.id;
log(` ${name}: reused`);
continue;
}
const created = await api('POST', '/data-tables', {
name,
projectId: project.id,
columns: spec.columns,
});
tables[spec.name] = created.id;
if (spec.rows.length > 0) {
await api('POST', `/data-tables/${created.id}/rows`, { data: spec.rows });
}
log(` ${name}: created with ${spec.rows.length} rows`);
}
log('Workflows...');
let created = 0;
for (const wf of preferenceWorkflows(tables)) {
// Recipes name the credential by key; resolve to the ids just created.
const nodes = wf.nodes.map((n) => {
const node = { ...n, id: rand(36) };
if (node.credentials) {
const resolved = {};
for (const key of Object.keys(node.credentials)) {
const spec = PREFERENCE_CREDENTIALS.find((c) => c.key === key);
const cred = creds[key];
if (spec && cred) resolved[spec.type] = { id: cred.id, name: cred.name };
}
node.credentials = resolved;
}
return node;
});
try {
await api('POST', '/workflows', {
name: `${SEED_PREFIX}${wf.name}`,
nodes,
connections: wf.connections,
settings: { executionOrder: 'v1' },
projectId: project.id,
});
created++;
log(` ${wf.name}`);
} catch (e) {
log(` workflow failed: ${wf.name}`, String(e).slice(0, 300));
}
}
log(`Done. ${created}/10 workflows, ${Object.keys(tables).length} data tables.`);
log('Total API requests:', totalReq);
}
async function main() {
if (CLEAR) {
await clearSeeded();
log('Clear finished');
return;
}
if (PROFILE === 'preference') {
await seedPreferenceProfile();
return;
}
log('Seeding n8n at', BASE);
log('Fetching projects...');
@@ -797,7 +981,7 @@ async function main() {
const n = randInt(CREDS_PER_PROJECT_RANGE);
const recipes = Array.from(
{ length: n },
() => CRED_RECIPES[Math.floor(Math.random() * CRED_RECIPES.length)],
() => CRED_RECIPES[Math.floor(random() * CRED_RECIPES.length)],
);
for (const recipe of recipes) {
try {
@@ -859,7 +1043,7 @@ async function main() {
name: `${pickWord(['Acme', 'Globex', 'Initech', 'Umbrella', 'Wonka', 'Stark', 'Wayne'])} ${i + 1}`,
plan: pickWord(['free', 'pro', 'enterprise']),
region: pickWord(['us-east', 'us-west', 'eu-west', 'ap-south']),
created_at: new Date(Date.now() - Math.floor(Math.random() * 365 * 86400e3)).toISOString(),
created_at: new Date(NOW - Math.floor(random() * 365 * 86400e3)).toISOString(),
}));
await api('POST', `/data-tables/${created.id}/rows`, { data: sampleRows });
log(`Central data table: ${created.name} → ${centralDtOwner.name} (${sampleRows.length} rows)`);
@@ -940,7 +1124,7 @@ async function main() {
const theme = themes[i];
const credPick = utilCreds.length ? [utilCreds[i % utilCreds.length]] : [];
const directDt =
isOrgUtil && centralDataTable && Math.random() < ORG_UTIL_DIRECT_DT_PROB
isOrgUtil && centralDataTable && random() < ORG_UTIL_DIRECT_DT_PROB
? centralDataTable
: null;
const wf = await createWf({
@@ -990,7 +1174,7 @@ async function main() {
// Customers-proxy ref → indirect access to central data table.
if (
customersProxy &&
Math.random() < CENTRAL_DT_REF_PROB &&
random() < CENTRAL_DT_REF_PROB &&
!updatedSubIds.includes(customersProxy.id) &&
(refs?.wfs.has(customersProxy.id) || refBudgetAllowsWf(proj.id, customersProxy))
) {
@@ -999,11 +1183,10 @@ async function main() {
}
// Generic utility-workflow ref.
if (Math.random() < UTILITY_REF_PROB && lynchpinWorkflows.length > 0) {
if (random() < UTILITY_REF_PROB && lynchpinWorkflows.length > 0) {
const alreadyOwned = lynchpinWorkflows.filter((w) => refs?.wfs.has(w.id));
const pool =
alreadyOwned.length > 0 && Math.random() < 0.7 ? alreadyOwned : lynchpinWorkflows;
const cand = pool[Math.floor(Math.random() * pool.length)];
const pool = alreadyOwned.length > 0 && random() < 0.7 ? alreadyOwned : lynchpinWorkflows;
const cand = pool[Math.floor(random() * pool.length)];
if (cand && (refs?.wfs.has(cand.id) || refBudgetAllowsWf(proj.id, cand))) {
if (!updatedSubIds.includes(cand.id)) {
updatedSubIds = [...updatedSubIds, cand.id];
@@ -1029,7 +1212,7 @@ async function main() {
// Roughly half the opt-outs go full self-contained.
const selfContainedIds = new Set();
for (const pid of nonUtilityUsingIds) {
if (Math.random() < 0.5) selfContainedIds.add(pid);
if (random() < 0.5) selfContainedIds.add(pid);
}
const projectBudget = new Map();
const projectRefs = new Map(); // projectId -> { wfs: Set, creds: Set }
@@ -1113,17 +1296,17 @@ async function main() {
const refs = projectRefs.get(projectId);
const ids = [];
for (let i = 0; i < count; i++) {
const r = Math.random();
const r = random();
let pool;
if (r < SUBWF_PROB_OWN) pool = ownPool;
else if (r < SUBWF_PROB_OWN + SUBWF_PROB_SIBLING)
pool = siblingPool.length ? siblingPool : ownPool;
else pool = lynchpinPool.length ? lynchpinPool : ownPool;
if (pool.length === 0) continue;
let cand = pool[Math.floor(Math.random() * pool.length)];
let cand = pool[Math.floor(random() * pool.length)];
if (!refBudgetAllowsWf(projectId, cand)) {
if (ownPool.length === 0) continue;
cand = ownPool[Math.floor(Math.random() * ownPool.length)];
cand = ownPool[Math.floor(random() * ownPool.length)];
}
if (ids.includes(cand.id)) continue;
ids.push(cand.id);
@@ -1144,7 +1327,7 @@ async function main() {
const refs = projectRefs.get(projectId);
const ids = [];
for (let i = 0; i < count; i++) {
const r = Math.random();
const r = random();
let pool;
if (r < 0.75) pool = sameGroupPool.length ? sameGroupPool : ownPool;
else if (r < 0.85)
@@ -1155,7 +1338,7 @@ async function main() {
: ownPool;
else pool = lynchpinPool.length ? lynchpinPool : ownPool;
if (pool.length === 0) continue;
const cand = pool[Math.floor(Math.random() * pool.length)];
const cand = pool[Math.floor(random() * pool.length)];
if (ids.includes(cand.id)) continue;
ids.push(cand.id);
if (cand.projectId !== projectId) refs.wfs.add(cand.id);
@@ -1188,8 +1371,8 @@ async function main() {
const theme = pickWord(themePool);
const group = groups.length ? groups[(p.leafCount + i) % groups.length] : undefined;
let credPick = p.creds.length ? pick(p.creds, Math.min(p.creds.length, randInt([0, 2]))) : [];
if (Math.random() < lynchpinCredProb && lynchpinCreds.length > 0) {
const lc = lynchpinCreds[Math.floor(Math.random() * lynchpinCreds.length)];
if (random() < lynchpinCredProb && lynchpinCreds.length > 0) {
const lc = lynchpinCreds[Math.floor(random() * lynchpinCreds.length)];
if (refBudgetAllowsCred(proj.id, lc.id, lc.ownerId)) {
credPick = [lc, ...credPick.filter((c) => c.type !== lc.type)];
refs.creds.add(lc.id);
@@ -1197,7 +1380,7 @@ async function main() {
}
}
const baseSubIds =
Math.random() < SUBWORKFLOW_PROB
random() < SUBWORKFLOW_PROB
? pickSubWorkflowIds(proj.id, proj.name, group, randInt([1, 3]))
: [];
const { subWorkflowIds: subIds } = applyOrgUtilityRefs({
@@ -1248,7 +1431,7 @@ async function main() {
const dataTablesByProject = new Map();
let dtTotal = 0;
for (const proj of allTargetProjects) {
if (Math.random() > DATA_TABLES_PER_PROJECT_PROB) continue;
if (random() > DATA_TABLES_PER_PROJECT_PROB) continue;
const dtName = `${SEED_DT_PREFIX}${pickWord(dtNames)}_${rand(4)}`;
try {
const dt = await api('POST', `/data-tables`, {
@@ -1268,9 +1451,9 @@ async function main() {
const rowCount = randInt([5, 20]);
const rows = Array.from({ length: rowCount }, () => ({
label: pickWord(['alpha', 'beta', 'gamma', 'delta', 'epsilon']),
score: Math.floor(Math.random() * 100),
is_active: Math.random() < 0.7,
recorded_at: new Date(Date.now() - Math.floor(Math.random() * 30 * 86400e3)).toISOString(),
score: Math.floor(random() * 100),
is_active: random() < 0.7,
recorded_at: new Date(NOW - Math.floor(random() * 30 * 86400e3)).toISOString(),
}));
try {
await api('POST', `/data-tables/${dt.id}/rows`, { data: rows });
@@ -1297,8 +1480,8 @@ async function main() {
const dt = dts[i % dts.length];
const theme = `${pickWord(['Sync to', 'Report from', 'Backfill', 'Reconcile', 'Audit'])} ${dt.name.replace(SEED_DT_PREFIX, '')}`;
let credPick = creds.length ? pick(creds, Math.min(creds.length, randInt([0, 1]))) : [];
if (Math.random() < LYNCHPIN_CRED_REF_PROB && lynchpinCreds.length > 0) {
const lc = lynchpinCreds[Math.floor(Math.random() * lynchpinCreds.length)];
if (random() < LYNCHPIN_CRED_REF_PROB && lynchpinCreds.length > 0) {
const lc = lynchpinCreds[Math.floor(random() * lynchpinCreds.length)];
credPick = [lc, ...credPick.filter((c) => c.type !== lc.type)];
}
const baseSubIds = pickSubWorkflowIds(proj.id, proj.name, null, 1);