summaryrefslogtreecommitdiffhomepage
path: root/packages/heartbeat/src/config-store.ts
blob: f06c9e88e3b9efb415745ceb8705d451e4430d38 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import type { StorageNamespace } from "@dispatch/kernel";
import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transport-contract";

/**
 * The default config returned for a workspace that has never configured a
 * heartbeat. A heartbeat is OFF until explicitly enabled.
 */
export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
  enabled: false,
  systemPrompt: "",
  taskPrompt: "",
  intervalMinutes: 30,
  model: "",
  reasoningEffort: null,
};

/** Minimum scheduling interval, in minutes (a heartbeat can't fire faster). */
export const MIN_INTERVAL_MINUTES = 1;

/** Storage key for a workspace's heartbeat config. */
function configKey(workspaceId: string): string {
  return `config:${workspaceId}`;
}

/**
 * Pure: apply a partial update to a config, returning the new config. Clamps
 * `intervalMinutes` to a minimum of 1 (a positive integer). Fields not present
 * in `update` are left unchanged. `reasoningEffort: null` clears the override
 * (back to inheriting the workspace default); `undefined` (absent) leaves it.
 *
 * Validation of `reasoningEffort` (rejecting unrecognized strings) is the
 * transport layer's job (→ HTTP 400); this function trusts a caller that has
 * already validated, but only ever produces a valid `HeartbeatConfig`.
 */
export function applyConfigUpdate(
  current: HeartbeatConfig,
  update: UpdateHeartbeatRequest,
): HeartbeatConfig {
  const next: HeartbeatConfig = {
    enabled: update.enabled !== undefined ? update.enabled : current.enabled,
    systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
    taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
    intervalMinutes:
      update.intervalMinutes !== undefined
        ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes))
        : current.intervalMinutes,
    model: update.model !== undefined ? update.model : current.model,
    reasoningEffort:
      update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort,
  };
  return next;
}

export interface HeartbeatConfigStore {
  /** The config for a workspace (the default when never set). */
  readonly get: (workspaceId: string) => Promise<HeartbeatConfig>;
  /** Apply a partial update and persist it; returns the new config. */
  readonly update: (
    workspaceId: string,
    update: UpdateHeartbeatRequest,
  ) => Promise<HeartbeatConfig>;
  /** Every workspace id that has a persisted (non-default) config. */
  readonly listWorkspaceIds: () => Promise<readonly string[]>;
}

export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore {
  return {
    async get(workspaceId: string): Promise<HeartbeatConfig> {
      const raw = await storage.get(configKey(workspaceId));
      if (raw === null) return DEFAULT_HEARTBEAT_CONFIG;
      try {
        const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
        return {
          enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
          systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
          taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
          intervalMinutes:
            typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0
              ? Math.trunc(parsed.intervalMinutes)
              : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes,
          model: typeof parsed.model === "string" ? parsed.model : "",
          reasoningEffort: parsed.reasoningEffort ?? null,
        };
      } catch {
        return DEFAULT_HEARTBEAT_CONFIG;
      }
    },

    async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> {
      const current = await this.get(workspaceId);
      const next = applyConfigUpdate(current, update);
      await storage.set(configKey(workspaceId), JSON.stringify(next));
      return next;
    },

    async listWorkspaceIds(): Promise<readonly string[]> {
      const keys = await storage.keys("config:");
      const ids = keys.map((k) => k.slice("config:".length));
      // De-duplicate + sort for a stable boot-scan order.
      return [...new Set(ids)].sort();
    },
  };
}