forge-openclaw-plugin 0.2.15 → 0.2.19
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 +39 -4
- package/dist/assets/{board-C_m78kvK.js → board-8L3uX7_O.js} +2 -2
- package/dist/assets/{board-C_m78kvK.js.map → board-8L3uX7_O.js.map} +1 -1
- package/dist/assets/index-Cj1IBH_w.js +36 -0
- package/dist/assets/index-Cj1IBH_w.js.map +1 -0
- package/dist/assets/index-DQT6EbuS.css +1 -0
- package/dist/assets/{motion-CpZvZumD.js → motion-1GAqqi8M.js} +2 -2
- package/dist/assets/{motion-CpZvZumD.js.map → motion-1GAqqi8M.js.map} +1 -1
- package/dist/assets/{table-DtyXTw03.js → table-DBGlgRjk.js} +2 -2
- package/dist/assets/{table-DtyXTw03.js.map → table-DBGlgRjk.js.map} +1 -1
- package/dist/assets/{ui-BXbpiKyS.js → ui-iTluWjC4.js} +2 -2
- package/dist/assets/{ui-BXbpiKyS.js.map → ui-iTluWjC4.js.map} +1 -1
- package/dist/assets/{vendor-QBH6qVEe.js → vendor-BvM2F9Dp.js} +151 -81
- package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
- package/dist/assets/{viz-w-IMeueL.js → viz-CNeunkfu.js} +2 -2
- package/dist/assets/{viz-w-IMeueL.js.map → viz-CNeunkfu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/local-runtime.js +142 -9
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/plugin-entry-shared.js +7 -1
- package/dist/openclaw/routes.js +7 -0
- package/dist/openclaw/tools.js +198 -16
- package/dist/server/app.js +2615 -251
- package/dist/server/managers/platform/secrets-manager.js +44 -1
- package/dist/server/managers/runtime.js +3 -1
- package/dist/server/openapi.js +2212 -170
- package/dist/server/repositories/calendar.js +1101 -0
- package/dist/server/repositories/deleted-entities.js +10 -2
- package/dist/server/repositories/habits.js +358 -0
- package/dist/server/repositories/notes.js +161 -28
- package/dist/server/repositories/projects.js +45 -13
- package/dist/server/repositories/rewards.js +176 -6
- package/dist/server/repositories/settings.js +47 -5
- package/dist/server/repositories/task-runs.js +46 -10
- package/dist/server/repositories/tasks.js +25 -9
- package/dist/server/repositories/weekly-reviews.js +109 -0
- package/dist/server/repositories/work-adjustments.js +105 -0
- package/dist/server/services/calendar-runtime.js +1301 -0
- package/dist/server/services/context.js +16 -6
- package/dist/server/services/dashboard.js +6 -3
- package/dist/server/services/entity-crud.js +116 -3
- package/dist/server/services/gamification.js +66 -18
- package/dist/server/services/insights.js +2 -1
- package/dist/server/services/projects.js +32 -8
- package/dist/server/services/reviews.js +17 -2
- package/dist/server/services/work-time.js +27 -0
- package/dist/server/types.js +1069 -45
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/003_habits.sql +30 -0
- package/server/migrations/004_habit_links.sql +8 -0
- package/server/migrations/005_habit_psyche_links.sql +24 -0
- package/server/migrations/006_work_adjustments.sql +14 -0
- package/server/migrations/007_weekly_review_closures.sql +17 -0
- package/server/migrations/008_calendar_execution.sql +147 -0
- package/server/migrations/009_true_calendar_events.sql +195 -0
- package/server/migrations/010_calendar_selection_state.sql +6 -0
- package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
- package/server/migrations/012_work_block_ranges.sql +7 -0
- package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
- package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
- package/skills/forge-openclaw/SKILL.md +130 -10
- package/skills/forge-openclaw/cron_jobs.md +395 -0
- package/dist/assets/index-BWtLtXwb.js +0 -36
- package/dist/assets/index-BWtLtXwb.js.map +0 -1
- package/dist/assets/index-Dp5GXY_z.css +0 -1
- package/dist/assets/vendor-QBH6qVEe.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getDatabase } from "../db.js";
|
|
3
3
|
import { recordEventLog } from "./event-log.js";
|
|
4
|
-
import { createManualRewardGrantSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
|
|
4
|
+
import { createManualRewardGrantSchema, workAdjustmentEntityTypeSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
|
|
5
5
|
const DEFAULT_RULES = [
|
|
6
6
|
{
|
|
7
7
|
id: "reward_rule_task_completion",
|
|
@@ -43,6 +43,22 @@ const DEFAULT_RULES = [
|
|
|
43
43
|
description: "Reward a concrete decision to apply a useful insight.",
|
|
44
44
|
config: { fixedXp: 15 }
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: "reward_rule_habit_aligned",
|
|
48
|
+
family: "consistency",
|
|
49
|
+
code: "habit_aligned",
|
|
50
|
+
title: "Habit alignment",
|
|
51
|
+
description: "Award XP when a habit outcome matches the intended direction.",
|
|
52
|
+
config: { award: "habit.rewardXp" }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "reward_rule_habit_misaligned",
|
|
56
|
+
family: "recovery",
|
|
57
|
+
code: "habit_misaligned",
|
|
58
|
+
title: "Habit miss",
|
|
59
|
+
description: "Apply a small XP penalty when a habit outcome moves against the intended direction.",
|
|
60
|
+
config: { penalty: "habit.penaltyXp" }
|
|
61
|
+
},
|
|
46
62
|
{
|
|
47
63
|
id: "reward_rule_psyche_reflection_capture",
|
|
48
64
|
family: "alignment",
|
|
@@ -91,6 +107,14 @@ const DEFAULT_RULES = [
|
|
|
91
107
|
description: "Reward giving a recurring mode enough shape to recognize it later.",
|
|
92
108
|
config: { fixedXp: 4 }
|
|
93
109
|
},
|
|
110
|
+
{
|
|
111
|
+
id: "reward_rule_weekly_review_completed",
|
|
112
|
+
family: "alignment",
|
|
113
|
+
code: "weekly_review_completed",
|
|
114
|
+
title: "Weekly review completed",
|
|
115
|
+
description: "Reward closing the current weekly review cycle and turning it into explicit evidence.",
|
|
116
|
+
config: { fixedXp: 250 }
|
|
117
|
+
},
|
|
94
118
|
{
|
|
95
119
|
id: "reward_rule_session_dwell",
|
|
96
120
|
family: "ambient",
|
|
@@ -178,6 +202,17 @@ export function getRewardRuleById(ruleId) {
|
|
|
178
202
|
function getRuleByCode(code) {
|
|
179
203
|
return listRewardRules().find((rule) => rule.code === code);
|
|
180
204
|
}
|
|
205
|
+
export function getTaskRunProgressRewardCadence() {
|
|
206
|
+
ensureDefaultRewardRules();
|
|
207
|
+
const rule = getRuleByCode("task_run_progress");
|
|
208
|
+
const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
|
|
209
|
+
return {
|
|
210
|
+
rule,
|
|
211
|
+
intervalMinutes,
|
|
212
|
+
intervalSeconds: intervalMinutes * 60,
|
|
213
|
+
fixedXp: Number(rule?.config.fixedXp ?? 4)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
181
216
|
export function updateRewardRule(ruleId, input, activity) {
|
|
182
217
|
ensureDefaultRewardRules();
|
|
183
218
|
const current = getRewardRuleById(ruleId);
|
|
@@ -263,6 +298,17 @@ export function listRewardLedger(filters = {}) {
|
|
|
263
298
|
.all(...params);
|
|
264
299
|
return rows.map(mapLedger);
|
|
265
300
|
}
|
|
301
|
+
export function getRewardLedgerEventById(rewardId) {
|
|
302
|
+
ensureDefaultRewardRules();
|
|
303
|
+
const row = getDatabase()
|
|
304
|
+
.prepare(`SELECT
|
|
305
|
+
id, rule_id, event_log_id, entity_type, entity_id, actor, source, delta_xp, reason_title, reason_summary,
|
|
306
|
+
reversible_group, reversed_by_reward_id, metadata_json, created_at
|
|
307
|
+
FROM reward_ledger
|
|
308
|
+
WHERE id = ?`)
|
|
309
|
+
.get(rewardId);
|
|
310
|
+
return row ? mapLedger(row) : null;
|
|
311
|
+
}
|
|
266
312
|
export function getTotalXp() {
|
|
267
313
|
ensureDefaultRewardRules();
|
|
268
314
|
const row = getDatabase().prepare(`SELECT COALESCE(SUM(delta_xp), 0) AS total FROM reward_ledger`).get();
|
|
@@ -521,11 +567,7 @@ export function recordTaskRunStartReward(taskRunId, taskId, actor, source) {
|
|
|
521
567
|
});
|
|
522
568
|
}
|
|
523
569
|
export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, creditedSeconds) {
|
|
524
|
-
|
|
525
|
-
const rule = getRuleByCode("task_run_progress");
|
|
526
|
-
const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
|
|
527
|
-
const intervalSeconds = intervalMinutes * 60;
|
|
528
|
-
const fixedXp = Number(rule?.config.fixedXp ?? 4);
|
|
570
|
+
const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
|
|
529
571
|
const earnedBuckets = Math.floor(Math.max(0, creditedSeconds) / intervalSeconds);
|
|
530
572
|
if (earnedBuckets <= 0) {
|
|
531
573
|
return [];
|
|
@@ -577,6 +619,55 @@ export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, c
|
|
|
577
619
|
}
|
|
578
620
|
return rewards;
|
|
579
621
|
}
|
|
622
|
+
export function recordWorkAdjustmentReward(input) {
|
|
623
|
+
const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
|
|
624
|
+
const entityType = workAdjustmentEntityTypeSchema.parse(input.entityType);
|
|
625
|
+
const previousBuckets = Math.floor(Math.max(0, input.previousCreditedSeconds) / intervalSeconds);
|
|
626
|
+
const nextBuckets = Math.floor(Math.max(0, input.nextCreditedSeconds) / intervalSeconds);
|
|
627
|
+
const bucketDelta = nextBuckets - previousBuckets;
|
|
628
|
+
if (bucketDelta === 0) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
const deltaXp = bucketDelta * fixedXp;
|
|
632
|
+
const direction = bucketDelta > 0 ? "added" : "removed";
|
|
633
|
+
const appliedMinutes = Math.abs(input.appliedDeltaMinutes);
|
|
634
|
+
const eventLog = recordEventLog({
|
|
635
|
+
eventKind: "reward.work_adjustment",
|
|
636
|
+
entityType,
|
|
637
|
+
entityId: input.entityId,
|
|
638
|
+
actor: input.actor ?? null,
|
|
639
|
+
source: input.source,
|
|
640
|
+
metadata: {
|
|
641
|
+
adjustmentId: input.adjustmentId,
|
|
642
|
+
requestedDeltaMinutes: input.requestedDeltaMinutes,
|
|
643
|
+
appliedDeltaMinutes: input.appliedDeltaMinutes,
|
|
644
|
+
bucketDelta,
|
|
645
|
+
deltaXp
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
return insertLedgerEvent({
|
|
649
|
+
ruleId: rule?.id ?? null,
|
|
650
|
+
eventLogId: eventLog.id,
|
|
651
|
+
entityType,
|
|
652
|
+
entityId: input.entityId,
|
|
653
|
+
actor: input.actor ?? null,
|
|
654
|
+
source: input.source,
|
|
655
|
+
deltaXp,
|
|
656
|
+
reasonTitle: bucketDelta > 0 ? "Manual work minutes added" : "Manual work minutes removed",
|
|
657
|
+
reasonSummary: `${appliedMinutes} manual minute${appliedMinutes === 1 ? "" : "s"} ${direction}, shifting ${Math.abs(bucketDelta)} ${intervalMinutes}-minute reward bucket${Math.abs(bucketDelta) === 1 ? "" : "s"} for ${input.targetTitle}.`,
|
|
658
|
+
reversibleGroup: `work_adjustment:${entityType}:${input.entityId}:${input.adjustmentId}`,
|
|
659
|
+
metadata: {
|
|
660
|
+
adjustmentId: input.adjustmentId,
|
|
661
|
+
requestedDeltaMinutes: input.requestedDeltaMinutes,
|
|
662
|
+
appliedDeltaMinutes: input.appliedDeltaMinutes,
|
|
663
|
+
previousCreditedSeconds: input.previousCreditedSeconds,
|
|
664
|
+
nextCreditedSeconds: input.nextCreditedSeconds,
|
|
665
|
+
bucketDelta,
|
|
666
|
+
intervalMinutes,
|
|
667
|
+
rewardCategory: "manual_work_adjustment"
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
580
671
|
export function recordSessionEvent(input, activity, now = new Date()) {
|
|
581
672
|
ensureDefaultRewardRules();
|
|
582
673
|
const sessionEvent = sessionEventSchema.parse({
|
|
@@ -633,6 +724,85 @@ export function recordSessionEvent(input, activity, now = new Date()) {
|
|
|
633
724
|
}, now);
|
|
634
725
|
return { sessionEvent, rewardEvent };
|
|
635
726
|
}
|
|
727
|
+
export function recordHabitCheckInReward(habit, status, dateKey, activity) {
|
|
728
|
+
ensureDefaultRewardRules();
|
|
729
|
+
const aligned = (habit.polarity === "positive" && status === "done") ||
|
|
730
|
+
(habit.polarity === "negative" && status === "missed");
|
|
731
|
+
const rule = getRuleByCode(aligned ? "habit_aligned" : "habit_misaligned");
|
|
732
|
+
const deltaXp = aligned ? habit.rewardXp : -Math.abs(habit.penaltyXp);
|
|
733
|
+
const actionLabel = habit.polarity === "positive"
|
|
734
|
+
? status === "done"
|
|
735
|
+
? "completed"
|
|
736
|
+
: "missed"
|
|
737
|
+
: status === "done"
|
|
738
|
+
? "performed"
|
|
739
|
+
: "resisted";
|
|
740
|
+
const eventLog = recordEventLog({
|
|
741
|
+
eventKind: aligned ? "reward.habit_aligned" : "reward.habit_misaligned",
|
|
742
|
+
entityType: "habit",
|
|
743
|
+
entityId: habit.id,
|
|
744
|
+
actor: activity.actor ?? null,
|
|
745
|
+
source: activity.source,
|
|
746
|
+
metadata: {
|
|
747
|
+
habitId: habit.id,
|
|
748
|
+
status,
|
|
749
|
+
polarity: habit.polarity,
|
|
750
|
+
dateKey,
|
|
751
|
+
deltaXp
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
return insertLedgerEvent({
|
|
755
|
+
ruleId: rule?.id ?? null,
|
|
756
|
+
eventLogId: eventLog.id,
|
|
757
|
+
entityType: "habit",
|
|
758
|
+
entityId: habit.id,
|
|
759
|
+
actor: activity.actor ?? null,
|
|
760
|
+
source: activity.source,
|
|
761
|
+
deltaXp,
|
|
762
|
+
reasonTitle: aligned ? `${habit.title} aligned` : `${habit.title} slipped`,
|
|
763
|
+
reasonSummary: `Habit ${actionLabel} on ${dateKey}.`,
|
|
764
|
+
reversibleGroup: `habit:${habit.id}:${dateKey}`,
|
|
765
|
+
metadata: {
|
|
766
|
+
habitId: habit.id,
|
|
767
|
+
status,
|
|
768
|
+
polarity: habit.polarity,
|
|
769
|
+
dateKey
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
export function recordWeeklyReviewCompletionReward(input, activity) {
|
|
774
|
+
ensureDefaultRewardRules();
|
|
775
|
+
const rule = getRuleByCode("weekly_review_completed");
|
|
776
|
+
const deltaXp = Math.max(0, Number(rule?.config.fixedXp ?? input.rewardXp));
|
|
777
|
+
const eventLog = recordEventLog({
|
|
778
|
+
eventKind: "reward.weekly_review_completed",
|
|
779
|
+
entityType: "system",
|
|
780
|
+
entityId: input.weekKey,
|
|
781
|
+
actor: activity.actor ?? null,
|
|
782
|
+
source: activity.source,
|
|
783
|
+
metadata: {
|
|
784
|
+
weekKey: input.weekKey,
|
|
785
|
+
windowLabel: input.windowLabel,
|
|
786
|
+
deltaXp
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return insertLedgerEvent({
|
|
790
|
+
ruleId: rule?.id ?? null,
|
|
791
|
+
eventLogId: eventLog.id,
|
|
792
|
+
entityType: "system",
|
|
793
|
+
entityId: input.weekKey,
|
|
794
|
+
actor: activity.actor ?? null,
|
|
795
|
+
source: activity.source,
|
|
796
|
+
deltaXp,
|
|
797
|
+
reasonTitle: rule?.title ?? "Weekly review completed",
|
|
798
|
+
reasonSummary: `Closed the review for ${input.windowLabel}.`,
|
|
799
|
+
reversibleGroup: `weekly_review_completed:${input.weekKey}`,
|
|
800
|
+
metadata: {
|
|
801
|
+
weekKey: input.weekKey,
|
|
802
|
+
windowLabel: input.windowLabel
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
636
806
|
export function listSessionEvents(limit = 50) {
|
|
637
807
|
const rows = getDatabase()
|
|
638
808
|
.prepare(`SELECT id, session_id, event_type, actor, source, metrics_json, created_at
|
|
@@ -12,6 +12,18 @@ function toInt(value) {
|
|
|
12
12
|
function hashToken(token) {
|
|
13
13
|
return createHash("sha256").update(token).digest("hex");
|
|
14
14
|
}
|
|
15
|
+
function defaultMicrosoftRedirectUri() {
|
|
16
|
+
const port = process.env.PORT?.trim() || "4317";
|
|
17
|
+
return `http://127.0.0.1:${port}/api/v1/calendar/oauth/microsoft/callback`;
|
|
18
|
+
}
|
|
19
|
+
function normalizeMicrosoftTenantId(value) {
|
|
20
|
+
const trimmed = value?.trim();
|
|
21
|
+
return trimmed && trimmed.length > 0 ? trimmed : "common";
|
|
22
|
+
}
|
|
23
|
+
function normalizeMicrosoftRedirectUri(value) {
|
|
24
|
+
const trimmed = value?.trim();
|
|
25
|
+
return trimmed && trimmed.length > 0 ? trimmed : defaultMicrosoftRedirectUri();
|
|
26
|
+
}
|
|
15
27
|
function buildTokenSecret() {
|
|
16
28
|
return `fg_live_${randomBytes(18).toString("hex")}`;
|
|
17
29
|
}
|
|
@@ -108,7 +120,7 @@ function readSettingsRow() {
|
|
|
108
120
|
.prepare(`SELECT
|
|
109
121
|
operator_name, operator_email, operator_title, theme_preference, locale_preference,
|
|
110
122
|
goal_drift_alerts, daily_quest_reminders, achievement_celebrations, max_active_tasks, time_accounting_mode,
|
|
111
|
-
integrity_score, last_audit_at, psyche_auth_required, created_at, updated_at
|
|
123
|
+
integrity_score, last_audit_at, psyche_auth_required, microsoft_client_id, microsoft_tenant_id, microsoft_redirect_uri, created_at, updated_at
|
|
112
124
|
FROM app_settings
|
|
113
125
|
WHERE id = 1`)
|
|
114
126
|
.get();
|
|
@@ -166,6 +178,9 @@ export function isPsycheAuthRequired() {
|
|
|
166
178
|
}
|
|
167
179
|
export function getSettings() {
|
|
168
180
|
const row = readSettingsRow();
|
|
181
|
+
const microsoftClientId = row.microsoft_client_id?.trim() ?? "";
|
|
182
|
+
const microsoftTenantId = normalizeMicrosoftTenantId(row.microsoft_tenant_id);
|
|
183
|
+
const microsoftRedirectUri = normalizeMicrosoftRedirectUri(row.microsoft_redirect_uri);
|
|
169
184
|
return settingsPayloadSchema.parse({
|
|
170
185
|
profile: {
|
|
171
186
|
operatorName: row.operator_name,
|
|
@@ -191,6 +206,21 @@ export function getSettings() {
|
|
|
191
206
|
tokenCount: listAgentTokens().filter((token) => token.status === "active").length,
|
|
192
207
|
psycheAuthRequired: boolFromInt(row.psyche_auth_required)
|
|
193
208
|
},
|
|
209
|
+
calendarProviders: {
|
|
210
|
+
microsoft: {
|
|
211
|
+
clientId: microsoftClientId,
|
|
212
|
+
tenantId: microsoftTenantId,
|
|
213
|
+
redirectUri: microsoftRedirectUri,
|
|
214
|
+
usesClientSecret: false,
|
|
215
|
+
readOnly: true,
|
|
216
|
+
authMode: "public_client_pkce",
|
|
217
|
+
isConfigured: microsoftClientId.length > 0,
|
|
218
|
+
isReadyForSignIn: microsoftClientId.length > 0,
|
|
219
|
+
setupMessage: microsoftClientId.length > 0
|
|
220
|
+
? "Microsoft local sign-in is configured. Test it if you want, then continue to the guided sign-in flow."
|
|
221
|
+
: "Save the Microsoft client ID and the Forge callback redirect URI here before you try to sign in."
|
|
222
|
+
}
|
|
223
|
+
},
|
|
194
224
|
agents: listAgentIdentities(),
|
|
195
225
|
agentTokens: listAgentTokens()
|
|
196
226
|
});
|
|
@@ -217,15 +247,25 @@ export function updateSettings(input, activity) {
|
|
|
217
247
|
},
|
|
218
248
|
themePreference: parsed.themePreference ?? current.themePreference,
|
|
219
249
|
localePreference: parsed.localePreference ?? current.localePreference,
|
|
220
|
-
psycheAuthRequired: parsed.security?.psycheAuthRequired ?? current.security.psycheAuthRequired
|
|
250
|
+
psycheAuthRequired: parsed.security?.psycheAuthRequired ?? current.security.psycheAuthRequired,
|
|
251
|
+
calendarProviders: {
|
|
252
|
+
microsoft: {
|
|
253
|
+
clientId: parsed.calendarProviders?.microsoft?.clientId?.trim() ??
|
|
254
|
+
current.calendarProviders.microsoft.clientId,
|
|
255
|
+
tenantId: normalizeMicrosoftTenantId(parsed.calendarProviders?.microsoft?.tenantId ??
|
|
256
|
+
current.calendarProviders.microsoft.tenantId),
|
|
257
|
+
redirectUri: normalizeMicrosoftRedirectUri(parsed.calendarProviders?.microsoft?.redirectUri ??
|
|
258
|
+
current.calendarProviders.microsoft.redirectUri)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
221
261
|
};
|
|
222
262
|
getDatabase()
|
|
223
263
|
.prepare(`UPDATE app_settings
|
|
224
264
|
SET operator_name = ?, operator_email = ?, operator_title = ?, theme_preference = ?, locale_preference = ?,
|
|
225
265
|
goal_drift_alerts = ?, daily_quest_reminders = ?, achievement_celebrations = ?, max_active_tasks = ?, time_accounting_mode = ?,
|
|
226
|
-
psyche_auth_required = ?, updated_at = ?
|
|
266
|
+
psyche_auth_required = ?, microsoft_client_id = ?, microsoft_tenant_id = ?, microsoft_redirect_uri = ?, updated_at = ?
|
|
227
267
|
WHERE id = 1`)
|
|
228
|
-
.run(next.profile.operatorName, next.profile.operatorEmail, next.profile.operatorTitle, next.themePreference, next.localePreference, toInt(next.notifications.goalDriftAlerts), toInt(next.notifications.dailyQuestReminders), toInt(next.notifications.achievementCelebrations), next.execution.maxActiveTasks, next.execution.timeAccountingMode, toInt(next.psycheAuthRequired), now);
|
|
268
|
+
.run(next.profile.operatorName, next.profile.operatorEmail, next.profile.operatorTitle, next.themePreference, next.localePreference, toInt(next.notifications.goalDriftAlerts), toInt(next.notifications.dailyQuestReminders), toInt(next.notifications.achievementCelebrations), next.execution.maxActiveTasks, next.execution.timeAccountingMode, toInt(next.psycheAuthRequired), next.calendarProviders.microsoft.clientId, next.calendarProviders.microsoft.tenantId, next.calendarProviders.microsoft.redirectUri, now);
|
|
229
269
|
if (activity) {
|
|
230
270
|
recordActivityEvent({
|
|
231
271
|
entityType: "system",
|
|
@@ -241,7 +281,9 @@ export function updateSettings(input, activity) {
|
|
|
241
281
|
goalDriftAlerts: next.notifications.goalDriftAlerts,
|
|
242
282
|
dailyQuestReminders: next.notifications.dailyQuestReminders,
|
|
243
283
|
maxActiveTasks: next.execution.maxActiveTasks,
|
|
244
|
-
timeAccountingMode: next.execution.timeAccountingMode
|
|
284
|
+
timeAccountingMode: next.execution.timeAccountingMode,
|
|
285
|
+
microsoftConfigured: next.calendarProviders.microsoft.clientId.trim().length > 0,
|
|
286
|
+
microsoftTenantId: next.calendarProviders.microsoft.tenantId
|
|
245
287
|
}
|
|
246
288
|
});
|
|
247
289
|
}
|
|
@@ -3,6 +3,7 @@ import { getDatabase, runInTransaction } from "../db.js";
|
|
|
3
3
|
import { HttpError } from "../errors.js";
|
|
4
4
|
import { computeWorkTime } from "../services/work-time.js";
|
|
5
5
|
import { recordActivityEvent } from "./activity-events.js";
|
|
6
|
+
import { bindTaskRunToTimebox, evaluateSchedulingForTask, finalizeTaskRunTimebox, heartbeatTaskRunTimebox } from "./calendar.js";
|
|
6
7
|
import { createLinkedNotes } from "./notes.js";
|
|
7
8
|
import { recordTaskRunCompletionReward, recordTaskRunProgressRewards, recordTaskRunStartReward } from "./rewards.js";
|
|
8
9
|
import { getTaskById, updateTaskInTransaction } from "./tasks.js";
|
|
@@ -27,6 +28,7 @@ function selectClause() {
|
|
|
27
28
|
task_runs.completed_at,
|
|
28
29
|
task_runs.released_at,
|
|
29
30
|
task_runs.timed_out_at,
|
|
31
|
+
task_runs.override_reason,
|
|
30
32
|
task_runs.updated_at,
|
|
31
33
|
tasks.title AS task_title
|
|
32
34
|
FROM task_runs
|
|
@@ -74,6 +76,7 @@ function mapTaskRun(row, now = new Date(), cached = computeWorkTime(now)) {
|
|
|
74
76
|
completedAt: row.completed_at,
|
|
75
77
|
releasedAt: row.released_at,
|
|
76
78
|
timedOutAt: row.timed_out_at,
|
|
79
|
+
overrideReason: row.override_reason ?? null,
|
|
77
80
|
updatedAt: row.updated_at
|
|
78
81
|
});
|
|
79
82
|
}
|
|
@@ -289,6 +292,15 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
|
|
|
289
292
|
const parsedInput = taskRunClaimSchema.parse(input);
|
|
290
293
|
markExpiredRunsTimedOutInTransaction(now);
|
|
291
294
|
requireKnownTask(taskId);
|
|
295
|
+
const task = getTaskById(taskId);
|
|
296
|
+
const scheduling = evaluateSchedulingForTask(task, now);
|
|
297
|
+
if (scheduling.blocked && (!parsedInput.overrideReason || parsedInput.overrideReason.trim().length === 0)) {
|
|
298
|
+
throw new HttpError(409, "task_run_calendar_blocked", `Calendar rules block starting ${task.title} right now. Add an override reason to proceed.`, {
|
|
299
|
+
taskId,
|
|
300
|
+
conflicts: scheduling.conflicts,
|
|
301
|
+
effectiveRules: scheduling.effectiveRules
|
|
302
|
+
});
|
|
303
|
+
}
|
|
292
304
|
const existing = getActiveTaskRunRow(taskId, now);
|
|
293
305
|
const nowIso = now.toISOString();
|
|
294
306
|
if (existing) {
|
|
@@ -298,9 +310,9 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
|
|
|
298
310
|
const nextExpiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
|
|
299
311
|
getDatabase()
|
|
300
312
|
.prepare(`UPDATE task_runs
|
|
301
|
-
SET timer_mode = ?, planned_duration_seconds = ?, is_current = ?, heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
|
|
313
|
+
SET timer_mode = ?, planned_duration_seconds = ?, is_current = ?, heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, override_reason = ?, updated_at = ?
|
|
302
314
|
WHERE id = ?`)
|
|
303
|
-
.run(parsedInput.timerMode, parsedInput.plannedDurationSeconds, existing.is_current, nowIso, nextExpiry, parsedInput.leaseTtlSeconds, parsedInput.note, nowIso, existing.id);
|
|
315
|
+
.run(parsedInput.timerMode, parsedInput.plannedDurationSeconds, existing.is_current, nowIso, nextExpiry, parsedInput.leaseTtlSeconds, parsedInput.note, parsedInput.overrideReason ?? null, nowIso, existing.id);
|
|
304
316
|
maybeSetCurrentRun(parsedInput.actor, existing.id, parsedInput.isCurrent, now);
|
|
305
317
|
touchTaskInProgress(taskId, parsedInput.actor, activity.source);
|
|
306
318
|
recordActivityEvent({
|
|
@@ -315,9 +327,15 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
|
|
|
315
327
|
taskId,
|
|
316
328
|
leaseTtlSeconds: parsedInput.leaseTtlSeconds,
|
|
317
329
|
timerMode: parsedInput.timerMode,
|
|
318
|
-
plannedDurationSeconds: parsedInput.plannedDurationSeconds
|
|
330
|
+
plannedDurationSeconds: parsedInput.plannedDurationSeconds,
|
|
331
|
+
overrideReason: parsedInput.overrideReason ?? null
|
|
319
332
|
}
|
|
320
333
|
});
|
|
334
|
+
heartbeatTaskRunTimebox(existing.id, {
|
|
335
|
+
title: task.title,
|
|
336
|
+
endsAt: nextExpiry,
|
|
337
|
+
overrideReason: parsedInput.overrideReason ?? null
|
|
338
|
+
});
|
|
321
339
|
const cached = computeWorkTime(now);
|
|
322
340
|
return {
|
|
323
341
|
run: mapTaskRun(requireRun(existing.id), now, cached),
|
|
@@ -329,13 +347,22 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
|
|
|
329
347
|
const expiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
|
|
330
348
|
getDatabase()
|
|
331
349
|
.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
|
|
350
|
+
id, task_id, actor, status, timer_mode, planned_duration_seconds, is_current, note, lease_ttl_seconds, claimed_at, heartbeat_at, lease_expires_at, override_reason, updated_at
|
|
333
351
|
)
|
|
334
|
-
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
335
|
-
.run(runId, taskId, parsedInput.actor, parsedInput.timerMode, parsedInput.plannedDurationSeconds, 0, parsedInput.note, parsedInput.leaseTtlSeconds, nowIso, nowIso, expiry, nowIso);
|
|
352
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
353
|
+
.run(runId, taskId, parsedInput.actor, parsedInput.timerMode, parsedInput.plannedDurationSeconds, 0, parsedInput.note, parsedInput.leaseTtlSeconds, nowIso, nowIso, expiry, parsedInput.overrideReason ?? null, nowIso);
|
|
336
354
|
maybeSetCurrentRun(parsedInput.actor, runId, parsedInput.isCurrent, now);
|
|
337
355
|
touchTaskInProgress(taskId, parsedInput.actor, activity.source);
|
|
338
356
|
const run = mapTaskRun(requireRun(runId), now);
|
|
357
|
+
bindTaskRunToTimebox({
|
|
358
|
+
taskId,
|
|
359
|
+
taskRunId: run.id,
|
|
360
|
+
startedAt: now,
|
|
361
|
+
title: task.title,
|
|
362
|
+
projectId: task.projectId,
|
|
363
|
+
plannedDurationSeconds: parsedInput.plannedDurationSeconds,
|
|
364
|
+
overrideReason: parsedInput.overrideReason ?? null
|
|
365
|
+
});
|
|
339
366
|
recordActivityEvent({
|
|
340
367
|
entityType: "task_run",
|
|
341
368
|
entityId: run.id,
|
|
@@ -350,7 +377,8 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
|
|
|
350
377
|
taskId: run.taskId,
|
|
351
378
|
leaseTtlSeconds: run.leaseTtlSeconds,
|
|
352
379
|
timerMode: run.timerMode,
|
|
353
|
-
plannedDurationSeconds: run.plannedDurationSeconds
|
|
380
|
+
plannedDurationSeconds: run.plannedDurationSeconds,
|
|
381
|
+
overrideReason: parsedInput.overrideReason ?? null
|
|
354
382
|
}
|
|
355
383
|
});
|
|
356
384
|
recordTaskRunStartReward(run.id, run.taskId, run.actor, activity.source);
|
|
@@ -370,15 +398,16 @@ export function heartbeatTaskRun(taskRunId, input, now = new Date(), activity =
|
|
|
370
398
|
const note = input.note ?? current.note;
|
|
371
399
|
getDatabase()
|
|
372
400
|
.prepare(`UPDATE task_runs
|
|
373
|
-
SET heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
|
|
401
|
+
SET heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, override_reason = ?, updated_at = ?
|
|
374
402
|
WHERE id = ?`)
|
|
375
|
-
.run(nowIso, nextExpiry, input.leaseTtlSeconds, note, nowIso, taskRunId);
|
|
403
|
+
.run(nowIso, nextExpiry, input.leaseTtlSeconds, note, input.overrideReason ?? current.override_reason, nowIso, taskRunId);
|
|
376
404
|
const run = mapTaskRun({
|
|
377
405
|
...current,
|
|
378
406
|
heartbeat_at: nowIso,
|
|
379
407
|
lease_expires_at: nextExpiry,
|
|
380
408
|
lease_ttl_seconds: input.leaseTtlSeconds,
|
|
381
409
|
note,
|
|
410
|
+
override_reason: input.overrideReason ?? current.override_reason,
|
|
382
411
|
updated_at: nowIso
|
|
383
412
|
}, now);
|
|
384
413
|
recordActivityEvent({
|
|
@@ -391,9 +420,15 @@ export function heartbeatTaskRun(taskRunId, input, now = new Date(), activity =
|
|
|
391
420
|
source: activity.source,
|
|
392
421
|
metadata: {
|
|
393
422
|
taskId: run.taskId,
|
|
394
|
-
leaseTtlSeconds: run.leaseTtlSeconds
|
|
423
|
+
leaseTtlSeconds: run.leaseTtlSeconds,
|
|
424
|
+
overrideReason: input.overrideReason ?? null
|
|
395
425
|
}
|
|
396
426
|
});
|
|
427
|
+
heartbeatTaskRunTimebox(taskRunId, {
|
|
428
|
+
title: run.taskTitle,
|
|
429
|
+
endsAt: nextExpiry,
|
|
430
|
+
overrideReason: input.overrideReason ?? null
|
|
431
|
+
});
|
|
397
432
|
recordTaskRunProgressRewards(run.id, run.taskId, input.actor ?? run.actor, activity.source, run.creditedSeconds);
|
|
398
433
|
return run;
|
|
399
434
|
});
|
|
@@ -451,6 +486,7 @@ function finishTaskRun(taskRunId, nextStatus, timestampColumn, input, now, activ
|
|
|
451
486
|
[timestampColumn]: nowIso,
|
|
452
487
|
updated_at: nowIso
|
|
453
488
|
}, now);
|
|
489
|
+
finalizeTaskRunTimebox(taskRunId, nextStatus === "completed" ? "completed" : "cancelled", nowIso);
|
|
454
490
|
recordActivityEvent({
|
|
455
491
|
entityType: "task_run",
|
|
456
492
|
entityId: run.id,
|
|
@@ -11,7 +11,7 @@ import { pruneLinkedEntityReferences } from "./psyche.js";
|
|
|
11
11
|
import { awardTaskCompletionReward, reverseLatestTaskCompletionReward } from "./rewards.js";
|
|
12
12
|
import { assertTaskRelations } from "../services/relations.js";
|
|
13
13
|
import { computeWorkTime, emptyTaskTimeSummary } from "../services/work-time.js";
|
|
14
|
-
import { taskSchema } from "../types.js";
|
|
14
|
+
import { calendarSchedulingRulesSchema, taskSchema } from "../types.js";
|
|
15
15
|
function readTaskTagIds(taskId) {
|
|
16
16
|
const rows = getDatabase()
|
|
17
17
|
.prepare(`SELECT tag_id FROM task_tags WHERE task_id = ? ORDER BY tag_id`)
|
|
@@ -32,6 +32,10 @@ function mapTask(row, time = emptyTaskTimeSummary()) {
|
|
|
32
32
|
effort: row.effort,
|
|
33
33
|
energy: row.energy,
|
|
34
34
|
points: row.points,
|
|
35
|
+
plannedDurationSeconds: row.planned_duration_seconds,
|
|
36
|
+
schedulingRules: row.scheduling_rules_json === null
|
|
37
|
+
? null
|
|
38
|
+
: calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json)),
|
|
35
39
|
sortOrder: row.sort_order,
|
|
36
40
|
completedAt: row.completed_at,
|
|
37
41
|
createdAt: row.created_at,
|
|
@@ -129,9 +133,17 @@ function updateTaskRecord(current, input, activity) {
|
|
|
129
133
|
getDatabase()
|
|
130
134
|
.prepare(`UPDATE tasks
|
|
131
135
|
SET title = ?, description = ?, status = ?, priority = ?, owner = ?, goal_id = ?, due_date = ?, effort = ?,
|
|
132
|
-
energy = ?, points = ?, sort_order = ?, completed_at = ?, updated_at = ?, project_id = ?
|
|
136
|
+
energy = ?, points = ?, planned_duration_seconds = ?, scheduling_rules_json = ?, sort_order = ?, completed_at = ?, updated_at = ?, project_id = ?
|
|
133
137
|
WHERE id = ?`)
|
|
134
|
-
.run(input.title ?? current.title, input.description ?? current.description, nextStatus, input.priority ?? current.priority, input.owner ?? current.owner, nextGoalId, input.dueDate === undefined ? current.dueDate : input.dueDate, input.effort ?? current.effort, input.energy ?? current.energy, input.points ?? current.points,
|
|
138
|
+
.run(input.title ?? current.title, input.description ?? current.description, nextStatus, input.priority ?? current.priority, input.owner ?? current.owner, nextGoalId, input.dueDate === undefined ? current.dueDate : input.dueDate, input.effort ?? current.effort, input.energy ?? current.energy, input.points ?? current.points, input.plannedDurationSeconds === undefined
|
|
139
|
+
? current.plannedDurationSeconds
|
|
140
|
+
: input.plannedDurationSeconds, input.schedulingRules === undefined
|
|
141
|
+
? current.schedulingRules === null
|
|
142
|
+
? null
|
|
143
|
+
: JSON.stringify(current.schedulingRules)
|
|
144
|
+
: input.schedulingRules === null
|
|
145
|
+
? null
|
|
146
|
+
: JSON.stringify(input.schedulingRules), nextSort, completedAt, updatedAt, nextProjectId, current.id);
|
|
135
147
|
replaceTaskTags(current.id, nextTagIds);
|
|
136
148
|
const updated = getTaskById(current.id);
|
|
137
149
|
if (updated && activity) {
|
|
@@ -210,6 +222,8 @@ function fingerprintTaskCreate(input) {
|
|
|
210
222
|
effort: input.effort,
|
|
211
223
|
energy: input.energy,
|
|
212
224
|
points: input.points,
|
|
225
|
+
plannedDurationSeconds: input.plannedDurationSeconds,
|
|
226
|
+
schedulingRules: input.schedulingRules,
|
|
213
227
|
sortOrder: input.sortOrder ?? null,
|
|
214
228
|
tagIds: input.tagIds,
|
|
215
229
|
notes: input.notes.map((note) => ({
|
|
@@ -232,11 +246,11 @@ function insertTaskRecord(input, activity) {
|
|
|
232
246
|
const completedAt = normalizeCompletedAt(input.status, null);
|
|
233
247
|
getDatabase()
|
|
234
248
|
.prepare(`INSERT INTO tasks (
|
|
235
|
-
id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
236
|
-
completed_at, created_at, updated_at
|
|
249
|
+
id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
250
|
+
planned_duration_seconds, scheduling_rules_json, sort_order, completed_at, created_at, updated_at
|
|
237
251
|
)
|
|
238
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
239
|
-
.run(id, input.title, input.description, input.status, input.priority, input.owner, relationState.goalId, relationState.projectId, input.dueDate, input.effort, input.energy, input.points, sortOrder, completedAt, now, now);
|
|
252
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
253
|
+
.run(id, input.title, input.description, input.status, input.priority, input.owner, relationState.goalId, relationState.projectId, input.dueDate, input.effort, input.energy, input.points, input.plannedDurationSeconds, input.schedulingRules === null ? null : JSON.stringify(input.schedulingRules), sortOrder, completedAt, now, now);
|
|
240
254
|
replaceTaskTags(id, input.tagIds);
|
|
241
255
|
const task = getTaskById(id);
|
|
242
256
|
if (activity) {
|
|
@@ -306,7 +320,8 @@ export function listTasks(filters = {}) {
|
|
|
306
320
|
params.push(filters.limit);
|
|
307
321
|
}
|
|
308
322
|
const rows = getDatabase()
|
|
309
|
-
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
323
|
+
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
324
|
+
planned_duration_seconds, scheduling_rules_json, sort_order,
|
|
310
325
|
completed_at, created_at, updated_at
|
|
311
326
|
FROM tasks
|
|
312
327
|
${whereSql}
|
|
@@ -330,7 +345,8 @@ export function getTaskById(taskId) {
|
|
|
330
345
|
return undefined;
|
|
331
346
|
}
|
|
332
347
|
const row = getDatabase()
|
|
333
|
-
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
348
|
+
.prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
|
|
349
|
+
planned_duration_seconds, scheduling_rules_json, sort_order,
|
|
334
350
|
completed_at, created_at, updated_at
|
|
335
351
|
FROM tasks
|
|
336
352
|
WHERE id = ?`)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
4
|
+
import { getRewardLedgerEventById, recordWeeklyReviewCompletionReward } from "./rewards.js";
|
|
5
|
+
import { weeklyReviewClosureSchema } from "../types.js";
|
|
6
|
+
function mapWeeklyReviewClosure(row) {
|
|
7
|
+
return weeklyReviewClosureSchema.parse({
|
|
8
|
+
id: row.id,
|
|
9
|
+
weekKey: row.week_key,
|
|
10
|
+
weekStartDate: row.week_start_date,
|
|
11
|
+
weekEndDate: row.week_end_date,
|
|
12
|
+
windowLabel: row.window_label,
|
|
13
|
+
actor: row.actor,
|
|
14
|
+
source: row.source,
|
|
15
|
+
rewardId: row.reward_id,
|
|
16
|
+
activityEventId: row.activity_event_id,
|
|
17
|
+
createdAt: row.created_at
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function getWeeklyReviewClosure(weekKey) {
|
|
21
|
+
const row = getDatabase()
|
|
22
|
+
.prepare(`SELECT
|
|
23
|
+
id,
|
|
24
|
+
week_key,
|
|
25
|
+
week_start_date,
|
|
26
|
+
week_end_date,
|
|
27
|
+
window_label,
|
|
28
|
+
actor,
|
|
29
|
+
source,
|
|
30
|
+
reward_id,
|
|
31
|
+
activity_event_id,
|
|
32
|
+
created_at
|
|
33
|
+
FROM weekly_review_closures
|
|
34
|
+
WHERE week_key = ?`)
|
|
35
|
+
.get(weekKey);
|
|
36
|
+
return row ? mapWeeklyReviewClosure(row) : null;
|
|
37
|
+
}
|
|
38
|
+
export function finalizeWeeklyReviewClosure(input) {
|
|
39
|
+
return runInTransaction(() => {
|
|
40
|
+
const existing = getWeeklyReviewClosure(input.weekKey);
|
|
41
|
+
if (existing) {
|
|
42
|
+
const existingReward = getRewardLedgerEventById(existing.rewardId);
|
|
43
|
+
if (!existingReward) {
|
|
44
|
+
throw new Error(`Weekly review closure ${existing.id} is missing reward ${existing.rewardId}.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
closure: existing,
|
|
48
|
+
reward: existingReward,
|
|
49
|
+
created: false
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const reward = recordWeeklyReviewCompletionReward({
|
|
53
|
+
weekKey: input.weekKey,
|
|
54
|
+
windowLabel: input.windowLabel,
|
|
55
|
+
rewardXp: input.rewardXp
|
|
56
|
+
}, {
|
|
57
|
+
actor: input.actor ?? null,
|
|
58
|
+
source: input.source
|
|
59
|
+
});
|
|
60
|
+
const activity = recordActivityEvent({
|
|
61
|
+
entityType: "system",
|
|
62
|
+
entityId: input.weekKey,
|
|
63
|
+
eventType: "weekly_review_finalized",
|
|
64
|
+
title: `Weekly review finalized: ${input.windowLabel}`,
|
|
65
|
+
description: `Review completion locked this cycle and awarded ${reward.deltaXp} XP.`,
|
|
66
|
+
actor: input.actor ?? null,
|
|
67
|
+
source: input.source,
|
|
68
|
+
metadata: {
|
|
69
|
+
weekKey: input.weekKey,
|
|
70
|
+
weekStartDate: input.weekStartDate,
|
|
71
|
+
weekEndDate: input.weekEndDate,
|
|
72
|
+
rewardId: reward.id,
|
|
73
|
+
rewardXp: reward.deltaXp
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const createdAt = new Date().toISOString();
|
|
77
|
+
const closure = weeklyReviewClosureSchema.parse({
|
|
78
|
+
id: `wrc_${randomUUID().replaceAll("-", "").slice(0, 10)}`,
|
|
79
|
+
weekKey: input.weekKey,
|
|
80
|
+
weekStartDate: input.weekStartDate,
|
|
81
|
+
weekEndDate: input.weekEndDate,
|
|
82
|
+
windowLabel: input.windowLabel,
|
|
83
|
+
actor: input.actor ?? null,
|
|
84
|
+
source: input.source,
|
|
85
|
+
rewardId: reward.id,
|
|
86
|
+
activityEventId: activity.id,
|
|
87
|
+
createdAt
|
|
88
|
+
});
|
|
89
|
+
getDatabase()
|
|
90
|
+
.prepare(`INSERT INTO weekly_review_closures (
|
|
91
|
+
id,
|
|
92
|
+
week_key,
|
|
93
|
+
week_start_date,
|
|
94
|
+
week_end_date,
|
|
95
|
+
window_label,
|
|
96
|
+
actor,
|
|
97
|
+
source,
|
|
98
|
+
reward_id,
|
|
99
|
+
activity_event_id,
|
|
100
|
+
created_at
|
|
101
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
102
|
+
.run(closure.id, closure.weekKey, closure.weekStartDate, closure.weekEndDate, closure.windowLabel, closure.actor, closure.source, closure.rewardId, closure.activityEventId, closure.createdAt);
|
|
103
|
+
return {
|
|
104
|
+
closure,
|
|
105
|
+
reward,
|
|
106
|
+
created: true
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|