autonomous-flow-daemon 1.1.0 → 1.6.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/CHANGELOG.md +39 -0
- package/README.ko.md +124 -164
- package/README.md +99 -170
- package/package.json +11 -5
- package/src/adapters/index.ts +246 -35
- package/src/cli.ts +71 -1
- package/src/commands/benchmark.ts +187 -0
- package/src/commands/diagnose.ts +56 -14
- package/src/commands/doctor.ts +243 -0
- package/src/commands/evolution.ts +107 -0
- package/src/commands/fix.ts +22 -2
- package/src/commands/hooks.ts +136 -0
- package/src/commands/mcp.ts +129 -0
- package/src/commands/restart.ts +14 -0
- package/src/commands/score.ts +164 -96
- package/src/commands/start.ts +74 -15
- package/src/commands/stats.ts +103 -0
- package/src/commands/status.ts +157 -0
- package/src/commands/stop.ts +23 -4
- package/src/commands/sync.ts +253 -20
- package/src/commands/vaccine.ts +177 -0
- package/src/constants.ts +25 -1
- package/src/core/boast.ts +27 -12
- package/src/core/db.ts +74 -3
- package/src/core/evolution.ts +215 -0
- package/src/core/hologram/engine.ts +71 -0
- package/src/core/hologram/fallback.ts +11 -0
- package/src/core/hologram/incremental.ts +227 -0
- package/src/core/hologram/py-extractor.ts +132 -0
- package/src/core/hologram/ts-extractor.ts +320 -0
- package/src/core/hologram/types.ts +25 -0
- package/src/core/hologram.ts +64 -236
- package/src/core/hook-manager.ts +259 -0
- package/src/core/i18n/messages.ts +43 -0
- package/src/core/immune.ts +8 -123
- package/src/core/log-rotate.ts +33 -0
- package/src/core/log-utils.ts +38 -0
- package/src/core/lru-map.ts +61 -0
- package/src/core/notify.ts +27 -19
- package/src/core/rule-engine.ts +287 -0
- package/src/core/semantic-diff.ts +432 -0
- package/src/core/telemetry.ts +94 -0
- package/src/core/vaccine-registry.ts +212 -0
- package/src/core/workspace.ts +28 -0
- package/src/core/yaml-minimal.ts +176 -0
- package/src/daemon/client.ts +34 -6
- package/src/daemon/event-batcher.ts +108 -0
- package/src/daemon/guards.ts +13 -0
- package/src/daemon/http-routes.ts +293 -0
- package/src/daemon/mcp-handler.ts +270 -0
- package/src/daemon/server.ts +439 -353
- package/src/daemon/types.ts +100 -0
- package/src/daemon/workspace-map.ts +92 -0
- package/src/platform.ts +23 -2
- package/src/version.ts +15 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vaccine Registry — central antibody package system
|
|
3
|
+
*
|
|
4
|
+
* Manages vaccine packages that can be published, searched, and installed.
|
|
5
|
+
* Registry index is stored at .afd/registry/index.json
|
|
6
|
+
* Each package is a directory under .afd/registry/packages/<name>/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { AFD_DIR } from "../constants";
|
|
12
|
+
|
|
13
|
+
export interface VaccinePackage {
|
|
14
|
+
name: string;
|
|
15
|
+
version: string;
|
|
16
|
+
description: string;
|
|
17
|
+
author: string;
|
|
18
|
+
ecosystem: string;
|
|
19
|
+
antibodies: VaccineAntibodyDef[];
|
|
20
|
+
createdAt: string;
|
|
21
|
+
signature?: string; // future: cryptographic signature
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface VaccineAntibodyDef {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
severity: "critical" | "warning" | "info";
|
|
29
|
+
condition: {
|
|
30
|
+
type: string;
|
|
31
|
+
path: string;
|
|
32
|
+
pattern?: string;
|
|
33
|
+
minLength?: number;
|
|
34
|
+
};
|
|
35
|
+
patches: { op: string; path: string; value?: string }[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RegistryIndex {
|
|
39
|
+
version: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
packages: RegistryEntry[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RegistryEntry {
|
|
45
|
+
name: string;
|
|
46
|
+
version: string;
|
|
47
|
+
description: string;
|
|
48
|
+
author: string;
|
|
49
|
+
ecosystem: string;
|
|
50
|
+
antibodyCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const REGISTRY_DIR = join(AFD_DIR, "registry");
|
|
54
|
+
const PACKAGES_DIR = join(REGISTRY_DIR, "packages");
|
|
55
|
+
const INDEX_FILE = join(REGISTRY_DIR, "index.json");
|
|
56
|
+
|
|
57
|
+
function ensureDirs() {
|
|
58
|
+
mkdirSync(PACKAGES_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadIndex(): RegistryIndex {
|
|
62
|
+
if (!existsSync(INDEX_FILE)) {
|
|
63
|
+
return { version: "1.0.0", updatedAt: new Date().toISOString(), packages: [] };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(INDEX_FILE, "utf-8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return { version: "1.0.0", updatedAt: new Date().toISOString(), packages: [] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function saveIndex(index: RegistryIndex) {
|
|
73
|
+
ensureDirs();
|
|
74
|
+
index.updatedAt = new Date().toISOString();
|
|
75
|
+
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), "utf-8");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Publish a vaccine package to the local registry */
|
|
79
|
+
export function publishPackage(pkg: VaccinePackage): { success: boolean; message: string } {
|
|
80
|
+
ensureDirs();
|
|
81
|
+
|
|
82
|
+
const pkgDir = join(PACKAGES_DIR, pkg.name);
|
|
83
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
84
|
+
writeFileSync(join(pkgDir, "vaccine.json"), JSON.stringify(pkg, null, 2), "utf-8");
|
|
85
|
+
|
|
86
|
+
// Update index
|
|
87
|
+
const index = loadIndex();
|
|
88
|
+
const existing = index.packages.findIndex(p => p.name === pkg.name);
|
|
89
|
+
const entry: RegistryEntry = {
|
|
90
|
+
name: pkg.name,
|
|
91
|
+
version: pkg.version,
|
|
92
|
+
description: pkg.description,
|
|
93
|
+
author: pkg.author,
|
|
94
|
+
ecosystem: pkg.ecosystem,
|
|
95
|
+
antibodyCount: pkg.antibodies.length,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (existing >= 0) {
|
|
99
|
+
index.packages[existing] = entry;
|
|
100
|
+
} else {
|
|
101
|
+
index.packages.push(entry);
|
|
102
|
+
}
|
|
103
|
+
saveIndex(index);
|
|
104
|
+
|
|
105
|
+
return { success: true, message: `Published ${pkg.name}@${pkg.version} (${pkg.antibodies.length} antibodies)` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Search packages in the registry */
|
|
109
|
+
export function searchPackages(query?: string): RegistryEntry[] {
|
|
110
|
+
const index = loadIndex();
|
|
111
|
+
if (!query) return index.packages;
|
|
112
|
+
const q = query.toLowerCase();
|
|
113
|
+
return index.packages.filter(p =>
|
|
114
|
+
p.name.toLowerCase().includes(q) ||
|
|
115
|
+
p.description.toLowerCase().includes(q) ||
|
|
116
|
+
p.ecosystem.toLowerCase().includes(q)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get full package details */
|
|
121
|
+
export function getPackage(name: string): VaccinePackage | null {
|
|
122
|
+
const pkgFile = join(PACKAGES_DIR, name, "vaccine.json");
|
|
123
|
+
if (!existsSync(pkgFile)) return null;
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(readFileSync(pkgFile, "utf-8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Install a package — writes antibody rules to .afd/rules/ */
|
|
132
|
+
export function installPackage(name: string): { success: boolean; installed: number; message: string } {
|
|
133
|
+
const pkg = getPackage(name);
|
|
134
|
+
if (!pkg) return { success: false, installed: 0, message: `Package '${name}' not found` };
|
|
135
|
+
|
|
136
|
+
const rulesDir = join(AFD_DIR, "rules");
|
|
137
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
let installed = 0;
|
|
140
|
+
for (const ab of pkg.antibodies) {
|
|
141
|
+
const ruleContent = buildYamlRule(ab);
|
|
142
|
+
const ruleFile = join(rulesDir, `${pkg.name}-${ab.id}.yml`);
|
|
143
|
+
writeFileSync(ruleFile, ruleContent, "utf-8");
|
|
144
|
+
installed++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
success: true,
|
|
149
|
+
installed,
|
|
150
|
+
message: `Installed ${pkg.name}@${pkg.version}: ${installed} rules added to .afd/rules/`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** List installed packages (by checking .afd/rules/ for package-prefixed files) */
|
|
155
|
+
export function listInstalled(): string[] {
|
|
156
|
+
const rulesDir = join(AFD_DIR, "rules");
|
|
157
|
+
if (!existsSync(rulesDir)) return [];
|
|
158
|
+
if (!existsSync(PACKAGES_DIR)) return [];
|
|
159
|
+
|
|
160
|
+
// Get all known package names from registry
|
|
161
|
+
let knownPackages: string[];
|
|
162
|
+
try {
|
|
163
|
+
knownPackages = readdirSync(PACKAGES_DIR);
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const files = readdirSync(rulesDir).filter(f => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
169
|
+
const installed = new Set<string>();
|
|
170
|
+
|
|
171
|
+
for (const pkgName of knownPackages) {
|
|
172
|
+
// Check if any rule file starts with this package name
|
|
173
|
+
if (files.some(f => f.startsWith(`${pkgName}-`))) {
|
|
174
|
+
installed.add(pkgName);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return [...installed];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildYamlRule(ab: VaccineAntibodyDef): string {
|
|
182
|
+
const lines: string[] = [
|
|
183
|
+
`# Auto-generated by afd vaccine registry`,
|
|
184
|
+
`id: ${ab.id}`,
|
|
185
|
+
`title: "${ab.title}"`,
|
|
186
|
+
`description: "${ab.description}"`,
|
|
187
|
+
`severity: ${ab.severity}`,
|
|
188
|
+
`condition:`,
|
|
189
|
+
` type: ${ab.condition.type}`,
|
|
190
|
+
` path: ${ab.condition.path}`,
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
if (ab.condition.pattern) {
|
|
194
|
+
lines.push(` pattern: "${ab.condition.pattern}"`);
|
|
195
|
+
}
|
|
196
|
+
if (ab.condition.minLength !== undefined) {
|
|
197
|
+
lines.push(` minLength: ${ab.condition.minLength}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (ab.patches.length > 0) {
|
|
201
|
+
lines.push(`patches:`);
|
|
202
|
+
for (const p of ab.patches) {
|
|
203
|
+
lines.push(` - op: ${p.op}`);
|
|
204
|
+
lines.push(` path: ${p.path}`);
|
|
205
|
+
if (p.value !== undefined) {
|
|
206
|
+
lines.push(` value: "${p.value.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines.join("\n") + "\n";
|
|
212
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join, dirname, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk upward from `from` to find the nearest directory containing `.afd/` or `.git/`.
|
|
6
|
+
* Returns the absolute workspace root path, or falls back to `from` itself.
|
|
7
|
+
*/
|
|
8
|
+
export function findWorkspaceRoot(from: string = process.cwd()): string {
|
|
9
|
+
let dir = resolve(from);
|
|
10
|
+
const root = dirname(dir) === dir ? dir : undefined; // filesystem root guard
|
|
11
|
+
|
|
12
|
+
while (true) {
|
|
13
|
+
if (existsSync(join(dir, ".afd")) || existsSync(join(dir, ".git"))) {
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
const parent = dirname(dir);
|
|
17
|
+
if (parent === dir) break; // reached filesystem root
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback: return original directory
|
|
22
|
+
return resolve(from);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve an `.afd/`-relative path against the workspace root */
|
|
26
|
+
export function resolveAfdPath(relativePath: string, from?: string): string {
|
|
27
|
+
return join(findWorkspaceRoot(from), relativePath);
|
|
28
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML parser — handles flat keys, nested objects, and simple arrays.
|
|
3
|
+
* No external dependencies. Sufficient for .afd/rules/*.yml diagnostic rule files.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* key: value
|
|
7
|
+
* key:
|
|
8
|
+
* nested: value
|
|
9
|
+
* items:
|
|
10
|
+
* - value
|
|
11
|
+
* items:
|
|
12
|
+
* - op: add
|
|
13
|
+
* path: /file
|
|
14
|
+
*
|
|
15
|
+
* Does NOT support: anchors, aliases, flow syntax, multi-line strings, tags.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type YamlValue = string | number | boolean | null | YamlObject | YamlValue[];
|
|
19
|
+
interface YamlObject {
|
|
20
|
+
[key: string]: YamlValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parse(text: string): YamlObject {
|
|
24
|
+
const lines = text.split("\n");
|
|
25
|
+
return parseBlock(lines, 0, 0).value as YamlObject;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ParseResult {
|
|
29
|
+
value: YamlValue;
|
|
30
|
+
nextLine: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getIndent(line: string): number {
|
|
34
|
+
const match = line.match(/^(\s*)/);
|
|
35
|
+
return match ? match[1].length : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isComment(line: string): boolean {
|
|
39
|
+
return line.trimStart().startsWith("#") || line.trim() === "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseScalar(raw: string): YamlValue {
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (trimmed === "" || trimmed === "null" || trimmed === "~") return null;
|
|
45
|
+
if (trimmed === "true") return true;
|
|
46
|
+
if (trimmed === "false") return false;
|
|
47
|
+
// Remove quotes
|
|
48
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
49
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
50
|
+
return trimmed.slice(1, -1);
|
|
51
|
+
}
|
|
52
|
+
const num = Number(trimmed);
|
|
53
|
+
if (!isNaN(num) && trimmed !== "") return num;
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseBlock(lines: string[], startLine: number, baseIndent: number): ParseResult {
|
|
58
|
+
const obj: YamlObject = {};
|
|
59
|
+
let i = startLine;
|
|
60
|
+
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
|
|
64
|
+
if (isComment(line)) { i++; continue; }
|
|
65
|
+
|
|
66
|
+
const indent = getIndent(line);
|
|
67
|
+
if (indent < baseIndent) break; // dedent → end of block
|
|
68
|
+
|
|
69
|
+
const content = line.trim();
|
|
70
|
+
|
|
71
|
+
// Array item at this level
|
|
72
|
+
if (content.startsWith("- ")) {
|
|
73
|
+
// This block is actually an array — delegate to array parser
|
|
74
|
+
const arr = parseArray(lines, i, indent);
|
|
75
|
+
return { value: arr.value, nextLine: arr.nextLine };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Key-value pair
|
|
79
|
+
const colonIdx = content.indexOf(":");
|
|
80
|
+
if (colonIdx === -1) { i++; continue; }
|
|
81
|
+
|
|
82
|
+
const key = content.slice(0, colonIdx).trim();
|
|
83
|
+
const rest = content.slice(colonIdx + 1).trim();
|
|
84
|
+
|
|
85
|
+
if (rest === "" || rest === "|" || rest === ">") {
|
|
86
|
+
// Check next line indent to determine nested type
|
|
87
|
+
const nextNonEmpty = findNextNonEmpty(lines, i + 1);
|
|
88
|
+
if (nextNonEmpty < lines.length) {
|
|
89
|
+
const nextIndent = getIndent(lines[nextNonEmpty]);
|
|
90
|
+
if (nextIndent > indent) {
|
|
91
|
+
const nextContent = lines[nextNonEmpty].trim();
|
|
92
|
+
if (nextContent.startsWith("- ")) {
|
|
93
|
+
const arr = parseArray(lines, nextNonEmpty, nextIndent);
|
|
94
|
+
obj[key] = arr.value;
|
|
95
|
+
i = arr.nextLine;
|
|
96
|
+
} else {
|
|
97
|
+
const nested = parseBlock(lines, nextNonEmpty, nextIndent);
|
|
98
|
+
obj[key] = nested.value;
|
|
99
|
+
i = nested.nextLine;
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
obj[key] = null;
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
obj[key] = null;
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
obj[key] = parseScalar(rest);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { value: obj, nextLine: i };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseArray(lines: string[], startLine: number, baseIndent: number): ParseResult {
|
|
119
|
+
const arr: YamlValue[] = [];
|
|
120
|
+
let i = startLine;
|
|
121
|
+
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
if (isComment(line)) { i++; continue; }
|
|
125
|
+
|
|
126
|
+
const indent = getIndent(line);
|
|
127
|
+
if (indent < baseIndent) break;
|
|
128
|
+
|
|
129
|
+
const content = line.trim();
|
|
130
|
+
if (!content.startsWith("- ")) break;
|
|
131
|
+
|
|
132
|
+
const itemContent = content.slice(2).trim();
|
|
133
|
+
|
|
134
|
+
// Check if this is a simple value or an inline key-value
|
|
135
|
+
const colonIdx = itemContent.indexOf(":");
|
|
136
|
+
if (colonIdx > 0 && !itemContent.startsWith('"') && !itemContent.startsWith("'")) {
|
|
137
|
+
// Inline object start: - key: value
|
|
138
|
+
const itemObj: YamlObject = {};
|
|
139
|
+
const key = itemContent.slice(0, colonIdx).trim();
|
|
140
|
+
const val = itemContent.slice(colonIdx + 1).trim();
|
|
141
|
+
itemObj[key] = parseScalar(val);
|
|
142
|
+
i++;
|
|
143
|
+
|
|
144
|
+
// Continuation lines at deeper indent
|
|
145
|
+
const itemIndent = indent + 2;
|
|
146
|
+
while (i < lines.length) {
|
|
147
|
+
const nextLine = lines[i];
|
|
148
|
+
if (isComment(nextLine)) { i++; continue; }
|
|
149
|
+
const nextIndent = getIndent(nextLine);
|
|
150
|
+
if (nextIndent < itemIndent) break;
|
|
151
|
+
const nc = nextLine.trim();
|
|
152
|
+
if (nc.startsWith("- ")) break; // next array item
|
|
153
|
+
const ci = nc.indexOf(":");
|
|
154
|
+
if (ci > 0) {
|
|
155
|
+
itemObj[nc.slice(0, ci).trim()] = parseScalar(nc.slice(ci + 1).trim());
|
|
156
|
+
}
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
arr.push(itemObj);
|
|
161
|
+
} else {
|
|
162
|
+
// Simple value
|
|
163
|
+
arr.push(parseScalar(itemContent));
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { value: arr, nextLine: i };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findNextNonEmpty(lines: string[], start: number): number {
|
|
172
|
+
for (let i = start; i < lines.length; i++) {
|
|
173
|
+
if (!isComment(lines[i])) return i;
|
|
174
|
+
}
|
|
175
|
+
return lines.length;
|
|
176
|
+
}
|
package/src/daemon/client.ts
CHANGED
|
@@ -1,17 +1,45 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "fs";
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync, existsSync, unlinkSync } from "fs";
|
|
2
|
+
import { resolveWorkspacePaths } from "../constants";
|
|
3
3
|
|
|
4
4
|
export interface DaemonInfo {
|
|
5
5
|
pid: number;
|
|
6
6
|
port: number;
|
|
7
|
+
workspace: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Read daemon PID/port from the workspace-local `.afd/` directory.
|
|
12
|
+
* Walks up from cwd to find the workspace root, so CLI commands
|
|
13
|
+
* work correctly even when invoked from subdirectories.
|
|
14
|
+
*
|
|
15
|
+
* If PID file exists but process is dead, cleans up stale files.
|
|
16
|
+
*/
|
|
9
17
|
export function getDaemonInfo(): DaemonInfo | null {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
const paths = resolveWorkspacePaths();
|
|
19
|
+
if (!existsSync(paths.pidFile) || !existsSync(paths.portFile)) return null;
|
|
20
|
+
|
|
21
|
+
const pid = parseInt(readFileSync(paths.pidFile, "utf-8").trim(), 10);
|
|
22
|
+
const port = parseInt(readFileSync(paths.portFile, "utf-8").trim(), 10);
|
|
13
23
|
if (isNaN(pid) || isNaN(port)) return null;
|
|
14
|
-
|
|
24
|
+
|
|
25
|
+
// Stale PID detection: check if process is alive at OS level
|
|
26
|
+
if (!isProcessAlive(pid)) {
|
|
27
|
+
try { unlinkSync(paths.pidFile); } catch {}
|
|
28
|
+
try { unlinkSync(paths.portFile); } catch {}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { pid, port, workspace: paths.root };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check if a process exists at OS level (does not verify it's afd) */
|
|
36
|
+
function isProcessAlive(pid: number): boolean {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0); // signal 0 = existence check
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
15
43
|
}
|
|
16
44
|
|
|
17
45
|
export async function isDaemonAlive(info: DaemonInfo): Promise<boolean> {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBatcher — Adaptive debounce for file watcher events.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* - Immune file changes → fast-path (immediate, no debounce)
|
|
6
|
+
* - All other events → 300ms debounce batch
|
|
7
|
+
* - Deduplicates: same file multiple events → last event wins
|
|
8
|
+
* - Cancels out: add + unlink on same file → removed from batch
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface BatchedEvent {
|
|
12
|
+
event: string;
|
|
13
|
+
path: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EventBatcherOptions {
|
|
18
|
+
/** Debounce window in ms (default: 300) */
|
|
19
|
+
debounceMs?: number;
|
|
20
|
+
/** Check if a path is an immune-protected file (fast-path) */
|
|
21
|
+
isImmunePath?: (path: string) => boolean;
|
|
22
|
+
/** Handler for fast-path (immediate) events */
|
|
23
|
+
onImmediate: (event: string, path: string) => void;
|
|
24
|
+
/** Handler for batched events (fired after debounce window) */
|
|
25
|
+
onBatch: (events: BatchedEvent[]) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class EventBatcher {
|
|
29
|
+
private readonly debounceMs: number;
|
|
30
|
+
private readonly isImmunePath: (path: string) => boolean;
|
|
31
|
+
private readonly onImmediate: (event: string, path: string) => void;
|
|
32
|
+
private readonly onBatch: (events: BatchedEvent[]) => void;
|
|
33
|
+
|
|
34
|
+
private pendingEvents = new Map<string, BatchedEvent>();
|
|
35
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
private batchCount = 0;
|
|
37
|
+
|
|
38
|
+
constructor(options: EventBatcherOptions) {
|
|
39
|
+
this.debounceMs = options.debounceMs ?? 300;
|
|
40
|
+
this.isImmunePath = options.isImmunePath ?? (() => false);
|
|
41
|
+
this.onImmediate = options.onImmediate;
|
|
42
|
+
this.onBatch = options.onBatch;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Push a new file event. Returns true if handled immediately (fast-path). */
|
|
46
|
+
push(event: string, path: string): boolean {
|
|
47
|
+
// Fast-path: immune file change → immediate processing for auto-heal responsiveness
|
|
48
|
+
if (event === "change" && this.isImmunePath(path)) {
|
|
49
|
+
this.onImmediate(event, path);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const existing = this.pendingEvents.get(path);
|
|
55
|
+
|
|
56
|
+
// Cancel out: add + unlink on same file
|
|
57
|
+
if (existing) {
|
|
58
|
+
if ((existing.event === "add" && event === "unlink") ||
|
|
59
|
+
(existing.event === "unlink" && event === "add")) {
|
|
60
|
+
this.pendingEvents.delete(path);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Last event wins for same file
|
|
66
|
+
this.pendingEvents.set(path, { event, path, timestamp: now });
|
|
67
|
+
|
|
68
|
+
// Start/reset debounce timer
|
|
69
|
+
if (this.timer) clearTimeout(this.timer);
|
|
70
|
+
this.timer = setTimeout(() => this.flush(), this.debounceMs);
|
|
71
|
+
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Flush all pending events immediately */
|
|
76
|
+
flush(): void {
|
|
77
|
+
if (this.timer) {
|
|
78
|
+
clearTimeout(this.timer);
|
|
79
|
+
this.timer = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (this.pendingEvents.size === 0) return;
|
|
83
|
+
|
|
84
|
+
const events = [...this.pendingEvents.values()];
|
|
85
|
+
this.pendingEvents.clear();
|
|
86
|
+
this.batchCount++;
|
|
87
|
+
this.onBatch(events);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Get the number of batches processed */
|
|
91
|
+
get totalBatches(): number {
|
|
92
|
+
return this.batchCount;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Get the number of pending events */
|
|
96
|
+
get pendingCount(): number {
|
|
97
|
+
return this.pendingEvents.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Destroy the batcher, clearing any pending timers */
|
|
101
|
+
destroy(): void {
|
|
102
|
+
if (this.timer) {
|
|
103
|
+
clearTimeout(this.timer);
|
|
104
|
+
this.timer = null;
|
|
105
|
+
}
|
|
106
|
+
this.pendingEvents.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guard: reject resolved paths outside the workspace root.
|
|
5
|
+
* Throws if absPath is not under wsRoot.
|
|
6
|
+
*/
|
|
7
|
+
export function assertInsideWorkspace(absPath: string, wsRoot: string): void {
|
|
8
|
+
const normalizedPath = absPath.replace(/\\/g, "/").toLowerCase();
|
|
9
|
+
const normalizedRoot = resolve(wsRoot).replace(/\\/g, "/").toLowerCase();
|
|
10
|
+
if (!normalizedPath.startsWith(normalizedRoot + "/") && normalizedPath !== normalizedRoot) {
|
|
11
|
+
throw new Error("Access denied: path outside workspace");
|
|
12
|
+
}
|
|
13
|
+
}
|