@locusai/server 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/LICENSE +21 -0
- package/package.json +24 -0
- package/src/controllers/artifact.controller.ts +45 -0
- package/src/controllers/ci.controller.ts +16 -0
- package/src/controllers/doc.controller.ts +116 -0
- package/src/controllers/event.controller.ts +20 -0
- package/src/controllers/sprint.controller.ts +34 -0
- package/src/controllers/task.controller.ts +98 -0
- package/src/db.ts +107 -0
- package/src/index.ts +133 -0
- package/src/middleware/error.middleware.ts +21 -0
- package/src/repositories/artifact.repository.ts +44 -0
- package/src/repositories/base.repository.ts +5 -0
- package/src/repositories/comment.repository.ts +32 -0
- package/src/repositories/event.repository.ts +34 -0
- package/src/repositories/sprint.repository.ts +60 -0
- package/src/repositories/task.repository.ts +159 -0
- package/src/routes/artifacts.routes.ts +11 -0
- package/src/routes/ci.routes.ts +10 -0
- package/src/routes/docs.routes.ts +13 -0
- package/src/routes/events.routes.ts +10 -0
- package/src/routes/sprints.routes.ts +12 -0
- package/src/routes/tasks.routes.ts +18 -0
- package/src/services/ci.service.ts +65 -0
- package/src/services/sprint.service.ts +28 -0
- package/src/services/task.service.ts +182 -0
- package/src/task-processor.ts +304 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BaseRepository } from "./base.repository.js";
|
|
2
|
+
|
|
3
|
+
export interface DBComment {
|
|
4
|
+
id: number;
|
|
5
|
+
taskId: number;
|
|
6
|
+
author: string;
|
|
7
|
+
text: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CommentRepository extends BaseRepository {
|
|
12
|
+
findByTaskId(taskId: number | string): DBComment[] {
|
|
13
|
+
return this.db
|
|
14
|
+
.prepare(
|
|
15
|
+
"SELECT * FROM comments WHERE taskId = ? ORDER BY createdAt DESC"
|
|
16
|
+
)
|
|
17
|
+
.all(taskId) as DBComment[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
create(taskId: number | string, author: string, text: string): void {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
this.db
|
|
23
|
+
.prepare(
|
|
24
|
+
"INSERT INTO comments (taskId, author, text, createdAt) VALUES (?, ?, ?, ?)"
|
|
25
|
+
)
|
|
26
|
+
.run(taskId, author, text, now);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
deleteByTaskId(taskId: number | string): void {
|
|
30
|
+
this.db.prepare("DELETE FROM comments WHERE taskId = ?").run(taskId);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BaseRepository } from "./base.repository.js";
|
|
2
|
+
|
|
3
|
+
export interface DBEvent {
|
|
4
|
+
id: number;
|
|
5
|
+
taskId: number;
|
|
6
|
+
type: string;
|
|
7
|
+
payload: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class EventRepository extends BaseRepository {
|
|
12
|
+
findByTaskId(taskId: number | string): DBEvent[] {
|
|
13
|
+
return this.db
|
|
14
|
+
.prepare("SELECT * FROM events WHERE taskId = ? ORDER BY createdAt DESC")
|
|
15
|
+
.all(taskId) as DBEvent[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
create(
|
|
19
|
+
taskId: number | string,
|
|
20
|
+
type: string,
|
|
21
|
+
payload: Record<string, unknown>
|
|
22
|
+
): void {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
this.db
|
|
25
|
+
.prepare(
|
|
26
|
+
"INSERT INTO events (taskId, type, payload, createdAt) VALUES (?, ?, ?, ?)"
|
|
27
|
+
)
|
|
28
|
+
.run(String(taskId), type, JSON.stringify(payload), now);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
deleteByTaskId(taskId: number | string): void {
|
|
32
|
+
this.db.prepare("DELETE FROM events WHERE taskId = ?").run(taskId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { BaseRepository } from "./base.repository.js";
|
|
2
|
+
|
|
3
|
+
export interface DBSprint {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
status: string;
|
|
7
|
+
startDate?: number;
|
|
8
|
+
endDate?: number;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SprintRepository extends BaseRepository {
|
|
13
|
+
findAll(): DBSprint[] {
|
|
14
|
+
return this.db
|
|
15
|
+
.prepare("SELECT * FROM sprints ORDER BY createdAt DESC")
|
|
16
|
+
.all() as DBSprint[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
findById(id: number | string): DBSprint | undefined {
|
|
20
|
+
return this.db.prepare("SELECT * FROM sprints WHERE id = ?").get(id) as
|
|
21
|
+
| DBSprint
|
|
22
|
+
| undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
findActive(): DBSprint | undefined {
|
|
26
|
+
return this.db
|
|
27
|
+
.prepare("SELECT * FROM sprints WHERE status = 'ACTIVE' LIMIT 1")
|
|
28
|
+
.get() as DBSprint | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
create(name: string): number {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const result = this.db
|
|
34
|
+
.prepare(
|
|
35
|
+
"INSERT INTO sprints (name, status, createdAt) VALUES (?, 'PLANNED', ?)"
|
|
36
|
+
)
|
|
37
|
+
.run(name, now);
|
|
38
|
+
return result.lastInsertRowid as number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
update(id: number | string, updates: Partial<DBSprint>): void {
|
|
42
|
+
const fields: string[] = [];
|
|
43
|
+
const vals: unknown[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [key, val] of Object.entries(updates)) {
|
|
46
|
+
if (key === "id" || key === "createdAt") continue;
|
|
47
|
+
fields.push(`${key} = ?`);
|
|
48
|
+
vals.push(val);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (fields.length === 0) return;
|
|
52
|
+
|
|
53
|
+
vals.push(id);
|
|
54
|
+
|
|
55
|
+
const stmt = this.db.prepare(
|
|
56
|
+
`UPDATE sprints SET ${fields.join(", ")} WHERE id = ?`
|
|
57
|
+
);
|
|
58
|
+
(stmt.run as (...args: unknown[]) => void)(...vals);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
import type { Task } from "@locusai/shared";
|
|
3
|
+
import { CreateTaskData } from "../services/task.service.js";
|
|
4
|
+
import { BaseRepository } from "./base.repository.js";
|
|
5
|
+
|
|
6
|
+
export class TaskRepository extends BaseRepository {
|
|
7
|
+
findAll(): Task[] {
|
|
8
|
+
const tasks = this.db
|
|
9
|
+
.prepare("SELECT * FROM tasks ORDER BY createdAt DESC")
|
|
10
|
+
.all() as Task[];
|
|
11
|
+
|
|
12
|
+
return tasks.map((t) => this.format(t));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
findById(id: number | string): Task | undefined {
|
|
16
|
+
const task = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as
|
|
17
|
+
| Task
|
|
18
|
+
| undefined;
|
|
19
|
+
|
|
20
|
+
return task ? this.format(task) : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
create(data: CreateTaskData): number {
|
|
24
|
+
const {
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
status,
|
|
28
|
+
priority,
|
|
29
|
+
labels,
|
|
30
|
+
assigneeRole,
|
|
31
|
+
parentId,
|
|
32
|
+
sprintId,
|
|
33
|
+
acceptanceChecklist,
|
|
34
|
+
} = data;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
const result = this.db
|
|
38
|
+
.prepare(`
|
|
39
|
+
INSERT INTO tasks (title, description, status, priority, labels, assigneeRole, parentId, sprintId, acceptanceChecklist, createdAt, updatedAt)
|
|
40
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
41
|
+
`)
|
|
42
|
+
.run(
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
status,
|
|
46
|
+
priority || "MEDIUM",
|
|
47
|
+
JSON.stringify(labels || []),
|
|
48
|
+
assigneeRole ?? null,
|
|
49
|
+
parentId ?? null,
|
|
50
|
+
sprintId ?? null,
|
|
51
|
+
JSON.stringify(acceptanceChecklist || []),
|
|
52
|
+
now,
|
|
53
|
+
now
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return result.lastInsertRowid as number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
update(id: number | string, updates: Partial<Task>): void {
|
|
60
|
+
const fields: string[] = [];
|
|
61
|
+
const vals: unknown[] = [];
|
|
62
|
+
|
|
63
|
+
for (const [key, val] of Object.entries(updates)) {
|
|
64
|
+
if (key === "id" || key === "createdAt" || key === "updatedAt") continue;
|
|
65
|
+
fields.push(`${key} = ?`);
|
|
66
|
+
vals.push(typeof val === "object" ? JSON.stringify(val) : val);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fields.length === 0) return;
|
|
70
|
+
|
|
71
|
+
vals.push(Date.now(), id);
|
|
72
|
+
|
|
73
|
+
const stmt = this.db.prepare(
|
|
74
|
+
`UPDATE tasks SET ${fields.join(", ")}, updatedAt = ? WHERE id = ?`
|
|
75
|
+
);
|
|
76
|
+
(stmt.run as (...args: unknown[]) => void)(...vals);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
delete(id: number | string): void {
|
|
80
|
+
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
findCandidateForDispatch(
|
|
84
|
+
now: number,
|
|
85
|
+
sprintId: number,
|
|
86
|
+
agentName: string,
|
|
87
|
+
expiresAt: number
|
|
88
|
+
): Task | undefined {
|
|
89
|
+
return this.db.transaction(() => {
|
|
90
|
+
const query = `
|
|
91
|
+
SELECT * FROM tasks
|
|
92
|
+
WHERE status = 'BACKLOG'
|
|
93
|
+
AND (lockedBy IS NULL OR lockExpiresAt < ?)
|
|
94
|
+
AND sprintId = ?
|
|
95
|
+
ORDER BY
|
|
96
|
+
CASE priority
|
|
97
|
+
WHEN 'CRITICAL' THEN 1
|
|
98
|
+
WHEN 'HIGH' THEN 2
|
|
99
|
+
WHEN 'MEDIUM' THEN 3
|
|
100
|
+
WHEN 'LOW' THEN 4
|
|
101
|
+
ELSE 5
|
|
102
|
+
END ASC,
|
|
103
|
+
createdAt ASC
|
|
104
|
+
LIMIT 1
|
|
105
|
+
`;
|
|
106
|
+
const params: SQLQueryBindings[] = [now, sprintId];
|
|
107
|
+
|
|
108
|
+
const candidate = this.db.prepare(query).get(...params) as
|
|
109
|
+
| Task
|
|
110
|
+
| undefined;
|
|
111
|
+
|
|
112
|
+
if (!candidate) return undefined;
|
|
113
|
+
|
|
114
|
+
// Lock it
|
|
115
|
+
this.db
|
|
116
|
+
.prepare(
|
|
117
|
+
"UPDATE tasks SET lockedBy = ?, lockExpiresAt = ?, updatedAt = ? WHERE id = ?"
|
|
118
|
+
)
|
|
119
|
+
.run(agentName, expiresAt, now, candidate.id);
|
|
120
|
+
|
|
121
|
+
return this.format(candidate);
|
|
122
|
+
})();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lock(
|
|
126
|
+
id: number | string,
|
|
127
|
+
agentId: string,
|
|
128
|
+
expiresAt: number,
|
|
129
|
+
now: number
|
|
130
|
+
): void {
|
|
131
|
+
this.db
|
|
132
|
+
.prepare(
|
|
133
|
+
"UPDATE tasks SET lockedBy = ?, lockExpiresAt = ?, updatedAt = ? WHERE id = ?"
|
|
134
|
+
)
|
|
135
|
+
.run(agentId, expiresAt, now, id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
unlock(id: number | string, now: number): void {
|
|
139
|
+
this.db
|
|
140
|
+
.prepare(
|
|
141
|
+
"UPDATE tasks SET lockedBy = NULL, lockExpiresAt = NULL, updatedAt = ? WHERE id = ?"
|
|
142
|
+
)
|
|
143
|
+
.run(now, id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private format(task: Task): Task {
|
|
147
|
+
return {
|
|
148
|
+
...task,
|
|
149
|
+
labels:
|
|
150
|
+
typeof task.labels === "string"
|
|
151
|
+
? JSON.parse(task.labels || "[]")
|
|
152
|
+
: task.labels || [],
|
|
153
|
+
acceptanceChecklist:
|
|
154
|
+
typeof task.acceptanceChecklist === "string"
|
|
155
|
+
? JSON.parse(task.acceptanceChecklist || "[]")
|
|
156
|
+
: task.acceptanceChecklist || [],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { ArtifactController } from "../controllers/artifact.controller.js";
|
|
3
|
+
|
|
4
|
+
export function createArtifactsRouter(controller: ArtifactController) {
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.get("/artifacts/:taskId", controller.getByTaskId);
|
|
8
|
+
router.get("/artifacts/:taskId/:type/:filename", controller.getArtifactFile);
|
|
9
|
+
|
|
10
|
+
return router;
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { DocController } from "../controllers/doc.controller.js";
|
|
3
|
+
|
|
4
|
+
export function createDocsRouter(controller: DocController) {
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.get("/", controller.getAll);
|
|
8
|
+
router.get("/tree", controller.getTree);
|
|
9
|
+
router.get("/read", controller.read);
|
|
10
|
+
router.post("/write", controller.write);
|
|
11
|
+
|
|
12
|
+
return router;
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { EventController } from "../controllers/event.controller.js";
|
|
3
|
+
|
|
4
|
+
export function createEventsRouter(controller: EventController) {
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.get("/", controller.getByTaskId);
|
|
8
|
+
|
|
9
|
+
return router;
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { SprintController } from "../controllers/sprint.controller.js";
|
|
3
|
+
|
|
4
|
+
export function createSprintsRouter(controller: SprintController) {
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.get("/", controller.getAll);
|
|
8
|
+
router.post("/", controller.create);
|
|
9
|
+
router.patch("/:id", controller.updateStatus);
|
|
10
|
+
|
|
11
|
+
return router;
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { TaskController } from "../controllers/task.controller.js";
|
|
3
|
+
|
|
4
|
+
export function createTaskRouter(controller: TaskController) {
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.get("/", controller.getAll);
|
|
8
|
+
router.get("/:id", controller.getById);
|
|
9
|
+
router.post("/", controller.create);
|
|
10
|
+
router.patch("/:id", controller.update);
|
|
11
|
+
router.delete("/:id", controller.delete);
|
|
12
|
+
router.post("/:id/comment", controller.addComment);
|
|
13
|
+
router.post("/dispatch", controller.dispatch);
|
|
14
|
+
router.post("/:id/lock", controller.lock);
|
|
15
|
+
router.post("/:id/unlock", controller.unlock);
|
|
16
|
+
|
|
17
|
+
return router;
|
|
18
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { ArtifactRepository } from "../repositories/artifact.repository.js";
|
|
3
|
+
import type { EventRepository } from "../repositories/event.repository.js";
|
|
4
|
+
import { ServiceError } from "./task.service.js";
|
|
5
|
+
|
|
6
|
+
export class CiService {
|
|
7
|
+
constructor(
|
|
8
|
+
private artifactRepo: ArtifactRepository,
|
|
9
|
+
private eventRepo: EventRepository,
|
|
10
|
+
private config: { ciPresetsPath: string; repoPath: string }
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async runCi(taskId: number | string, preset: string) {
|
|
14
|
+
const presets = JSON.parse(
|
|
15
|
+
readFileSync(this.config.ciPresetsPath, "utf-8")
|
|
16
|
+
);
|
|
17
|
+
const commands = presets[preset];
|
|
18
|
+
if (!commands) throw new ServiceError(`Preset ${preset} not found`);
|
|
19
|
+
|
|
20
|
+
const results = [];
|
|
21
|
+
let allOk = true;
|
|
22
|
+
let combinedOutput = "";
|
|
23
|
+
|
|
24
|
+
for (const cmd of commands) {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
try {
|
|
27
|
+
if (/[;&|><$`\n]/.test(cmd)) throw new Error("Invalid command");
|
|
28
|
+
|
|
29
|
+
const proc = Bun.spawn(cmd.split(" "), {
|
|
30
|
+
cwd: this.config.repoPath,
|
|
31
|
+
stdout: "pipe",
|
|
32
|
+
stderr: "pipe",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const stdout = await new Response(proc.stdout).text();
|
|
36
|
+
const stderr = await new Response(proc.stderr).text();
|
|
37
|
+
const exitCode = await proc.exited;
|
|
38
|
+
|
|
39
|
+
const duration = Date.now() - start;
|
|
40
|
+
results.push({ cmd, exitCode, durationMs: duration });
|
|
41
|
+
combinedOutput += `\n> ${cmd}\n${stdout}${stderr}\n`;
|
|
42
|
+
if (exitCode !== 0) allOk = false;
|
|
43
|
+
} catch (err: unknown) {
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
results.push({ cmd, exitCode: -1, error: message });
|
|
46
|
+
allOk = false;
|
|
47
|
+
combinedOutput += `\n> ${cmd}\nError: ${message}\n`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const summary = allOk ? "All checks passed" : "Some checks failed";
|
|
52
|
+
|
|
53
|
+
this.artifactRepo.create({
|
|
54
|
+
taskId: Number(taskId),
|
|
55
|
+
type: "CI_OUTPUT",
|
|
56
|
+
title: `CI Run: ${preset}`,
|
|
57
|
+
contentText: combinedOutput,
|
|
58
|
+
createdBy: "system",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.eventRepo.create(taskId, "CI_RAN", { preset, ok: allOk, summary });
|
|
62
|
+
|
|
63
|
+
return { ok: allOk, preset, commands: results, summary };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DBSprint,
|
|
3
|
+
SprintRepository,
|
|
4
|
+
} from "../repositories/sprint.repository.js";
|
|
5
|
+
|
|
6
|
+
export class SprintService {
|
|
7
|
+
constructor(private sprintRepo: SprintRepository) {}
|
|
8
|
+
|
|
9
|
+
getAllSprints() {
|
|
10
|
+
return this.sprintRepo.findAll();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getSprintById(id: number | string) {
|
|
14
|
+
return this.sprintRepo.findById(id);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
createSprint(name: string) {
|
|
18
|
+
return this.sprintRepo.create(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
updateSprint(id: number | string, updates: Partial<DBSprint>) {
|
|
22
|
+
this.sprintRepo.update(id, updates);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getActiveSprint() {
|
|
26
|
+
return this.sprintRepo.findActive();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceItem,
|
|
3
|
+
AssigneeRole,
|
|
4
|
+
EventType,
|
|
5
|
+
Task,
|
|
6
|
+
TaskPriority,
|
|
7
|
+
TaskStatus,
|
|
8
|
+
} from "@locusai/shared";
|
|
9
|
+
import type { ArtifactRepository } from "../repositories/artifact.repository.js";
|
|
10
|
+
import type { CommentRepository } from "../repositories/comment.repository.js";
|
|
11
|
+
import type { EventRepository } from "../repositories/event.repository.js";
|
|
12
|
+
import type { TaskRepository } from "../repositories/task.repository.js";
|
|
13
|
+
import type { TaskProcessor } from "../task-processor.js";
|
|
14
|
+
|
|
15
|
+
export class ServiceError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
public message: string,
|
|
18
|
+
public statusCode: number = 400
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ServiceError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CreateTaskData {
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
status: TaskStatus;
|
|
29
|
+
priority: TaskPriority;
|
|
30
|
+
labels: string[];
|
|
31
|
+
assigneeRole?: AssigneeRole;
|
|
32
|
+
parentId?: number | null;
|
|
33
|
+
sprintId?: number | null;
|
|
34
|
+
acceptanceChecklist?: AcceptanceItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TaskService {
|
|
38
|
+
constructor(
|
|
39
|
+
private taskRepo: TaskRepository,
|
|
40
|
+
private eventRepo: EventRepository,
|
|
41
|
+
private commentRepo: CommentRepository,
|
|
42
|
+
private artifactRepo: ArtifactRepository,
|
|
43
|
+
private processor: TaskProcessor
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
getAllTasks(): Task[] {
|
|
47
|
+
return this.taskRepo.findAll();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getTaskById(id: number | string): Task {
|
|
51
|
+
const task = this.taskRepo.findById(id);
|
|
52
|
+
if (!task) throw new ServiceError("Task not found", 404);
|
|
53
|
+
|
|
54
|
+
const events = this.eventRepo.findByTaskId(id);
|
|
55
|
+
const comments = this.commentRepo.findByTaskId(id);
|
|
56
|
+
const artifacts = this.artifactRepo.findByTaskId(id);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...task,
|
|
60
|
+
activityLog: events.map((e) => ({
|
|
61
|
+
id: e.id,
|
|
62
|
+
taskId: Number(e.taskId),
|
|
63
|
+
type: e.type as EventType,
|
|
64
|
+
payload: JSON.parse(e.payload || "{}"),
|
|
65
|
+
createdAt: e.createdAt,
|
|
66
|
+
})),
|
|
67
|
+
comments: comments.map((c) => ({
|
|
68
|
+
id: c.id,
|
|
69
|
+
taskId: Number(c.taskId),
|
|
70
|
+
author: c.author,
|
|
71
|
+
text: c.text,
|
|
72
|
+
createdAt: c.createdAt,
|
|
73
|
+
})),
|
|
74
|
+
artifacts: artifacts.map((a) => ({
|
|
75
|
+
id: a.id,
|
|
76
|
+
taskId: Number(a.taskId),
|
|
77
|
+
type: a.type,
|
|
78
|
+
title: a.title,
|
|
79
|
+
url: a.filePath || "",
|
|
80
|
+
size: "0",
|
|
81
|
+
createdBy: a.createdBy || "system",
|
|
82
|
+
createdAt: a.createdAt,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
createTask(data: CreateTaskData): number {
|
|
88
|
+
const id = this.taskRepo.create(data);
|
|
89
|
+
this.eventRepo.create(id, "TASK_CREATED", { title: data.title });
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
updateTask(id: number | string, updates: Partial<Task>): void {
|
|
94
|
+
const oldTask = this.taskRepo.findById(id);
|
|
95
|
+
if (!oldTask) throw new ServiceError("Task not found", 404);
|
|
96
|
+
|
|
97
|
+
if (updates.status === "DONE" && oldTask.status !== "VERIFICATION") {
|
|
98
|
+
throw new ServiceError(
|
|
99
|
+
"Cannot move directly to DONE. Tasks must be in VERIFICATION first for human review."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.taskRepo.update(id, updates);
|
|
104
|
+
|
|
105
|
+
if (updates.status && updates.status !== oldTask.status) {
|
|
106
|
+
this.eventRepo.create(id, "STATUS_CHANGED", {
|
|
107
|
+
oldStatus: oldTask.status,
|
|
108
|
+
newStatus: updates.status,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this.processor.onStatusChanged(
|
|
112
|
+
id.toString(),
|
|
113
|
+
oldTask.status,
|
|
114
|
+
updates.status
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
deleteTask(id: number | string): void {
|
|
120
|
+
const task = this.taskRepo.findById(id);
|
|
121
|
+
if (!task) throw new ServiceError("Task not found", 404);
|
|
122
|
+
|
|
123
|
+
this.commentRepo.deleteByTaskId(id);
|
|
124
|
+
this.eventRepo.deleteByTaskId(id);
|
|
125
|
+
this.artifactRepo.deleteByTaskId(id);
|
|
126
|
+
this.taskRepo.delete(id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
addComment(taskId: number | string, author: string, text: string): void {
|
|
130
|
+
this.commentRepo.create(taskId, author, text);
|
|
131
|
+
this.eventRepo.create(taskId, "COMMENT_ADDED", { author, text });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
dispatchTask(workerId: string, sprintId: number): Task {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const expiresAt = now + 3600 * 1000;
|
|
137
|
+
const agentName = workerId || `agent-${crypto.randomUUID().slice(0, 8)}`;
|
|
138
|
+
|
|
139
|
+
const task = this.taskRepo.findCandidateForDispatch(
|
|
140
|
+
now,
|
|
141
|
+
sprintId,
|
|
142
|
+
agentName,
|
|
143
|
+
expiresAt
|
|
144
|
+
);
|
|
145
|
+
if (!task) throw new ServiceError("No tasks available", 404);
|
|
146
|
+
|
|
147
|
+
this.eventRepo.create(task.id, "LOCKED", { agentId: agentName, expiresAt });
|
|
148
|
+
return task;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lockTask(id: number | string, agentId: string, ttlSeconds: number): void {
|
|
152
|
+
const task = this.taskRepo.findById(id);
|
|
153
|
+
if (!task) throw new ServiceError("Task not found", 404);
|
|
154
|
+
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const expiresAt = now + ttlSeconds * 1000;
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
task.lockedBy &&
|
|
160
|
+
task.lockedBy !== agentId &&
|
|
161
|
+
(task.lockExpiresAt || 0) > now
|
|
162
|
+
) {
|
|
163
|
+
throw new ServiceError(`Task locked by ${task.lockedBy}`, 403);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.taskRepo.lock(id, agentId, expiresAt, now);
|
|
167
|
+
this.eventRepo.create(id, "LOCKED", { agentId, expiresAt });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
unlockTask(id: number | string, agentId: string): void {
|
|
171
|
+
const task = this.taskRepo.findById(id);
|
|
172
|
+
if (!task) throw new ServiceError("Task not found", 404);
|
|
173
|
+
|
|
174
|
+
if (task.lockedBy && task.lockedBy !== agentId && agentId !== "human") {
|
|
175
|
+
throw new ServiceError("Not authorized to unlock", 403);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
this.taskRepo.unlock(id, now);
|
|
180
|
+
this.eventRepo.create(id, "UNLOCKED", { agentId });
|
|
181
|
+
}
|
|
182
|
+
}
|