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.
Files changed (117) hide show
  1. package/README.md +114 -6
  2. package/dist/assets/board-CzgvdLO8.js +6 -0
  3. package/dist/assets/board-CzgvdLO8.js.map +1 -0
  4. package/dist/assets/favicon-BCHm9dUV.ico +0 -0
  5. package/dist/assets/index-8d_oM8fL.js +27 -0
  6. package/dist/assets/index-8d_oM8fL.js.map +1 -0
  7. package/dist/assets/index-D4A_bq8m.css +1 -0
  8. package/dist/assets/motion-STUd1O46.js +10 -0
  9. package/dist/assets/motion-STUd1O46.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-CtNlETLc.js +23 -0
  28. package/dist/assets/table-CtNlETLc.js.map +1 -0
  29. package/dist/assets/ui-ThzkR_oW.js +46 -0
  30. package/dist/assets/ui-ThzkR_oW.js.map +1 -0
  31. package/dist/assets/vendor-CRS-psbw.css +1 -0
  32. package/dist/assets/vendor-DyHAI6nk.js +423 -0
  33. package/dist/assets/vendor-DyHAI6nk.js.map +1 -0
  34. package/dist/assets/viz-BJuBCz_G.js +34 -0
  35. package/dist/assets/viz-BJuBCz_G.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 +8 -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 +135 -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 +5 -3
  46. package/dist/openclaw/plugin-entry-shared.js +52 -10
  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 +2450 -0
  51. package/dist/server/db.js +313 -0
  52. package/dist/server/e2e-server.js +20 -0
  53. package/dist/server/errors.js +15 -0
  54. package/dist/server/index.js +16 -0
  55. package/dist/server/managers/base.js +17 -0
  56. package/dist/server/managers/contracts.js +47 -0
  57. package/dist/server/managers/platform/api-gateway-manager.js +11 -0
  58. package/dist/server/managers/platform/audit-manager.js +15 -0
  59. package/dist/server/managers/platform/authentication-manager.js +56 -0
  60. package/dist/server/managers/platform/authorization-manager.js +56 -0
  61. package/dist/server/managers/platform/background-job-manager.js +10 -0
  62. package/dist/server/managers/platform/configuration-manager.js +33 -0
  63. package/dist/server/managers/platform/database-manager.js +14 -0
  64. package/dist/server/managers/platform/event-bus-manager.js +7 -0
  65. package/dist/server/managers/platform/external-service-manager.js +11 -0
  66. package/dist/server/managers/platform/health-manager.js +7 -0
  67. package/dist/server/managers/platform/migration-manager.js +8 -0
  68. package/dist/server/managers/platform/search-index-manager.js +4 -0
  69. package/dist/server/managers/platform/secrets-manager.js +19 -0
  70. package/dist/server/managers/platform/session-manager.js +121 -0
  71. package/dist/server/managers/platform/storage-manager.js +16 -0
  72. package/dist/server/managers/platform/token-manager.js +37 -0
  73. package/dist/server/managers/platform/transaction-manager.js +8 -0
  74. package/dist/server/managers/platform/trusted-network.js +39 -0
  75. package/dist/server/managers/runtime.js +56 -0
  76. package/dist/server/managers/type-guards.js +4 -0
  77. package/dist/server/openapi.js +3512 -0
  78. package/dist/server/psyche-types.js +395 -0
  79. package/dist/server/repositories/activity-events.js +157 -0
  80. package/dist/server/repositories/collaboration.js +497 -0
  81. package/dist/server/repositories/comments.js +176 -0
  82. package/dist/server/repositories/deleted-entities.js +192 -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 +159 -0
  86. package/dist/server/repositories/projects.js +214 -0
  87. package/dist/server/repositories/psyche.js +1356 -0
  88. package/dist/server/repositories/rewards.js +675 -0
  89. package/dist/server/repositories/settings.js +399 -0
  90. package/dist/server/repositories/tags.js +160 -0
  91. package/dist/server/repositories/task-runs.js +488 -0
  92. package/dist/server/repositories/tasks.js +413 -0
  93. package/dist/server/services/context.js +214 -0
  94. package/dist/server/services/dashboard.js +170 -0
  95. package/dist/server/services/entity-crud.js +576 -0
  96. package/dist/server/services/gamification.js +215 -0
  97. package/dist/server/services/insights.js +91 -0
  98. package/dist/server/services/projects.js +75 -0
  99. package/dist/server/services/psyche.js +63 -0
  100. package/dist/server/services/relations.js +28 -0
  101. package/dist/server/services/reviews.js +88 -0
  102. package/dist/server/services/run-recovery.js +13 -0
  103. package/dist/server/services/tagging.js +49 -0
  104. package/dist/server/services/task-run-watchdog.js +92 -0
  105. package/dist/server/services/work-time.js +176 -0
  106. package/dist/server/types.js +999 -0
  107. package/dist/server/web.js +91 -0
  108. package/openclaw.plugin.json +22 -10
  109. package/package.json +17 -4
  110. package/server/migrations/001_core.sql +333 -0
  111. package/server/migrations/002_psyche.sql +241 -0
  112. package/server/migrations/003_timer_execution.sql +18 -0
  113. package/server/migrations/004_psyche_linked_entities.sql +5 -0
  114. package/server/migrations/005_adaptive_schemas.sql +157 -0
  115. package/server/migrations/006_psyche_auth_setting.sql +4 -0
  116. package/server/migrations/007_deleted_entities.sql +16 -0
  117. 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
+ }