forge-openclaw-plugin 0.2.4 → 0.2.10

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