memorix 1.1.9 → 1.1.11
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/CHANGELOG.md +28 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +2452 -845
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +29 -3
- package/dist/dashboard/static/index.html +201 -201
- package/dist/dashboard/static/style.css +3762 -3584
- package/dist/index.js +2790 -1310
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.d.ts +40 -0
- package/dist/maintenance-runner.js +8193 -0
- package/dist/maintenance-runner.js.map +1 -0
- package/dist/memcode-runtime/CHANGELOG.md +28 -0
- package/dist/memcode-runtime/package.json +4 -4
- package/dist/sdk.js +2787 -1307
- package/dist/sdk.js.map +1 -1
- package/docs/CONFIGURATION.md +15 -4
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/GIT_MEMORY.md +2 -2
- package/docs/PERFORMANCE.md +23 -0
- package/docs/dev-log/progress.txt +54 -24
- package/package.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/cleanup.ts +279 -255
- package/src/cli/commands/codegraph.ts +7 -5
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +66 -66
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +73 -73
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +96 -96
- package/src/cli/commands/message.ts +121 -121
- package/src/cli/commands/poll.ts +70 -70
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +132 -132
- package/src/cli/commands/retention.ts +107 -108
- package/src/cli/commands/serve-http.ts +49 -22
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/skills.ts +123 -123
- package/src/cli/commands/task.ts +192 -192
- package/src/cli/commands/transfer.ts +73 -73
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Panels.tsx +632 -632
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/chat-service.ts +742 -742
- package/src/cli/tui/data.ts +547 -547
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/theme.ts +178 -178
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/auto-context.ts +50 -29
- package/src/codegraph/binder.ts +109 -16
- package/src/codegraph/exclude.ts +10 -0
- package/src/codegraph/lite-provider.ts +148 -18
- package/src/codegraph/project-context.ts +58 -12
- package/src/codegraph/store.ts +182 -0
- package/src/compact/token-budget.ts +74 -74
- package/src/config/behavior.ts +97 -59
- package/src/config/resolved-config.ts +4 -0
- package/src/config/toml-loader.ts +2 -0
- package/src/config/yaml-loader.ts +3 -1
- package/src/dashboard/project-classification.ts +64 -64
- package/src/dashboard/server.ts +31 -30
- package/src/dashboard/static/index.html +201 -201
- package/src/dashboard/static/style.css +3762 -3584
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/git/noise-filter.ts +226 -210
- package/src/hooks/handler.ts +10 -2
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/auto-relations.ts +107 -107
- package/src/memory/consolidation.ts +328 -302
- package/src/memory/disclosure-policy.ts +141 -141
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/export-import.ts +3 -1
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/observations.ts +91 -27
- package/src/memory/refs.ts +94 -94
- package/src/memory/retention.ts +511 -433
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +542 -523
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +380 -380
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/verify-gate.ts +219 -219
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/runtime/control-plane-maintenance.ts +62 -0
- package/src/runtime/isolated-maintenance.ts +187 -0
- package/src/runtime/maintenance-jobs.ts +512 -0
- package/src/runtime/maintenance-runner.ts +96 -0
- package/src/runtime/maintenance-targets.ts +59 -0
- package/src/runtime/project-maintenance.ts +274 -0
- package/src/sdk.ts +327 -327
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/server.ts +212 -224
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/file-lock.ts +100 -100
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +293 -255
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/session-store.ts +268 -259
- package/src/store/sqlite-db.ts +40 -0
- package/src/store/sqlite-store.ts +429 -339
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +167 -167
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/wiki/generator.ts +237 -237
- package/src/wiki/knowledge-graph.ts +334 -334
- package/src/wiki/types.ts +85 -85
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- package/src/workspace/workflow-sync.ts +131 -131
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { getDatabase } from '../store/sqlite-db.js';
|
|
4
|
+
import { sanitizeCredentials } from '../memory/secret-filter.js';
|
|
5
|
+
|
|
6
|
+
export const MAINTENANCE_JOB_KINDS = [
|
|
7
|
+
'vector-backfill',
|
|
8
|
+
'retention-archive',
|
|
9
|
+
'consolidation',
|
|
10
|
+
'codegraph-refresh',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type MaintenanceJobKind = typeof MAINTENANCE_JOB_KINDS[number];
|
|
14
|
+
export type MaintenanceJobStatus = 'pending' | 'running' | 'retry' | 'completed' | 'failed';
|
|
15
|
+
|
|
16
|
+
export interface MaintenanceJob {
|
|
17
|
+
id: string;
|
|
18
|
+
projectId: string;
|
|
19
|
+
kind: MaintenanceJobKind;
|
|
20
|
+
dedupeKey: string;
|
|
21
|
+
payload: Record<string, unknown>;
|
|
22
|
+
status: MaintenanceJobStatus;
|
|
23
|
+
attempts: number;
|
|
24
|
+
maxAttempts: number;
|
|
25
|
+
runAfter: number;
|
|
26
|
+
leaseOwner?: string;
|
|
27
|
+
leaseExpiresAt?: number;
|
|
28
|
+
lastError?: string;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
completedAt?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EnqueueMaintenanceJobInput {
|
|
35
|
+
projectId: string;
|
|
36
|
+
kind: MaintenanceJobKind;
|
|
37
|
+
dedupeKey?: string;
|
|
38
|
+
payload?: Record<string, unknown>;
|
|
39
|
+
maxAttempts?: number;
|
|
40
|
+
runAfter?: number;
|
|
41
|
+
now?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClaimMaintenanceJobOptions {
|
|
45
|
+
workerId: string;
|
|
46
|
+
projectId?: string;
|
|
47
|
+
/** Limit this worker to compatible job kinds without claiming other work. */
|
|
48
|
+
kinds?: readonly MaintenanceJobKind[];
|
|
49
|
+
leaseMs?: number;
|
|
50
|
+
now?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MaintenanceJobSummary {
|
|
54
|
+
total: number;
|
|
55
|
+
pending: number;
|
|
56
|
+
running: number;
|
|
57
|
+
retrying: number;
|
|
58
|
+
completed: number;
|
|
59
|
+
failed: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type MaintenanceJobRunResult =
|
|
63
|
+
| { action: 'complete' }
|
|
64
|
+
| { action: 'reschedule'; delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown> };
|
|
65
|
+
|
|
66
|
+
export type MaintenanceJobHandler = (
|
|
67
|
+
job: MaintenanceJob,
|
|
68
|
+
) => Promise<MaintenanceJobRunResult | void>;
|
|
69
|
+
|
|
70
|
+
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
71
|
+
const DEFAULT_LEASE_MS = 30_000;
|
|
72
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
73
|
+
const MAX_ERROR_LENGTH = 1_000;
|
|
74
|
+
const DEFAULT_COMPLETED_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
75
|
+
|
|
76
|
+
function parsePayload(raw: unknown): Record<string, unknown> {
|
|
77
|
+
if (typeof raw !== 'string' || !raw) return {};
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
81
|
+
? parsed as Record<string, unknown>
|
|
82
|
+
: {};
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rowToJob(row: any): MaintenanceJob {
|
|
89
|
+
return {
|
|
90
|
+
id: row.id,
|
|
91
|
+
projectId: row.project_id,
|
|
92
|
+
kind: row.kind as MaintenanceJobKind,
|
|
93
|
+
dedupeKey: row.dedupe_key,
|
|
94
|
+
payload: parsePayload(row.payload_json),
|
|
95
|
+
status: row.status as MaintenanceJobStatus,
|
|
96
|
+
attempts: Number(row.attempts),
|
|
97
|
+
maxAttempts: Number(row.max_attempts),
|
|
98
|
+
runAfter: Number(row.run_after),
|
|
99
|
+
...(row.lease_owner ? { leaseOwner: row.lease_owner } : {}),
|
|
100
|
+
...(row.lease_expires_at != null ? { leaseExpiresAt: Number(row.lease_expires_at) } : {}),
|
|
101
|
+
...(row.last_error ? { lastError: row.last_error } : {}),
|
|
102
|
+
createdAt: Number(row.created_at),
|
|
103
|
+
updatedAt: Number(row.updated_at),
|
|
104
|
+
...(row.completed_at != null ? { completedAt: Number(row.completed_at) } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clampPositiveInteger(value: number | undefined, fallback: number): number {
|
|
109
|
+
if (!Number.isFinite(value) || value == null) return fallback;
|
|
110
|
+
return Math.max(1, Math.floor(value));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorText(error: unknown): string {
|
|
114
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
115
|
+
return sanitizeCredentials(text).slice(0, MAX_ERROR_LENGTH);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizedKinds(kinds: readonly MaintenanceJobKind[] | undefined): MaintenanceJobKind[] | undefined {
|
|
119
|
+
if (!kinds) return undefined;
|
|
120
|
+
return [...new Set(kinds.filter((kind) => MAINTENANCE_JOB_KINDS.includes(kind)))];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Retry delay grows slowly enough for a local control plane but never spins. */
|
|
124
|
+
export function getMaintenanceRetryDelayMs(attempts: number): number {
|
|
125
|
+
return Math.min(60_000, 1_000 * (2 ** Math.max(0, attempts - 1)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persistent queue for work that must never run inside an MCP tool handler.
|
|
130
|
+
* SQLite leases make recovery deterministic when a process exits mid-job.
|
|
131
|
+
*/
|
|
132
|
+
export class MaintenanceJobStore {
|
|
133
|
+
private readonly db: any;
|
|
134
|
+
|
|
135
|
+
constructor(dataDir: string) {
|
|
136
|
+
this.db = getDatabase(dataDir);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
enqueue(input: EnqueueMaintenanceJobInput): MaintenanceJob {
|
|
140
|
+
const now = input.now ?? Date.now();
|
|
141
|
+
const dedupeKey = input.dedupeKey ?? input.kind;
|
|
142
|
+
const runAfter = input.runAfter ?? now;
|
|
143
|
+
const maxAttempts = clampPositiveInteger(input.maxAttempts, DEFAULT_MAX_ATTEMPTS);
|
|
144
|
+
const payloadJson = JSON.stringify(input.payload ?? {});
|
|
145
|
+
|
|
146
|
+
this.begin();
|
|
147
|
+
try {
|
|
148
|
+
this.pruneCompletedHistory({ now });
|
|
149
|
+
const existing = this.db.prepare(`
|
|
150
|
+
SELECT * FROM maintenance_jobs
|
|
151
|
+
WHERE project_id = ? AND kind = ? AND dedupe_key = ?
|
|
152
|
+
AND status IN ('pending', 'running', 'retry')
|
|
153
|
+
LIMIT 1
|
|
154
|
+
`).get(input.projectId, input.kind, dedupeKey);
|
|
155
|
+
|
|
156
|
+
if (existing) {
|
|
157
|
+
const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
|
|
158
|
+
this.db.prepare(`
|
|
159
|
+
UPDATE maintenance_jobs
|
|
160
|
+
SET payload_json = ?, run_after = ?, updated_at = ?
|
|
161
|
+
WHERE id = ?
|
|
162
|
+
`).run(payloadJson, nextRunAfter, now, existing.id);
|
|
163
|
+
const updated = this.getRow(existing.id)!;
|
|
164
|
+
this.commit();
|
|
165
|
+
return rowToJob(updated);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const id = randomUUID();
|
|
169
|
+
this.db.prepare(`
|
|
170
|
+
INSERT INTO maintenance_jobs (
|
|
171
|
+
id, project_id, kind, dedupe_key, payload_json, status, attempts,
|
|
172
|
+
max_attempts, run_after, created_at, updated_at
|
|
173
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)
|
|
174
|
+
`).run(id, input.projectId, input.kind, dedupeKey, payloadJson, maxAttempts, runAfter, now, now);
|
|
175
|
+
const created = this.getRow(id)!;
|
|
176
|
+
this.commit();
|
|
177
|
+
return rowToJob(created);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
this.rollback();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get(id: string): MaintenanceJob | undefined {
|
|
185
|
+
const row = this.getRow(id);
|
|
186
|
+
return row ? rowToJob(row) : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
list(options: { projectId?: string; status?: MaintenanceJobStatus; limit?: number } = {}): MaintenanceJob[] {
|
|
190
|
+
const limit = Math.min(500, clampPositiveInteger(options.limit, 100));
|
|
191
|
+
const clauses: string[] = [];
|
|
192
|
+
const values: unknown[] = [];
|
|
193
|
+
if (options.projectId) {
|
|
194
|
+
clauses.push('project_id = ?');
|
|
195
|
+
values.push(options.projectId);
|
|
196
|
+
}
|
|
197
|
+
if (options.status) {
|
|
198
|
+
clauses.push('status = ?');
|
|
199
|
+
values.push(options.status);
|
|
200
|
+
}
|
|
201
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
202
|
+
const rows = this.db.prepare(`
|
|
203
|
+
SELECT * FROM maintenance_jobs
|
|
204
|
+
${where}
|
|
205
|
+
ORDER BY run_after ASC, created_at ASC
|
|
206
|
+
LIMIT ?
|
|
207
|
+
`).all(...values, limit);
|
|
208
|
+
return rows.map(rowToJob);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
summary(projectId?: string): MaintenanceJobSummary {
|
|
212
|
+
const row = projectId
|
|
213
|
+
? this.db.prepare(`
|
|
214
|
+
SELECT
|
|
215
|
+
COUNT(*) AS total,
|
|
216
|
+
SUM(status = 'pending') AS pending,
|
|
217
|
+
SUM(status = 'running') AS running,
|
|
218
|
+
SUM(status = 'retry') AS retrying,
|
|
219
|
+
SUM(status = 'completed') AS completed,
|
|
220
|
+
SUM(status = 'failed') AS failed
|
|
221
|
+
FROM maintenance_jobs WHERE project_id = ?
|
|
222
|
+
`).get(projectId)
|
|
223
|
+
: this.db.prepare(`
|
|
224
|
+
SELECT
|
|
225
|
+
COUNT(*) AS total,
|
|
226
|
+
SUM(status = 'pending') AS pending,
|
|
227
|
+
SUM(status = 'running') AS running,
|
|
228
|
+
SUM(status = 'retry') AS retrying,
|
|
229
|
+
SUM(status = 'completed') AS completed,
|
|
230
|
+
SUM(status = 'failed') AS failed
|
|
231
|
+
FROM maintenance_jobs
|
|
232
|
+
`).get();
|
|
233
|
+
return {
|
|
234
|
+
total: Number(row?.total ?? 0),
|
|
235
|
+
pending: Number(row?.pending ?? 0),
|
|
236
|
+
running: Number(row?.running ?? 0),
|
|
237
|
+
retrying: Number(row?.retrying ?? 0),
|
|
238
|
+
completed: Number(row?.completed ?? 0),
|
|
239
|
+
failed: Number(row?.failed ?? 0),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Completed jobs are useful recent operator history, not permanent data.
|
|
245
|
+
* Failed jobs stay available for diagnosis until an operator explicitly
|
|
246
|
+
* resolves them or clears the database.
|
|
247
|
+
*/
|
|
248
|
+
pruneCompletedHistory(options: { now?: number; maxAgeMs?: number } = {}): number {
|
|
249
|
+
const now = options.now ?? Date.now();
|
|
250
|
+
const maxAgeMs = Number.isFinite(options.maxAgeMs)
|
|
251
|
+
? Math.max(0, Math.floor(options.maxAgeMs!))
|
|
252
|
+
: DEFAULT_COMPLETED_HISTORY_RETENTION_MS;
|
|
253
|
+
const result = this.db.prepare(`
|
|
254
|
+
DELETE FROM maintenance_jobs
|
|
255
|
+
WHERE status = 'completed'
|
|
256
|
+
AND completed_at IS NOT NULL
|
|
257
|
+
AND completed_at < ?
|
|
258
|
+
`).run(now - maxAgeMs);
|
|
259
|
+
return Number(result.changes ?? 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
claimNext(options: ClaimMaintenanceJobOptions): MaintenanceJob | undefined {
|
|
263
|
+
const now = options.now ?? Date.now();
|
|
264
|
+
const leaseMs = clampPositiveInteger(options.leaseMs, DEFAULT_LEASE_MS);
|
|
265
|
+
const kinds = normalizedKinds(options.kinds);
|
|
266
|
+
|
|
267
|
+
this.begin();
|
|
268
|
+
try {
|
|
269
|
+
// A dead worker leaves a leased row behind. Make it retryable before
|
|
270
|
+
// selecting the next candidate, while respecting its retry budget.
|
|
271
|
+
this.db.prepare(`
|
|
272
|
+
UPDATE maintenance_jobs
|
|
273
|
+
SET
|
|
274
|
+
status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'retry' END,
|
|
275
|
+
run_after = CASE WHEN attempts >= max_attempts THEN run_after ELSE ? END,
|
|
276
|
+
lease_owner = NULL,
|
|
277
|
+
lease_expires_at = NULL,
|
|
278
|
+
last_error = COALESCE(last_error, 'worker lease expired'),
|
|
279
|
+
updated_at = ?
|
|
280
|
+
WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
|
|
281
|
+
`).run(now, now, now);
|
|
282
|
+
this.db.prepare(`
|
|
283
|
+
UPDATE maintenance_jobs
|
|
284
|
+
SET status = 'failed', updated_at = ?
|
|
285
|
+
WHERE status IN ('pending', 'retry') AND attempts >= max_attempts
|
|
286
|
+
`).run(now);
|
|
287
|
+
|
|
288
|
+
if (options.kinds && kinds?.length === 0) {
|
|
289
|
+
this.commit();
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const projectClause = options.projectId ? 'AND project_id = ?' : '';
|
|
293
|
+
const kindClause = kinds?.length
|
|
294
|
+
? `AND kind IN (${kinds.map(() => '?').join(', ')})`
|
|
295
|
+
: '';
|
|
296
|
+
const values = [
|
|
297
|
+
now,
|
|
298
|
+
...(kinds ?? []),
|
|
299
|
+
...(options.projectId ? [options.projectId] : []),
|
|
300
|
+
];
|
|
301
|
+
const candidate = this.db.prepare(`
|
|
302
|
+
SELECT * FROM maintenance_jobs
|
|
303
|
+
WHERE status IN ('pending', 'retry') AND run_after <= ?
|
|
304
|
+
${kindClause}
|
|
305
|
+
${projectClause}
|
|
306
|
+
ORDER BY run_after ASC, created_at ASC
|
|
307
|
+
LIMIT 1
|
|
308
|
+
`).get(...values);
|
|
309
|
+
|
|
310
|
+
if (!candidate) {
|
|
311
|
+
this.commit();
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const leaseExpiresAt = now + leaseMs;
|
|
316
|
+
this.db.prepare(`
|
|
317
|
+
UPDATE maintenance_jobs
|
|
318
|
+
SET status = 'running', attempts = attempts + 1, lease_owner = ?,
|
|
319
|
+
lease_expires_at = ?, updated_at = ?
|
|
320
|
+
WHERE id = ?
|
|
321
|
+
`).run(options.workerId, leaseExpiresAt, now, candidate.id);
|
|
322
|
+
const claimed = this.getRow(candidate.id)!;
|
|
323
|
+
this.commit();
|
|
324
|
+
return rowToJob(claimed);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
this.rollback();
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Extend a running job's lease. A worker may only renew its own lease, so a
|
|
333
|
+
* stale worker cannot reclaim work that was already recovered elsewhere.
|
|
334
|
+
*/
|
|
335
|
+
renewLease(id: string, workerId: string, leaseMs = DEFAULT_LEASE_MS, now = Date.now()): boolean {
|
|
336
|
+
const expiresAt = now + clampPositiveInteger(leaseMs, DEFAULT_LEASE_MS);
|
|
337
|
+
const result = this.db.prepare(`
|
|
338
|
+
UPDATE maintenance_jobs
|
|
339
|
+
SET lease_expires_at = ?, updated_at = ?
|
|
340
|
+
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
341
|
+
`).run(expiresAt, now, id, workerId);
|
|
342
|
+
return Number(result.changes) > 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
complete(id: string, workerId: string, now = Date.now()): MaintenanceJob | undefined {
|
|
346
|
+
this.db.prepare(`
|
|
347
|
+
UPDATE maintenance_jobs
|
|
348
|
+
SET status = 'completed', lease_owner = NULL, lease_expires_at = NULL,
|
|
349
|
+
completed_at = ?, updated_at = ?
|
|
350
|
+
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
351
|
+
`).run(now, now, id, workerId);
|
|
352
|
+
return this.get(id);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
reschedule(
|
|
356
|
+
id: string,
|
|
357
|
+
workerId: string,
|
|
358
|
+
options: { delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown>; now?: number },
|
|
359
|
+
): MaintenanceJob | undefined {
|
|
360
|
+
const now = options.now ?? Date.now();
|
|
361
|
+
const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
|
|
362
|
+
const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
|
|
363
|
+
this.db.prepare(`
|
|
364
|
+
UPDATE maintenance_jobs
|
|
365
|
+
SET status = 'pending', run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
|
|
366
|
+
payload_json = COALESCE(?, payload_json),
|
|
367
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
368
|
+
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
369
|
+
`).run(now + delayMs, options.resetAttempts ? 1 : 0, payloadJson, now, id, workerId);
|
|
370
|
+
return this.get(id);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
|
|
374
|
+
this.begin();
|
|
375
|
+
try {
|
|
376
|
+
const current = this.db.prepare(`
|
|
377
|
+
SELECT * FROM maintenance_jobs
|
|
378
|
+
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
379
|
+
`).get(id, workerId);
|
|
380
|
+
if (!current) {
|
|
381
|
+
this.commit();
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const attempts = Number(current.attempts);
|
|
386
|
+
const exhausted = attempts >= Number(current.max_attempts);
|
|
387
|
+
const status: MaintenanceJobStatus = exhausted ? 'failed' : 'retry';
|
|
388
|
+
const runAfter = exhausted ? Number(current.run_after) : now + getMaintenanceRetryDelayMs(attempts);
|
|
389
|
+
this.db.prepare(`
|
|
390
|
+
UPDATE maintenance_jobs
|
|
391
|
+
SET status = ?, run_after = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
392
|
+
last_error = ?, updated_at = ?
|
|
393
|
+
WHERE id = ?
|
|
394
|
+
`).run(status, runAfter, errorText(error), now, id);
|
|
395
|
+
const updated = this.getRow(id)!;
|
|
396
|
+
this.commit();
|
|
397
|
+
return rowToJob(updated);
|
|
398
|
+
} catch (caught) {
|
|
399
|
+
this.rollback();
|
|
400
|
+
throw caught;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private getRow(id: string): any {
|
|
405
|
+
return this.db.prepare(`SELECT * FROM maintenance_jobs WHERE id = ?`).get(id);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private begin(): void {
|
|
409
|
+
this.db.prepare('BEGIN IMMEDIATE').run();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private commit(): void {
|
|
413
|
+
this.db.prepare('COMMIT').run();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private rollback(): void {
|
|
417
|
+
try { this.db.prepare('ROLLBACK').run(); } catch { /* transaction already closed */ }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export class MaintenanceJobWorker {
|
|
422
|
+
private readonly workerId: string;
|
|
423
|
+
private readonly leaseMs: number;
|
|
424
|
+
private readonly pollIntervalMs: number;
|
|
425
|
+
private readonly projectId?: string;
|
|
426
|
+
private readonly kinds?: readonly MaintenanceJobKind[];
|
|
427
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
428
|
+
private running = false;
|
|
429
|
+
|
|
430
|
+
constructor(
|
|
431
|
+
private readonly store: MaintenanceJobStore,
|
|
432
|
+
private readonly handler: MaintenanceJobHandler,
|
|
433
|
+
options: {
|
|
434
|
+
workerId?: string;
|
|
435
|
+
leaseMs?: number;
|
|
436
|
+
pollIntervalMs?: number;
|
|
437
|
+
projectId?: string;
|
|
438
|
+
kinds?: readonly MaintenanceJobKind[];
|
|
439
|
+
} = {},
|
|
440
|
+
) {
|
|
441
|
+
this.workerId = options.workerId ?? `memorix-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
442
|
+
this.leaseMs = clampPositiveInteger(options.leaseMs, DEFAULT_LEASE_MS);
|
|
443
|
+
this.pollIntervalMs = clampPositiveInteger(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS);
|
|
444
|
+
this.projectId = options.projectId;
|
|
445
|
+
this.kinds = options.kinds;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async runOnce(now = Date.now()): Promise<{ state: 'idle' | 'busy' | 'completed' | 'rescheduled' | 'failed'; job?: MaintenanceJob }> {
|
|
449
|
+
if (this.running) return { state: 'busy' };
|
|
450
|
+
this.running = true;
|
|
451
|
+
try {
|
|
452
|
+
const job = this.store.claimNext({
|
|
453
|
+
workerId: this.workerId,
|
|
454
|
+
projectId: this.projectId,
|
|
455
|
+
kinds: this.kinds,
|
|
456
|
+
now,
|
|
457
|
+
leaseMs: this.leaseMs,
|
|
458
|
+
});
|
|
459
|
+
if (!job) return { state: 'idle' };
|
|
460
|
+
|
|
461
|
+
const heartbeatMs = Math.max(10, Math.floor(this.leaseMs / 3));
|
|
462
|
+
let leaseLost = false;
|
|
463
|
+
const heartbeat = setInterval(() => {
|
|
464
|
+
try {
|
|
465
|
+
leaseLost = !this.store.renewLease(job.id, this.workerId, this.leaseMs);
|
|
466
|
+
} catch {
|
|
467
|
+
// The next heartbeat can recover from a transient SQLite error. The
|
|
468
|
+
// job itself still has its prior lease until it expires.
|
|
469
|
+
}
|
|
470
|
+
}, heartbeatMs);
|
|
471
|
+
heartbeat.unref?.();
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const result = await this.handler(job);
|
|
475
|
+
if (leaseLost) {
|
|
476
|
+
return { state: 'failed', job: this.store.get(job.id) };
|
|
477
|
+
}
|
|
478
|
+
if (result?.action === 'reschedule') {
|
|
479
|
+
const updated = this.store.reschedule(job.id, this.workerId, {
|
|
480
|
+
delayMs: result.delayMs,
|
|
481
|
+
resetAttempts: result.resetAttempts,
|
|
482
|
+
payload: result.payload,
|
|
483
|
+
now,
|
|
484
|
+
});
|
|
485
|
+
return { state: 'rescheduled', job: updated };
|
|
486
|
+
}
|
|
487
|
+
const updated = this.store.complete(job.id, this.workerId, now);
|
|
488
|
+
return { state: 'completed', job: updated };
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const updated = this.store.fail(job.id, this.workerId, error, now);
|
|
491
|
+
return { state: 'failed', job: updated };
|
|
492
|
+
} finally {
|
|
493
|
+
clearInterval(heartbeat);
|
|
494
|
+
}
|
|
495
|
+
} finally {
|
|
496
|
+
this.running = false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
start(): void {
|
|
501
|
+
if (this.timer) return;
|
|
502
|
+
void this.runOnce().catch(() => {});
|
|
503
|
+
this.timer = setInterval(() => { void this.runOnce().catch(() => {}); }, this.pollIntervalMs);
|
|
504
|
+
this.timer.unref?.();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
stop(): void {
|
|
508
|
+
if (!this.timer) return;
|
|
509
|
+
clearInterval(this.timer);
|
|
510
|
+
this.timer = null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { closeAllDatabases } from '../store/sqlite-db.js';
|
|
2
|
+
import { loadDotenv } from '../config/dotenv-loader.js';
|
|
3
|
+
import { initProjectRoot } from '../config/yaml-loader.js';
|
|
4
|
+
import { initLLM } from '../llm/provider.js';
|
|
5
|
+
import { initObservationStore } from '../store/obs-store.js';
|
|
6
|
+
import {
|
|
7
|
+
MAINTENANCE_JOB_KINDS,
|
|
8
|
+
type MaintenanceJob,
|
|
9
|
+
type MaintenanceJobRunResult,
|
|
10
|
+
} from './maintenance-jobs.js';
|
|
11
|
+
import {
|
|
12
|
+
ISOLATED_MAINTENANCE_JOB_KINDS,
|
|
13
|
+
MAINTENANCE_RESULT_PREFIX,
|
|
14
|
+
type IsolatedMaintenanceRequest,
|
|
15
|
+
} from './isolated-maintenance.js';
|
|
16
|
+
import { createProjectMaintenanceHandler } from './project-maintenance.js';
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isJob(value: unknown): value is MaintenanceJob {
|
|
23
|
+
if (!isRecord(value)) return false;
|
|
24
|
+
return typeof value.id === 'string'
|
|
25
|
+
&& typeof value.projectId === 'string'
|
|
26
|
+
&& typeof value.kind === 'string'
|
|
27
|
+
&& MAINTENANCE_JOB_KINDS.includes(value.kind as MaintenanceJob['kind'])
|
|
28
|
+
&& ISOLATED_MAINTENANCE_JOB_KINDS.includes(value.kind as MaintenanceJob['kind'])
|
|
29
|
+
&& isRecord(value.payload);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseMaintenanceRequest(raw: string): IsolatedMaintenanceRequest {
|
|
33
|
+
let value: unknown;
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error('Maintenance runner received invalid JSON input');
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(value) || !isJob(value.job)) {
|
|
40
|
+
throw new Error('Maintenance runner received an invalid job payload');
|
|
41
|
+
}
|
|
42
|
+
if (typeof value.projectRoot !== 'string' || !value.projectRoot) {
|
|
43
|
+
throw new Error('Maintenance runner requires projectRoot');
|
|
44
|
+
}
|
|
45
|
+
if (typeof value.dataDir !== 'string' || !value.dataDir) {
|
|
46
|
+
throw new Error('Maintenance runner requires dataDir');
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
job: value.job,
|
|
50
|
+
projectRoot: value.projectRoot,
|
|
51
|
+
dataDir: value.dataDir,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function executeMaintenanceRequest(
|
|
56
|
+
request: IsolatedMaintenanceRequest,
|
|
57
|
+
): Promise<MaintenanceJobRunResult> {
|
|
58
|
+
initProjectRoot(request.projectRoot);
|
|
59
|
+
loadDotenv(request.projectRoot);
|
|
60
|
+
await initObservationStore(request.dataDir);
|
|
61
|
+
if (request.job.kind === 'consolidation') initLLM();
|
|
62
|
+
|
|
63
|
+
const handler = createProjectMaintenanceHandler(
|
|
64
|
+
request.job.projectId,
|
|
65
|
+
request.dataDir,
|
|
66
|
+
request.projectRoot,
|
|
67
|
+
);
|
|
68
|
+
return (await handler(request.job)) ?? { action: 'complete' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readStdin(): Promise<string> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
let raw = '';
|
|
74
|
+
process.stdin.setEncoding('utf8');
|
|
75
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
76
|
+
process.stdin.once('error', reject);
|
|
77
|
+
process.stdin.once('end', () => resolve(raw));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function main(): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const request = parseMaintenanceRequest(await readStdin());
|
|
84
|
+
const result = await executeMaintenanceRequest(request);
|
|
85
|
+
process.stdout.write(`${MAINTENANCE_RESULT_PREFIX}${JSON.stringify(result)}\n`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
process.stderr.write(`[memorix] maintenance runner failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
} finally {
|
|
90
|
+
closeAllDatabases();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (process.argv[1] && process.argv[1].endsWith('maintenance-runner.js')) {
|
|
95
|
+
void main();
|
|
96
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getDatabase } from '../store/sqlite-db.js';
|
|
2
|
+
|
|
3
|
+
export interface MaintenanceTarget {
|
|
4
|
+
projectId: string;
|
|
5
|
+
projectRoot: string;
|
|
6
|
+
dataDir: string;
|
|
7
|
+
updatedAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RegisterMaintenanceTargetInput {
|
|
11
|
+
projectId: string;
|
|
12
|
+
projectRoot: string;
|
|
13
|
+
dataDir: string;
|
|
14
|
+
now?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rowToTarget(row: any): MaintenanceTarget {
|
|
18
|
+
return {
|
|
19
|
+
projectId: row.project_id,
|
|
20
|
+
projectRoot: row.project_root,
|
|
21
|
+
dataDir: row.data_dir,
|
|
22
|
+
updatedAt: Number(row.updated_at),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A local registry for isolated maintenance jobs. It deliberately stores only
|
|
28
|
+
* project identity and local paths; it is never sent through MCP responses.
|
|
29
|
+
*/
|
|
30
|
+
export class MaintenanceTargetStore {
|
|
31
|
+
private readonly db: any;
|
|
32
|
+
|
|
33
|
+
constructor(dataDir: string) {
|
|
34
|
+
this.db = getDatabase(dataDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
register(input: RegisterMaintenanceTargetInput): MaintenanceTarget {
|
|
38
|
+
if (!input.projectId.trim()) throw new Error('Maintenance target requires a project ID');
|
|
39
|
+
if (!input.projectRoot.trim()) throw new Error('Maintenance target requires a project root');
|
|
40
|
+
if (!input.dataDir.trim()) throw new Error('Maintenance target requires a data directory');
|
|
41
|
+
const now = input.now ?? Date.now();
|
|
42
|
+
this.db.prepare(`
|
|
43
|
+
INSERT INTO maintenance_targets (project_id, project_root, data_dir, updated_at)
|
|
44
|
+
VALUES (?, ?, ?, ?)
|
|
45
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
46
|
+
project_root = excluded.project_root,
|
|
47
|
+
data_dir = excluded.data_dir,
|
|
48
|
+
updated_at = excluded.updated_at
|
|
49
|
+
`).run(input.projectId, input.projectRoot, input.dataDir, now);
|
|
50
|
+
return this.get(input.projectId)!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(projectId: string): MaintenanceTarget | undefined {
|
|
54
|
+
const row = this.db.prepare(`
|
|
55
|
+
SELECT * FROM maintenance_targets WHERE project_id = ?
|
|
56
|
+
`).get(projectId);
|
|
57
|
+
return row ? rowToTarget(row) : undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|