feinai 0.7.2 → 0.8.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 +20 -0
- package/package.json +1 -1
- package/skills/feinai-implement/SKILL.md +19 -3
- package/src/cli.ts +78 -75
- package/src/dashboard.html +1283 -1304
- package/src/db.ts +15 -1
- package/src/format.ts +1 -4
- package/src/server-state.ts +72 -8
- package/src/specs.ts +9 -9
- package/src/tasks.ts +2 -2
package/src/db.ts
CHANGED
|
@@ -16,7 +16,7 @@ const SCHEMA_STATEMENTS: string[] = [
|
|
|
16
16
|
id TEXT PRIMARY KEY,
|
|
17
17
|
numero INTEGER,
|
|
18
18
|
title TEXT NOT NULL,
|
|
19
|
-
status TEXT NOT NULL DEFAULT '
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
20
20
|
content TEXT,
|
|
21
21
|
pr TEXT,
|
|
22
22
|
merged_date TEXT,
|
|
@@ -133,6 +133,20 @@ const MIGRATIONS: Array<{ version: number; stmts: string[] }> = [
|
|
|
133
133
|
`ALTER TABLE tasks ADD COLUMN worktree TEXT`,
|
|
134
134
|
],
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
version: 3,
|
|
138
|
+
stmts: [
|
|
139
|
+
// Normalize spec status names from Spanish to English.
|
|
140
|
+
`UPDATE specs SET status = CASE status
|
|
141
|
+
WHEN 'lista' THEN 'pending'
|
|
142
|
+
WHEN 'en_progreso' THEN 'in_progress'
|
|
143
|
+
WHEN 'hecha' THEN 'completed'
|
|
144
|
+
WHEN 'archivada' THEN 'archived'
|
|
145
|
+
ELSE status
|
|
146
|
+
END
|
|
147
|
+
WHERE status IN ('lista', 'en_progreso', 'hecha', 'archivada')`,
|
|
148
|
+
],
|
|
149
|
+
},
|
|
136
150
|
];
|
|
137
151
|
|
|
138
152
|
function applyMigrations(db: DbInstance): void {
|
package/src/format.ts
CHANGED
|
@@ -26,10 +26,7 @@ const STATUS_COLOR: Record<string, keyof typeof COLORS> = {
|
|
|
26
26
|
completed: "green",
|
|
27
27
|
failed: "red",
|
|
28
28
|
deleted: "gray",
|
|
29
|
-
|
|
30
|
-
en_progreso: "blue",
|
|
31
|
-
hecha: "green",
|
|
32
|
-
archivada: "gray",
|
|
29
|
+
archived: "gray",
|
|
33
30
|
};
|
|
34
31
|
|
|
35
32
|
export function formatTask(task: Task, format: OutputFormat): string {
|
package/src/server-state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
3
4
|
import type { DbInstance } from "./db";
|
|
4
5
|
|
|
5
6
|
export interface ServerState {
|
|
@@ -105,17 +106,16 @@ function isPidRunning(pid: number): boolean {
|
|
|
105
106
|
* Get the port that a specific pid is listening on.
|
|
106
107
|
* Returns the port number or null if the pid has no tcp listeners.
|
|
107
108
|
*/
|
|
108
|
-
function portOfPid(pid: number): number | null {
|
|
109
|
+
export function portOfPid(pid: number): number | null {
|
|
109
110
|
try {
|
|
110
|
-
|
|
111
|
+
// Use -a so that -p and -i are ANDed together, otherwise lsof returns
|
|
112
|
+
// all internet listeners OR all files of pid.
|
|
113
|
+
const result = spawnSync("lsof", ["-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
|
|
111
114
|
if (result.status !== 0) return null;
|
|
112
|
-
// Parse lsof output: find "TCP *:{port}" pattern
|
|
113
115
|
const out = result.stdout.toString();
|
|
114
|
-
|
|
116
|
+
// Parse "TCP {host}:{port} (LISTEN)" or "TCP *:{port} (LISTEN)"
|
|
117
|
+
const match = out.match(/TCP\s+[^\s:]+:(\d+)\s+\(LISTEN\)/);
|
|
115
118
|
if (match) return parseInt(match[1]!, 10);
|
|
116
|
-
// Alternate format: "LISTEN" followed by port
|
|
117
|
-
const altMatch = out.match(/:(\d+)\s.*LISTEN/);
|
|
118
|
-
if (altMatch) return parseInt(altMatch[1]!, 10);
|
|
119
119
|
return null;
|
|
120
120
|
} catch {
|
|
121
121
|
return null;
|
|
@@ -178,3 +178,67 @@ export function repairServerState(db: DbInstance): ServerState | null {
|
|
|
178
178
|
const { valid, record } = validateServerState(db);
|
|
179
179
|
return valid ? record : null;
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
export interface OrphanKillResult {
|
|
183
|
+
pid: number;
|
|
184
|
+
port: number | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Kill any TCP-listening processes whose current working directory matches
|
|
189
|
+
* the project directory, regardless of whether they are recorded in the DB.
|
|
190
|
+
*
|
|
191
|
+
* Used by `feinai server --down` to clean up orphan/unregistered servers.
|
|
192
|
+
* Returns a list of killed pids (and their listening port, when detectable).
|
|
193
|
+
*/
|
|
194
|
+
export function killOrphansByProjectDir(projectDir: string): OrphanKillResult[] {
|
|
195
|
+
const killed: OrphanKillResult[] = [];
|
|
196
|
+
const normalizedProject = resolve(projectDir);
|
|
197
|
+
|
|
198
|
+
// Collect all pids that are listening on any TCP port.
|
|
199
|
+
const listenerPids = new Set<number>();
|
|
200
|
+
try {
|
|
201
|
+
const result = spawnSync("lsof", ["-t", "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
|
|
202
|
+
if (result.status !== 0) return killed;
|
|
203
|
+
result.stdout
|
|
204
|
+
.toString()
|
|
205
|
+
.trim()
|
|
206
|
+
.split("\n")
|
|
207
|
+
.forEach((line) => {
|
|
208
|
+
const pid = parseInt(line.trim(), 10);
|
|
209
|
+
if (!isNaN(pid)) listenerPids.add(pid);
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
return killed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const pid of listenerPids) {
|
|
216
|
+
if (pid === process.pid) continue;
|
|
217
|
+
|
|
218
|
+
let cwd: string | null = null;
|
|
219
|
+
try {
|
|
220
|
+
if (process.platform === "linux") {
|
|
221
|
+
cwd = realpathSync(`/proc/${pid}/cwd`, { encoding: "utf8" });
|
|
222
|
+
} else {
|
|
223
|
+
const result = spawnSync("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-F", "n"]);
|
|
224
|
+
if (result.status !== 0) continue;
|
|
225
|
+
const match = result.stdout.toString().match(/^n(.+)$/m);
|
|
226
|
+
if (match) cwd = match[1]!;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!cwd) continue;
|
|
233
|
+
if (resolve(cwd) !== normalizedProject) continue;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
process.kill(pid, "SIGTERM");
|
|
237
|
+
killed.push({ pid, port: portOfPid(pid) });
|
|
238
|
+
} catch {
|
|
239
|
+
// Process may have exited between discovery and signal; ignore.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return killed;
|
|
244
|
+
}
|
package/src/specs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DbInstance } from "./db";
|
|
2
2
|
import { recordEvent } from "./db";
|
|
3
3
|
|
|
4
|
-
export type SpecStatus = "
|
|
4
|
+
export type SpecStatus = "pending" | "in_progress" | "completed" | "archived";
|
|
5
5
|
|
|
6
6
|
export interface Spec {
|
|
7
7
|
id: string;
|
|
@@ -51,9 +51,9 @@ export function addSpec(db: DbInstance, input: AddSpecInput, actor: string | nul
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
db.prepare(
|
|
54
|
-
`INSERT INTO specs (id, numero, title, content)
|
|
55
|
-
VALUES (?, ?, ?, ?)`,
|
|
56
|
-
).run(input.id, numero ?? null, input.title, input.content ?? null);
|
|
54
|
+
`INSERT INTO specs (id, numero, title, status, content)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
56
|
+
).run(input.id, numero ?? null, input.title, "pending", input.content ?? null);
|
|
57
57
|
|
|
58
58
|
recordEvent(db, "spec", input.id, "created", { title: input.title }, actor);
|
|
59
59
|
|
|
@@ -63,8 +63,8 @@ export function addSpec(db: DbInstance, input: AddSpecInput, actor: string | nul
|
|
|
63
63
|
export function startSpec(db: DbInstance, id: string, actor: string | null = null): Spec {
|
|
64
64
|
const upd = db
|
|
65
65
|
.prepare(
|
|
66
|
-
`UPDATE specs SET status = '
|
|
67
|
-
WHERE id = ? AND status = '
|
|
66
|
+
`UPDATE specs SET status = 'in_progress', updated_at = datetime('now')
|
|
67
|
+
WHERE id = ? AND status = 'pending'`,
|
|
68
68
|
)
|
|
69
69
|
.run(id);
|
|
70
70
|
|
|
@@ -86,7 +86,7 @@ export function doneSpec(
|
|
|
86
86
|
): Spec {
|
|
87
87
|
const upd = db
|
|
88
88
|
.prepare(
|
|
89
|
-
`UPDATE specs SET status = '
|
|
89
|
+
`UPDATE specs SET status = 'completed',
|
|
90
90
|
pr = COALESCE(?, pr),
|
|
91
91
|
merged_date = COALESCE(?, merged_date),
|
|
92
92
|
updated_at = datetime('now')
|
|
@@ -228,7 +228,7 @@ export function archiveSpec(
|
|
|
228
228
|
|
|
229
229
|
const upd = db
|
|
230
230
|
.prepare(
|
|
231
|
-
`UPDATE specs SET status = '
|
|
231
|
+
`UPDATE specs SET status = 'archived', updated_at = datetime('now') WHERE id = ?`,
|
|
232
232
|
)
|
|
233
233
|
.run(id);
|
|
234
234
|
|
|
@@ -256,7 +256,7 @@ export function unarchiveSpec(
|
|
|
256
256
|
|
|
257
257
|
const upd = db
|
|
258
258
|
.prepare(
|
|
259
|
-
`UPDATE specs SET status = '
|
|
259
|
+
`UPDATE specs SET status = 'pending', updated_at = datetime('now') WHERE id = ? AND status = 'archived'`,
|
|
260
260
|
)
|
|
261
261
|
.run(id);
|
|
262
262
|
|
package/src/tasks.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function listTasks(db: DbInstance, filter: ListFilter = {}): Task[] {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// Exclude tasks from archived specs so archived work leaves the active task list
|
|
83
|
-
where.push("(spec_id IS NULL OR spec_id NOT IN (SELECT id FROM specs WHERE status = '
|
|
83
|
+
where.push("(spec_id IS NULL OR spec_id NOT IN (SELECT id FROM specs WHERE status = 'archived'))");
|
|
84
84
|
|
|
85
85
|
const sql =
|
|
86
86
|
`SELECT * FROM tasks` +
|
|
@@ -163,7 +163,7 @@ export function takeTask(
|
|
|
163
163
|
|
|
164
164
|
if (task.spec_id) {
|
|
165
165
|
const spec = getSpec(db, task.spec_id);
|
|
166
|
-
if (spec && spec.status === '
|
|
166
|
+
if (spec && spec.status === 'pending') {
|
|
167
167
|
startSpec(db, task.spec_id, owner);
|
|
168
168
|
}
|
|
169
169
|
}
|