@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/task-store.mjs
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-store.mjs — Internal JSON kanban store (source of truth for all task state)
|
|
3
|
+
*
|
|
4
|
+
* Stores data in .cache/kanban-state.json relative to this file.
|
|
5
|
+
* Provides an in-memory cache with auto-persist on every mutation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import {
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
const TAG = "[task-store]";
|
|
21
|
+
|
|
22
|
+
function inferRepoRoot(startDir) {
|
|
23
|
+
let current = resolve(startDir || process.cwd());
|
|
24
|
+
while (true) {
|
|
25
|
+
if (existsSync(resolve(current, ".git"))) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
const parent = dirname(current);
|
|
29
|
+
if (parent === current) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveDefaultStorePath() {
|
|
37
|
+
const repoRoot =
|
|
38
|
+
inferRepoRoot(process.cwd()) || resolve(__dirname, "..", "..");
|
|
39
|
+
return resolve(repoRoot, ".openfleet", ".cache", "kanban-state.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let storePath = resolveDefaultStorePath();
|
|
43
|
+
let storeTmpPath = storePath + ".tmp";
|
|
44
|
+
const MAX_STATUS_HISTORY = 50;
|
|
45
|
+
const MAX_AGENT_OUTPUT = 2000;
|
|
46
|
+
const MAX_ERROR_LENGTH = 1000;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// In-memory state
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
let _store = null; // { _meta: {...}, tasks: { [id]: Task } }
|
|
53
|
+
let _loaded = false;
|
|
54
|
+
let _writeChain = Promise.resolve(); // simple write lock
|
|
55
|
+
|
|
56
|
+
export function configureTaskStore(options = {}) {
|
|
57
|
+
const baseDir = options.baseDir ? resolve(options.baseDir) : null;
|
|
58
|
+
const nextPath = options.storePath
|
|
59
|
+
? resolve(baseDir || process.cwd(), options.storePath)
|
|
60
|
+
: resolve(
|
|
61
|
+
baseDir ||
|
|
62
|
+
inferRepoRoot(process.cwd()) ||
|
|
63
|
+
resolve(__dirname, "..", ".."),
|
|
64
|
+
".openfleet",
|
|
65
|
+
".cache",
|
|
66
|
+
"kanban-state.json",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (nextPath !== storePath) {
|
|
70
|
+
storePath = nextPath;
|
|
71
|
+
storeTmpPath = storePath + ".tmp";
|
|
72
|
+
_store = null;
|
|
73
|
+
_loaded = false;
|
|
74
|
+
_writeChain = Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return storePath;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Helpers
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function now() {
|
|
85
|
+
return new Date().toISOString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function truncate(str, max) {
|
|
89
|
+
if (str == null) return null;
|
|
90
|
+
const s = String(str);
|
|
91
|
+
return s.length > max ? s.slice(0, max) : s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeTags(raw) {
|
|
95
|
+
if (!raw) return [];
|
|
96
|
+
const values = Array.isArray(raw)
|
|
97
|
+
? raw
|
|
98
|
+
: String(raw || "")
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((entry) => entry.trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const tags = [];
|
|
104
|
+
for (const value of values) {
|
|
105
|
+
const normalized = String(value || "")
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase();
|
|
108
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
109
|
+
seen.add(normalized);
|
|
110
|
+
tags.push(normalized);
|
|
111
|
+
}
|
|
112
|
+
return tags;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultMeta() {
|
|
116
|
+
return {
|
|
117
|
+
version: 1,
|
|
118
|
+
projectId: null,
|
|
119
|
+
lastFullSync: null,
|
|
120
|
+
taskCount: 0,
|
|
121
|
+
stats: {
|
|
122
|
+
draft: 0,
|
|
123
|
+
todo: 0,
|
|
124
|
+
inprogress: 0,
|
|
125
|
+
inreview: 0,
|
|
126
|
+
done: 0,
|
|
127
|
+
blocked: 0,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function defaultTask(overrides = {}) {
|
|
133
|
+
const ts = now();
|
|
134
|
+
return {
|
|
135
|
+
id: null,
|
|
136
|
+
title: "",
|
|
137
|
+
description: "",
|
|
138
|
+
status: "todo",
|
|
139
|
+
externalStatus: null,
|
|
140
|
+
externalId: null,
|
|
141
|
+
externalBackend: null,
|
|
142
|
+
assignee: null,
|
|
143
|
+
priority: null,
|
|
144
|
+
tags: [],
|
|
145
|
+
draft: false,
|
|
146
|
+
projectId: null,
|
|
147
|
+
baseBranch: null,
|
|
148
|
+
branchName: null,
|
|
149
|
+
prNumber: null,
|
|
150
|
+
prUrl: null,
|
|
151
|
+
|
|
152
|
+
createdAt: ts,
|
|
153
|
+
updatedAt: ts,
|
|
154
|
+
lastActivityAt: ts,
|
|
155
|
+
statusHistory: [],
|
|
156
|
+
|
|
157
|
+
agentAttempts: 0,
|
|
158
|
+
consecutiveNoCommits: 0,
|
|
159
|
+
lastAgentOutput: null,
|
|
160
|
+
lastError: null,
|
|
161
|
+
errorPattern: null,
|
|
162
|
+
|
|
163
|
+
reviewStatus: null,
|
|
164
|
+
reviewIssues: null,
|
|
165
|
+
reviewedAt: null,
|
|
166
|
+
|
|
167
|
+
cooldownUntil: null,
|
|
168
|
+
blockedReason: null,
|
|
169
|
+
|
|
170
|
+
lastSyncedAt: null,
|
|
171
|
+
syncDirty: false,
|
|
172
|
+
|
|
173
|
+
meta: {},
|
|
174
|
+
...overrides,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function recalcStats() {
|
|
179
|
+
const stats = {
|
|
180
|
+
draft: 0,
|
|
181
|
+
todo: 0,
|
|
182
|
+
inprogress: 0,
|
|
183
|
+
inreview: 0,
|
|
184
|
+
done: 0,
|
|
185
|
+
blocked: 0,
|
|
186
|
+
};
|
|
187
|
+
for (const t of Object.values(_store.tasks)) {
|
|
188
|
+
if (t.status === "blocked") {
|
|
189
|
+
stats.blocked++;
|
|
190
|
+
} else if (stats[t.status] !== undefined) {
|
|
191
|
+
stats[t.status]++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
_store._meta.taskCount = Object.keys(_store.tasks).length;
|
|
195
|
+
_store._meta.stats = stats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ensureLoaded() {
|
|
199
|
+
if (!_loaded) {
|
|
200
|
+
loadStore();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Store management
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load store from disk. Called automatically on first access.
|
|
210
|
+
*/
|
|
211
|
+
export function loadStore() {
|
|
212
|
+
try {
|
|
213
|
+
if (existsSync(storePath)) {
|
|
214
|
+
const raw = readFileSync(storePath, "utf-8");
|
|
215
|
+
const data = JSON.parse(raw);
|
|
216
|
+
_store = {
|
|
217
|
+
_meta: { ...defaultMeta(), ...(data._meta || {}) },
|
|
218
|
+
tasks: data.tasks || {},
|
|
219
|
+
};
|
|
220
|
+
console.log(
|
|
221
|
+
TAG,
|
|
222
|
+
`Loaded ${Object.keys(_store.tasks).length} tasks from disk`,
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
_store = { _meta: defaultMeta(), tasks: {} };
|
|
226
|
+
console.log(TAG, "No store file found — initialised empty store");
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(TAG, "Failed to load store, starting fresh:", err.message);
|
|
230
|
+
_store = { _meta: defaultMeta(), tasks: {} };
|
|
231
|
+
}
|
|
232
|
+
_loaded = true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Persist store to disk (atomic write via tmp+rename).
|
|
237
|
+
*/
|
|
238
|
+
export function saveStore() {
|
|
239
|
+
ensureLoaded();
|
|
240
|
+
recalcStats();
|
|
241
|
+
|
|
242
|
+
_writeChain = _writeChain
|
|
243
|
+
.then(() => {
|
|
244
|
+
try {
|
|
245
|
+
const dir = dirname(storePath);
|
|
246
|
+
if (!existsSync(dir)) {
|
|
247
|
+
mkdirSync(dir, { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
const json = JSON.stringify(_store, null, 2);
|
|
250
|
+
writeFileSync(storeTmpPath, json, "utf-8");
|
|
251
|
+
renameSync(storeTmpPath, storePath);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error(TAG, "Failed to save store:", err.message);
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
.catch((err) => {
|
|
257
|
+
console.error(TAG, "Write chain error:", err.message);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Return the resolved path of the store file.
|
|
263
|
+
*/
|
|
264
|
+
export function getStorePath() {
|
|
265
|
+
return storePath;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Core CRUD
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get a single task by ID. Returns null if not found.
|
|
274
|
+
*/
|
|
275
|
+
export function getTask(taskId) {
|
|
276
|
+
ensureLoaded();
|
|
277
|
+
if (!taskId) return null;
|
|
278
|
+
return _store.tasks[taskId] ?? null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get all tasks as an array.
|
|
283
|
+
*/
|
|
284
|
+
export function getAllTasks() {
|
|
285
|
+
ensureLoaded();
|
|
286
|
+
return Object.values(_store.tasks);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get tasks filtered by status.
|
|
291
|
+
*/
|
|
292
|
+
export function getTasksByStatus(status) {
|
|
293
|
+
ensureLoaded();
|
|
294
|
+
return Object.values(_store.tasks).filter((t) => t.status === status);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Partial update of a task. Auto-sets updatedAt and syncDirty.
|
|
299
|
+
* Returns the updated task or null if not found.
|
|
300
|
+
*/
|
|
301
|
+
export function updateTask(taskId, updates) {
|
|
302
|
+
ensureLoaded();
|
|
303
|
+
const task = _store.tasks[taskId];
|
|
304
|
+
if (!task) {
|
|
305
|
+
console.warn(TAG, `updateTask: task ${taskId} not found`);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Apply updates (shallow merge)
|
|
310
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
311
|
+
if (k === "id") continue; // never overwrite id
|
|
312
|
+
if (k === "lastAgentOutput") {
|
|
313
|
+
task[k] = truncate(v, MAX_AGENT_OUTPUT);
|
|
314
|
+
} else if (k === "lastError") {
|
|
315
|
+
task[k] = truncate(v, MAX_ERROR_LENGTH);
|
|
316
|
+
} else if (k === "tags") {
|
|
317
|
+
task[k] = normalizeTags(v);
|
|
318
|
+
} else {
|
|
319
|
+
task[k] = v;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (typeof updates.draft === "boolean") {
|
|
324
|
+
task.draft = updates.draft;
|
|
325
|
+
if (updates.draft && task.status !== "draft") {
|
|
326
|
+
task.status = "draft";
|
|
327
|
+
} else if (!updates.draft && task.status === "draft") {
|
|
328
|
+
task.status = "todo";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (task.status === "draft") {
|
|
332
|
+
task.draft = true;
|
|
333
|
+
} else if (task.draft && updates.draft == null) {
|
|
334
|
+
task.draft = false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
task.updatedAt = now();
|
|
338
|
+
task.syncDirty = true;
|
|
339
|
+
|
|
340
|
+
saveStore();
|
|
341
|
+
return { ...task };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Add a new task to the store. Sets createdAt.
|
|
346
|
+
* Returns the created task.
|
|
347
|
+
*/
|
|
348
|
+
export function addTask(taskData) {
|
|
349
|
+
ensureLoaded();
|
|
350
|
+
const task = defaultTask(taskData);
|
|
351
|
+
if (!task.id) {
|
|
352
|
+
console.error(TAG, "addTask: task must have an id");
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
task.tags = normalizeTags(task.tags);
|
|
356
|
+
task.draft = Boolean(task.draft || task.status === "draft");
|
|
357
|
+
if (task.draft) task.status = "draft";
|
|
358
|
+
task.lastAgentOutput = truncate(task.lastAgentOutput, MAX_AGENT_OUTPUT);
|
|
359
|
+
task.lastError = truncate(task.lastError, MAX_ERROR_LENGTH);
|
|
360
|
+
|
|
361
|
+
_store.tasks[task.id] = task;
|
|
362
|
+
console.log(TAG, `Added task ${task.id}: ${task.title}`);
|
|
363
|
+
|
|
364
|
+
saveStore();
|
|
365
|
+
return { ...task };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Remove a task from the store. Returns true if removed, false if not found.
|
|
370
|
+
*/
|
|
371
|
+
export function removeTask(taskId) {
|
|
372
|
+
ensureLoaded();
|
|
373
|
+
if (!_store.tasks[taskId]) return false;
|
|
374
|
+
delete _store.tasks[taskId];
|
|
375
|
+
console.log(TAG, `Removed task ${taskId}`);
|
|
376
|
+
saveStore();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Status management
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Set task status with source tracking. Appends to statusHistory.
|
|
386
|
+
* source: "agent" | "orchestrator" | "external" | "review"
|
|
387
|
+
*/
|
|
388
|
+
export function setTaskStatus(taskId, status, source) {
|
|
389
|
+
ensureLoaded();
|
|
390
|
+
const task = _store.tasks[taskId];
|
|
391
|
+
if (!task) {
|
|
392
|
+
console.warn(TAG, `setTaskStatus: task ${taskId} not found`);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const prev = task.status;
|
|
397
|
+
task.status = status;
|
|
398
|
+
task.updatedAt = now();
|
|
399
|
+
task.lastActivityAt = now();
|
|
400
|
+
|
|
401
|
+
// Append to history (FIFO, max 50)
|
|
402
|
+
task.statusHistory.push({
|
|
403
|
+
status,
|
|
404
|
+
timestamp: now(),
|
|
405
|
+
source: source || "unknown",
|
|
406
|
+
});
|
|
407
|
+
if (task.statusHistory.length > MAX_STATUS_HISTORY) {
|
|
408
|
+
task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Mark dirty unless change came from external source
|
|
412
|
+
if (source !== "external") {
|
|
413
|
+
task.syncDirty = true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
console.log(
|
|
417
|
+
TAG,
|
|
418
|
+
`Task ${taskId} status: ${prev} → ${status} (source: ${source})`,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
saveStore();
|
|
422
|
+
return { ...task };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get the status history for a task.
|
|
427
|
+
*/
|
|
428
|
+
export function getTaskHistory(taskId) {
|
|
429
|
+
ensureLoaded();
|
|
430
|
+
const task = _store.tasks[taskId];
|
|
431
|
+
if (!task) return [];
|
|
432
|
+
return [...task.statusHistory];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Agent tracking
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Record an agent attempt on a task.
|
|
441
|
+
* @param {string} taskId
|
|
442
|
+
* @param {{ output?: string, error?: string, hasCommits?: boolean }} info
|
|
443
|
+
*/
|
|
444
|
+
export function recordAgentAttempt(taskId, { output, error, hasCommits } = {}) {
|
|
445
|
+
ensureLoaded();
|
|
446
|
+
const task = _store.tasks[taskId];
|
|
447
|
+
if (!task) {
|
|
448
|
+
console.warn(TAG, `recordAgentAttempt: task ${taskId} not found`);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
task.agentAttempts = (task.agentAttempts || 0) + 1;
|
|
453
|
+
task.lastActivityAt = now();
|
|
454
|
+
task.updatedAt = now();
|
|
455
|
+
|
|
456
|
+
if (output !== undefined) {
|
|
457
|
+
task.lastAgentOutput = truncate(output, MAX_AGENT_OUTPUT);
|
|
458
|
+
}
|
|
459
|
+
if (error !== undefined) {
|
|
460
|
+
task.lastError = truncate(error, MAX_ERROR_LENGTH);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (hasCommits) {
|
|
464
|
+
task.consecutiveNoCommits = 0;
|
|
465
|
+
} else {
|
|
466
|
+
task.consecutiveNoCommits = (task.consecutiveNoCommits || 0) + 1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
task.syncDirty = true;
|
|
470
|
+
saveStore();
|
|
471
|
+
return { ...task };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Record a classified error pattern on a task.
|
|
476
|
+
* @param {string} taskId
|
|
477
|
+
* @param {string|null} pattern - "plan_stuck" | "rate_limit" | "token_overflow" | "api_error" | null
|
|
478
|
+
*/
|
|
479
|
+
export function recordErrorPattern(taskId, pattern) {
|
|
480
|
+
ensureLoaded();
|
|
481
|
+
const task = _store.tasks[taskId];
|
|
482
|
+
if (!task) {
|
|
483
|
+
console.warn(TAG, `recordErrorPattern: task ${taskId} not found`);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
task.errorPattern = pattern;
|
|
488
|
+
task.updatedAt = now();
|
|
489
|
+
task.syncDirty = true;
|
|
490
|
+
|
|
491
|
+
saveStore();
|
|
492
|
+
return { ...task };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Set a cooldown on a task (prevents re-scheduling until timestamp).
|
|
497
|
+
*/
|
|
498
|
+
export function setTaskCooldown(taskId, untilTimestamp, reason) {
|
|
499
|
+
ensureLoaded();
|
|
500
|
+
const task = _store.tasks[taskId];
|
|
501
|
+
if (!task) {
|
|
502
|
+
console.warn(TAG, `setTaskCooldown: task ${taskId} not found`);
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
task.cooldownUntil = untilTimestamp;
|
|
507
|
+
task.blockedReason = reason || null;
|
|
508
|
+
task.updatedAt = now();
|
|
509
|
+
task.syncDirty = true;
|
|
510
|
+
|
|
511
|
+
console.log(
|
|
512
|
+
TAG,
|
|
513
|
+
`Task ${taskId} cooldown until ${untilTimestamp}: ${reason}`,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
saveStore();
|
|
517
|
+
return { ...task };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Clear the cooldown on a task.
|
|
522
|
+
*/
|
|
523
|
+
export function clearTaskCooldown(taskId) {
|
|
524
|
+
ensureLoaded();
|
|
525
|
+
const task = _store.tasks[taskId];
|
|
526
|
+
if (!task) {
|
|
527
|
+
console.warn(TAG, `clearTaskCooldown: task ${taskId} not found`);
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
task.cooldownUntil = null;
|
|
532
|
+
task.blockedReason = null;
|
|
533
|
+
task.updatedAt = now();
|
|
534
|
+
task.syncDirty = true;
|
|
535
|
+
|
|
536
|
+
saveStore();
|
|
537
|
+
return { ...task };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Check if a task is currently cooling down.
|
|
542
|
+
*/
|
|
543
|
+
export function isTaskCoolingDown(taskId) {
|
|
544
|
+
ensureLoaded();
|
|
545
|
+
const task = _store.tasks[taskId];
|
|
546
|
+
if (!task || !task.cooldownUntil) return false;
|
|
547
|
+
return new Date(task.cooldownUntil) > new Date();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// Review tracking
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Set the review result for a task.
|
|
556
|
+
* @param {string} taskId
|
|
557
|
+
* @param {{ approved: boolean, issues?: Array<{severity: string, description: string}> }} result
|
|
558
|
+
*/
|
|
559
|
+
export function setReviewResult(taskId, { approved, issues } = {}) {
|
|
560
|
+
ensureLoaded();
|
|
561
|
+
const task = _store.tasks[taskId];
|
|
562
|
+
if (!task) {
|
|
563
|
+
console.warn(TAG, `setReviewResult: task ${taskId} not found`);
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
task.reviewStatus = approved ? "approved" : "changes_requested";
|
|
568
|
+
task.reviewIssues = issues || null;
|
|
569
|
+
task.reviewedAt = now();
|
|
570
|
+
task.updatedAt = now();
|
|
571
|
+
task.lastActivityAt = now();
|
|
572
|
+
task.syncDirty = true;
|
|
573
|
+
|
|
574
|
+
console.log(
|
|
575
|
+
TAG,
|
|
576
|
+
`Task ${taskId} review: ${task.reviewStatus}${issues ? ` (${issues.length} issues)` : ""}`,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
saveStore();
|
|
580
|
+
return { ...task };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get tasks that are pending review (status === "inreview").
|
|
585
|
+
*/
|
|
586
|
+
export function getTasksPendingReview() {
|
|
587
|
+
ensureLoaded();
|
|
588
|
+
return Object.values(_store.tasks).filter((t) => t.status === "inreview");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// Sync helpers
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Get all tasks that need syncing to external backend.
|
|
597
|
+
*/
|
|
598
|
+
export function getDirtyTasks() {
|
|
599
|
+
ensureLoaded();
|
|
600
|
+
return Object.values(_store.tasks).filter((t) => t.syncDirty);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Mark a task as synced (clears syncDirty, sets lastSyncedAt).
|
|
605
|
+
*/
|
|
606
|
+
export function markSynced(taskId) {
|
|
607
|
+
ensureLoaded();
|
|
608
|
+
const task = _store.tasks[taskId];
|
|
609
|
+
if (!task) return;
|
|
610
|
+
|
|
611
|
+
task.syncDirty = false;
|
|
612
|
+
task.lastSyncedAt = now();
|
|
613
|
+
|
|
614
|
+
saveStore();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Add or update a task from an external source.
|
|
619
|
+
* Only overrides fields the external backend controls.
|
|
620
|
+
* Sets syncDirty = false for the imported data.
|
|
621
|
+
*/
|
|
622
|
+
export function upsertFromExternal(externalTask) {
|
|
623
|
+
ensureLoaded();
|
|
624
|
+
if (!externalTask || !externalTask.id) {
|
|
625
|
+
console.warn(TAG, "upsertFromExternal: task must have an id");
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const existing = _store.tasks[externalTask.id];
|
|
630
|
+
|
|
631
|
+
if (existing) {
|
|
632
|
+
const externalBaseBranch =
|
|
633
|
+
externalTask.baseBranch ??
|
|
634
|
+
externalTask.base_branch ??
|
|
635
|
+
externalTask.meta?.base_branch ??
|
|
636
|
+
externalTask.meta?.baseBranch;
|
|
637
|
+
// Update only externally-controlled fields
|
|
638
|
+
if (externalTask.title !== undefined) existing.title = externalTask.title;
|
|
639
|
+
if (externalTask.description !== undefined)
|
|
640
|
+
existing.description = externalTask.description;
|
|
641
|
+
if (externalTask.assignee !== undefined)
|
|
642
|
+
existing.assignee = externalTask.assignee;
|
|
643
|
+
if (externalTask.priority !== undefined)
|
|
644
|
+
existing.priority = externalTask.priority;
|
|
645
|
+
if (externalTask.projectId !== undefined)
|
|
646
|
+
existing.projectId = externalTask.projectId;
|
|
647
|
+
if (externalBaseBranch !== undefined)
|
|
648
|
+
existing.baseBranch = externalBaseBranch;
|
|
649
|
+
if (externalTask.branchName !== undefined)
|
|
650
|
+
existing.branchName = externalTask.branchName;
|
|
651
|
+
if (externalTask.prNumber !== undefined)
|
|
652
|
+
existing.prNumber = externalTask.prNumber;
|
|
653
|
+
if (externalTask.prUrl !== undefined) existing.prUrl = externalTask.prUrl;
|
|
654
|
+
if (externalTask.meta !== undefined)
|
|
655
|
+
existing.meta = { ...existing.meta, ...externalTask.meta };
|
|
656
|
+
|
|
657
|
+
// Update external tracking fields
|
|
658
|
+
if (externalTask.externalId !== undefined)
|
|
659
|
+
existing.externalId = externalTask.externalId;
|
|
660
|
+
if (externalTask.externalBackend !== undefined)
|
|
661
|
+
existing.externalBackend = externalTask.externalBackend;
|
|
662
|
+
|
|
663
|
+
// Only update status if external changed it (human override)
|
|
664
|
+
if (
|
|
665
|
+
externalTask.status !== undefined &&
|
|
666
|
+
externalTask.status !== existing.externalStatus
|
|
667
|
+
) {
|
|
668
|
+
existing.externalStatus = externalTask.status;
|
|
669
|
+
// If the external status differs from our status, adopt it
|
|
670
|
+
if (externalTask.status !== existing.status) {
|
|
671
|
+
existing.status = externalTask.status;
|
|
672
|
+
existing.statusHistory.push({
|
|
673
|
+
status: externalTask.status,
|
|
674
|
+
timestamp: now(),
|
|
675
|
+
source: "external",
|
|
676
|
+
});
|
|
677
|
+
if (existing.statusHistory.length > MAX_STATUS_HISTORY) {
|
|
678
|
+
existing.statusHistory =
|
|
679
|
+
existing.statusHistory.slice(-MAX_STATUS_HISTORY);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} else if (externalTask.status !== undefined) {
|
|
683
|
+
existing.externalStatus = externalTask.status;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
existing.updatedAt = now();
|
|
687
|
+
existing.syncDirty = false;
|
|
688
|
+
existing.lastSyncedAt = now();
|
|
689
|
+
|
|
690
|
+
saveStore();
|
|
691
|
+
return { ...existing };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// New task from external — create it
|
|
695
|
+
const externalBaseBranch =
|
|
696
|
+
externalTask.baseBranch ??
|
|
697
|
+
externalTask.base_branch ??
|
|
698
|
+
externalTask.meta?.base_branch ??
|
|
699
|
+
externalTask.meta?.baseBranch;
|
|
700
|
+
const task = defaultTask({
|
|
701
|
+
...externalTask,
|
|
702
|
+
...(externalBaseBranch !== undefined ? { baseBranch: externalBaseBranch } : {}),
|
|
703
|
+
externalStatus: externalTask.status || null,
|
|
704
|
+
syncDirty: false,
|
|
705
|
+
lastSyncedAt: now(),
|
|
706
|
+
});
|
|
707
|
+
task.lastAgentOutput = truncate(task.lastAgentOutput, MAX_AGENT_OUTPUT);
|
|
708
|
+
task.lastError = truncate(task.lastError, MAX_ERROR_LENGTH);
|
|
709
|
+
|
|
710
|
+
_store.tasks[task.id] = task;
|
|
711
|
+
console.log(TAG, `Upserted external task ${task.id}: ${task.title}`);
|
|
712
|
+
|
|
713
|
+
saveStore();
|
|
714
|
+
return { ...task };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
// Statistics
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Get aggregate stats across all tasks.
|
|
723
|
+
*/
|
|
724
|
+
export function getStats() {
|
|
725
|
+
ensureLoaded();
|
|
726
|
+
recalcStats();
|
|
727
|
+
return {
|
|
728
|
+
..._store._meta.stats,
|
|
729
|
+
total: _store._meta.taskCount,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Get tasks that have been "inprogress" for longer than maxAgeMs.
|
|
735
|
+
*/
|
|
736
|
+
export function getStaleInProgressTasks(maxAgeMs) {
|
|
737
|
+
ensureLoaded();
|
|
738
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
739
|
+
return Object.values(_store.tasks).filter(
|
|
740
|
+
(t) => t.status === "inprogress" && t.lastActivityAt < cutoff,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Get tasks that have been "inreview" for longer than maxAgeMs.
|
|
746
|
+
*/
|
|
747
|
+
export function getStaleInReviewTasks(maxAgeMs) {
|
|
748
|
+
ensureLoaded();
|
|
749
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
750
|
+
return Object.values(_store.tasks).filter(
|
|
751
|
+
(t) => t.status === "inreview" && t.lastActivityAt < cutoff,
|
|
752
|
+
);
|
|
753
|
+
}
|