@drewpayment/mink 0.1.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/README.md +347 -0
- package/package.json +32 -0
- package/src/cli.ts +176 -0
- package/src/commands/bug-search.ts +32 -0
- package/src/commands/config.ts +109 -0
- package/src/commands/cron.ts +295 -0
- package/src/commands/daemon.ts +46 -0
- package/src/commands/dashboard.ts +21 -0
- package/src/commands/designqc.ts +160 -0
- package/src/commands/detect-waste.ts +81 -0
- package/src/commands/framework-advisor.ts +52 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/post-read.ts +123 -0
- package/src/commands/post-write.ts +157 -0
- package/src/commands/pre-read.ts +109 -0
- package/src/commands/pre-write.ts +136 -0
- package/src/commands/reflect.ts +39 -0
- package/src/commands/restore.ts +31 -0
- package/src/commands/scan.ts +101 -0
- package/src/commands/session-start.ts +21 -0
- package/src/commands/session-stop.ts +115 -0
- package/src/commands/status.ts +152 -0
- package/src/commands/update.ts +121 -0
- package/src/core/action-log.ts +341 -0
- package/src/core/backup.ts +122 -0
- package/src/core/bug-memory.ts +223 -0
- package/src/core/cron-parser.ts +94 -0
- package/src/core/daemon.ts +152 -0
- package/src/core/dashboard-api.ts +280 -0
- package/src/core/dashboard-server.ts +580 -0
- package/src/core/description.ts +232 -0
- package/src/core/design-eval/capture.ts +269 -0
- package/src/core/design-eval/route-detect.ts +165 -0
- package/src/core/design-eval/server-detect.ts +91 -0
- package/src/core/framework-advisor/catalog.ts +360 -0
- package/src/core/framework-advisor/decision-tree.ts +287 -0
- package/src/core/framework-advisor/generate.ts +132 -0
- package/src/core/framework-advisor/migration-prompts.ts +502 -0
- package/src/core/framework-advisor/validate.ts +137 -0
- package/src/core/fs-utils.ts +30 -0
- package/src/core/global-config.ts +74 -0
- package/src/core/index-store.ts +72 -0
- package/src/core/learning-memory.ts +120 -0
- package/src/core/paths.ts +86 -0
- package/src/core/pattern-engine.ts +108 -0
- package/src/core/project-id.ts +19 -0
- package/src/core/project-registry.ts +64 -0
- package/src/core/reflection.ts +256 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/scheduler.ts +352 -0
- package/src/core/seed.ts +239 -0
- package/src/core/session.ts +128 -0
- package/src/core/stdin.ts +13 -0
- package/src/core/task-registry.ts +202 -0
- package/src/core/token-estimate.ts +36 -0
- package/src/core/token-ledger.ts +185 -0
- package/src/core/waste-detection.ts +214 -0
- package/src/core/write-exclusions.ts +24 -0
- package/src/types/action-log.ts +20 -0
- package/src/types/backup.ts +6 -0
- package/src/types/bug-memory.ts +24 -0
- package/src/types/config.ts +59 -0
- package/src/types/dashboard.ts +104 -0
- package/src/types/design-eval.ts +64 -0
- package/src/types/file-index.ts +38 -0
- package/src/types/framework-advisor.ts +97 -0
- package/src/types/hook-input.ts +27 -0
- package/src/types/learning-memory.ts +36 -0
- package/src/types/scheduler.ts +82 -0
- package/src/types/session.ts +50 -0
- package/src/types/token-ledger.ts +43 -0
- package/src/types/waste-detection.ts +21 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { LearningMemory, SectionName, ReflectionResult } from "../types/learning-memory";
|
|
2
|
+
import { serializeLearningMemory } from "./learning-memory";
|
|
3
|
+
import { estimateTokens } from "./token-estimate";
|
|
4
|
+
|
|
5
|
+
// Trim order: Decision Log first → Key Learnings → User Preferences → Do-Not-Repeat last
|
|
6
|
+
const TRIM_ORDER: SectionName[] = [
|
|
7
|
+
"Decision Log",
|
|
8
|
+
"Key Learnings",
|
|
9
|
+
"User Preferences",
|
|
10
|
+
"Do-Not-Repeat",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function normalizeWhitespace(s: string): string {
|
|
14
|
+
return s.replace(/\s+/g, " ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract the quoted pattern from a Do-Not-Repeat entry, e.g.
|
|
19
|
+
* `Don't use "var" in code` → `var`
|
|
20
|
+
* Only matches double-quoted strings to avoid matching apostrophes in contractions.
|
|
21
|
+
* Returns null if no double-quoted pattern found.
|
|
22
|
+
*/
|
|
23
|
+
function extractQuotedPattern(entry: string): string | null {
|
|
24
|
+
const m = entry.match(/"([^"]+)"/);
|
|
25
|
+
return m ? m[1] : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract date from an entry in the form `[YYYY-MM-DD]`
|
|
30
|
+
* Returns the date string or null.
|
|
31
|
+
*/
|
|
32
|
+
function extractDate(entry: string): string | null {
|
|
33
|
+
const m = entry.match(/\[(\d{4}-\d{2}-\d{2})\]/);
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deepCopy(mem: LearningMemory): LearningMemory {
|
|
38
|
+
return {
|
|
39
|
+
projectName: mem.projectName,
|
|
40
|
+
sections: {
|
|
41
|
+
"User Preferences": [...mem.sections["User Preferences"]],
|
|
42
|
+
"Key Learnings": [...mem.sections["Key Learnings"]],
|
|
43
|
+
"Do-Not-Repeat": [...mem.sections["Do-Not-Repeat"]],
|
|
44
|
+
"Decision Log": [...mem.sections["Decision Log"]],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge duplicates within each section.
|
|
51
|
+
* - Exact duplicates (normalized whitespace) → keep one
|
|
52
|
+
* - Do-Not-Repeat: entries sharing same quoted pattern → merge, keep newer date
|
|
53
|
+
* Returns a new LearningMemory (does not mutate input).
|
|
54
|
+
*/
|
|
55
|
+
export function mergeDuplicates(mem: LearningMemory): LearningMemory {
|
|
56
|
+
const result = deepCopy(mem);
|
|
57
|
+
|
|
58
|
+
const sectionNames: SectionName[] = [
|
|
59
|
+
"User Preferences",
|
|
60
|
+
"Key Learnings",
|
|
61
|
+
"Do-Not-Repeat",
|
|
62
|
+
"Decision Log",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
for (const section of sectionNames) {
|
|
66
|
+
const entries = result.sections[section];
|
|
67
|
+
|
|
68
|
+
if (section === "Do-Not-Repeat") {
|
|
69
|
+
// First pass: group by quoted pattern (where it exists)
|
|
70
|
+
const byQuotedPattern = new Map<string, string[]>();
|
|
71
|
+
const noPattern: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const qp = extractQuotedPattern(entry);
|
|
75
|
+
if (qp !== null) {
|
|
76
|
+
if (!byQuotedPattern.has(qp)) {
|
|
77
|
+
byQuotedPattern.set(qp, []);
|
|
78
|
+
}
|
|
79
|
+
byQuotedPattern.get(qp)!.push(entry);
|
|
80
|
+
} else {
|
|
81
|
+
noPattern.push(entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const merged: string[] = [];
|
|
86
|
+
|
|
87
|
+
// For each quoted pattern group, keep the one with the newer date (or last entry)
|
|
88
|
+
for (const [, group] of byQuotedPattern) {
|
|
89
|
+
if (group.length === 1) {
|
|
90
|
+
merged.push(group[0]);
|
|
91
|
+
} else {
|
|
92
|
+
// Find the entry with the newest date
|
|
93
|
+
let best = group[0];
|
|
94
|
+
let bestDate = extractDate(group[0]);
|
|
95
|
+
for (let i = 1; i < group.length; i++) {
|
|
96
|
+
const d = extractDate(group[i]);
|
|
97
|
+
if (d !== null && (bestDate === null || d > bestDate)) {
|
|
98
|
+
best = group[i];
|
|
99
|
+
bestDate = d;
|
|
100
|
+
} else if (d === null && bestDate === null) {
|
|
101
|
+
// No dates — keep last (newer = later in list)
|
|
102
|
+
best = group[i];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
merged.push(best);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// For entries without quoted patterns, deduplicate by normalized whitespace
|
|
110
|
+
const seenNoPattern = new Set<string>();
|
|
111
|
+
for (const entry of noPattern) {
|
|
112
|
+
const norm = normalizeWhitespace(entry);
|
|
113
|
+
if (!seenNoPattern.has(norm)) {
|
|
114
|
+
seenNoPattern.add(norm);
|
|
115
|
+
merged.push(entry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
result.sections[section] = merged;
|
|
120
|
+
} else {
|
|
121
|
+
// Standard deduplication: normalize whitespace and deduplicate
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const deduped: string[] = [];
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const norm = normalizeWhitespace(entry);
|
|
126
|
+
if (!seen.has(norm)) {
|
|
127
|
+
seen.add(norm);
|
|
128
|
+
deduped.push(entry);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
result.sections[section] = deduped;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Trim oldest entries from sections in the given order.
|
|
140
|
+
* Trim order: Decision Log → Key Learnings → User Preferences → Do-Not-Repeat
|
|
141
|
+
* Within each section, remove oldest (first) entries.
|
|
142
|
+
* Returns a new LearningMemory (does not mutate input).
|
|
143
|
+
*/
|
|
144
|
+
export function trimOldest(mem: LearningMemory, trimCount: number): LearningMemory {
|
|
145
|
+
if (trimCount <= 0) {
|
|
146
|
+
return deepCopy(mem);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result = deepCopy(mem);
|
|
150
|
+
let remaining = trimCount;
|
|
151
|
+
|
|
152
|
+
for (const section of TRIM_ORDER) {
|
|
153
|
+
if (remaining <= 0) break;
|
|
154
|
+
const entries = result.sections[section];
|
|
155
|
+
const toRemove = Math.min(remaining, entries.length);
|
|
156
|
+
result.sections[section] = entries.slice(toRemove);
|
|
157
|
+
remaining -= toRemove;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reflect memory against a token budget: merge duplicates then trim oldest until within budget.
|
|
165
|
+
* If budget <= 0, skip pruning.
|
|
166
|
+
*/
|
|
167
|
+
export function reflectMemory(
|
|
168
|
+
mem: LearningMemory,
|
|
169
|
+
tokenBudget: number
|
|
170
|
+
): { memory: LearningMemory; result: ReflectionResult } {
|
|
171
|
+
const serialized = serializeLearningMemory(mem);
|
|
172
|
+
const beforeTokens = estimateTokens(serialized, "learning-memory.md");
|
|
173
|
+
|
|
174
|
+
if (tokenBudget <= 0) {
|
|
175
|
+
return {
|
|
176
|
+
memory: deepCopy(mem),
|
|
177
|
+
result: {
|
|
178
|
+
beforeTokens,
|
|
179
|
+
afterTokens: beforeTokens,
|
|
180
|
+
mergedCount: 0,
|
|
181
|
+
trimmedCount: 0,
|
|
182
|
+
withinBudget: true,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Step 1: Always merge duplicates (regardless of budget)
|
|
188
|
+
const beforeMergeCount = countEntries(mem);
|
|
189
|
+
const afterMerge = mergeDuplicates(mem);
|
|
190
|
+
const afterMergeCount = countEntries(afterMerge);
|
|
191
|
+
const mergedCount = beforeMergeCount - afterMergeCount;
|
|
192
|
+
|
|
193
|
+
const afterMergeSerialized = serializeLearningMemory(afterMerge);
|
|
194
|
+
const afterMergeTokens = estimateTokens(afterMergeSerialized, "learning-memory.md");
|
|
195
|
+
|
|
196
|
+
if (afterMergeTokens <= tokenBudget) {
|
|
197
|
+
return {
|
|
198
|
+
memory: afterMerge,
|
|
199
|
+
result: {
|
|
200
|
+
beforeTokens,
|
|
201
|
+
afterTokens: afterMergeTokens,
|
|
202
|
+
mergedCount,
|
|
203
|
+
trimmedCount: 0,
|
|
204
|
+
withinBudget: true,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Step 2: Trim oldest one at a time until within budget or empty
|
|
210
|
+
let current = afterMerge;
|
|
211
|
+
let trimmedCount = 0;
|
|
212
|
+
|
|
213
|
+
while (true) {
|
|
214
|
+
const currentSerialized = serializeLearningMemory(current);
|
|
215
|
+
const currentTokens = estimateTokens(currentSerialized, "learning-memory.md");
|
|
216
|
+
|
|
217
|
+
if (currentTokens <= tokenBudget) {
|
|
218
|
+
return {
|
|
219
|
+
memory: current,
|
|
220
|
+
result: {
|
|
221
|
+
beforeTokens,
|
|
222
|
+
afterTokens: currentTokens,
|
|
223
|
+
mergedCount,
|
|
224
|
+
trimmedCount,
|
|
225
|
+
withinBudget: true,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const total = countEntries(current);
|
|
231
|
+
if (total === 0) {
|
|
232
|
+
return {
|
|
233
|
+
memory: current,
|
|
234
|
+
result: {
|
|
235
|
+
beforeTokens,
|
|
236
|
+
afterTokens: currentTokens,
|
|
237
|
+
mergedCount,
|
|
238
|
+
trimmedCount,
|
|
239
|
+
withinBudget: false,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
current = trimOldest(current, 1);
|
|
245
|
+
trimmedCount += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function countEntries(mem: LearningMemory): number {
|
|
250
|
+
return (
|
|
251
|
+
mem.sections["User Preferences"].length +
|
|
252
|
+
mem.sections["Key Learnings"].length +
|
|
253
|
+
mem.sections["Do-Not-Repeat"].length +
|
|
254
|
+
mem.sections["Decision Log"].length
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "fs";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
|
+
import type { ScannedFile, ProjectConfig } from "../types/file-index";
|
|
4
|
+
import { safeReadJson } from "./fs-utils";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_EXCLUDES: string[] = [
|
|
7
|
+
"node_modules", "vendor", ".venv", "venv", "__pycache__",
|
|
8
|
+
"bower_components", ".yarn", ".pnp",
|
|
9
|
+
"dist", "build", "out", ".next", ".nuxt", ".svelte-kit",
|
|
10
|
+
".turbo", ".vercel", ".output",
|
|
11
|
+
"coverage", ".nyc_output",
|
|
12
|
+
".git", ".hg", ".svn",
|
|
13
|
+
"package-lock.json", "bun.lock", "yarn.lock",
|
|
14
|
+
"pnpm-lock.yaml", "Gemfile.lock", "poetry.lock", "composer.lock",
|
|
15
|
+
"*.min.js", "*.min.css", "*.map",
|
|
16
|
+
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.ico",
|
|
17
|
+
"*.woff", "*.woff2", "*.ttf", "*.eot",
|
|
18
|
+
"*.mp3", "*.mp4", "*.webm", "*.zip", "*.tar", "*.gz",
|
|
19
|
+
"*.pdf", "*.exe", "*.dll", "*.so", "*.dylib",
|
|
20
|
+
".env", ".env.*",
|
|
21
|
+
".mink",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_FILES = 500;
|
|
25
|
+
|
|
26
|
+
function matchesPattern(name: string, pattern: string): boolean {
|
|
27
|
+
if (pattern.includes("*")) {
|
|
28
|
+
// Glob: *.min.js -> match against basename
|
|
29
|
+
const regex = new RegExp(
|
|
30
|
+
"^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
|
|
31
|
+
);
|
|
32
|
+
return regex.test(name);
|
|
33
|
+
}
|
|
34
|
+
// Exact match against name
|
|
35
|
+
return name === pattern;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isExcluded(name: string, excludes: string[]): boolean {
|
|
39
|
+
return excludes.some((pattern) => matchesPattern(name, pattern));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walkDirectory(
|
|
43
|
+
dir: string,
|
|
44
|
+
projectRoot: string,
|
|
45
|
+
excludes: string[],
|
|
46
|
+
results: ScannedFile[]
|
|
47
|
+
): void {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return; // Permission denied or other error — skip
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.isSymbolicLink()) continue;
|
|
57
|
+
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (isExcluded(entry.name, excludes)) continue;
|
|
60
|
+
walkDirectory(join(dir, entry.name), projectRoot, excludes, results);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (entry.isFile()) {
|
|
65
|
+
if (isExcluded(entry.name, excludes)) continue;
|
|
66
|
+
try {
|
|
67
|
+
const fullPath = join(dir, entry.name);
|
|
68
|
+
const stat = statSync(fullPath);
|
|
69
|
+
results.push({
|
|
70
|
+
relativePath: relative(projectRoot, fullPath),
|
|
71
|
+
mtimeMs: stat.mtimeMs,
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// stat failed — skip
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadConfig(configPath: string): ProjectConfig {
|
|
81
|
+
const raw = safeReadJson(configPath);
|
|
82
|
+
if (raw && typeof raw === "object") return raw as ProjectConfig;
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getExcludes(config: ProjectConfig): string[] {
|
|
87
|
+
return [...DEFAULT_EXCLUDES, ...(config.excludePatterns ?? [])];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function scanProject(
|
|
91
|
+
projectRoot: string,
|
|
92
|
+
excludes: string[],
|
|
93
|
+
maxFiles: number = DEFAULT_MAX_FILES
|
|
94
|
+
): ScannedFile[] {
|
|
95
|
+
const results: ScannedFile[] = [];
|
|
96
|
+
walkDirectory(projectRoot, projectRoot, excludes, results);
|
|
97
|
+
results.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
98
|
+
return results.slice(0, maxFiles);
|
|
99
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { parseCronExpression, nextRunAfter, isInCurrentPeriod } from "./cron-parser";
|
|
2
|
+
import { getBuiltInTasks, getTaskById, executeTask } from "./task-registry";
|
|
3
|
+
import { schedulerManifestPath } from "./paths";
|
|
4
|
+
import { atomicWriteJson, safeReadJson } from "./fs-utils";
|
|
5
|
+
import type {
|
|
6
|
+
SchedulerManifest,
|
|
7
|
+
TaskRunRecord,
|
|
8
|
+
DeadLetterEntry,
|
|
9
|
+
HealthStatus,
|
|
10
|
+
TaskStatus,
|
|
11
|
+
} from "../types/scheduler";
|
|
12
|
+
|
|
13
|
+
// ── Backoff ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function calculateBackoffMs(
|
|
16
|
+
baseDelayMs: number,
|
|
17
|
+
attempt: number
|
|
18
|
+
): number {
|
|
19
|
+
return baseDelayMs * Math.pow(2, attempt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Dead Letter Operations ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export function addToDeadLetter(
|
|
25
|
+
manifest: SchedulerManifest,
|
|
26
|
+
entry: DeadLetterEntry
|
|
27
|
+
): void {
|
|
28
|
+
// Remove existing entry for same task if present
|
|
29
|
+
manifest.deadLetterQueue = manifest.deadLetterQueue.filter(
|
|
30
|
+
(e) => e.taskId !== entry.taskId
|
|
31
|
+
);
|
|
32
|
+
manifest.deadLetterQueue.push(entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function removeFromDeadLetter(
|
|
36
|
+
manifest: SchedulerManifest,
|
|
37
|
+
taskId: string
|
|
38
|
+
): DeadLetterEntry | undefined {
|
|
39
|
+
const idx = manifest.deadLetterQueue.findIndex((e) => e.taskId === taskId);
|
|
40
|
+
if (idx === -1) return undefined;
|
|
41
|
+
return manifest.deadLetterQueue.splice(idx, 1)[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listDeadLetterEntries(
|
|
45
|
+
manifest: SchedulerManifest
|
|
46
|
+
): DeadLetterEntry[] {
|
|
47
|
+
return manifest.deadLetterQueue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Manifest Management ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function createInitialManifest(now: Date = new Date()): SchedulerManifest {
|
|
53
|
+
const tasks: TaskRunRecord[] = getBuiltInTasks().map((task) => {
|
|
54
|
+
const schedule = parseCronExpression(task.schedule);
|
|
55
|
+
return {
|
|
56
|
+
taskId: task.id,
|
|
57
|
+
lastRunAt: null,
|
|
58
|
+
lastSuccessAt: null,
|
|
59
|
+
lastFailureAt: null,
|
|
60
|
+
nextRunAt: nextRunAfter(schedule, now).toISOString(),
|
|
61
|
+
status: "idle" as TaskStatus,
|
|
62
|
+
consecutiveFailures: 0,
|
|
63
|
+
currentAttempt: 0,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
tasks,
|
|
69
|
+
deadLetterQueue: [],
|
|
70
|
+
lastHeartbeat: now.toISOString(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function loadManifest(cwd: string): SchedulerManifest | null {
|
|
75
|
+
const raw = safeReadJson(schedulerManifestPath(cwd));
|
|
76
|
+
if (
|
|
77
|
+
raw &&
|
|
78
|
+
typeof raw === "object" &&
|
|
79
|
+
"tasks" in (raw as object) &&
|
|
80
|
+
"deadLetterQueue" in (raw as object)
|
|
81
|
+
) {
|
|
82
|
+
return raw as SchedulerManifest;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveManifest(cwd: string, manifest: SchedulerManifest): void {
|
|
88
|
+
atomicWriteJson(schedulerManifestPath(cwd), manifest);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getOrCreateManifest(cwd: string, now: Date): SchedulerManifest {
|
|
92
|
+
const existing = loadManifest(cwd);
|
|
93
|
+
if (existing) return existing;
|
|
94
|
+
const fresh = createInitialManifest(now);
|
|
95
|
+
saveManifest(cwd, fresh);
|
|
96
|
+
return fresh;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Crash Recovery ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export function recoverManifest(
|
|
102
|
+
manifest: SchedulerManifest,
|
|
103
|
+
now: Date
|
|
104
|
+
): void {
|
|
105
|
+
for (const record of manifest.tasks) {
|
|
106
|
+
const task = getTaskById(record.taskId);
|
|
107
|
+
if (!task) continue;
|
|
108
|
+
|
|
109
|
+
const schedule = parseCronExpression(task.schedule);
|
|
110
|
+
|
|
111
|
+
if (record.status === "running") {
|
|
112
|
+
// Crashed during execution — treat as failure
|
|
113
|
+
record.status = "retrying";
|
|
114
|
+
record.currentAttempt++;
|
|
115
|
+
record.consecutiveFailures++;
|
|
116
|
+
record.lastFailureAt = now.toISOString();
|
|
117
|
+
|
|
118
|
+
if (record.currentAttempt >= task.retryPolicy.maxAttempts) {
|
|
119
|
+
record.status = "dead-lettered";
|
|
120
|
+
addToDeadLetter(manifest, {
|
|
121
|
+
taskId: record.taskId,
|
|
122
|
+
deadLetteredAt: now.toISOString(),
|
|
123
|
+
failureTimestamps: [now.toISOString()],
|
|
124
|
+
errorMessages: ["Daemon crashed during execution"],
|
|
125
|
+
attemptCount: record.currentAttempt,
|
|
126
|
+
});
|
|
127
|
+
record.currentAttempt = 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// For idle/retrying tasks, check if they need schedule recalculation
|
|
132
|
+
if (record.status === "idle" && record.lastRunAt) {
|
|
133
|
+
const lastRun = new Date(record.lastRunAt);
|
|
134
|
+
if (isInCurrentPeriod(schedule, lastRun, now)) {
|
|
135
|
+
// Already ran in current period — advance to next
|
|
136
|
+
record.nextRunAt = nextRunAfter(schedule, lastRun).toISOString();
|
|
137
|
+
} else {
|
|
138
|
+
// Missed the window — due now
|
|
139
|
+
record.nextRunAt = now.toISOString();
|
|
140
|
+
}
|
|
141
|
+
} else if (record.status === "idle" && !record.lastRunAt) {
|
|
142
|
+
// Never ran — recalculate next run
|
|
143
|
+
record.nextRunAt = nextRunAfter(schedule, now).toISOString();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Scheduler ───────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface Scheduler {
|
|
151
|
+
start(): void;
|
|
152
|
+
stop(): void;
|
|
153
|
+
runTask(taskId: string): Promise<void>;
|
|
154
|
+
getHealth(): HealthStatus;
|
|
155
|
+
getManifest(): SchedulerManifest;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createScheduler(
|
|
159
|
+
projectCwd: string,
|
|
160
|
+
options: {
|
|
161
|
+
tickMs?: number;
|
|
162
|
+
heartbeatMs?: number;
|
|
163
|
+
startedAt?: Date;
|
|
164
|
+
} = {}
|
|
165
|
+
): Scheduler {
|
|
166
|
+
const tickMs = options.tickMs ?? 60_000;
|
|
167
|
+
const heartbeatMs = options.heartbeatMs ?? 30 * 60 * 1000;
|
|
168
|
+
const startedAt = options.startedAt ?? new Date();
|
|
169
|
+
|
|
170
|
+
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
|
171
|
+
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
172
|
+
let manifest: SchedulerManifest;
|
|
173
|
+
let activeTasks: string[] = [];
|
|
174
|
+
let ticking = false;
|
|
175
|
+
|
|
176
|
+
// Initialize manifest
|
|
177
|
+
manifest = getOrCreateManifest(projectCwd, startedAt);
|
|
178
|
+
recoverManifest(manifest, startedAt);
|
|
179
|
+
saveManifest(projectCwd, manifest);
|
|
180
|
+
|
|
181
|
+
async function tick(): Promise<void> {
|
|
182
|
+
if (ticking) return; // Prevent overlapping ticks
|
|
183
|
+
ticking = true;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const now = new Date();
|
|
187
|
+
const queue: string[] = [];
|
|
188
|
+
|
|
189
|
+
for (const record of manifest.tasks) {
|
|
190
|
+
const task = getTaskById(record.taskId);
|
|
191
|
+
if (!task || !task.enabled) continue;
|
|
192
|
+
if (record.status === "dead-lettered") continue;
|
|
193
|
+
|
|
194
|
+
if (record.status === "retrying") {
|
|
195
|
+
// Check if backoff delay has elapsed
|
|
196
|
+
const retryAfter =
|
|
197
|
+
new Date(record.lastFailureAt!).getTime() +
|
|
198
|
+
calculateBackoffMs(
|
|
199
|
+
task.retryPolicy.baseDelayMs,
|
|
200
|
+
record.currentAttempt - 1
|
|
201
|
+
);
|
|
202
|
+
if (now.getTime() >= retryAfter) {
|
|
203
|
+
queue.push(record.taskId);
|
|
204
|
+
}
|
|
205
|
+
} else if (
|
|
206
|
+
record.status === "idle" &&
|
|
207
|
+
now.getTime() >= new Date(record.nextRunAt).getTime()
|
|
208
|
+
) {
|
|
209
|
+
queue.push(record.taskId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Execute sequentially, sorted by task ID for determinism
|
|
214
|
+
queue.sort();
|
|
215
|
+
|
|
216
|
+
for (const taskId of queue) {
|
|
217
|
+
await executeTaskWithRetry(taskId, now);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
ticking = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function executeTaskWithRetry(
|
|
225
|
+
taskId: string,
|
|
226
|
+
now: Date
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
const task = getTaskById(taskId);
|
|
229
|
+
if (!task) return;
|
|
230
|
+
|
|
231
|
+
const record = manifest.tasks.find((r) => r.taskId === taskId);
|
|
232
|
+
if (!record) return;
|
|
233
|
+
|
|
234
|
+
record.status = "running";
|
|
235
|
+
record.lastRunAt = now.toISOString();
|
|
236
|
+
activeTasks.push(taskId);
|
|
237
|
+
saveManifest(projectCwd, manifest);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await executeTask(taskId, projectCwd);
|
|
241
|
+
|
|
242
|
+
// Success
|
|
243
|
+
record.status = "idle";
|
|
244
|
+
record.lastSuccessAt = now.toISOString();
|
|
245
|
+
record.consecutiveFailures = 0;
|
|
246
|
+
record.currentAttempt = 0;
|
|
247
|
+
const schedule = parseCronExpression(task.schedule);
|
|
248
|
+
record.nextRunAt = nextRunAfter(schedule, now).toISOString();
|
|
249
|
+
|
|
250
|
+
console.log(`[mink] task ${taskId} completed successfully`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const errorMsg =
|
|
253
|
+
err instanceof Error ? err.message : String(err);
|
|
254
|
+
record.currentAttempt++;
|
|
255
|
+
record.consecutiveFailures++;
|
|
256
|
+
record.lastFailureAt = now.toISOString();
|
|
257
|
+
|
|
258
|
+
console.error(`[mink] task ${taskId} failed: ${errorMsg}`);
|
|
259
|
+
|
|
260
|
+
if (record.currentAttempt >= task.retryPolicy.maxAttempts) {
|
|
261
|
+
record.status = "dead-lettered";
|
|
262
|
+
addToDeadLetter(manifest, {
|
|
263
|
+
taskId,
|
|
264
|
+
deadLetteredAt: now.toISOString(),
|
|
265
|
+
failureTimestamps: [now.toISOString()],
|
|
266
|
+
errorMessages: [errorMsg],
|
|
267
|
+
attemptCount: record.currentAttempt,
|
|
268
|
+
});
|
|
269
|
+
record.currentAttempt = 0;
|
|
270
|
+
console.error(
|
|
271
|
+
`[mink] task ${taskId} moved to dead letter queue after ${task.retryPolicy.maxAttempts} failures`
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
record.status = "retrying";
|
|
275
|
+
console.log(
|
|
276
|
+
`[mink] task ${taskId} will retry (attempt ${record.currentAttempt}/${task.retryPolicy.maxAttempts})`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
activeTasks = activeTasks.filter((id) => id !== taskId);
|
|
281
|
+
saveManifest(projectCwd, manifest);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function emitHeartbeat(): void {
|
|
286
|
+
manifest.lastHeartbeat = new Date().toISOString();
|
|
287
|
+
saveManifest(projectCwd, manifest);
|
|
288
|
+
console.log(`[mink] heartbeat at ${manifest.lastHeartbeat}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
start(): void {
|
|
293
|
+
tickInterval = setInterval(() => {
|
|
294
|
+
tick().catch((err) => {
|
|
295
|
+
console.error(`[mink] scheduler tick error: ${err}`);
|
|
296
|
+
});
|
|
297
|
+
}, tickMs);
|
|
298
|
+
|
|
299
|
+
heartbeatInterval = setInterval(emitHeartbeat, heartbeatMs);
|
|
300
|
+
|
|
301
|
+
// Emit initial heartbeat
|
|
302
|
+
emitHeartbeat();
|
|
303
|
+
|
|
304
|
+
// Run first tick immediately
|
|
305
|
+
tick().catch((err) => {
|
|
306
|
+
console.error(`[mink] scheduler initial tick error: ${err}`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
console.log("[mink] scheduler started");
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
stop(): void {
|
|
313
|
+
if (tickInterval) {
|
|
314
|
+
clearInterval(tickInterval);
|
|
315
|
+
tickInterval = null;
|
|
316
|
+
}
|
|
317
|
+
if (heartbeatInterval) {
|
|
318
|
+
clearInterval(heartbeatInterval);
|
|
319
|
+
heartbeatInterval = null;
|
|
320
|
+
}
|
|
321
|
+
console.log("[mink] scheduler stopped");
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async runTask(taskId: string): Promise<void> {
|
|
325
|
+
const task = getTaskById(taskId);
|
|
326
|
+
if (!task) {
|
|
327
|
+
throw new Error(`Unknown task: ${taskId}`);
|
|
328
|
+
}
|
|
329
|
+
// Reload manifest to get latest state
|
|
330
|
+
const fresh = loadManifest(projectCwd);
|
|
331
|
+
if (fresh) manifest = fresh;
|
|
332
|
+
|
|
333
|
+
await executeTaskWithRetry(taskId, new Date());
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
getHealth(): HealthStatus {
|
|
337
|
+
return {
|
|
338
|
+
pid: process.pid,
|
|
339
|
+
startedAt: startedAt.toISOString(),
|
|
340
|
+
lastHeartbeatAt: manifest.lastHeartbeat,
|
|
341
|
+
uptimeMs: Date.now() - startedAt.getTime(),
|
|
342
|
+
activeTasks: [...activeTasks],
|
|
343
|
+
deadLetterCount: manifest.deadLetterQueue.length,
|
|
344
|
+
taskCount: manifest.tasks.length,
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
getManifest(): SchedulerManifest {
|
|
349
|
+
return manifest;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|