forge-openclaw-plugin 0.2.3 → 0.2.7
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 +114 -6
- package/dist/assets/board-CzgvdLO8.js +6 -0
- package/dist/assets/board-CzgvdLO8.js.map +1 -0
- package/dist/assets/favicon-BCHm9dUV.ico +0 -0
- package/dist/assets/index-8d_oM8fL.js +27 -0
- package/dist/assets/index-8d_oM8fL.js.map +1 -0
- package/dist/assets/index-D4A_bq8m.css +1 -0
- package/dist/assets/motion-STUd1O46.js +10 -0
- package/dist/assets/motion-STUd1O46.js.map +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/sora-latin-ext-wght-normal-CawQDOvP.woff2 +0 -0
- package/dist/assets/sora-latin-wght-normal-DdqRvwsR.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
- package/dist/assets/table-CtNlETLc.js +23 -0
- package/dist/assets/table-CtNlETLc.js.map +1 -0
- package/dist/assets/ui-ThzkR_oW.js +46 -0
- package/dist/assets/ui-ThzkR_oW.js.map +1 -0
- package/dist/assets/vendor-CRS-psbw.css +1 -0
- package/dist/assets/vendor-DyHAI6nk.js +423 -0
- package/dist/assets/vendor-DyHAI6nk.js.map +1 -0
- package/dist/assets/viz-BJuBCz_G.js +34 -0
- package/dist/assets/viz-BJuBCz_G.js.map +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +29 -0
- package/dist/openclaw/api-client.d.ts +8 -0
- package/dist/openclaw/api-client.js +31 -4
- package/dist/openclaw/local-runtime.d.ts +3 -0
- package/dist/openclaw/local-runtime.js +135 -0
- package/dist/openclaw/parity.d.ts +4 -4
- package/dist/openclaw/parity.js +23 -33
- package/dist/openclaw/plugin-entry-shared.d.ts +5 -3
- package/dist/openclaw/plugin-entry-shared.js +52 -10
- package/dist/openclaw/routes.d.ts +12 -3
- package/dist/openclaw/routes.js +156 -924
- package/dist/openclaw/tools.js +242 -1100
- package/dist/server/app.js +2450 -0
- package/dist/server/db.js +313 -0
- package/dist/server/e2e-server.js +20 -0
- package/dist/server/errors.js +15 -0
- package/dist/server/index.js +16 -0
- package/dist/server/managers/base.js +17 -0
- package/dist/server/managers/contracts.js +47 -0
- package/dist/server/managers/platform/api-gateway-manager.js +11 -0
- package/dist/server/managers/platform/audit-manager.js +15 -0
- package/dist/server/managers/platform/authentication-manager.js +56 -0
- package/dist/server/managers/platform/authorization-manager.js +56 -0
- package/dist/server/managers/platform/background-job-manager.js +10 -0
- package/dist/server/managers/platform/configuration-manager.js +33 -0
- package/dist/server/managers/platform/database-manager.js +14 -0
- package/dist/server/managers/platform/event-bus-manager.js +7 -0
- package/dist/server/managers/platform/external-service-manager.js +11 -0
- package/dist/server/managers/platform/health-manager.js +7 -0
- package/dist/server/managers/platform/migration-manager.js +8 -0
- package/dist/server/managers/platform/search-index-manager.js +4 -0
- package/dist/server/managers/platform/secrets-manager.js +19 -0
- package/dist/server/managers/platform/session-manager.js +121 -0
- package/dist/server/managers/platform/storage-manager.js +16 -0
- package/dist/server/managers/platform/token-manager.js +37 -0
- package/dist/server/managers/platform/transaction-manager.js +8 -0
- package/dist/server/managers/platform/trusted-network.js +39 -0
- package/dist/server/managers/runtime.js +56 -0
- package/dist/server/managers/type-guards.js +4 -0
- package/dist/server/openapi.js +3512 -0
- package/dist/server/psyche-types.js +395 -0
- package/dist/server/repositories/activity-events.js +157 -0
- package/dist/server/repositories/collaboration.js +497 -0
- package/dist/server/repositories/comments.js +176 -0
- package/dist/server/repositories/deleted-entities.js +192 -0
- package/dist/server/repositories/domains.js +30 -0
- package/dist/server/repositories/event-log.js +64 -0
- package/dist/server/repositories/goals.js +159 -0
- package/dist/server/repositories/projects.js +214 -0
- package/dist/server/repositories/psyche.js +1356 -0
- package/dist/server/repositories/rewards.js +675 -0
- package/dist/server/repositories/settings.js +399 -0
- package/dist/server/repositories/tags.js +160 -0
- package/dist/server/repositories/task-runs.js +488 -0
- package/dist/server/repositories/tasks.js +413 -0
- package/dist/server/services/context.js +214 -0
- package/dist/server/services/dashboard.js +170 -0
- package/dist/server/services/entity-crud.js +576 -0
- package/dist/server/services/gamification.js +215 -0
- package/dist/server/services/insights.js +91 -0
- package/dist/server/services/projects.js +75 -0
- package/dist/server/services/psyche.js +63 -0
- package/dist/server/services/relations.js +28 -0
- package/dist/server/services/reviews.js +88 -0
- package/dist/server/services/run-recovery.js +13 -0
- package/dist/server/services/tagging.js +49 -0
- package/dist/server/services/task-run-watchdog.js +92 -0
- package/dist/server/services/work-time.js +176 -0
- package/dist/server/types.js +999 -0
- package/dist/server/web.js +91 -0
- package/openclaw.plugin.json +22 -10
- package/package.json +17 -4
- package/server/migrations/001_core.sql +333 -0
- package/server/migrations/002_psyche.sql +241 -0
- package/server/migrations/003_timer_execution.sql +18 -0
- package/server/migrations/004_psyche_linked_entities.sql +5 -0
- package/server/migrations/005_adaptive_schemas.sql +157 -0
- package/server/migrations/006_psyche_auth_setting.sql +4 -0
- package/server/migrations/007_deleted_entities.sql +16 -0
- package/skills/forge-openclaw/SKILL.md +189 -275
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { computeWorkTime } from "../services/work-time.js";
|
|
5
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
6
|
+
import { recordTaskRunCompletionReward, recordTaskRunProgressRewards, recordTaskRunStartReward } from "./rewards.js";
|
|
7
|
+
import { getTaskById, updateTaskInTransaction } from "./tasks.js";
|
|
8
|
+
import { taskRunClaimSchema, taskRunSchema } from "../types.js";
|
|
9
|
+
function leaseExpiry(now, ttlSeconds) {
|
|
10
|
+
return new Date(now.getTime() + ttlSeconds * 1000).toISOString();
|
|
11
|
+
}
|
|
12
|
+
function selectClause() {
|
|
13
|
+
return `SELECT
|
|
14
|
+
task_runs.id,
|
|
15
|
+
task_runs.task_id,
|
|
16
|
+
task_runs.actor,
|
|
17
|
+
task_runs.status,
|
|
18
|
+
task_runs.timer_mode,
|
|
19
|
+
task_runs.planned_duration_seconds,
|
|
20
|
+
task_runs.is_current,
|
|
21
|
+
task_runs.note,
|
|
22
|
+
task_runs.lease_ttl_seconds,
|
|
23
|
+
task_runs.claimed_at,
|
|
24
|
+
task_runs.heartbeat_at,
|
|
25
|
+
task_runs.lease_expires_at,
|
|
26
|
+
task_runs.completed_at,
|
|
27
|
+
task_runs.released_at,
|
|
28
|
+
task_runs.timed_out_at,
|
|
29
|
+
task_runs.updated_at,
|
|
30
|
+
tasks.title AS task_title
|
|
31
|
+
FROM task_runs
|
|
32
|
+
INNER JOIN tasks ON tasks.id = task_runs.task_id`;
|
|
33
|
+
}
|
|
34
|
+
function readExecutionConfig() {
|
|
35
|
+
try {
|
|
36
|
+
const row = getDatabase()
|
|
37
|
+
.prepare(`SELECT max_active_tasks, time_accounting_mode
|
|
38
|
+
FROM app_settings
|
|
39
|
+
WHERE id = 1`)
|
|
40
|
+
.get();
|
|
41
|
+
return {
|
|
42
|
+
maxActiveTasks: Math.max(1, row?.max_active_tasks ?? 2),
|
|
43
|
+
timeAccountingMode: row?.time_accounting_mode ?? "split"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {
|
|
48
|
+
maxActiveTasks: 2,
|
|
49
|
+
timeAccountingMode: "split"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function mapTaskRun(row, now = new Date(), cached = computeWorkTime(now)) {
|
|
54
|
+
const metric = cached.runMetrics.get(row.id);
|
|
55
|
+
return taskRunSchema.parse({
|
|
56
|
+
id: row.id,
|
|
57
|
+
taskId: row.task_id,
|
|
58
|
+
taskTitle: row.task_title,
|
|
59
|
+
actor: row.actor,
|
|
60
|
+
status: row.status,
|
|
61
|
+
timerMode: row.timer_mode,
|
|
62
|
+
plannedDurationSeconds: row.planned_duration_seconds,
|
|
63
|
+
elapsedWallSeconds: metric?.elapsedWallSeconds ?? 0,
|
|
64
|
+
creditedSeconds: metric?.creditedSeconds ?? 0,
|
|
65
|
+
remainingSeconds: metric?.remainingSeconds ?? row.planned_duration_seconds,
|
|
66
|
+
overtimeSeconds: metric?.overtimeSeconds ?? 0,
|
|
67
|
+
isCurrent: metric?.isCurrent ?? false,
|
|
68
|
+
note: row.note,
|
|
69
|
+
leaseTtlSeconds: row.lease_ttl_seconds,
|
|
70
|
+
claimedAt: row.claimed_at,
|
|
71
|
+
heartbeatAt: row.heartbeat_at,
|
|
72
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
73
|
+
completedAt: row.completed_at,
|
|
74
|
+
releasedAt: row.released_at,
|
|
75
|
+
timedOutAt: row.timed_out_at,
|
|
76
|
+
updatedAt: row.updated_at
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function getTaskRunRowById(taskRunId) {
|
|
80
|
+
return getDatabase()
|
|
81
|
+
.prepare(`${selectClause()}
|
|
82
|
+
WHERE task_runs.id = ?`)
|
|
83
|
+
.get(taskRunId);
|
|
84
|
+
}
|
|
85
|
+
function listActiveRunRowsByActor(actor, now, excludeRunId) {
|
|
86
|
+
const params = [actor, now.toISOString()];
|
|
87
|
+
const excludeSql = excludeRunId ? "AND task_runs.id != ?" : "";
|
|
88
|
+
if (excludeRunId) {
|
|
89
|
+
params.push(excludeRunId);
|
|
90
|
+
}
|
|
91
|
+
return getDatabase()
|
|
92
|
+
.prepare(`${selectClause()}
|
|
93
|
+
WHERE task_runs.actor = ?
|
|
94
|
+
AND task_runs.status = 'active'
|
|
95
|
+
AND task_runs.lease_expires_at >= ?
|
|
96
|
+
${excludeSql}
|
|
97
|
+
ORDER BY task_runs.is_current DESC, task_runs.claimed_at DESC`)
|
|
98
|
+
.all(...params);
|
|
99
|
+
}
|
|
100
|
+
function getActiveTaskRunRow(taskId, now) {
|
|
101
|
+
return getDatabase()
|
|
102
|
+
.prepare(`${selectClause()}
|
|
103
|
+
WHERE task_runs.task_id = ?
|
|
104
|
+
AND task_runs.status = 'active'
|
|
105
|
+
AND task_runs.lease_expires_at >= ?
|
|
106
|
+
ORDER BY task_runs.claimed_at DESC
|
|
107
|
+
LIMIT 1`)
|
|
108
|
+
.get(taskId, now.toISOString());
|
|
109
|
+
}
|
|
110
|
+
function requireRun(runId) {
|
|
111
|
+
const run = getTaskRunRowById(runId);
|
|
112
|
+
if (!run) {
|
|
113
|
+
throw new HttpError(404, "task_run_not_found", `Task run ${runId} does not exist`);
|
|
114
|
+
}
|
|
115
|
+
return run;
|
|
116
|
+
}
|
|
117
|
+
function secondsUntilLeaseExpiry(leaseExpiresAt, now) {
|
|
118
|
+
return Math.max(0, Math.ceil((Date.parse(leaseExpiresAt) - now.getTime()) / 1000));
|
|
119
|
+
}
|
|
120
|
+
function buildTaskRunErrorDetails(run, now, details = {}) {
|
|
121
|
+
const cached = computeWorkTime(now);
|
|
122
|
+
const response = {
|
|
123
|
+
...details,
|
|
124
|
+
...(run ? { taskRun: mapTaskRun(run, now, cached) } : {})
|
|
125
|
+
};
|
|
126
|
+
if (run?.status === "active" && response.retryAfterSeconds === undefined) {
|
|
127
|
+
response.retryAfterSeconds = secondsUntilLeaseExpiry(run.lease_expires_at, now);
|
|
128
|
+
}
|
|
129
|
+
return response;
|
|
130
|
+
}
|
|
131
|
+
function assertActorMatch(run, actualActor, now) {
|
|
132
|
+
if (actualActor && actualActor !== run.actor) {
|
|
133
|
+
throw new HttpError(409, "task_run_actor_conflict", `Task run is owned by ${run.actor}, not ${actualActor}`, buildTaskRunErrorDetails(run, now, { requestedActor: actualActor }));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function requireKnownTask(taskId) {
|
|
137
|
+
if (!getTaskById(taskId)) {
|
|
138
|
+
throw new HttpError(404, "task_not_found", `Task ${taskId} does not exist`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function setCurrentRunInTransaction(actor, taskRunId) {
|
|
142
|
+
const db = getDatabase();
|
|
143
|
+
// Two-step update to avoid UNIQUE index violation on
|
|
144
|
+
// idx_task_runs_single_current_per_actor (actor WHERE status='active' AND is_current=1).
|
|
145
|
+
// A single CASE UPDATE can momentarily have two is_current=1 rows which SQLite rejects.
|
|
146
|
+
db.prepare(`UPDATE task_runs SET is_current = 0 WHERE actor = ? AND status = 'active' AND is_current = 1`).run(actor);
|
|
147
|
+
db.prepare(`UPDATE task_runs SET is_current = 1 WHERE id = ? AND actor = ? AND status = 'active'`).run(taskRunId, actor);
|
|
148
|
+
}
|
|
149
|
+
function ensureCurrentRunExistsInTransaction(actor, now) {
|
|
150
|
+
const current = getDatabase()
|
|
151
|
+
.prepare(`SELECT id
|
|
152
|
+
FROM task_runs
|
|
153
|
+
WHERE actor = ? AND status = 'active' AND lease_expires_at >= ? AND is_current = 1
|
|
154
|
+
LIMIT 1`)
|
|
155
|
+
.get(actor, now.toISOString());
|
|
156
|
+
if (current) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const fallback = getDatabase()
|
|
160
|
+
.prepare(`SELECT id
|
|
161
|
+
FROM task_runs
|
|
162
|
+
WHERE actor = ? AND status = 'active' AND lease_expires_at >= ?
|
|
163
|
+
ORDER BY claimed_at DESC
|
|
164
|
+
LIMIT 1`)
|
|
165
|
+
.get(actor, now.toISOString());
|
|
166
|
+
if (fallback) {
|
|
167
|
+
setCurrentRunInTransaction(actor, fallback.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function touchTaskInProgress(taskId, actor, source) {
|
|
171
|
+
const task = getTaskById(taskId);
|
|
172
|
+
if (!task || task.status === "done" || task.status === "in_progress") {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
updateTaskInTransaction(taskId, { status: "in_progress" }, { actor, source });
|
|
176
|
+
}
|
|
177
|
+
function enforceActiveRunLimit(actor, taskId, now) {
|
|
178
|
+
const config = readExecutionConfig();
|
|
179
|
+
const activeRuns = listActiveRunRowsByActor(actor, now);
|
|
180
|
+
if (activeRuns.length < config.maxActiveTasks) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
throw new HttpError(409, "task_run_limit_exceeded", `Cannot start ${taskId} because ${actor} already has ${activeRuns.length} active task timers (limit ${config.maxActiveTasks}).`, {
|
|
184
|
+
activeRuns: activeRuns.map((run) => mapTaskRun(run, now)),
|
|
185
|
+
limit: config.maxActiveTasks,
|
|
186
|
+
timeAccountingMode: config.timeAccountingMode
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function maybeSetCurrentRun(actor, taskRunId, requestedIsCurrent, now) {
|
|
190
|
+
if (requestedIsCurrent) {
|
|
191
|
+
setCurrentRunInTransaction(actor, taskRunId);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
ensureCurrentRunExistsInTransaction(actor, now);
|
|
195
|
+
}
|
|
196
|
+
function promoteFallbackCurrentRun(actor, now) {
|
|
197
|
+
ensureCurrentRunExistsInTransaction(actor, now);
|
|
198
|
+
}
|
|
199
|
+
function markExpiredRunsTimedOutInTransaction(now, limit) {
|
|
200
|
+
const nowIso = now.toISOString();
|
|
201
|
+
const params = [nowIso];
|
|
202
|
+
const limitSql = limit ? "LIMIT ?" : "";
|
|
203
|
+
if (limit) {
|
|
204
|
+
params.push(limit);
|
|
205
|
+
}
|
|
206
|
+
const expired = getDatabase()
|
|
207
|
+
.prepare(`${selectClause()}
|
|
208
|
+
WHERE task_runs.status = 'active' AND task_runs.lease_expires_at < ?
|
|
209
|
+
ORDER BY task_runs.lease_expires_at
|
|
210
|
+
${limitSql}`)
|
|
211
|
+
.all(...params);
|
|
212
|
+
if (expired.length === 0) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const update = getDatabase().prepare(`UPDATE task_runs
|
|
216
|
+
SET status = 'timed_out', timed_out_at = ?, is_current = 0, updated_at = ?
|
|
217
|
+
WHERE id = ?`);
|
|
218
|
+
for (const run of expired) {
|
|
219
|
+
update.run(nowIso, nowIso, run.id);
|
|
220
|
+
recordActivityEvent({
|
|
221
|
+
entityType: "task_run",
|
|
222
|
+
entityId: run.id,
|
|
223
|
+
eventType: "task_run_timed_out",
|
|
224
|
+
title: `Task timer timed out: ${run.task_title}`,
|
|
225
|
+
description: `${run.actor} lost the live timer on ${run.task_title}.`,
|
|
226
|
+
actor: run.actor,
|
|
227
|
+
source: "system",
|
|
228
|
+
metadata: {
|
|
229
|
+
taskId: run.task_id,
|
|
230
|
+
leaseExpiresAt: run.lease_expires_at
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
promoteFallbackCurrentRun(run.actor, now);
|
|
234
|
+
}
|
|
235
|
+
const cached = computeWorkTime(now);
|
|
236
|
+
return expired.map((run) => mapTaskRun({
|
|
237
|
+
...run,
|
|
238
|
+
status: "timed_out",
|
|
239
|
+
is_current: 0,
|
|
240
|
+
timed_out_at: nowIso,
|
|
241
|
+
updated_at: nowIso
|
|
242
|
+
}, now, cached));
|
|
243
|
+
}
|
|
244
|
+
export function recoverTimedOutTaskRuns(options = {}) {
|
|
245
|
+
return runInTransaction(() => markExpiredRunsTimedOutInTransaction(options.now ?? new Date(), options.limit));
|
|
246
|
+
}
|
|
247
|
+
export function listTaskRuns(filters = {}, now = new Date()) {
|
|
248
|
+
return runInTransaction(() => {
|
|
249
|
+
markExpiredRunsTimedOutInTransaction(now);
|
|
250
|
+
const whereClauses = [];
|
|
251
|
+
const params = [];
|
|
252
|
+
if (filters.taskId) {
|
|
253
|
+
whereClauses.push("task_runs.task_id = ?");
|
|
254
|
+
params.push(filters.taskId);
|
|
255
|
+
}
|
|
256
|
+
if (filters.active) {
|
|
257
|
+
whereClauses.push("task_runs.status = 'active'");
|
|
258
|
+
}
|
|
259
|
+
else if (filters.status) {
|
|
260
|
+
whereClauses.push("task_runs.status = ?");
|
|
261
|
+
params.push(filters.status);
|
|
262
|
+
}
|
|
263
|
+
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
264
|
+
const limitSql = filters.limit ? "LIMIT ?" : "";
|
|
265
|
+
if (filters.limit) {
|
|
266
|
+
params.push(filters.limit);
|
|
267
|
+
}
|
|
268
|
+
const rows = getDatabase()
|
|
269
|
+
.prepare(`${selectClause()}
|
|
270
|
+
${whereSql}
|
|
271
|
+
ORDER BY
|
|
272
|
+
CASE task_runs.status
|
|
273
|
+
WHEN 'active' THEN 0
|
|
274
|
+
WHEN 'timed_out' THEN 1
|
|
275
|
+
WHEN 'released' THEN 2
|
|
276
|
+
ELSE 3
|
|
277
|
+
END,
|
|
278
|
+
task_runs.is_current DESC,
|
|
279
|
+
task_runs.updated_at DESC
|
|
280
|
+
${limitSql}`)
|
|
281
|
+
.all(...params);
|
|
282
|
+
const cached = computeWorkTime(now);
|
|
283
|
+
return rows.map((row) => mapTaskRun(row, now, cached));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
export function claimTaskRun(taskId, input, now = new Date(), activity = { source: "ui" }) {
|
|
287
|
+
return runInTransaction(() => {
|
|
288
|
+
const parsedInput = taskRunClaimSchema.parse(input);
|
|
289
|
+
markExpiredRunsTimedOutInTransaction(now);
|
|
290
|
+
requireKnownTask(taskId);
|
|
291
|
+
const existing = getActiveTaskRunRow(taskId, now);
|
|
292
|
+
const nowIso = now.toISOString();
|
|
293
|
+
if (existing) {
|
|
294
|
+
if (existing.actor !== parsedInput.actor) {
|
|
295
|
+
throw new HttpError(409, "task_run_conflict", `Task ${taskId} already has an active timer owned by ${existing.actor}.`, buildTaskRunErrorDetails(existing, now, { requestedActor: parsedInput.actor }));
|
|
296
|
+
}
|
|
297
|
+
const nextExpiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
|
|
298
|
+
getDatabase()
|
|
299
|
+
.prepare(`UPDATE task_runs
|
|
300
|
+
SET timer_mode = ?, planned_duration_seconds = ?, is_current = ?, heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
|
|
301
|
+
WHERE id = ?`)
|
|
302
|
+
.run(parsedInput.timerMode, parsedInput.plannedDurationSeconds, existing.is_current, nowIso, nextExpiry, parsedInput.leaseTtlSeconds, parsedInput.note, nowIso, existing.id);
|
|
303
|
+
maybeSetCurrentRun(parsedInput.actor, existing.id, parsedInput.isCurrent, now);
|
|
304
|
+
touchTaskInProgress(taskId, parsedInput.actor, activity.source);
|
|
305
|
+
recordActivityEvent({
|
|
306
|
+
entityType: "task_run",
|
|
307
|
+
entityId: existing.id,
|
|
308
|
+
eventType: "task_run_renewed",
|
|
309
|
+
title: `Task timer renewed: ${existing.task_title}`,
|
|
310
|
+
description: `${parsedInput.actor} refreshed the live timer.`,
|
|
311
|
+
actor: parsedInput.actor,
|
|
312
|
+
source: activity.source,
|
|
313
|
+
metadata: {
|
|
314
|
+
taskId,
|
|
315
|
+
leaseTtlSeconds: parsedInput.leaseTtlSeconds,
|
|
316
|
+
timerMode: parsedInput.timerMode,
|
|
317
|
+
plannedDurationSeconds: parsedInput.plannedDurationSeconds
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
const cached = computeWorkTime(now);
|
|
321
|
+
return {
|
|
322
|
+
run: mapTaskRun(requireRun(existing.id), now, cached),
|
|
323
|
+
replayed: true
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
enforceActiveRunLimit(parsedInput.actor, taskId, now);
|
|
327
|
+
const runId = `run_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
328
|
+
const expiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
|
|
329
|
+
getDatabase()
|
|
330
|
+
.prepare(`INSERT INTO task_runs (
|
|
331
|
+
id, task_id, actor, status, timer_mode, planned_duration_seconds, is_current, note, lease_ttl_seconds, claimed_at, heartbeat_at, lease_expires_at, updated_at
|
|
332
|
+
)
|
|
333
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
334
|
+
.run(runId, taskId, parsedInput.actor, parsedInput.timerMode, parsedInput.plannedDurationSeconds, 0, parsedInput.note, parsedInput.leaseTtlSeconds, nowIso, nowIso, expiry, nowIso);
|
|
335
|
+
maybeSetCurrentRun(parsedInput.actor, runId, parsedInput.isCurrent, now);
|
|
336
|
+
touchTaskInProgress(taskId, parsedInput.actor, activity.source);
|
|
337
|
+
const run = mapTaskRun(requireRun(runId), now);
|
|
338
|
+
recordActivityEvent({
|
|
339
|
+
entityType: "task_run",
|
|
340
|
+
entityId: run.id,
|
|
341
|
+
eventType: "task_run_claimed",
|
|
342
|
+
title: `Task timer started: ${run.taskTitle}`,
|
|
343
|
+
description: run.timerMode === "planned"
|
|
344
|
+
? `${run.actor} started a planned work timer.`
|
|
345
|
+
: `${run.actor} started an unlimited work timer.`,
|
|
346
|
+
actor: run.actor,
|
|
347
|
+
source: activity.source,
|
|
348
|
+
metadata: {
|
|
349
|
+
taskId: run.taskId,
|
|
350
|
+
leaseTtlSeconds: run.leaseTtlSeconds,
|
|
351
|
+
timerMode: run.timerMode,
|
|
352
|
+
plannedDurationSeconds: run.plannedDurationSeconds
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
recordTaskRunStartReward(run.id, run.taskId, run.actor, activity.source);
|
|
356
|
+
return { run, replayed: false };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
export function heartbeatTaskRun(taskRunId, input, now = new Date(), activity = { source: "ui" }) {
|
|
360
|
+
return runInTransaction(() => {
|
|
361
|
+
markExpiredRunsTimedOutInTransaction(now);
|
|
362
|
+
const current = requireRun(taskRunId);
|
|
363
|
+
if (current.status !== "active") {
|
|
364
|
+
throw new HttpError(409, "task_run_not_active", `Task run ${taskRunId} is ${current.status} and cannot accept heartbeats`, buildTaskRunErrorDetails(current, now));
|
|
365
|
+
}
|
|
366
|
+
assertActorMatch(current, input.actor, now);
|
|
367
|
+
const nowIso = now.toISOString();
|
|
368
|
+
const nextExpiry = leaseExpiry(now, input.leaseTtlSeconds);
|
|
369
|
+
const note = input.note ?? current.note;
|
|
370
|
+
getDatabase()
|
|
371
|
+
.prepare(`UPDATE task_runs
|
|
372
|
+
SET heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
|
|
373
|
+
WHERE id = ?`)
|
|
374
|
+
.run(nowIso, nextExpiry, input.leaseTtlSeconds, note, nowIso, taskRunId);
|
|
375
|
+
const run = mapTaskRun({
|
|
376
|
+
...current,
|
|
377
|
+
heartbeat_at: nowIso,
|
|
378
|
+
lease_expires_at: nextExpiry,
|
|
379
|
+
lease_ttl_seconds: input.leaseTtlSeconds,
|
|
380
|
+
note,
|
|
381
|
+
updated_at: nowIso
|
|
382
|
+
}, now);
|
|
383
|
+
recordActivityEvent({
|
|
384
|
+
entityType: "task_run",
|
|
385
|
+
entityId: run.id,
|
|
386
|
+
eventType: "task_run_heartbeat",
|
|
387
|
+
title: `Task timer heartbeat: ${run.taskTitle}`,
|
|
388
|
+
description: `${run.actor} renewed timer liveness.`,
|
|
389
|
+
actor: run.actor,
|
|
390
|
+
source: activity.source,
|
|
391
|
+
metadata: {
|
|
392
|
+
taskId: run.taskId,
|
|
393
|
+
leaseTtlSeconds: run.leaseTtlSeconds
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
recordTaskRunProgressRewards(run.id, run.taskId, input.actor ?? run.actor, activity.source, run.creditedSeconds);
|
|
397
|
+
return run;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
export function focusTaskRun(taskRunId, input, now = new Date(), activity = { source: "ui" }) {
|
|
401
|
+
return runInTransaction(() => {
|
|
402
|
+
markExpiredRunsTimedOutInTransaction(now);
|
|
403
|
+
const current = requireRun(taskRunId);
|
|
404
|
+
if (current.status !== "active") {
|
|
405
|
+
throw new HttpError(409, "task_run_not_active", `Task run ${taskRunId} is ${current.status} and cannot be focused`, buildTaskRunErrorDetails(current, now));
|
|
406
|
+
}
|
|
407
|
+
assertActorMatch(current, input.actor, now);
|
|
408
|
+
setCurrentRunInTransaction(current.actor, taskRunId);
|
|
409
|
+
const focused = mapTaskRun({ ...current, is_current: 1 }, now);
|
|
410
|
+
recordActivityEvent({
|
|
411
|
+
entityType: "task_run",
|
|
412
|
+
entityId: focused.id,
|
|
413
|
+
eventType: "task_run_focused",
|
|
414
|
+
title: `Task timer focused: ${focused.taskTitle}`,
|
|
415
|
+
description: `${focused.actor} made this the current work timer.`,
|
|
416
|
+
actor: input.actor ?? focused.actor,
|
|
417
|
+
source: activity.source,
|
|
418
|
+
metadata: {
|
|
419
|
+
taskId: focused.taskId
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
return focused;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function finishTaskRun(taskRunId, nextStatus, timestampColumn, input, now, activity) {
|
|
426
|
+
return runInTransaction(() => {
|
|
427
|
+
markExpiredRunsTimedOutInTransaction(now);
|
|
428
|
+
const current = requireRun(taskRunId);
|
|
429
|
+
if (current.status === nextStatus) {
|
|
430
|
+
assertActorMatch(current, input.actor, now);
|
|
431
|
+
return mapTaskRun(current, now);
|
|
432
|
+
}
|
|
433
|
+
if (current.status !== "active") {
|
|
434
|
+
throw new HttpError(409, "task_run_not_active", `Task run ${taskRunId} is ${current.status} and cannot transition to ${nextStatus}`, buildTaskRunErrorDetails(current, now));
|
|
435
|
+
}
|
|
436
|
+
assertActorMatch(current, input.actor, now);
|
|
437
|
+
const nowIso = now.toISOString();
|
|
438
|
+
const note = input.note.length > 0 ? input.note : current.note;
|
|
439
|
+
getDatabase()
|
|
440
|
+
.prepare(`UPDATE task_runs
|
|
441
|
+
SET status = ?, note = ?, is_current = 0, ${timestampColumn} = ?, updated_at = ?
|
|
442
|
+
WHERE id = ?`)
|
|
443
|
+
.run(nextStatus, note, nowIso, nowIso, taskRunId);
|
|
444
|
+
promoteFallbackCurrentRun(current.actor, now);
|
|
445
|
+
const run = mapTaskRun({
|
|
446
|
+
...current,
|
|
447
|
+
status: nextStatus,
|
|
448
|
+
note,
|
|
449
|
+
is_current: 0,
|
|
450
|
+
[timestampColumn]: nowIso,
|
|
451
|
+
updated_at: nowIso
|
|
452
|
+
}, now);
|
|
453
|
+
recordActivityEvent({
|
|
454
|
+
entityType: "task_run",
|
|
455
|
+
entityId: run.id,
|
|
456
|
+
eventType: nextStatus === "completed" ? "task_run_completed" : "task_run_released",
|
|
457
|
+
title: `${nextStatus === "completed" ? "Task timer completed" : "Task timer paused"}: ${run.taskTitle}`,
|
|
458
|
+
description: nextStatus === "completed"
|
|
459
|
+
? `${run.actor} completed the work timer.`
|
|
460
|
+
: `${run.actor} paused the work timer.`,
|
|
461
|
+
actor: run.actor,
|
|
462
|
+
source: activity.source,
|
|
463
|
+
metadata: {
|
|
464
|
+
taskId: run.taskId,
|
|
465
|
+
status: run.status,
|
|
466
|
+
creditedSeconds: run.creditedSeconds
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
recordTaskRunProgressRewards(run.id, run.taskId, input.actor ?? run.actor, activity.source, run.creditedSeconds);
|
|
470
|
+
if (nextStatus === "completed") {
|
|
471
|
+
recordTaskRunCompletionReward(run.id, run.taskId, input.actor ?? run.actor, activity.source);
|
|
472
|
+
const task = getTaskById(run.taskId);
|
|
473
|
+
if (task && task.status !== "done") {
|
|
474
|
+
updateTaskInTransaction(run.taskId, { status: "done" }, {
|
|
475
|
+
source: activity.source,
|
|
476
|
+
actor: input.actor ?? run.actor
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return run;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
export function completeTaskRun(taskRunId, input, now = new Date(), activity = { source: "ui" }) {
|
|
484
|
+
return finishTaskRun(taskRunId, "completed", "completed_at", input, now, activity);
|
|
485
|
+
}
|
|
486
|
+
export function releaseTaskRun(taskRunId, input, now = new Date(), activity = { source: "ui" }) {
|
|
487
|
+
return finishTaskRun(taskRunId, "released", "released_at", input, now, activity);
|
|
488
|
+
}
|