@eventmodelers/cli 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -9
- package/cli.js +369 -36
- package/lib/build-kits.js +23 -0
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/build-log-share.js +149 -0
- package/shared/build-kit/lib/events-view.js +18 -3
- package/shared/build-kit/lib/ralph.js +27 -16
- package/shared/build-kit/lib/slice-events.js +42 -2
- package/shared/build-kit/lib/slice-info.js +52 -0
- package/shared/build-kit/lib/slice-run.js +2 -2
- package/shared/build-kit/lib/slice-state.js +6 -4
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
- package/shared/skills/ui-contract/SKILL.md +6 -6
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
- package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
- package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/umadb/templates/build-kit/lib/prompt.md +24 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Builds the `model.json` the scaffold generators read, from the board's REST API.
|
|
2
|
+
// `api` is `{ get(path), post(path, body) }` rooted at /api/org/:orgId/boards/:boardId, so this
|
|
3
|
+
// stays testable without a server.
|
|
4
|
+
|
|
5
|
+
const SCREEN_BATCH = 20;
|
|
6
|
+
// Class names that only a Bulma mockup uses; any one in a screen's HTML means the screen is Bulma.
|
|
7
|
+
const BULMA_CLASS = /class="[^"]*\b(navbar|is-primary|modal-card|columns|box)\b/;
|
|
8
|
+
|
|
9
|
+
// A route is only taken when the screen's description states one ("route /orders/:orderId").
|
|
10
|
+
export function routeFrom(description) {
|
|
11
|
+
const m = /\broute\s+(\/[^\s,;)]*)/i.exec(description ?? '');
|
|
12
|
+
return m ? m[1].replace(/[.]+$/, '') : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isHomeScreen(description) {
|
|
16
|
+
return /\bhome\s*page\b|\b(?:page|screen)\s+(?:shown\s+)?after\s+sign-?\s?in\b|\bshown\s+after\s+sign-?\s?in\b/i.test(description ?? '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const descriptionOf = (node) => node.description ?? node.meta?.description ?? node.data?.description ?? '';
|
|
20
|
+
|
|
21
|
+
export async function buildModel({ api, appTitle }) {
|
|
22
|
+
const { chapters = [] } = await api.get('/chapters/bounds');
|
|
23
|
+
const timelines = chapters.filter((c) => c.title).map(({ title, x, y }) => ({ title, x, y }));
|
|
24
|
+
|
|
25
|
+
// Timelines of one context return the same slices, so slices are keyed by id.
|
|
26
|
+
const slicesById = new Map();
|
|
27
|
+
for (const t of timelines) {
|
|
28
|
+
const { slices = [] } = await api.get(`/slicedata?contextName=${encodeURIComponent(t.title)}&projection=outline`);
|
|
29
|
+
for (const s of slices) {
|
|
30
|
+
if (s.title && !slicesById.has(s.id ?? s.title)) slicesById.set(s.id ?? s.title, s);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One screen node id per distinct screen title is enough to read route, home flag and CSS.
|
|
35
|
+
const screenIdByTitle = new Map();
|
|
36
|
+
for (const s of slicesById.values()) {
|
|
37
|
+
for (const screen of s.screens ?? []) {
|
|
38
|
+
if (screen?.title && screen.id && !screenIdByTitle.has(screen.title)) screenIdByTitle.set(screen.title, screen.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const titleById = new Map([...screenIdByTitle].map(([title, id]) => [id, title]));
|
|
42
|
+
const ids = [...screenIdByTitle.values()];
|
|
43
|
+
const screens = [];
|
|
44
|
+
let bulma = false;
|
|
45
|
+
for (let i = 0; i < ids.length; i += SCREEN_BATCH) {
|
|
46
|
+
const nodes = await api.post('/nodes/query', { ids: ids.slice(i, i + SCREEN_BATCH) });
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const title = titleById.get(node.id);
|
|
49
|
+
if (!title) continue;
|
|
50
|
+
const description = descriptionOf(node);
|
|
51
|
+
const route = routeFrom(description);
|
|
52
|
+
const home = isHomeScreen(description);
|
|
53
|
+
screens.push({ title, ...(route ? { route } : {}), ...(home ? { home: true } : {}) });
|
|
54
|
+
const pages = Array.isArray(node.meta?.pages) ? node.meta.pages : [];
|
|
55
|
+
if (pages.some((html) => typeof html === 'string' && BULMA_CLASS.test(html))) bulma = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
appTitle: appTitle || 'App',
|
|
61
|
+
css: bulma ? 'bulma' : 'none',
|
|
62
|
+
timelines,
|
|
63
|
+
slices: [...slicesById.values()].map((s) => ({
|
|
64
|
+
title: s.title,
|
|
65
|
+
sliceType: s.sliceType,
|
|
66
|
+
chapter: s.chapter || s.context,
|
|
67
|
+
screens: (s.screens ?? []).map((sc) => sc.title).filter(Boolean),
|
|
68
|
+
})),
|
|
69
|
+
screens,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `eventmodelers scaffold`: turns a board into a runnable app skeleton the build kits then add
|
|
2
|
+
// slices to. A scaffold *type* names the kits it installs, the tools its app needs and the
|
|
3
|
+
// generator that writes the skeleton around them — adding a type (e.g. a Java one) is adding an
|
|
4
|
+
// entry to SCAFFOLD_TYPES plus a generator under shared/scaffold/<type>/.
|
|
5
|
+
//
|
|
6
|
+
// The skeleton generators are adapted from the em-scaffold skill by Adrian Mowat (MIT,
|
|
7
|
+
// shared/scaffold/LICENSE-em-scaffold).
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { createServer } from 'node:net';
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const SCAFFOLD_DIR = join(here, '..', 'shared', 'scaffold');
|
|
16
|
+
|
|
17
|
+
export const SCAFFOLD_TYPES = {
|
|
18
|
+
supabase: {
|
|
19
|
+
label: 'Supabase backend + React front end (runs supabase + supabase-react)',
|
|
20
|
+
// Installed in order; `dir` is relative to the target.
|
|
21
|
+
kits: [
|
|
22
|
+
{ stack: 'supabase', dir: '.' },
|
|
23
|
+
{ stack: 'supabase-react', dir: 'web' },
|
|
24
|
+
],
|
|
25
|
+
generator: join(SCAFFOLD_DIR, 'supabase', 'generate.mjs'),
|
|
26
|
+
tools: [
|
|
27
|
+
{ command: 'docker', name: 'Docker' },
|
|
28
|
+
{ command: 'supabase', name: 'Supabase CLI' },
|
|
29
|
+
{ command: 'flyway', name: 'Flyway' },
|
|
30
|
+
{ command: 'java', name: 'Java (Flyway runtime)' },
|
|
31
|
+
],
|
|
32
|
+
minNode: 22,
|
|
33
|
+
// Command (run in the app root) that adds a sign-in user; omit it for a type without sign-in — `scaffold add-user` is then a no-op.
|
|
34
|
+
addUser: { requires: 'scripts/create-user.mjs', command: (email, password) => [process.execPath, 'scripts/create-user.mjs', email, password] },
|
|
35
|
+
// Run in the target after the generator; the first failure stops the run.
|
|
36
|
+
installSteps: [
|
|
37
|
+
['npm', 'install'],
|
|
38
|
+
['npm', '--prefix', 'web', 'install'],
|
|
39
|
+
],
|
|
40
|
+
checkSteps: [
|
|
41
|
+
['npx', 'tsc', '--noEmit'],
|
|
42
|
+
['npx', 'tsx', '--test', 'src/common/notImplemented.test.ts'],
|
|
43
|
+
['npm', '--prefix', 'web', 'run', 'build'],
|
|
44
|
+
['npm', '--prefix', 'web', 'run', 'lint'],
|
|
45
|
+
],
|
|
46
|
+
// One entry per port the generator takes. `flag` is passed to it with the picked value, `saved` is the
|
|
47
|
+
// key it writes to skeleton.json (so a refresh keeps its ports), `occupies` lists every TCP port the value
|
|
48
|
+
// needs free (default: itself), `render` turns it into the flag's value. A Supabase prefix NNN owns
|
|
49
|
+
// NNN20..NNN29 and can't go above 654.
|
|
50
|
+
ports: [
|
|
51
|
+
{ name: 'api', flag: '--api-port', saved: 'apiPort', start: 3100 },
|
|
52
|
+
{ name: 'web', flag: '--web-port', saved: 'webPort', start: 5273 },
|
|
53
|
+
{ name: 'db', flag: '--db-port-prefix', saved: 'dbPortPrefix', start: 564, limit: 654,
|
|
54
|
+
occupies: (n) => Array.from({ length: 10 }, (_, i) => n * 100 + 20 + i) },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const walkFiles = (dir) => readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walkFiles(join(dir, n)) : [join(dir, n)]));
|
|
60
|
+
|
|
61
|
+
// The type's built-in templates as template path (the path they generate, always '/') -> source file.
|
|
62
|
+
export function builtInTemplates(type) {
|
|
63
|
+
const dir = join(dirname(type.generator), 'template');
|
|
64
|
+
return new Map(walkFiles(dir).sort().map((file) => [relative(dir, file).split(sep).join('/').replace(/\.tmpl$/, ''), file]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Copies built-in templates into `dest` so they can be edited there; the generator then uses what is in `dest`
|
|
68
|
+
// and falls back to the built-in template for everything else. `only` limits it to paths (a file, or a folder
|
|
69
|
+
// prefix); an existing file is kept unless `force`.
|
|
70
|
+
export function initTemplates(type, dest, { only = [], force = false } = {}) {
|
|
71
|
+
const all = builtInTemplates(type);
|
|
72
|
+
const wanted = only.map((p) => p.replace(/\\/g, '/').replace(/\.tmpl$/, '').replace(/\/+$/, ''));
|
|
73
|
+
const unknown = wanted.filter((p) => ![...all.keys()].some((rel) => rel === p || rel.startsWith(`${p}/`)));
|
|
74
|
+
if (unknown.length) throw new Error(`no built-in template at ${unknown.map((p) => `"${p}"`).join(', ')} — \`init-templates --list\` shows them`);
|
|
75
|
+
const copied = [];
|
|
76
|
+
const kept = [];
|
|
77
|
+
for (const [rel, file] of all) {
|
|
78
|
+
if (wanted.length && !wanted.some((p) => rel === p || rel.startsWith(`${p}/`))) continue;
|
|
79
|
+
const out = join(dest, `${rel}.tmpl`);
|
|
80
|
+
if (existsSync(out) && !force) { kept.push(rel); continue; }
|
|
81
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
82
|
+
copyFileSync(file, out);
|
|
83
|
+
copied.push(rel);
|
|
84
|
+
}
|
|
85
|
+
return { copied, kept };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function scaffoldTypeNames() {
|
|
89
|
+
return Object.keys(SCAFFOLD_TYPES);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// How to start `file` on this platform. On Windows npm and npx are .cmd shims that only start through a
|
|
93
|
+
// shell (Node refuses to spawn them directly), so they get one; everything else is a plain executable.
|
|
94
|
+
export function invocation(file, args, platform = process.platform) {
|
|
95
|
+
const shimmed = platform === 'win32' && ['npm', 'npx'].includes(file);
|
|
96
|
+
return shimmed ? { file: [file, ...args].join(' '), args: [], shell: true } : { file, args, shell: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The command that answers "is `command` on the PATH": `where` on Windows (it honours PATHEXT), `command -v` elsewhere.
|
|
100
|
+
export function probe(command, platform = process.platform) {
|
|
101
|
+
return platform === 'win32' ? { file: 'where', args: [command] } : { file: 'sh', args: ['-c', `command -v ${command}`] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function hasCommand(command) {
|
|
105
|
+
const { file, args } = probe(command);
|
|
106
|
+
try {
|
|
107
|
+
execFileSync(file, args, { stdio: 'ignore' });
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What the type needs that this machine lacks, as printable names. Node is checked by version.
|
|
115
|
+
export function missingPrerequisites(type, { nodeVersion = process.versions.node, has = hasCommand } = {}) {
|
|
116
|
+
const missing = [];
|
|
117
|
+
if (Number(nodeVersion.split('.')[0]) < type.minNode) missing.push(`Node ${type.minNode} or newer (found ${nodeVersion})`);
|
|
118
|
+
for (const tool of type.tools) if (!has(tool.command)) missing.push(tool.name);
|
|
119
|
+
return missing;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Free means nothing listens on any interface, which is what a Docker-published port looks like too.
|
|
123
|
+
export function isPortFree(port) {
|
|
124
|
+
return new Promise((done) => {
|
|
125
|
+
const server = createServer();
|
|
126
|
+
server.once('error', () => done(false));
|
|
127
|
+
server.listen(port, () => server.close(() => done(true)));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The first value from the spec's start whose ports are all free, one spec at a time. Specs picked in the
|
|
132
|
+
// same run don't share ports as long as their ranges differ, which they do for every type so far.
|
|
133
|
+
export async function pickPorts(specs, { free = isPortFree } = {}) {
|
|
134
|
+
const picked = {};
|
|
135
|
+
for (const spec of specs) {
|
|
136
|
+
const occupies = spec.occupies ?? ((n) => [n]);
|
|
137
|
+
const limit = spec.limit ?? spec.start + 100;
|
|
138
|
+
let value = spec.start;
|
|
139
|
+
while (value <= limit && !(await Promise.all(occupies(value).map(free))).every(Boolean)) value++;
|
|
140
|
+
if (value > limit) throw new Error(`no free "${spec.name}" port from ${spec.start} up to ${limit}`);
|
|
141
|
+
picked[spec.name] = String(value);
|
|
142
|
+
}
|
|
143
|
+
return picked;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An empty/new directory or an earlier skeleton (has skeleton.json) is fair game; anything else is not ours.
|
|
147
|
+
export function targetState(target) {
|
|
148
|
+
if (!existsSync(target)) return 'new';
|
|
149
|
+
if (existsSync(join(target, 'skeleton.json'))) return 'skeleton';
|
|
150
|
+
return readdirSync(target).length === 0 ? 'new' : 'foreign';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function run(file, args, { cwd, env, quiet = false } = {}) {
|
|
154
|
+
const inv = invocation(file, args);
|
|
155
|
+
execFileSync(inv.file, inv.args, { cwd, env: { ...process.env, ...env }, stdio: quiet ? 'ignore' : 'inherit', shell: inv.shell });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// cfg: { token, organizationId, boardId, baseUrl }. Credentials reach the child installers through
|
|
159
|
+
// EVENTMODELERS_* env vars so the token never shows up in a process list.
|
|
160
|
+
// templates: a folder of *.tmpl files overriding the type's built-in templates; when omitted, `<target>/templates`
|
|
161
|
+
// is used if it exists, so a project's customisations survive every refresh.
|
|
162
|
+
export async function runScaffold({ typeName, target, cfg, model, cliPath, install = true, check = true, templates, ports: portOverrides = {}, log = console.log }) {
|
|
163
|
+
const type = SCAFFOLD_TYPES[typeName];
|
|
164
|
+
const root = resolve(target);
|
|
165
|
+
const state = targetState(root);
|
|
166
|
+
if (state === 'foreign') {
|
|
167
|
+
throw new Error(`${root} is not empty and is not a scaffolded skeleton — pass an empty or new directory`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const unknown = Object.keys(portOverrides).filter((n) => !type.ports.some((p) => p.name === n));
|
|
171
|
+
if (unknown.length) {
|
|
172
|
+
throw new Error(`unknown port ${unknown.map((n) => `"${n}"`).join(', ')} for type ${typeName} (it has: ${type.ports.map((p) => p.name).join(', ')})`);
|
|
173
|
+
}
|
|
174
|
+
// A refresh keeps the ports the skeleton already runs on; only a new one picks free ports.
|
|
175
|
+
const previous = state === 'skeleton' ? JSON.parse(readFileSync(join(root, 'skeleton.json'), 'utf8')) : {};
|
|
176
|
+
const pending = type.ports.filter((p) => previous[p.saved] === undefined && portOverrides[p.name] === undefined);
|
|
177
|
+
const picked = await pickPorts(pending);
|
|
178
|
+
const ports = Object.fromEntries(type.ports.map((p) => [p.name, String(portOverrides[p.name] ?? previous[p.saved] ?? picked[p.name])]));
|
|
179
|
+
|
|
180
|
+
for (const kit of type.kits) mkdirSync(join(root, kit.dir), { recursive: true });
|
|
181
|
+
if (!existsSync(join(root, '.git'))) run('git', ['init', '-q'], { cwd: root });
|
|
182
|
+
|
|
183
|
+
const env = {
|
|
184
|
+
EVENTMODELERS_TOKEN: cfg.token,
|
|
185
|
+
EVENTMODELERS_ORGANIZATION_ID: cfg.organizationId,
|
|
186
|
+
EVENTMODELERS_BOARD_ID: cfg.boardId,
|
|
187
|
+
...(cfg.baseUrl ? { EVENTMODELERS_BASE_URL: cfg.baseUrl } : {}),
|
|
188
|
+
};
|
|
189
|
+
for (const kit of type.kits) {
|
|
190
|
+
const cwd = join(root, kit.dir);
|
|
191
|
+
if (existsSync(join(cwd, '.build-kit'))) {
|
|
192
|
+
log(`▶ ${kit.stack}: already installed in ${kit.dir}, skipping`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
log(`▶ Installing the ${kit.stack} kit in ${kit.dir}`);
|
|
196
|
+
run(process.execPath, [cliPath, 'init', '--stack', kit.stack, '--print'], { cwd, env });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Next to the target, not in it: the model is an input of the skeleton, not a project file.
|
|
200
|
+
const modelFile = `${root}.model.json`;
|
|
201
|
+
writeFileSync(modelFile, JSON.stringify(model, null, 2) + '\n');
|
|
202
|
+
const templatesDir = templates ? resolve(templates) : existsSync(join(root, 'templates')) ? join(root, 'templates') : null;
|
|
203
|
+
if (templates && !existsSync(templatesDir)) throw new Error(`--templates ${templatesDir} does not exist`);
|
|
204
|
+
log(`▶ Generating the skeleton (model: ${modelFile}${templatesDir ? `, templates: ${templatesDir}` : ''})`);
|
|
205
|
+
run(process.execPath, [
|
|
206
|
+
type.generator, '--model', modelFile, '--target', root,
|
|
207
|
+
...type.ports.flatMap((p) => [p.flag, ports[p.name]]),
|
|
208
|
+
...(templatesDir ? ['--templates', templatesDir] : []),
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
if (install) {
|
|
212
|
+
for (const [file, ...args] of type.installSteps) run(file, args, { cwd: root });
|
|
213
|
+
if (check) {
|
|
214
|
+
for (const [file, ...args] of type.checkSteps) run(file, args, { cwd: root });
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
run('git', ['add', '-A'], { cwd: root });
|
|
218
|
+
run('git', ['commit', '-q', '-m', 'chore: app skeleton from the event model'], { cwd: root });
|
|
219
|
+
} catch {
|
|
220
|
+
log('ℹ️ Nothing to commit (or git refused) — commit the skeleton yourself.');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { root, ports, modelFile };
|
|
224
|
+
}
|
package/lib/supervise.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Runs several long-lived processes side by side in one terminal: output prefixed with the process name,
|
|
2
|
+
// Ctrl+c (or any one of them exiting) stops all of them, children included.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { invocation } from './scaffold.js';
|
|
5
|
+
|
|
6
|
+
// Stops a process and everything it started. POSIX: the child leads its own process group, so one signal
|
|
7
|
+
// reaches the tree. Windows has no groups, taskkill walks the tree instead.
|
|
8
|
+
export function killTree(pid, signal = 'SIGINT', platform = process.platform) {
|
|
9
|
+
if (platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
10
|
+
else try { process.kill(-pid, signal); } catch {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// specs: [{ name, file, args, cwd }]. Resolves with the exit code once everything has stopped.
|
|
14
|
+
export function supervise(specs, { log = console.log, graceMs = 3000, handleSignals = true } = {}) {
|
|
15
|
+
const width = Math.max(...specs.map((s) => s.name.length));
|
|
16
|
+
return new Promise((done) => {
|
|
17
|
+
let stopping = false;
|
|
18
|
+
let exitCode = 0;
|
|
19
|
+
let running = specs.length;
|
|
20
|
+
const children = [];
|
|
21
|
+
|
|
22
|
+
const finish = () => done(exitCode);
|
|
23
|
+
const stop = (code = 0) => {
|
|
24
|
+
if (stopping) return;
|
|
25
|
+
stopping = true;
|
|
26
|
+
exitCode = code;
|
|
27
|
+
for (const child of children) if (child.exitCode === null) killTree(child.pid);
|
|
28
|
+
setTimeout(finish, graceMs).unref();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const spec of specs) {
|
|
32
|
+
const inv = invocation(spec.file, spec.args);
|
|
33
|
+
// POSIX: detached makes the child its own group leader (see killTree). Windows: it would open a console window.
|
|
34
|
+
const child = spawn(inv.file, inv.args, { cwd: spec.cwd, shell: inv.shell, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
35
|
+
children.push(child);
|
|
36
|
+
for (const out of [child.stdout, child.stderr]) {
|
|
37
|
+
let rest = '';
|
|
38
|
+
out.on('data', (chunk) => {
|
|
39
|
+
const lines = (rest + chunk).split('\n');
|
|
40
|
+
rest = lines.pop();
|
|
41
|
+
for (const line of lines) log(`${spec.name.padEnd(width)} | ${line}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
child.on('error', (err) => {
|
|
45
|
+
log(`${spec.name.padEnd(width)} | failed to start: ${err.message}`);
|
|
46
|
+
stop(1);
|
|
47
|
+
});
|
|
48
|
+
child.on('exit', (code) => {
|
|
49
|
+
if (!stopping) log(`${spec.name.padEnd(width)} | exited (${code}), stopping the rest`);
|
|
50
|
+
stop(code ?? 0);
|
|
51
|
+
if (--running === 0) finish();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (handleSignals) {
|
|
56
|
+
process.once('SIGINT', () => stop());
|
|
57
|
+
process.once('SIGTERM', () => stop());
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Decides whether a slice's UI needs work: a hash of what the board says about the UI (the screen nodes'
|
|
2
|
+
// node hash, the user's `codeGen.uiPrompts`) is recorded after each
|
|
3
|
+
// successful UI build, and compared on the next one. Only a difference sends the agent back to the UI.
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export const BASELINE_FILE = 'ui-baseline.json';
|
|
9
|
+
|
|
10
|
+
const sha = (value) => createHash('sha256').update(stable(value)).digest('hex').slice(0, 16);
|
|
11
|
+
|
|
12
|
+
// JSON with sorted keys, so key order on the board never reads as a change.
|
|
13
|
+
function stable(value) {
|
|
14
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
15
|
+
if (value && typeof value === 'object') {
|
|
16
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value ?? null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const readJson = (path) => {
|
|
22
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The slice's folder under `.slices/`, found by id or (case-insensitive) title.
|
|
26
|
+
export function findSliceDir(kitDir, idOrTitle) {
|
|
27
|
+
const slicesDir = join(kitDir, '.slices');
|
|
28
|
+
if (!existsSync(slicesDir)) return null;
|
|
29
|
+
const wanted = String(idOrTitle).trim().toLowerCase();
|
|
30
|
+
for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
31
|
+
if (!ctx.isDirectory()) continue;
|
|
32
|
+
for (const dir of readdirSync(join(slicesDir, ctx.name), { withFileTypes: true })) {
|
|
33
|
+
if (!dir.isDirectory()) continue;
|
|
34
|
+
const path = join(slicesDir, ctx.name, dir.name);
|
|
35
|
+
const slice = readJson(join(path, 'slice.json'));
|
|
36
|
+
if (slice && (slice.id === idOrTitle || slice.title?.toLowerCase() === wanted)) return { path, slice };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Lists of the slice that are not board elements (or are hashed on their own).
|
|
43
|
+
const NOT_ELEMENTS = new Set(['screens', 'comments', 'screenImages']);
|
|
44
|
+
const BATCH = 50;
|
|
45
|
+
|
|
46
|
+
// `fetchNodes(ids)` -> the board's nodes for those ids (POST /nodes/query).
|
|
47
|
+
// The board stamps every node with a `hash`; use it as is. Content hash only as a fallback, for a board
|
|
48
|
+
// (or a /nodes/query) that does not return it.
|
|
49
|
+
// scope 'backend' (a kit that builds no UI) leaves out the screens and the UI prompts: a screen edit must not
|
|
50
|
+
// make it rebuild.
|
|
51
|
+
export async function fingerprint({ slice, fetchNodes, scope = 'ui' }) {
|
|
52
|
+
const ui = scope !== 'backend';
|
|
53
|
+
const prompts = ui ? slice.codeGen?.uiPrompts ?? [] : [];
|
|
54
|
+
const screens = ui ? (slice.screens ?? []).filter((s) => s?.id) : [];
|
|
55
|
+
// Every element the slice carries — commands, events, read models, processors, tables, actors, aggregates,
|
|
56
|
+
// specifications, ... — so a new list the export grows is covered without touching this.
|
|
57
|
+
const elements = Object.entries(slice)
|
|
58
|
+
.filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
|
|
59
|
+
.flatMap(([, v]) => v)
|
|
60
|
+
.filter((e) => e && typeof e === 'object' && e.id);
|
|
61
|
+
const ids = [...new Set([...screens, ...elements].map((e) => e.id))];
|
|
62
|
+
const byId = new Map();
|
|
63
|
+
for (let i = 0; i < ids.length; i += BATCH) {
|
|
64
|
+
for (const node of await fetchNodes(ids.slice(i, i + BATCH))) byId.set(node.id, node);
|
|
65
|
+
}
|
|
66
|
+
const missing = screens.filter((s) => !byId.has(s.id)).map((s) => s.title ?? s.id);
|
|
67
|
+
if (missing.length) throw new Error(`screen node(s) not found on the board: ${missing.join(', ')}`);
|
|
68
|
+
// One entry per node, so a later check can say *which* ones moved, not just that something did.
|
|
69
|
+
const entry = (kind) => (item) => {
|
|
70
|
+
const node = byId.get(item.id);
|
|
71
|
+
const base = { id: item.id, kind, title: item.title ?? node?.node?.data?.title ?? node?.data?.title ?? item.id };
|
|
72
|
+
if (!node) return { ...base, hash: 'missing' };
|
|
73
|
+
if (node.hash) return { ...base, hash: node.hash };
|
|
74
|
+
return { ...base, hash: sha({ content: node.meta ?? node.data ?? null, fields: item.fields ?? [] }) };
|
|
75
|
+
};
|
|
76
|
+
const screenNodes = screens.map(entry('screen'));
|
|
77
|
+
const elementNodes = Object.entries(slice)
|
|
78
|
+
.filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
|
|
79
|
+
.flatMap(([k, v]) => v.filter((e) => e && typeof e === 'object' && e.id).map(entry(k)));
|
|
80
|
+
return {
|
|
81
|
+
screen: screenNodes.length ? sha(screenNodes.map((n) => [n.id, n.hash])) : null,
|
|
82
|
+
elements: elementNodes.length ? sha(elementNodes.map((n) => [n.id, n.hash])) : null,
|
|
83
|
+
prompts: sha(prompts),
|
|
84
|
+
promptList: prompts,
|
|
85
|
+
nodes: [...screenNodes, ...elementNodes],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readBaseline(sliceDir) {
|
|
90
|
+
return readJson(join(sliceDir, BASELINE_FILE));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeBaseline(sliceDir, fp, scope = 'ui') {
|
|
94
|
+
const baseline = { scope, screenHash: fp.screen, elementsHash: fp.elements, uiPromptsHash: fp.prompts, uiPrompts: fp.promptList, nodes: fp.nodes, recordedAt: new Date().toISOString() };
|
|
95
|
+
writeFileSync(join(sliceDir, BASELINE_FILE), JSON.stringify(baseline, null, 2) + '\n');
|
|
96
|
+
return baseline;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What differs node by node, against a baseline that recorded its nodes ({id, kind, title, hash}).
|
|
100
|
+
function diffNodes(before, now) {
|
|
101
|
+
const was = new Map(before.map((n) => [n.id, n]));
|
|
102
|
+
const is = new Map(now.map((n) => [n.id, n]));
|
|
103
|
+
return {
|
|
104
|
+
changedNodes: now.filter((n) => was.has(n.id) && was.get(n.id).hash !== n.hash),
|
|
105
|
+
addedNodes: now.filter((n) => !was.has(n.id)),
|
|
106
|
+
removedNodes: before.filter((n) => !is.has(n.id)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verdict: NO_BASELINE | UNCHANGED | CHANGED, with which parts changed, which nodes, and the prompts that are new.
|
|
111
|
+
// A baseline from before elements/nodes were tracked has no elementsHash/nodes — that is not a change.
|
|
112
|
+
export function compare(baseline, fp, scope = 'ui') {
|
|
113
|
+
const none = { changedNodes: [], addedNodes: [], removedNodes: [] };
|
|
114
|
+
// A baseline recorded under another scope says nothing about this one.
|
|
115
|
+
if (!baseline || (baseline.scope ?? 'ui') !== scope) return { verdict: 'NO_BASELINE', screenChanged: true, elementsChanged: true, promptsChanged: fp.promptList.length > 0, newPrompts: fp.promptList, ...none };
|
|
116
|
+
const screenChanged = baseline.screenHash !== fp.screen;
|
|
117
|
+
const elementsChanged = baseline.elementsHash !== undefined && baseline.elementsHash !== fp.elements;
|
|
118
|
+
const promptsChanged = baseline.uiPromptsHash !== fp.prompts;
|
|
119
|
+
const seen = new Set(baseline.uiPrompts ?? []);
|
|
120
|
+
const newPrompts = promptsChanged ? fp.promptList.filter((p) => !seen.has(p)) : [];
|
|
121
|
+
const nodes = Array.isArray(baseline.nodes) ? diffNodes(baseline.nodes, fp.nodes) : none;
|
|
122
|
+
return { verdict: screenChanged || elementsChanged || promptsChanged ? 'CHANGED' : 'UNCHANGED', screenChanged, elementsChanged, promptsChanged, newPrompts, ...nodes };
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A build agent shares its event log with the board — two capabilities, both build-agent only (a modeling
|
|
2
|
+
// agent gets no-ops):
|
|
3
|
+
//
|
|
4
|
+
// iterations every slice build turn is an iteration: `iteration-started` / `iteration-closed` events in the
|
|
5
|
+
// log, each carrying the slice, the iteration id, the board, the org and the date — and a
|
|
6
|
+
// `slice-done` after an iteration that settled the slice as Done. Always on.
|
|
7
|
+
// sync with --sync, the events that name a slice are uploaded to POST …/boards/:boardId/buildlog,
|
|
8
|
+
// numbered (the event's own `seq`, which defines the order) and tagged with the kit type (backend, or ui for a kit that picks up from Waiting) —
|
|
9
|
+
// constantly, on a short interval for as long as the loop runs, so a live dashboard follows the
|
|
10
|
+
// build, and once more at the start and end of each iteration.
|
|
11
|
+
//
|
|
12
|
+
// Best effort, like tracing: a cursor (the last event id sent) lives next to the log and only moves forward
|
|
13
|
+
// when the upload succeeded, so a failed upload is simply sent again with the next one. The server holds one
|
|
14
|
+
// iteration per slice and kit type — every `iteration-started` resets that log — and at most 1000 entries.
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from 'crypto';
|
|
17
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { appendEvent, readEvents } from './slice-events.js';
|
|
20
|
+
import { statusKey } from './slice-state.js';
|
|
21
|
+
|
|
22
|
+
export const SYNC_STATE_FILE = 'build-log-sync.json';
|
|
23
|
+
export const SYNC_INTERVAL_MS = 2_000;
|
|
24
|
+
const NOOP = { iterationStarted: () => null, iterationClosed: () => {}, flush: async () => {}, start: () => {}, stop: () => {} };
|
|
25
|
+
|
|
26
|
+
function readState(path) {
|
|
27
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeState(path, state) {
|
|
31
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf-8');
|
|
33
|
+
renameSync(tmp, path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The wire shape of one log event: the envelope the endpoint reads, everything else as payload.
|
|
37
|
+
export function toBuildLogEntry(e, { agentId, iteration, boardId, organizationId, kitType, seq }) {
|
|
38
|
+
const { id, at, type, slice, meta: _meta, tags: _tags, ...rest } = e;
|
|
39
|
+
return { ...rest, id, type, sliceId: slice, agentId, iteration, occurredAt: at, boardId, organizationId, kitType, seq };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A kit that picks slices up from Waiting builds the UI; any other builds the backend.
|
|
43
|
+
export const kitTypeOf = (pickupStatus) => (String(pickupStatus ?? '').trim().toLowerCase() === 'waiting' ? 'ui' : 'backend');
|
|
44
|
+
|
|
45
|
+
export function createBuildLogShare({ kitDir, cfg, meta, agentType = 'BUILD', kitType = 'backend', sync = false, enabled = true, intervalMs = SYNC_INTERVAL_MS, log = () => {} } = {}) {
|
|
46
|
+
if (agentType !== 'BUILD') return NOOP;
|
|
47
|
+
const statePath = join(kitDir, SYNC_STATE_FILE);
|
|
48
|
+
const canUpload = !!(sync && enabled && cfg?.baseUrl && cfg?.token && cfg?.organizationId && cfg?.boardId && cfg?.agentId);
|
|
49
|
+
const open = new Map(); // slice → the iteration in progress
|
|
50
|
+
let timer = null;
|
|
51
|
+
let inFlight = null;
|
|
52
|
+
let warned = false;
|
|
53
|
+
|
|
54
|
+
async function upload() {
|
|
55
|
+
const state = readState(statePath);
|
|
56
|
+
const events = readEvents(kitDir);
|
|
57
|
+
const from = state.cursor ? events.findIndex((e) => e.id === state.cursor) + 1 : 0; // a pruned cursor re-sends; the server drops duplicates
|
|
58
|
+
const fresh = events.slice(from);
|
|
59
|
+
if (!fresh.length) return;
|
|
60
|
+
const iterations = { ...(state.iterations ?? {}) };
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const e of fresh) {
|
|
63
|
+
if (!e.slice) continue;
|
|
64
|
+
if (e.type === 'iteration-started') iterations[e.slice] = e.iteration;
|
|
65
|
+
entries.push(toBuildLogEntry(e, { agentId: cfg.agentId, iteration: e.iteration ?? iterations[e.slice], boardId: cfg.boardId, organizationId: cfg.organizationId, kitType, seq: e.seq }));
|
|
66
|
+
}
|
|
67
|
+
if (entries.length) {
|
|
68
|
+
const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/buildlog`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-agent-id': cfg.agentId },
|
|
71
|
+
body: JSON.stringify({ events: entries }),
|
|
72
|
+
signal: AbortSignal.timeout(15_000),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const reason = `HTTP ${res.status}${await res.text().then((t) => `: ${t}`).catch(() => '')}`;
|
|
76
|
+
// A batch the platform rejects as invalid can never succeed: skip it rather than resend it every interval.
|
|
77
|
+
if (res.status !== 400) throw new Error(reason);
|
|
78
|
+
log(`info: the platform did not take ${entries.length} build log entries (${reason}) — skipped`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
writeState(statePath, { cursor: fresh[fresh.length - 1].id, iterations });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One upload at a time. A call that arrives while one runs asks for another pass right after it, so events
|
|
85
|
+
// appended in between are never left to wait for the interval.
|
|
86
|
+
let again = false;
|
|
87
|
+
function flush() {
|
|
88
|
+
if (!canUpload) return Promise.resolve();
|
|
89
|
+
if (inFlight) { again = true; return inFlight; }
|
|
90
|
+
inFlight = (async () => {
|
|
91
|
+
do {
|
|
92
|
+
again = false;
|
|
93
|
+
try { await upload(); warned = false; } catch (err) {
|
|
94
|
+
if (!warned) { warned = true; log(`info: build log upload failed (${err.message}) — will send it with the next one`); }
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
} while (again);
|
|
98
|
+
})().finally(() => { inFlight = null; });
|
|
99
|
+
return inFlight;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
iterationStarted(planned) {
|
|
104
|
+
const iteration = randomUUID();
|
|
105
|
+
try {
|
|
106
|
+
appendEvent(kitDir, { type: 'iteration-started', slice: planned.id, iteration, title: planned.title, kitType, boardId: cfg?.boardId, organizationId: cfg?.organizationId }, meta);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log(`info: could not record the start of an iteration (${err.message}) — the build goes on`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
open.set(planned.id, iteration);
|
|
112
|
+
flush();
|
|
113
|
+
return iteration;
|
|
114
|
+
},
|
|
115
|
+
async iterationClosed(planned, status) {
|
|
116
|
+
const iteration = open.get(planned.id);
|
|
117
|
+
if (!iteration) return;
|
|
118
|
+
open.delete(planned.id);
|
|
119
|
+
try {
|
|
120
|
+
appendEvent(kitDir, { type: 'iteration-closed', slice: planned.id, iteration, status, kitType, boardId: cfg?.boardId, organizationId: cfg?.organizationId }, meta);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(`info: could not record the end of an iteration (${err.message}) — the build goes on`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// The iteration that settled the slice as Done ends with `slice-done`; if it is the slice's newest entry when
|
|
126
|
+
// the next iteration starts, the platform knows the slice was reopened and starts its log over.
|
|
127
|
+
try {
|
|
128
|
+
const events = readEvents(kitDir);
|
|
129
|
+
const from = events.findLastIndex((e) => e.type === 'iteration-started' && e.iteration === iteration);
|
|
130
|
+
if (events.slice(from + 1).some((e) => e.type === 'settled' && e.slice === planned.id && statusKey(e.status) === 'done')) {
|
|
131
|
+
appendEvent(kitDir, { type: 'slice-done', slice: planned.id, iteration, kitType, boardId: cfg?.boardId, organizationId: cfg?.organizationId }, meta);
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
log(`info: could not record that the slice is done (${err.message}) — the build goes on`);
|
|
135
|
+
}
|
|
136
|
+
await flush();
|
|
137
|
+
},
|
|
138
|
+
flush,
|
|
139
|
+
start() {
|
|
140
|
+
if (!canUpload || timer) return;
|
|
141
|
+
timer = setInterval(flush, intervalMs);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
},
|
|
144
|
+
stop() {
|
|
145
|
+
if (timer) clearInterval(timer);
|
|
146
|
+
timer = null;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|