ultra-builder-pro-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/changes/active/.gitkeep +1 -0
- package/.ultra-template/changes/archive/.gitkeep +1 -0
- package/CHANGELOG.md +62 -11
- package/README.md +26 -18
- package/adapters/_shared/handbook.cjs +7 -7
- package/adapters/_shared/runtime-assets.cjs +2 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
- package/adapters/opencode.js +81 -20
- package/adapters/tests/codex.test.cjs +5 -3
- package/adapters/tests/opencode.test.cjs +16 -9
- package/bin/install.js +0 -19
- package/bin/orchestrator.js +1 -26
- package/commands/ultra-change.md +47 -0
- package/commands/ultra-deliver.md +14 -7
- package/commands/ultra-dev.md +6 -3
- package/commands/ultra-doctor.md +36 -0
- package/commands/ultra-init.md +4 -3
- package/commands/ultra-status.md +7 -5
- package/commands/ultra-test.md +2 -1
- package/docs/AGENT-CONTEXT.md +31 -23
- package/docs/ARCHITECTURE.md +46 -17
- package/docs/PLAN.zh-CN.md +35 -29
- package/docs/ROADMAP.md +14 -14
- package/docs/RUNTIME-COMPAT-MATRIX.md +11 -11
- package/hooks/README.md +8 -7
- package/hooks/active_task_context.py +22 -15
- package/hooks/health_check.py +136 -38
- package/hooks/workflow_context.py +120 -30
- package/mcp-server/lib/change-workflow.cjs +556 -0
- package/mcp-server/lib/change-workflow.test.cjs +259 -0
- package/mcp-server/lib/doctor.cjs +112 -0
- package/mcp-server/lib/doctor.test.cjs +86 -0
- package/mcp-server/lib/init-project.cjs +32 -6
- package/mcp-server/lib/runtime-state.cjs +222 -0
- package/mcp-server/lib/runtime-state.test.cjs +92 -0
- package/mcp-server/lib/state-db.cjs +31 -8
- package/mcp-server/lib/state-ops.cjs +17 -3
- package/mcp-server/server.cjs +102 -10
- package/mcp-server/tests/db-ops.test.cjs +5 -1
- package/mcp-server/tests/init-project.test.cjs +45 -0
- package/mcp-server/tests/server-contract.test.cjs +123 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +19 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/tests/daemon.test.cjs +50 -0
- package/package.json +3 -3
- package/settings.json +1 -2
- package/skills/ultra-change/SKILL.md +140 -0
- package/skills/ultra-deliver/SKILL.md +81 -15
- package/skills/ultra-dev/SKILL.md +32 -12
- package/skills/ultra-doctor/SKILL.md +90 -0
- package/skills/ultra-init/SKILL.md +8 -5
- package/skills/ultra-review/SKILL.md +16 -0
- package/skills/ultra-status/SKILL.md +30 -7
- package/skills/ultra-test/SKILL.md +14 -2
- package/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +337 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +117 -0
- package/ultra-tools/cli.cjs +3 -0
- package/ultra-tools/commands/system.cjs +57 -0
- package/ultra-tools/system-command.test.cjs +53 -0
- package/adapters/_shared/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/docs/LEGACY-HERMES.md +0 -1099
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const ops = require('./state-ops.cjs');
|
|
6
|
+
const defaultProjector = require('./projector.cjs');
|
|
7
|
+
|
|
8
|
+
class RuntimeStateError extends Error {
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'RuntimeStateError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nowIso() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseJson(value) {
|
|
21
|
+
if (value == null) return null;
|
|
22
|
+
try { return JSON.parse(value); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new RuntimeStateError('STATE_CORRUPT', `invalid runtime JSON: ${error.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowToIncident(row) {
|
|
29
|
+
if (!row) return null;
|
|
30
|
+
return {
|
|
31
|
+
...row,
|
|
32
|
+
retryable: Boolean(row.retryable),
|
|
33
|
+
evidence: parseJson(row.evidence_json),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function recordIncident(db, input) {
|
|
38
|
+
if (!input || !input.code || !input.message) {
|
|
39
|
+
throw new RuntimeStateError('VALIDATION_ERROR', 'incident code and message required');
|
|
40
|
+
}
|
|
41
|
+
return ops.tx(db, () => {
|
|
42
|
+
const existing = db.prepare(
|
|
43
|
+
`SELECT * FROM incidents
|
|
44
|
+
WHERE status = 'open' AND code = ?
|
|
45
|
+
AND change_id IS ? AND task_id IS ? AND session_id IS ?
|
|
46
|
+
AND source_kind IS ? AND source_id IS ?
|
|
47
|
+
ORDER BY first_seen_at ASC LIMIT 1`,
|
|
48
|
+
).get(
|
|
49
|
+
input.code, input.change_id ?? null, input.task_id ?? null, input.session_id ?? null,
|
|
50
|
+
input.source_kind ?? null, input.source_id ?? null,
|
|
51
|
+
);
|
|
52
|
+
const ts = nowIso();
|
|
53
|
+
if (existing) {
|
|
54
|
+
db.prepare(
|
|
55
|
+
`UPDATE incidents SET message = ?, severity = ?, retryable = ?, evidence_json = ?,
|
|
56
|
+
occurrence_count = occurrence_count + 1, last_seen_at = ? WHERE id = ?`,
|
|
57
|
+
).run(
|
|
58
|
+
input.message, input.severity || 'error', input.retryable ? 1 : 0,
|
|
59
|
+
input.evidence === undefined ? existing.evidence_json : JSON.stringify(input.evidence),
|
|
60
|
+
ts, existing.id,
|
|
61
|
+
);
|
|
62
|
+
return rowToIncident(db.prepare('SELECT * FROM incidents WHERE id = ?').get(existing.id));
|
|
63
|
+
}
|
|
64
|
+
const id = `inc-${randomUUID().slice(0, 12)}`;
|
|
65
|
+
db.prepare(
|
|
66
|
+
`INSERT INTO incidents
|
|
67
|
+
(id, code, severity, retryable, message, change_id, task_id, session_id,
|
|
68
|
+
source_kind, source_id, evidence_json, first_seen_at, last_seen_at)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
70
|
+
).run(
|
|
71
|
+
id, input.code, input.severity || 'error', input.retryable ? 1 : 0, input.message,
|
|
72
|
+
input.change_id ?? null, input.task_id ?? null, input.session_id ?? null,
|
|
73
|
+
input.source_kind ?? null, input.source_id ?? null,
|
|
74
|
+
input.evidence === undefined ? null : JSON.stringify(input.evidence), ts, ts,
|
|
75
|
+
);
|
|
76
|
+
return rowToIncident(db.prepare('SELECT * FROM incidents WHERE id = ?').get(id));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveIncident(db, id, resolution) {
|
|
81
|
+
const result = db.prepare(
|
|
82
|
+
`UPDATE incidents SET status = 'resolved', resolved_at = ?, resolution = ?
|
|
83
|
+
WHERE id = ? AND status = 'open'`,
|
|
84
|
+
).run(nowIso(), resolution || 'resolved', id);
|
|
85
|
+
return { resolved: result.changes > 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LIST_INCIDENTS_SQL = "SELECT * FROM incidents WHERE (@status IS NULL OR status = @status) AND (@change_id IS NULL OR change_id = @change_id) AND (@task_id IS NULL OR task_id = @task_id) ORDER BY last_seen_at DESC LIMIT @maxn";
|
|
89
|
+
|
|
90
|
+
function listIncidents(db, { status = null, change_id = null, task_id = null, limit = 100 } = {}) {
|
|
91
|
+
return db.prepare(LIST_INCIDENTS_SQL).all({
|
|
92
|
+
status, change_id, task_id, maxn: Math.min(Math.max(limit, 1), 500),
|
|
93
|
+
}).map(rowToIncident);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function enqueueProjection(db, { tool_name, event_cursor, max_attempts = 3 } = {}) {
|
|
97
|
+
if (!tool_name) throw new RuntimeStateError('VALIDATION_ERROR', 'projection tool_name required');
|
|
98
|
+
const cursor = Number.isInteger(event_cursor)
|
|
99
|
+
? event_cursor
|
|
100
|
+
: Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM events').get().id);
|
|
101
|
+
const result = db.prepare(
|
|
102
|
+
`INSERT INTO projection_jobs (tool_name, event_cursor, max_attempts)
|
|
103
|
+
VALUES (?, ?, ?)`,
|
|
104
|
+
).run(tool_name, cursor, max_attempts);
|
|
105
|
+
return readProjectionJob(db, Number(result.lastInsertRowid));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readProjectionJob(db, id) {
|
|
109
|
+
return db.prepare('SELECT * FROM projection_jobs WHERE id = ?').get(id) || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const LIST_PROJECTION_JOBS_SQL = "SELECT * FROM projection_jobs WHERE (@status IS NULL OR status = @status) ORDER BY id ASC LIMIT @maxn";
|
|
113
|
+
|
|
114
|
+
function listProjectionJobs(db, { status = null, limit = 100 } = {}) {
|
|
115
|
+
return db.prepare(LIST_PROJECTION_JOBS_SQL).all({
|
|
116
|
+
status, maxn: Math.min(Math.max(limit, 1), 500),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function processProjectionJobs(db, {
|
|
121
|
+
rootDir = '.', project = defaultProjector.projectAll, limit = 10,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const pending = listProjectionJobs(db, { status: 'pending', limit });
|
|
124
|
+
const jobs = [];
|
|
125
|
+
for (const row of pending) {
|
|
126
|
+
const claimed = db.prepare(
|
|
127
|
+
"UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'",
|
|
128
|
+
).run(nowIso(), row.id);
|
|
129
|
+
if (claimed.changes === 0) continue;
|
|
130
|
+
const active = readProjectionJob(db, row.id);
|
|
131
|
+
try {
|
|
132
|
+
const projection = project(db, { rootDir });
|
|
133
|
+
const ts = nowIso();
|
|
134
|
+
db.prepare(
|
|
135
|
+
`UPDATE projection_jobs SET status = 'completed', attempts = attempts + 1,
|
|
136
|
+
last_error = NULL, updated_at = ?, completed_at = ? WHERE id = ?`,
|
|
137
|
+
).run(ts, ts, row.id);
|
|
138
|
+
const related = db.prepare(
|
|
139
|
+
"SELECT id FROM incidents WHERE status = 'open' AND source_kind = 'projection_job' AND source_id = ?",
|
|
140
|
+
).all(String(row.id));
|
|
141
|
+
for (const incident of related) resolveIncident(db, incident.id, 'projection completed on retry');
|
|
142
|
+
jobs.push({ id: row.id, status: 'completed', projection });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const attempts = active.attempts + 1;
|
|
145
|
+
const status = attempts >= active.max_attempts ? 'failed' : 'pending';
|
|
146
|
+
db.prepare(
|
|
147
|
+
`UPDATE projection_jobs SET status = ?, attempts = ?, last_error = ?, updated_at = ?
|
|
148
|
+
WHERE id = ?`,
|
|
149
|
+
).run(status, attempts, error.message, nowIso(), row.id);
|
|
150
|
+
const incident = recordIncident(db, {
|
|
151
|
+
code: 'PROJECTION_FAILED', severity: 'error', retryable: true,
|
|
152
|
+
message: error.message, source_kind: 'projection_job', source_id: String(row.id),
|
|
153
|
+
evidence: { tool_name: row.tool_name, event_cursor: row.event_cursor, attempts },
|
|
154
|
+
});
|
|
155
|
+
jobs.push({ id: row.id, status: status === 'pending' ? 'retrying' : 'failed', error: error.message, incident_id: incident.id });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { processed: jobs.length, jobs };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function requeueInterruptedProjections(db, { staleBefore } = {}) {
|
|
162
|
+
const cutoff = staleBefore || new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
163
|
+
if (typeof cutoff !== 'string' || Number.isNaN(Date.parse(cutoff))) {
|
|
164
|
+
throw new RuntimeStateError('VALIDATION_ERROR', 'staleBefore must be an ISO date-time');
|
|
165
|
+
}
|
|
166
|
+
const result = db.prepare(
|
|
167
|
+
`UPDATE projection_jobs
|
|
168
|
+
SET status = 'pending', last_error = 'interrupted projection requeued by doctor',
|
|
169
|
+
updated_at = ?, completed_at = NULL
|
|
170
|
+
WHERE status = 'running' AND updated_at < ?`,
|
|
171
|
+
).run(nowIso(), cutoff);
|
|
172
|
+
return { requeued: result.changes, stale_before: cutoff };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requeueFailedProjections(db) {
|
|
176
|
+
const result = db.prepare(
|
|
177
|
+
"UPDATE projection_jobs SET status = 'pending', attempts = 0, updated_at = ? WHERE status = 'failed'",
|
|
178
|
+
).run(nowIso());
|
|
179
|
+
return { requeued: result.changes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ensureProjectionJob(db, { tool_name = 'runtime.reconcile' } = {}) {
|
|
183
|
+
const eventCursor = Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM events').get().id);
|
|
184
|
+
const projectedCursor = Number(db.prepare(
|
|
185
|
+
"SELECT COALESCE(MAX(event_cursor), 0) AS id FROM projection_jobs WHERE status IN ('pending', 'running', 'completed')",
|
|
186
|
+
).get().id);
|
|
187
|
+
if (eventCursor <= projectedCursor) return { enqueued: false, event_cursor: eventCursor };
|
|
188
|
+
return { enqueued: true, job: enqueueProjection(db, { tool_name, event_cursor: eventCursor }) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readConsumerCursor(db, name) {
|
|
192
|
+
const row = db.prepare('SELECT cursor FROM event_consumers WHERE name = ?').get(name);
|
|
193
|
+
return row ? Number(row.cursor) : 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function writeConsumerCursor(db, name, cursor) {
|
|
197
|
+
if (!name || !Number.isInteger(cursor) || cursor < 0) {
|
|
198
|
+
throw new RuntimeStateError('VALIDATION_ERROR', 'consumer name and non-negative cursor required');
|
|
199
|
+
}
|
|
200
|
+
db.prepare(
|
|
201
|
+
`INSERT INTO event_consumers (name, cursor, updated_at) VALUES (?, ?, ?)
|
|
202
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
203
|
+
cursor = MAX(event_consumers.cursor, excluded.cursor), updated_at = excluded.updated_at`,
|
|
204
|
+
).run(name, cursor, nowIso());
|
|
205
|
+
return { name, cursor: readConsumerCursor(db, name) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
RuntimeStateError,
|
|
210
|
+
recordIncident,
|
|
211
|
+
resolveIncident,
|
|
212
|
+
listIncidents,
|
|
213
|
+
enqueueProjection,
|
|
214
|
+
readProjectionJob,
|
|
215
|
+
listProjectionJobs,
|
|
216
|
+
processProjectionJobs,
|
|
217
|
+
requeueInterruptedProjections,
|
|
218
|
+
requeueFailedProjections,
|
|
219
|
+
ensureProjectionJob,
|
|
220
|
+
readConsumerCursor,
|
|
221
|
+
writeConsumerCursor,
|
|
222
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
10
|
+
const runtime = require('./runtime-state.cjs');
|
|
11
|
+
|
|
12
|
+
function fixture() {
|
|
13
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-runtime-state-'));
|
|
14
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
15
|
+
return { rootDir, db };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanup({ rootDir, db }) {
|
|
19
|
+
closeStateDb(db);
|
|
20
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('projection jobs complete and retain their event cursor', () => {
|
|
24
|
+
const fx = fixture();
|
|
25
|
+
try {
|
|
26
|
+
const job = runtime.enqueueProjection(fx.db, { tool_name: 'task.create', event_cursor: 7 });
|
|
27
|
+
const result = runtime.processProjectionJobs(fx.db, {
|
|
28
|
+
rootDir: fx.rootDir,
|
|
29
|
+
project: () => ({ tasks_json: { count: 0 }, contexts: [] }),
|
|
30
|
+
});
|
|
31
|
+
assert.equal(result.processed, 1);
|
|
32
|
+
assert.equal(result.jobs[0].status, 'completed');
|
|
33
|
+
const stored = runtime.readProjectionJob(fx.db, job.id);
|
|
34
|
+
assert.equal(stored.status, 'completed');
|
|
35
|
+
assert.equal(stored.event_cursor, 7);
|
|
36
|
+
} finally {
|
|
37
|
+
cleanup(fx);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('projection failures are explicit, retryable incidents rather than swallowed warnings', () => {
|
|
42
|
+
const fx = fixture();
|
|
43
|
+
try {
|
|
44
|
+
runtime.enqueueProjection(fx.db, { tool_name: 'task.update', event_cursor: 4, max_attempts: 1 });
|
|
45
|
+
// Test Double rationale: deterministic projector failure is required; filesystem permission failures vary by host.
|
|
46
|
+
const result = runtime.processProjectionJobs(fx.db, {
|
|
47
|
+
rootDir: fx.rootDir,
|
|
48
|
+
project: () => { throw new Error('projection disk unavailable'); },
|
|
49
|
+
});
|
|
50
|
+
assert.equal(result.jobs[0].status, 'failed');
|
|
51
|
+
assert.match(result.jobs[0].error, /projection disk unavailable/);
|
|
52
|
+
const incidents = runtime.listIncidents(fx.db, { status: 'open' });
|
|
53
|
+
assert.equal(incidents.length, 1);
|
|
54
|
+
assert.equal(incidents[0].code, 'PROJECTION_FAILED');
|
|
55
|
+
assert.equal(incidents[0].retryable, true);
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup(fx);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('event consumer cursors are durable and monotonic', () => {
|
|
62
|
+
const fx = fixture();
|
|
63
|
+
try {
|
|
64
|
+
assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 0);
|
|
65
|
+
runtime.writeConsumerCursor(fx.db, 'spec-staleness', 12);
|
|
66
|
+
runtime.writeConsumerCursor(fx.db, 'spec-staleness', 9);
|
|
67
|
+
assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 12);
|
|
68
|
+
} finally {
|
|
69
|
+
cleanup(fx);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('interrupted running projections are requeued only after the stale cutoff', () => {
|
|
74
|
+
const fx = fixture();
|
|
75
|
+
try {
|
|
76
|
+
const stale = runtime.enqueueProjection(fx.db, { tool_name: 'task.update', event_cursor: 3 });
|
|
77
|
+
const fresh = runtime.enqueueProjection(fx.db, { tool_name: 'task.create', event_cursor: 4 });
|
|
78
|
+
fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
|
|
79
|
+
.run('2000-01-01T00:00:00.000Z', stale.id);
|
|
80
|
+
fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
|
|
81
|
+
.run('2999-01-01T00:00:00.000Z', fresh.id);
|
|
82
|
+
|
|
83
|
+
const result = runtime.requeueInterruptedProjections(fx.db, {
|
|
84
|
+
staleBefore: '2026-01-01T00:00:00.000Z',
|
|
85
|
+
});
|
|
86
|
+
assert.equal(result.requeued, 1);
|
|
87
|
+
assert.equal(runtime.readProjectionJob(fx.db, stale.id).status, 'pending');
|
|
88
|
+
assert.equal(runtime.readProjectionJob(fx.db, fresh.id).status, 'running');
|
|
89
|
+
} finally {
|
|
90
|
+
cleanup(fx);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -9,7 +9,7 @@ const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
|
9
9
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
10
10
|
: path.resolve(__dirname, '..', '..');
|
|
11
11
|
const SCHEMA_FILE = path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql');
|
|
12
|
-
const EXPECTED_VERSION = '
|
|
12
|
+
const EXPECTED_VERSION = '9.0';
|
|
13
13
|
|
|
14
14
|
const REQUIRED_TABLES = Object.freeze([
|
|
15
15
|
'tasks',
|
|
@@ -20,6 +20,13 @@ const REQUIRED_TABLES = Object.freeze([
|
|
|
20
20
|
'telemetry',
|
|
21
21
|
'specs_refs',
|
|
22
22
|
'circuit_breaker',
|
|
23
|
+
'changes',
|
|
24
|
+
'artifacts',
|
|
25
|
+
'context_snapshots',
|
|
26
|
+
'trace_links',
|
|
27
|
+
'incidents',
|
|
28
|
+
'projection_jobs',
|
|
29
|
+
'event_consumers',
|
|
23
30
|
]);
|
|
24
31
|
|
|
25
32
|
function readSchemaSql() {
|
|
@@ -64,18 +71,33 @@ function columnNames(db, table) {
|
|
|
64
71
|
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
function
|
|
74
|
+
function applyCompatibleColumns(db) {
|
|
75
|
+
const tables = new Set(tableNames(db));
|
|
76
|
+
if (!tables.has('tasks')) return;
|
|
68
77
|
const taskColumns = columnNames(db, 'tasks');
|
|
69
78
|
if (!taskColumns.has('estimated_days')) {
|
|
70
|
-
db.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
db.exec('ALTER TABLE tasks ADD COLUMN estimated_days REAL CHECK (estimated_days IS NULL OR estimated_days > 0)');
|
|
80
|
+
}
|
|
81
|
+
if (!taskColumns.has('change_id')) {
|
|
82
|
+
db.exec('ALTER TABLE tasks ADD COLUMN change_id TEXT REFERENCES changes(id) ON DELETE SET NULL');
|
|
83
|
+
}
|
|
84
|
+
if (tables.has('events')) {
|
|
85
|
+
const eventColumns = columnNames(db, 'events');
|
|
86
|
+
if (!eventColumns.has('change_id')) db.exec('ALTER TABLE events ADD COLUMN change_id TEXT');
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
function applyCompatibleUpgrades(db) {
|
|
91
|
+
db.transaction(() => {
|
|
92
|
+
applyCompatibleColumns(db);
|
|
93
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
|
|
94
|
+
db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
|
|
95
|
+
db.prepare(
|
|
96
|
+
'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
|
|
97
|
+
).run(EXPECTED_VERSION, 'Continuous change units and recoverable runtime state');
|
|
98
|
+
})();
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
function ensureSchemaVersion(db) {
|
|
80
102
|
// schema_version is an audit trail — multiple rows across phase upgrades.
|
|
81
103
|
// Guard by checking whether the expected version row exists rather than
|
|
@@ -94,6 +116,7 @@ function ensureSchemaVersion(db) {
|
|
|
94
116
|
function initStateDb(dbPath) {
|
|
95
117
|
const db = openStateDb(dbPath);
|
|
96
118
|
const existing = new Set(tableNames(db));
|
|
119
|
+
applyCompatibleColumns(db);
|
|
97
120
|
const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
|
|
98
121
|
if (missing.length > 0) {
|
|
99
122
|
applySchema(db);
|
|
@@ -27,6 +27,7 @@ const TASK_FIELDS = Object.freeze([
|
|
|
27
27
|
'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
|
|
28
28
|
'deps', 'files_modified', 'session_id', 'stale', 'complexity_hint',
|
|
29
29
|
'tag', 'trace_to', 'context_file', 'completion_commit', 'parent_id',
|
|
30
|
+
'change_id',
|
|
30
31
|
'created_at', 'updated_at',
|
|
31
32
|
]);
|
|
32
33
|
|
|
@@ -34,6 +35,7 @@ const PATCHABLE_FIELDS = Object.freeze([
|
|
|
34
35
|
'priority', 'complexity', 'estimated_days', 'deps', 'files_modified',
|
|
35
36
|
'session_id', 'stale', 'complexity_hint', 'tag', 'trace_to',
|
|
36
37
|
'context_file', 'completion_commit',
|
|
38
|
+
'change_id',
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
const SESSION_PATCHABLE = Object.freeze([
|
|
@@ -153,10 +155,14 @@ function createTask(db, input) {
|
|
|
153
155
|
context_file: input.context_file ?? null,
|
|
154
156
|
completion_commit: null,
|
|
155
157
|
parent_id: input.parent_id ?? null,
|
|
158
|
+
change_id: input.change_id ?? null,
|
|
156
159
|
created_at: ts,
|
|
157
160
|
updated_at: ts,
|
|
158
161
|
};
|
|
159
162
|
return tx(db, () => {
|
|
163
|
+
if (row.change_id && !db.prepare('SELECT id FROM changes WHERE id = ?').get(row.change_id)) {
|
|
164
|
+
throw new StateOpsError('CHANGE_NOT_FOUND', `change ${row.change_id} does not exist`);
|
|
165
|
+
}
|
|
160
166
|
try {
|
|
161
167
|
db.prepare(
|
|
162
168
|
`INSERT INTO tasks (${TASK_FIELDS.join(', ')})
|
|
@@ -171,6 +177,7 @@ function createTask(db, input) {
|
|
|
171
177
|
appendEventInTx(db, {
|
|
172
178
|
type: 'task_created',
|
|
173
179
|
task_id: row.id,
|
|
180
|
+
change_id: row.change_id,
|
|
174
181
|
payload: { priority: row.priority, type: row.type },
|
|
175
182
|
});
|
|
176
183
|
return readTask(db, row.id);
|
|
@@ -193,6 +200,10 @@ function patchTask(db, id, patch = {}) {
|
|
|
193
200
|
throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
|
|
194
201
|
}
|
|
195
202
|
let value = patch[key];
|
|
203
|
+
if (key === 'change_id' && value !== null
|
|
204
|
+
&& !db.prepare('SELECT id FROM changes WHERE id = ?').get(value)) {
|
|
205
|
+
throw new StateOpsError('CHANGE_NOT_FOUND', `change ${value} does not exist`);
|
|
206
|
+
}
|
|
196
207
|
if (key === 'deps' || key === 'files_modified') {
|
|
197
208
|
if (value !== null && !Array.isArray(value)) {
|
|
198
209
|
throw new StateOpsError('VALIDATION_ERROR', `${key} must be an array`);
|
|
@@ -224,6 +235,7 @@ function patchTask(db, id, patch = {}) {
|
|
|
224
235
|
appendEventInTx(db, {
|
|
225
236
|
type: statusEventType(nextStatus),
|
|
226
237
|
task_id: id,
|
|
238
|
+
change_id: patch.change_id !== undefined ? patch.change_id : current.change_id,
|
|
227
239
|
payload: { from: current.status, to: nextStatus },
|
|
228
240
|
});
|
|
229
241
|
}
|
|
@@ -268,11 +280,12 @@ function appendEventInTx(db, event) {
|
|
|
268
280
|
throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${event.runtime}`);
|
|
269
281
|
}
|
|
270
282
|
const result = db.prepare(
|
|
271
|
-
`INSERT INTO events (type, task_id, session_id, runtime, payload_json)
|
|
272
|
-
VALUES (?, ?, ?, ?, ?)`,
|
|
283
|
+
`INSERT INTO events (type, task_id, change_id, session_id, runtime, payload_json)
|
|
284
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
273
285
|
).run(
|
|
274
286
|
event.type,
|
|
275
287
|
event.task_id ?? null,
|
|
288
|
+
event.change_id ?? null,
|
|
276
289
|
event.session_id ?? null,
|
|
277
290
|
event.runtime ?? null,
|
|
278
291
|
event.payload === undefined ? null : JSON.stringify(event.payload),
|
|
@@ -287,7 +300,7 @@ function appendEvent(db, event) {
|
|
|
287
300
|
|
|
288
301
|
// Single static SQL using json_each for the optional types IN-list and
|
|
289
302
|
// NULL-pass-through for task_id/session_id. Frozen literal — no concat, no interpolation.
|
|
290
|
-
const SUBSCRIBE_EVENTS_SQL = "SELECT id, ts, type, task_id, session_id, runtime, payload_json FROM events WHERE id > @since_id AND (@types_json IS NULL OR EXISTS (SELECT 1 FROM json_each(@types_json) WHERE value = events.type)) AND (@task_id IS NULL OR task_id = @task_id) AND (@session_id IS NULL OR session_id = @session_id) ORDER BY id ASC LIMIT @maxn";
|
|
303
|
+
const SUBSCRIBE_EVENTS_SQL = "SELECT id, ts, type, task_id, change_id, session_id, runtime, payload_json FROM events WHERE id > @since_id AND (@types_json IS NULL OR EXISTS (SELECT 1 FROM json_each(@types_json) WHERE value = events.type)) AND (@task_id IS NULL OR task_id = @task_id) AND (@session_id IS NULL OR session_id = @session_id) ORDER BY id ASC LIMIT @maxn";
|
|
291
304
|
|
|
292
305
|
function subscribeEventsSince(db, { since_id = 0, types, task_id, session_id, limit = 100 } = {}) {
|
|
293
306
|
const events = db.prepare(SUBSCRIBE_EVENTS_SQL).all({
|
|
@@ -704,6 +717,7 @@ module.exports = {
|
|
|
704
717
|
deleteTask,
|
|
705
718
|
// events
|
|
706
719
|
appendEvent,
|
|
720
|
+
appendEventInTx,
|
|
707
721
|
subscribeEventsSince,
|
|
708
722
|
// sessions
|
|
709
723
|
createSession,
|
package/mcp-server/server.cjs
CHANGED
|
@@ -28,6 +28,9 @@ const parser = require('./lib/prd-parser.cjs');
|
|
|
28
28
|
const expander = require('./lib/task-expander.cjs');
|
|
29
29
|
const planStore = require('./lib/plan-store.cjs');
|
|
30
30
|
const { initProject } = require('./lib/init-project.cjs');
|
|
31
|
+
const changes = require('./lib/change-workflow.cjs');
|
|
32
|
+
const runtimeState = require('./lib/runtime-state.cjs');
|
|
33
|
+
const doctor = require('./lib/doctor.cjs');
|
|
31
34
|
|
|
32
35
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
33
36
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
@@ -64,8 +67,22 @@ const PLAN_TOOLS = Object.freeze([
|
|
|
64
67
|
'plan.get',
|
|
65
68
|
]);
|
|
66
69
|
|
|
70
|
+
const CHANGE_TOOLS = Object.freeze([
|
|
71
|
+
'change.create',
|
|
72
|
+
'change.update',
|
|
73
|
+
'change.get',
|
|
74
|
+
'change.list',
|
|
75
|
+
'change.context',
|
|
76
|
+
'change.converge',
|
|
77
|
+
'change.archive',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const SYSTEM_TOOLS = Object.freeze([
|
|
81
|
+
'system.doctor',
|
|
82
|
+
]);
|
|
83
|
+
|
|
67
84
|
const REGISTERED_TOOLS = Object.freeze([
|
|
68
|
-
...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS,
|
|
85
|
+
...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...CHANGE_TOOLS, ...SYSTEM_TOOLS,
|
|
69
86
|
]);
|
|
70
87
|
|
|
71
88
|
const STATELESS_TOOLS = new Set(['task.init_project']);
|
|
@@ -76,6 +93,8 @@ const MUTATING_TOOLS = new Set([
|
|
|
76
93
|
'task.create', 'task.update', 'task.delete', 'task.append_event', 'task.switch_tag',
|
|
77
94
|
'task.parse_prd', 'task.expand',
|
|
78
95
|
'session.spawn', 'session.close', 'session.heartbeat',
|
|
96
|
+
'plan.export',
|
|
97
|
+
'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
|
|
79
98
|
]);
|
|
80
99
|
|
|
81
100
|
function loadRegisteredTools() {
|
|
@@ -145,6 +164,7 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
145
164
|
const r = ops.appendEvent(db, {
|
|
146
165
|
type: input.type,
|
|
147
166
|
task_id: input.task_id,
|
|
167
|
+
change_id: input.change_id,
|
|
148
168
|
session_id: input.session_id,
|
|
149
169
|
runtime: input.runtime,
|
|
150
170
|
payload: input.payload,
|
|
@@ -328,6 +348,53 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
328
348
|
}
|
|
329
349
|
return { plan: planStore.selectSection(loaded, input.section) };
|
|
330
350
|
}
|
|
351
|
+
case 'change.create': {
|
|
352
|
+
const { randomUUID } = require('node:crypto');
|
|
353
|
+
const id = input.id || `chg-${randomUUID().slice(0, 12)}`;
|
|
354
|
+
return changes.createChange(db, { ...input, id }, { rootDir: ctx.rootDir || process.cwd() });
|
|
355
|
+
}
|
|
356
|
+
case 'change.update': {
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
change: changes.updateChange(
|
|
360
|
+
db, input.id, input.patch || {}, { rootDir: ctx.rootDir || process.cwd() },
|
|
361
|
+
),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
case 'change.get': {
|
|
365
|
+
const change = changes.readChange(db, input.id);
|
|
366
|
+
if (!change) {
|
|
367
|
+
const err = new Error(`change ${input.id} not found`);
|
|
368
|
+
err.code = 'CHANGE_NOT_FOUND';
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
return { change };
|
|
372
|
+
}
|
|
373
|
+
case 'change.list': {
|
|
374
|
+
const rows = changes.listChanges(db, input || {});
|
|
375
|
+
return { changes: rows, count: rows.length };
|
|
376
|
+
}
|
|
377
|
+
case 'change.context': {
|
|
378
|
+
const out = changes.compileContext(db, input, { rootDir: ctx.rootDir || process.cwd() });
|
|
379
|
+
return {
|
|
380
|
+
manifest_path: out.context_manifest_path,
|
|
381
|
+
manifest_hash: out.manifest_hash,
|
|
382
|
+
manifest: out.manifest,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
case 'change.converge': {
|
|
386
|
+
return changes.convergeChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
|
|
387
|
+
}
|
|
388
|
+
case 'change.archive': {
|
|
389
|
+
return changes.archiveChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
|
|
390
|
+
}
|
|
391
|
+
case 'system.doctor': {
|
|
392
|
+
return doctor.runDoctor(db, {
|
|
393
|
+
rootDir: ctx.rootDir || process.cwd(),
|
|
394
|
+
repair: input.repair === true,
|
|
395
|
+
project: ctx.projector || projector.projectAll,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
331
398
|
default:
|
|
332
399
|
throw new Error(`unhandled tool ${name}`);
|
|
333
400
|
}
|
|
@@ -345,7 +412,7 @@ function errorResponse(code, message, retriable = false, details = undefined) {
|
|
|
345
412
|
};
|
|
346
413
|
}
|
|
347
414
|
|
|
348
|
-
function startServer({ dbPath, rootDir, projectOnWrite = true }) {
|
|
415
|
+
function startServer({ dbPath, rootDir, projectOnWrite = true, project = projector.projectAll }) {
|
|
349
416
|
let db = null;
|
|
350
417
|
const getDb = () => {
|
|
351
418
|
if (!db) db = initStateDb(dbPath).db;
|
|
@@ -415,7 +482,7 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
|
|
|
415
482
|
try {
|
|
416
483
|
toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
|
|
417
484
|
if (toolDb) assertStateAuthority(toolDb, rootDir);
|
|
418
|
-
result = await dispatchTool(name, args, toolDb, { rootDir });
|
|
485
|
+
result = await dispatchTool(name, args, toolDb, { rootDir, projector: project });
|
|
419
486
|
} catch (err) {
|
|
420
487
|
const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
|
|
421
488
|
toolError = code;
|
|
@@ -423,22 +490,45 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
|
|
|
423
490
|
return errorResponse(code, err.message, !!err.retriable, err.details);
|
|
424
491
|
}
|
|
425
492
|
|
|
493
|
+
let runtimeMeta = null;
|
|
494
|
+
if (toolDb && projectOnWrite && MUTATING_TOOLS.has(name)) {
|
|
495
|
+
const job = runtimeState.enqueueProjection(toolDb, { tool_name: name });
|
|
496
|
+
const processed = runtimeState.processProjectionJobs(toolDb, { rootDir, project, limit: 500 });
|
|
497
|
+
const own = processed.jobs.find((item) => item.id === job.id);
|
|
498
|
+
runtimeMeta = {
|
|
499
|
+
state_commit: 'committed',
|
|
500
|
+
projection_status: own ? own.status : 'failed',
|
|
501
|
+
projection_job_id: job.id,
|
|
502
|
+
};
|
|
503
|
+
if (own && own.incident_id) runtimeMeta.incident_id = own.incident_id;
|
|
504
|
+
} else if (toolDb && name === 'system.doctor' && args.repair === true) {
|
|
505
|
+
runtimeMeta = {
|
|
506
|
+
state_commit: 'committed',
|
|
507
|
+
projection_status: result.checks.projections.status === 'pass' ? 'completed' : 'failed',
|
|
508
|
+
backup_path: result.backup_path,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
426
512
|
const validateOutput = outputValidators.get(name);
|
|
427
513
|
if (!validateOutput(result)) {
|
|
428
514
|
toolError = 'OUTPUT_SCHEMA_DRIFT';
|
|
429
515
|
emitTelemetry();
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
516
|
+
const response = errorResponse(
|
|
517
|
+
'OUTPUT_SCHEMA_DRIFT',
|
|
518
|
+
ajv.errorsText(validateOutput.errors),
|
|
519
|
+
false,
|
|
520
|
+
runtimeMeta ? { _ultra: runtimeMeta } : undefined,
|
|
521
|
+
);
|
|
522
|
+
if (runtimeMeta) response._meta = { ultra: runtimeMeta };
|
|
523
|
+
return response;
|
|
436
524
|
}
|
|
437
525
|
|
|
438
526
|
emitTelemetry();
|
|
527
|
+
const visible = runtimeMeta ? { ...result, _ultra: runtimeMeta } : result;
|
|
439
528
|
return {
|
|
440
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
529
|
+
content: [{ type: 'text', text: JSON.stringify(visible) }],
|
|
441
530
|
structuredContent: result,
|
|
531
|
+
...(runtimeMeta ? { _meta: { ultra: runtimeMeta } } : {}),
|
|
442
532
|
};
|
|
443
533
|
});
|
|
444
534
|
|
|
@@ -478,6 +568,8 @@ module.exports = {
|
|
|
478
568
|
dispatchTool,
|
|
479
569
|
TASK_TOOLS,
|
|
480
570
|
SESSION_TOOLS,
|
|
571
|
+
CHANGE_TOOLS,
|
|
572
|
+
SYSTEM_TOOLS,
|
|
481
573
|
REGISTERED_TOOLS,
|
|
482
574
|
STATELESS_TOOLS,
|
|
483
575
|
MUTATING_TOOLS,
|
|
@@ -46,7 +46,11 @@ test('db vacuum reports reclaimed bytes after deleting rows', () => {
|
|
|
46
46
|
try {
|
|
47
47
|
const init = initStateDb(dbPath);
|
|
48
48
|
for (let i = 0; i < 50; i++) {
|
|
49
|
-
ops.appendEvent(init.db, {
|
|
49
|
+
ops.appendEvent(init.db, {
|
|
50
|
+
type: 'task_created',
|
|
51
|
+
task_id: `t-${i}`,
|
|
52
|
+
payload: { padding: 'x'.repeat(16 * 1024) },
|
|
53
|
+
});
|
|
50
54
|
}
|
|
51
55
|
init.db.prepare('DELETE FROM events').run();
|
|
52
56
|
closeStateDb(init.db);
|