ultra-builder-pro-cli 0.5.3 → 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 +90 -14
- package/README.md +38 -32
- package/adapters/_shared/codex-assets.cjs +32 -151
- package/adapters/_shared/frontmatter.cjs +2 -2
- package/adapters/_shared/handbook.cjs +10 -10
- package/adapters/_shared/path-rewrite.cjs +0 -3
- package/adapters/_shared/runtime-assets.cjs +13 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/path-rewrite.test.cjs +1 -3
- package/adapters/_shared/tests/resolve-target.test.cjs +1 -11
- package/adapters/_shared/tests/runtime-assets.test.cjs +7 -6
- package/adapters/opencode.js +82 -26
- package/adapters/tests/claude.test.cjs +1 -4
- package/adapters/tests/codex.test.cjs +9 -8
- package/adapters/tests/opencode.test.cjs +17 -11
- package/bin/install.js +6 -28
- package/bin/orchestrator.js +12 -30
- 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 +35 -28
- package/docs/ARCHITECTURE.md +49 -21
- package/docs/PLAN.zh-CN.md +67 -68
- package/docs/ROADMAP.md +17 -17
- package/docs/RUNTIME-COMPAT-MATRIX.md +63 -62
- 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/pricing.cjs +0 -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 +24 -3
- package/mcp-server/lib/telemetry.cjs +4 -0
- 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 +140 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +42 -0
- package/mcp-server/tests/telemetry.test.cjs +14 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/dispatch-rules.cjs +15 -4
- package/orchestrator/tests/daemon.test.cjs +63 -12
- package/orchestrator/tests/dispatch-rules.test.cjs +12 -10
- package/package.json +5 -7
- 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 -6
- 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/skills/ultra-verify/SKILL.md +22 -41
- package/skills/ultra-verify/evals/evals.json +22 -24
- package/skills/ultra-verify/references/confidence-system.md +15 -32
- package/skills/ultra-verify/references/cross-verify-modes.md +14 -59
- package/skills/ultra-verify/scripts/verify_wait.py +44 -81
- package/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +340 -4
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/skill-manifest.schema.json +2 -2
- package/spec/schemas/state-db.sql +119 -2
- 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/md-to-toml.cjs +0 -54
- package/adapters/_shared/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/md-to-toml.test.cjs +0 -48
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/adapters/gemini.js +0 -204
- package/adapters/tests/gemini.test.cjs +0 -112
- package/docs/LEGACY-HERMES.md +0 -1175
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
- package/skills/gemini-collab/SKILL.md +0 -51
- package/skills/gemini-collab/evals/evals.json +0 -42
|
@@ -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);
|
|
@@ -13,6 +13,7 @@ const path = require('node:path');
|
|
|
13
13
|
const { spawnSync } = require('node:child_process');
|
|
14
14
|
|
|
15
15
|
const { openStateDb } = require('./state-db.cjs');
|
|
16
|
+
const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
|
|
16
17
|
|
|
17
18
|
const STATUS_TRANSITIONS = Object.freeze({
|
|
18
19
|
pending: new Set(['in_progress', 'blocked', 'expanded']),
|
|
@@ -26,6 +27,7 @@ const TASK_FIELDS = Object.freeze([
|
|
|
26
27
|
'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
|
|
27
28
|
'deps', 'files_modified', 'session_id', 'stale', 'complexity_hint',
|
|
28
29
|
'tag', 'trace_to', 'context_file', 'completion_commit', 'parent_id',
|
|
30
|
+
'change_id',
|
|
29
31
|
'created_at', 'updated_at',
|
|
30
32
|
]);
|
|
31
33
|
|
|
@@ -33,6 +35,7 @@ const PATCHABLE_FIELDS = Object.freeze([
|
|
|
33
35
|
'priority', 'complexity', 'estimated_days', 'deps', 'files_modified',
|
|
34
36
|
'session_id', 'stale', 'complexity_hint', 'tag', 'trace_to',
|
|
35
37
|
'context_file', 'completion_commit',
|
|
38
|
+
'change_id',
|
|
36
39
|
]);
|
|
37
40
|
|
|
38
41
|
const SESSION_PATCHABLE = Object.freeze([
|
|
@@ -152,10 +155,14 @@ function createTask(db, input) {
|
|
|
152
155
|
context_file: input.context_file ?? null,
|
|
153
156
|
completion_commit: null,
|
|
154
157
|
parent_id: input.parent_id ?? null,
|
|
158
|
+
change_id: input.change_id ?? null,
|
|
155
159
|
created_at: ts,
|
|
156
160
|
updated_at: ts,
|
|
157
161
|
};
|
|
158
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
|
+
}
|
|
159
166
|
try {
|
|
160
167
|
db.prepare(
|
|
161
168
|
`INSERT INTO tasks (${TASK_FIELDS.join(', ')})
|
|
@@ -170,6 +177,7 @@ function createTask(db, input) {
|
|
|
170
177
|
appendEventInTx(db, {
|
|
171
178
|
type: 'task_created',
|
|
172
179
|
task_id: row.id,
|
|
180
|
+
change_id: row.change_id,
|
|
173
181
|
payload: { priority: row.priority, type: row.type },
|
|
174
182
|
});
|
|
175
183
|
return readTask(db, row.id);
|
|
@@ -192,6 +200,10 @@ function patchTask(db, id, patch = {}) {
|
|
|
192
200
|
throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
|
|
193
201
|
}
|
|
194
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
|
+
}
|
|
195
207
|
if (key === 'deps' || key === 'files_modified') {
|
|
196
208
|
if (value !== null && !Array.isArray(value)) {
|
|
197
209
|
throw new StateOpsError('VALIDATION_ERROR', `${key} must be an array`);
|
|
@@ -223,6 +235,7 @@ function patchTask(db, id, patch = {}) {
|
|
|
223
235
|
appendEventInTx(db, {
|
|
224
236
|
type: statusEventType(nextStatus),
|
|
225
237
|
task_id: id,
|
|
238
|
+
change_id: patch.change_id !== undefined ? patch.change_id : current.change_id,
|
|
226
239
|
payload: { from: current.status, to: nextStatus },
|
|
227
240
|
});
|
|
228
241
|
}
|
|
@@ -263,12 +276,16 @@ function appendEventInTx(db, event) {
|
|
|
263
276
|
if (!event || !event.type) {
|
|
264
277
|
throw new StateOpsError('VALIDATION_ERROR', 'event.type is required');
|
|
265
278
|
}
|
|
279
|
+
if (event.runtime != null && !isSupportedRuntime(event.runtime)) {
|
|
280
|
+
throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${event.runtime}`);
|
|
281
|
+
}
|
|
266
282
|
const result = db.prepare(
|
|
267
|
-
`INSERT INTO events (type, task_id, session_id, runtime, payload_json)
|
|
268
|
-
VALUES (?, ?, ?, ?, ?)`,
|
|
283
|
+
`INSERT INTO events (type, task_id, change_id, session_id, runtime, payload_json)
|
|
284
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
269
285
|
).run(
|
|
270
286
|
event.type,
|
|
271
287
|
event.task_id ?? null,
|
|
288
|
+
event.change_id ?? null,
|
|
272
289
|
event.session_id ?? null,
|
|
273
290
|
event.runtime ?? null,
|
|
274
291
|
event.payload === undefined ? null : JSON.stringify(event.payload),
|
|
@@ -283,7 +300,7 @@ function appendEvent(db, event) {
|
|
|
283
300
|
|
|
284
301
|
// Single static SQL using json_each for the optional types IN-list and
|
|
285
302
|
// NULL-pass-through for task_id/session_id. Frozen literal — no concat, no interpolation.
|
|
286
|
-
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";
|
|
287
304
|
|
|
288
305
|
function subscribeEventsSince(db, { since_id = 0, types, task_id, session_id, limit = 100 } = {}) {
|
|
289
306
|
const events = db.prepare(SUBSCRIBE_EVENTS_SQL).all({
|
|
@@ -311,6 +328,9 @@ function createSession(db, { sid, task_id, runtime, pid = null, worktree_path, a
|
|
|
311
328
|
if (!sid || !task_id || !runtime || !worktree_path || !artifact_dir) {
|
|
312
329
|
throw new StateOpsError('VALIDATION_ERROR', 'sid, task_id, runtime, worktree_path, artifact_dir required');
|
|
313
330
|
}
|
|
331
|
+
if (!isSupportedRuntime(runtime)) {
|
|
332
|
+
throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${runtime}`);
|
|
333
|
+
}
|
|
314
334
|
const lease = new Date(Date.now() + lease_seconds * 1000).toISOString();
|
|
315
335
|
return tx(db, () => {
|
|
316
336
|
if (!readTask(db, task_id)) {
|
|
@@ -697,6 +717,7 @@ module.exports = {
|
|
|
697
717
|
deleteTask,
|
|
698
718
|
// events
|
|
699
719
|
appendEvent,
|
|
720
|
+
appendEventInTx,
|
|
700
721
|
subscribeEventsSince,
|
|
701
722
|
// sessions
|
|
702
723
|
createSession,
|
|
@@ -12,6 +12,7 @@ const fs = require('node:fs');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
|
|
14
14
|
const { computeCost } = require('./pricing.cjs');
|
|
15
|
+
const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
|
|
15
16
|
|
|
16
17
|
function todayStamp() {
|
|
17
18
|
return new Date().toISOString().slice(0, 10);
|
|
@@ -33,6 +34,9 @@ function appendTelemetry(db, {
|
|
|
33
34
|
payload = null,
|
|
34
35
|
rootDir = '.',
|
|
35
36
|
} = {}) {
|
|
37
|
+
if (runtime != null && !isSupportedRuntime(runtime)) {
|
|
38
|
+
throw new Error(`unsupported runtime: ${runtime}`);
|
|
39
|
+
}
|
|
36
40
|
if (!event_type) throw new Error('appendTelemetry: event_type required');
|
|
37
41
|
|
|
38
42
|
// Auto-derive cost when tokens + runtime known and caller didn't supply one.
|