@teamvibe/poller 0.4.3 → 0.5.0

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.
@@ -6,21 +6,20 @@ import { emitError } from './event-queue.js';
6
6
  import { registerBrainForMetrics } from './metrics/scheduler.js';
7
7
  import { collectMetrics } from './metrics/collector.js';
8
8
  import { sendMetrics } from './metrics/sender.js';
9
- import { markHeartbeatJobFired, markHeartbeatJobSuccess, markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
9
+ import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
10
10
  import { join } from 'path';
11
11
  const trackedBrains = new Map();
12
- let heartbeatJob = null;
13
12
  let maintenanceJob = null;
14
13
  /**
15
- * Register a brain from a real message so we know about it for heartbeats.
14
+ * Register a brain from a real message so the maintenance cron knows about it.
16
15
  * Called from processMessage() after brain path is resolved.
17
16
  */
18
17
  export function registerBrain(msg) {
19
18
  const brain = msg.teamvibe.brain;
20
19
  if (!brain?.brainId)
21
20
  return;
22
- // Don't register from heartbeat/maintenance messages (avoid self-referential loop)
23
- if (msg.source === 'heartbeat' || msg.source === 'maintenance')
21
+ // Don't register from maintenance messages (avoid self-referential loop)
22
+ if (msg.source === 'maintenance')
24
23
  return;
25
24
  const isNew = !trackedBrains.has(brain.brainId);
26
25
  trackedBrains.set(brain.brainId, {
@@ -32,7 +31,7 @@ export function registerBrain(msg) {
32
31
  botToken: msg.teamvibe.botToken,
33
32
  channelId: msg.teamvibe.channelId,
34
33
  });
35
- logger.debug(`[Heartbeat] Registered brain: ${brain.brainId}`);
34
+ logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
36
35
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
37
36
  // first sighting of this brain, fire one immediate collect+send so we don't
38
37
  // wait up to an hour for the first sample.
@@ -52,25 +51,25 @@ export function registerBrain(msg) {
52
51
  }
53
52
  }
54
53
  /**
55
- * Send a synthetic message to all tracked brains.
54
+ * Send a synthetic maintenance message to all tracked brains.
56
55
  */
57
- async function triggerAllBrains(source, processMessageFn) {
56
+ async function triggerAllBrains(processMessageFn) {
58
57
  const now = Date.now();
59
58
  for (const [brainId, info] of trackedBrains.entries()) {
60
59
  if (isAtCapacity()) {
61
- logger.debug(`[${source}] At capacity, skipping ${brainId}`);
60
+ logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
62
61
  continue;
63
62
  }
64
- logger.info(`[${source}] Triggering ${source} for brain ${brainId}`);
63
+ logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
65
64
  const syntheticMessage = {
66
- id: `${source}_${brainId}_${now}`,
67
- source,
68
- thread_id: `${source}_${brainId}_${now}`,
65
+ id: `maintenance_${brainId}_${now}`,
66
+ source: 'maintenance',
67
+ thread_id: `maintenance_${brainId}_${now}`,
69
68
  sender: {
70
69
  id: 'system',
71
70
  name: 'System',
72
71
  },
73
- text: source,
72
+ text: 'maintenance',
74
73
  attachments: [],
75
74
  response_context: {},
76
75
  type: 'message',
@@ -94,8 +93,8 @@ async function triggerAllBrains(source, processMessageFn) {
94
93
  });
95
94
  }
96
95
  catch (error) {
97
- logger.error(`[${source}] Failed for ${brainId}: ${error instanceof Error ? error.message : error}`);
98
- emitError('session', `${source} failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
96
+ logger.error(`[Maintenance] Failed for ${brainId}: ${error instanceof Error ? error.message : error}`);
97
+ emitError('session', `maintenance failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
99
98
  channelId: info.channelId,
100
99
  context: { brainId },
101
100
  });
@@ -103,68 +102,48 @@ async function triggerAllBrains(source, processMessageFn) {
103
102
  }
104
103
  }
105
104
  /**
106
- * Start the cron-based heartbeat and maintenance schedules.
107
- *
108
- * - Heartbeat: lightweight, frequent (default: every 5 min)
109
- * - Maintenance: heavy, daily (default: 03:30 UTC)
105
+ * Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
110
106
  */
111
- export function startHeartbeatLoop(processMessageFn) {
107
+ export function startMaintenanceLoop(processMessageFn) {
112
108
  if (!config.MAINTENANCE_ENABLED) {
113
- logger.info('[Heartbeat] Maintenance disabled via config');
109
+ logger.info('[Maintenance] Maintenance disabled via config');
114
110
  return;
115
111
  }
116
- // Heartbeat cron — frequent, lightweight
117
- logger.info(`[Heartbeat] Starting heartbeat cron: "${config.HEARTBEAT_CRON}" (tz: ${config.POLLER_TZ})`);
118
- heartbeatJob = new Cron(config.HEARTBEAT_CRON, { timezone: config.POLLER_TZ, protect: true }, async () => {
119
- await runHeartbeatJob(processMessageFn);
120
- });
121
- // Maintenance cron — daily, heavy (consolidation, memory cleanup)
122
- logger.info(`[Maintenance] Starting maintenance cron: "${config.MAINTENANCE_CRON}" (tz: ${config.POLLER_TZ})`);
123
- maintenanceJob = new Cron(config.MAINTENANCE_CRON, { timezone: config.POLLER_TZ, protect: true }, async () => {
112
+ // Croner uses the host system TZ when `timezone` option is omitted.
113
+ // Build options conditionally so undefined POLLER_TZ becomes "host TZ"
114
+ // rather than overriding it with `timezone: undefined`.
115
+ const cronOpts = config.POLLER_TZ
116
+ ? { timezone: config.POLLER_TZ, protect: true }
117
+ : { protect: true };
118
+ const tzLabel = config.POLLER_TZ ?? `host (${Intl.DateTimeFormat().resolvedOptions().timeZone})`;
119
+ logger.info(`[Maintenance] Starting maintenance cron: "${config.MAINTENANCE_CRON}" (tz: ${tzLabel})`);
120
+ maintenanceJob = new Cron(config.MAINTENANCE_CRON, cronOpts, async () => {
124
121
  await runMaintenanceJob(processMessageFn);
125
122
  });
126
123
  }
127
124
  /**
128
- * Run the heartbeat cron handler with telemetry instrumentation (#119).
125
+ * Run the maintenance cron handler with telemetry instrumentation (#119).
129
126
  * Records last-fire timestamp on entry and last-success timestamp on completion.
130
127
  * Exported for use by the cron registration above and tests that fire it manually.
131
128
  */
132
- export async function runHeartbeatJob(processMessageFn) {
133
- markHeartbeatJobFired();
134
- try {
135
- await triggerAllBrains('heartbeat', processMessageFn);
136
- markHeartbeatJobSuccess();
137
- }
138
- catch (e) {
139
- logger.error(`[Heartbeat] heartbeatJob handler threw: ${e instanceof Error ? e.message : e}`);
140
- throw e; // preserve existing behavior; croner protect handles overlap
141
- }
142
- }
143
- /**
144
- * Run the maintenance cron handler with telemetry instrumentation (#119).
145
- */
146
129
  export async function runMaintenanceJob(processMessageFn) {
147
130
  markMaintenanceJobFired();
148
131
  try {
149
- await triggerAllBrains('maintenance', processMessageFn);
132
+ await triggerAllBrains(processMessageFn);
150
133
  markMaintenanceJobSuccess();
151
134
  }
152
135
  catch (e) {
153
136
  logger.error(`[Maintenance] maintenanceJob handler threw: ${e instanceof Error ? e.message : e}`);
154
- throw e;
137
+ throw e; // preserve existing behavior; croner protect handles overlap
155
138
  }
156
139
  }
157
140
  /**
158
- * Stop the heartbeat and maintenance crons (for shutdown).
141
+ * Stop the maintenance cron (for shutdown).
159
142
  */
160
- export function stopHeartbeatLoop() {
161
- if (heartbeatJob) {
162
- heartbeatJob.stop();
163
- heartbeatJob = null;
164
- }
143
+ export function stopMaintenanceLoop() {
165
144
  if (maintenanceJob) {
166
145
  maintenanceJob.stop();
167
146
  maintenanceJob = null;
168
147
  }
169
- logger.info('[Heartbeat] Stopped heartbeat & maintenance crons');
148
+ logger.info('[Maintenance] Stopped maintenance cron');
170
149
  }
@@ -11,8 +11,6 @@ export interface MetricsPayload {
11
11
  todayMdLines?: number;
12
12
  summaryMdBytes?: number;
13
13
  summaryMdLines?: number;
14
- heartbeatMdBytes?: number;
15
- heartbeatMdLines?: number;
16
14
  brainDirBytes?: number;
17
15
  vmDiskFreeBytes?: number;
18
16
  vmDiskUsedBytes?: number;
@@ -21,8 +19,6 @@ export interface MetricsPayload {
21
19
  oldestInboxMessageAgeSeconds?: number;
22
20
  uncommittedChangeCount?: number;
23
21
  daysSinceMaintenance?: number;
24
- lastHeartbeatJobMs?: number;
25
- lastHeartbeatJobSuccessMs?: number;
26
22
  lastMaintenanceJobMs?: number;
27
23
  lastMaintenanceJobSuccessMs?: number;
28
24
  lastAutoUpdateJobMs?: number;
@@ -7,6 +7,10 @@ import { getCronJobTimestamps } from '../cron-telemetry.js';
7
7
  const statfsAsync = promisify(statfs);
8
8
  const execFileAsync = promisify(execFile);
9
9
  const MS_PER_DAY = 86_400_000;
10
+ // Directory names skipped at any depth during brainDirBytes walk.
11
+ // Holds `du --exclude=.git --exclude=node_modules` parity for the brain dir signal.
12
+ // See teamvibeai/teamvibe.ai#114.
13
+ const EXCLUDED_DIR_NAMES = new Set(['.git', 'node_modules']);
10
14
  /**
11
15
  * Recursively sum file sizes under a directory.
12
16
  * Skips entries we cannot stat (permission errors, broken symlinks).
@@ -21,6 +25,8 @@ async function dirSizeBytes(path) {
21
25
  return 0;
22
26
  }
23
27
  for (const entry of entries) {
28
+ if (entry.isDirectory() && EXCLUDED_DIR_NAMES.has(entry.name))
29
+ continue;
24
30
  const full = join(path, entry.name);
25
31
  try {
26
32
  if (entry.isDirectory()) {
@@ -135,19 +141,6 @@ export async function collectMetrics(brainPath, brainId) {
135
141
  catch {
136
142
  // omit
137
143
  }
138
- // HEARTBEAT.md size + line count — see poller-brain#101. The *number of
139
- // items* in the file is what drives the Step 8b sweep crash risk, so we
140
- // report size + line count rather than a binary presence flag. Both omitted
141
- // on any error (file missing or unreadable).
142
- try {
143
- const heartbeatPath = join(brainPath, 'HEARTBEAT.md');
144
- const s = await stat(heartbeatPath);
145
- payload.heartbeatMdBytes = s.size;
146
- payload.heartbeatMdLines = await countLines(heartbeatPath);
147
- }
148
- catch {
149
- // omit
150
- }
151
144
  // brain dir total size
152
145
  try {
153
146
  payload.brainDirBytes = await dirSizeBytes(brainPath);
@@ -224,17 +217,11 @@ export async function collectMetrics(brainPath, brainId) {
224
217
  catch {
225
218
  // Non-git brain, missing git binary, timeout — omit.
226
219
  }
227
- // Poller-level cron-job telemetry (#119). Same six values for every brain on
220
+ // Poller-level cron-job telemetry (#119). Same four values for every brain on
228
221
  // the poller — see issue body for storage cost rationale. Each measure is
229
222
  // `now - storedTs`; if the cron has never fired (storedTs null), omit it.
230
223
  const now = Date.now();
231
224
  const cron = getCronJobTimestamps();
232
- if (cron.lastHeartbeatJobFiredAt !== null) {
233
- payload.lastHeartbeatJobMs = now - cron.lastHeartbeatJobFiredAt;
234
- }
235
- if (cron.lastHeartbeatJobSuccessAt !== null) {
236
- payload.lastHeartbeatJobSuccessMs = now - cron.lastHeartbeatJobSuccessAt;
237
- }
238
225
  if (cron.lastMaintenanceJobFiredAt !== null) {
239
226
  payload.lastMaintenanceJobMs = now - cron.lastMaintenanceJobFiredAt;
240
227
  }
package/dist/poller.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare function releaseAllActiveLocks(locks: Map<string, string>, releas
10
10
  * `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
11
11
  * can lock in the "swallow but still mark fired" contract that
12
12
  * distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
13
- * in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
13
+ * in morning-check telemetry. Same DI shape as runMaintenanceJob.
14
14
  */
15
15
  export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
16
16
  export declare function startPoller(): Promise<void>;
package/dist/poller.js CHANGED
@@ -2,13 +2,14 @@ import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
4
  import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
- import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
5
+ import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
+ import { classifyFailedIteration } from './classify-failed-iteration.js';
6
7
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
8
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
8
9
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
9
10
  import { postMaintenanceReports } from './maintenance-reporter.js';
10
11
  import { initAuth, stopRefresh } from './auth-provider.js';
11
- import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
12
+ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
12
13
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
13
14
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
14
15
  import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
@@ -39,7 +40,7 @@ export async function releaseAllActiveLocks(locks, release, log = logger) {
39
40
  })));
40
41
  locks.clear();
41
42
  }
42
- // Poller auto-update cron (separate from brain heartbeats)
43
+ // Poller auto-update cron (separate from the maintenance cron)
43
44
  let autoUpdateJob = null;
44
45
  /**
45
46
  * Run the auto-update cron handler with telemetry instrumentation (#119).
@@ -48,7 +49,7 @@ let autoUpdateJob = null;
48
49
  * `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
49
50
  * can lock in the "swallow but still mark fired" contract that
50
51
  * distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
51
- * in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
52
+ * in morning-check telemetry. Same DI shape as runMaintenanceJob.
52
53
  */
53
54
  export async function runAutoUpdateJob(checkForUpdateFn = checkForUpdate) {
54
55
  markAutoUpdateJobFired();
@@ -220,10 +221,14 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
220
221
  lastExitCode = result.exitCode;
221
222
  sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
222
223
  if (hasSlackContext) {
223
- const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
224
- const isTimeout = errorMessage.includes('timed out');
225
- await sendSlackError(queueMessage, errorMessage);
226
- await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
224
+ const decision = classifyFailedIteration(result);
225
+ if (decision.kind === 'neutral') {
226
+ await sendSlackMessage(queueMessage, decision.text);
227
+ }
228
+ else {
229
+ await sendSlackError(queueMessage, decision.text);
230
+ }
231
+ await addReaction(queueMessage, decision.reaction);
227
232
  }
228
233
  }
229
234
  }
@@ -275,7 +280,7 @@ async function processMessage(received) {
275
280
  const threadId = queueMessage.thread_id;
276
281
  const sessionLog = logger.createSession(logSessionId);
277
282
  // Fetch user info if we only have ID
278
- if (queueMessage.source !== 'cron' && queueMessage.source !== 'heartbeat' && queueMessage.source !== 'maintenance' &&
283
+ if (queueMessage.source !== 'cron' && queueMessage.source !== 'maintenance' &&
279
284
  queueMessage.sender.name === 'Unknown User' &&
280
285
  queueMessage.sender.id !== 'unknown') {
281
286
  const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
@@ -311,7 +316,7 @@ async function processMessage(received) {
311
316
  }
312
317
  return;
313
318
  }
314
- // Register brain for heartbeat tracking
319
+ // Register brain so the maintenance cron knows about it.
315
320
  registerBrain(queueMessage);
316
321
  // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
317
322
  writeMessage(kbPath, threadId, JSON.stringify({
@@ -501,7 +506,7 @@ async function shutdown(signal, exitCode = 0) {
501
506
  shuttingDown = true;
502
507
  logger.info(`${signal} received, shutting down gracefully...`);
503
508
  stopRefresh();
504
- stopHeartbeatLoop();
509
+ stopMaintenanceLoop();
505
510
  stopAutoUpdateCron();
506
511
  stopMetricsCron();
507
512
  stopEventQueue();
@@ -549,7 +554,7 @@ export async function startPoller() {
549
554
  const sweep = await cleanupOrphanWorktreesForAllBrains(config.BRAINS_PATH);
550
555
  logger.info(` Orphan worktree sweep: scanned ${sweep.scanned} brains, removed ${sweep.removed} stale worktrees`);
551
556
  }
552
- startHeartbeatLoop(processMessage);
557
+ startMaintenanceLoop(processMessage);
553
558
  startAutoUpdateCron();
554
559
  startMetricsCron();
555
560
  await pollLoop();
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Poller-side secrets client — teamvibe.ai#105 (PR4).
3
+ *
4
+ * Fetches per-spawn secret envelopes from the Poller API
5
+ * (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
6
+ * response into a child-process env map plus a metadata array for system
7
+ * reminder rendering.
8
+ *
9
+ * Defense-in-depth principles enforced here:
10
+ *
11
+ * 1. **Localized unwrap.** The decrypted env map is returned wrapped in
12
+ * an opaque container with a single `unwrap()` method. The caller
13
+ * (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
14
+ * process spawn site. No other call site touches the values.
15
+ *
16
+ * 2. **Reminder rendering never sees values.** `reminders[]` carries
17
+ * only `{name, scope, expiresAt, overrodeScopes}`. A separate render
18
+ * function `buildSecretsReminder` produces the system-prompt text
19
+ * from this metadata; it has no access to the env map.
20
+ *
21
+ * 3. **Caller-side log redaction.** This module's logging functions
22
+ * explicitly never log response bodies. Error paths log only the
23
+ * HTTP status + a sanitised error message. We do NOT rely solely on
24
+ * the API's `x-tv-no-log: response-body` header — the poller is its
25
+ * own trust boundary.
26
+ *
27
+ * If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
28
+ * + reminders so that spawn proceeds without secrets rather than failing
29
+ * the user's session. Errors are logged at warn level; production
30
+ * monitoring should alarm on `secrets-client.fetch_failed` metric counter.
31
+ */
32
+ export type SecretScope = 'poller' | 'workspace' | 'channel';
33
+ export interface SecretReminderEntry {
34
+ name: string;
35
+ scope: SecretScope;
36
+ expiresAt?: string;
37
+ overrodeScopes?: SecretScope[];
38
+ }
39
+ /**
40
+ * Opaque envelope around the decrypted env map. The values inside MUST NOT
41
+ * be read except via `unwrap()`, and `unwrap()` is intended to be called
42
+ * exactly once at the child-process spawn site.
43
+ *
44
+ * Default JSON / toString render `[REDACTED]`. If you ever find yourself
45
+ * adding a new caller of `unwrap()`, treat it as a security-relevant
46
+ * change and ping DevGuru.
47
+ */
48
+ export declare class SpawnSecretsEnvelope {
49
+ #private;
50
+ constructor(env: Record<string, string>);
51
+ /** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
52
+ unwrap(): Record<string, string>;
53
+ /** Number of secrets carried — safe for logging. */
54
+ size(): number;
55
+ toString(): string;
56
+ toJSON(): string;
57
+ }
58
+ export interface SpawnSecretsResult {
59
+ env: SpawnSecretsEnvelope;
60
+ reminders: SecretReminderEntry[];
61
+ }
62
+ export interface FetchSecretsInput {
63
+ pollerId: string;
64
+ workspaceId: string;
65
+ channelId: string;
66
+ }
67
+ /**
68
+ * Fetch secrets for a spawn. Returns an empty envelope (and empty
69
+ * reminders array) on any failure mode — spawn continues without
70
+ * secrets. The error is logged here so monitoring can alarm; do NOT
71
+ * pass error details into the spawn pipeline where they might surface
72
+ * to the user.
73
+ */
74
+ export declare function fetchSecretsForSpawn(input: FetchSecretsInput): Promise<SpawnSecretsResult>;
75
+ /**
76
+ * Render the system-reminder text from reminder metadata. This function
77
+ * intentionally has NO access to the env map — its inputs are name +
78
+ * scope + expiration only. A future code-reader who needs to add a value
79
+ * to the reminder body must change the function signature, which is a
80
+ * loud signal to think twice.
81
+ */
82
+ export declare function buildSecretsReminder(reminders: SecretReminderEntry[]): string;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Poller-side secrets client — teamvibe.ai#105 (PR4).
3
+ *
4
+ * Fetches per-spawn secret envelopes from the Poller API
5
+ * (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
6
+ * response into a child-process env map plus a metadata array for system
7
+ * reminder rendering.
8
+ *
9
+ * Defense-in-depth principles enforced here:
10
+ *
11
+ * 1. **Localized unwrap.** The decrypted env map is returned wrapped in
12
+ * an opaque container with a single `unwrap()` method. The caller
13
+ * (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
14
+ * process spawn site. No other call site touches the values.
15
+ *
16
+ * 2. **Reminder rendering never sees values.** `reminders[]` carries
17
+ * only `{name, scope, expiresAt, overrodeScopes}`. A separate render
18
+ * function `buildSecretsReminder` produces the system-prompt text
19
+ * from this metadata; it has no access to the env map.
20
+ *
21
+ * 3. **Caller-side log redaction.** This module's logging functions
22
+ * explicitly never log response bodies. Error paths log only the
23
+ * HTTP status + a sanitised error message. We do NOT rely solely on
24
+ * the API's `x-tv-no-log: response-body` header — the poller is its
25
+ * own trust boundary.
26
+ *
27
+ * If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
28
+ * + reminders so that spawn proceeds without secrets rather than failing
29
+ * the user's session. Errors are logged at warn level; production
30
+ * monitoring should alarm on `secrets-client.fetch_failed` metric counter.
31
+ */
32
+ import { logger } from './logger.js';
33
+ import { config } from './config.js';
34
+ /**
35
+ * Opaque envelope around the decrypted env map. The values inside MUST NOT
36
+ * be read except via `unwrap()`, and `unwrap()` is intended to be called
37
+ * exactly once at the child-process spawn site.
38
+ *
39
+ * Default JSON / toString render `[REDACTED]`. If you ever find yourself
40
+ * adding a new caller of `unwrap()`, treat it as a security-relevant
41
+ * change and ping DevGuru.
42
+ */
43
+ export class SpawnSecretsEnvelope {
44
+ #env;
45
+ constructor(env) {
46
+ this.#env = env;
47
+ }
48
+ /** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
49
+ unwrap() {
50
+ return this.#env;
51
+ }
52
+ /** Number of secrets carried — safe for logging. */
53
+ size() {
54
+ return Object.keys(this.#env).length;
55
+ }
56
+ toString() {
57
+ return `[SpawnSecretsEnvelope size=${this.size()} REDACTED]`;
58
+ }
59
+ toJSON() {
60
+ return '[REDACTED]';
61
+ }
62
+ [Symbol.for('nodejs.util.inspect.custom')]() {
63
+ return this.toString();
64
+ }
65
+ }
66
+ /**
67
+ * Fetch secrets for a spawn. Returns an empty envelope (and empty
68
+ * reminders array) on any failure mode — spawn continues without
69
+ * secrets. The error is logged here so monitoring can alarm; do NOT
70
+ * pass error details into the spawn pipeline where they might surface
71
+ * to the user.
72
+ */
73
+ export async function fetchSecretsForSpawn(input) {
74
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
75
+ logger.warn('secrets-client: TEAMVIBE_API_URL or TEAMVIBE_POLLER_TOKEN not set; skipping fetch');
76
+ return emptyResult();
77
+ }
78
+ // Upfront short-circuit: an empty pollerId would fail server-side
79
+ // validation and round-trip an HTTP error. Skip the call entirely so
80
+ // the warn log makes the cause obvious from poller stdout.
81
+ if (!input.pollerId) {
82
+ logger.warn('secrets-client: pollerId empty; skipping fetch (no secrets injected for spawn)');
83
+ return emptyResult();
84
+ }
85
+ // URL construction can throw on malformed TEAMVIBE_API_URL — must run
86
+ // inside the fail-soft envelope so a config typo doesn't crash the
87
+ // spawn pipeline. Wrapping here keeps the contract uniform with the
88
+ // fetch / parse error paths below.
89
+ let url;
90
+ try {
91
+ url = new URL('/secrets/spawn', config.TEAMVIBE_API_URL);
92
+ url.searchParams.set('pollerId', input.pollerId);
93
+ url.searchParams.set('workspaceId', input.workspaceId);
94
+ url.searchParams.set('channelId', input.channelId);
95
+ }
96
+ catch (err) {
97
+ logger.warn(`secrets-client.config_error: invalid TEAMVIBE_API_URL=${String(config.TEAMVIBE_API_URL)} ` +
98
+ `error=${err?.message ?? String(err)}`);
99
+ return emptyResult();
100
+ }
101
+ let response;
102
+ try {
103
+ response = await fetch(url.toString(), {
104
+ method: 'GET',
105
+ headers: {
106
+ Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
107
+ // Hint to any intermediary that this request returns sensitive
108
+ // data; the API itself sets `x-tv-no-log` on the response.
109
+ 'Cache-Control': 'no-store',
110
+ },
111
+ });
112
+ }
113
+ catch (err) {
114
+ logger.warn(`secrets-client.fetch_failed: network error pollerId=${input.pollerId} ` +
115
+ `workspaceId=${input.workspaceId} channelId=${input.channelId} ` +
116
+ `error=${err?.message ?? String(err)}`);
117
+ return emptyResult();
118
+ }
119
+ if (!response.ok) {
120
+ // Read body to surface a useful error log, but cap the size so a
121
+ // misconfigured API can't dump arbitrary content into our logs.
122
+ // Status + a small slice is sufficient diagnostics — no body
123
+ // pass-through.
124
+ let snippet = '';
125
+ try {
126
+ snippet = (await response.text()).slice(0, 200);
127
+ }
128
+ catch {
129
+ snippet = '<failed-to-read-body>';
130
+ }
131
+ logger.warn(`secrets-client.fetch_failed: status=${response.status} ` +
132
+ `pollerId=${input.pollerId} workspaceId=${input.workspaceId} ` +
133
+ `channelId=${input.channelId} bodySnippet=${snippet}`);
134
+ return emptyResult();
135
+ }
136
+ // The response is sensitive — parse it and immediately wrap. From this
137
+ // point on, the env map is only accessible via `.unwrap()`.
138
+ let parsed;
139
+ try {
140
+ parsed = (await response.json());
141
+ }
142
+ catch (err) {
143
+ logger.warn(`secrets-client.parse_failed: pollerId=${input.pollerId} ` +
144
+ `error=${err?.message ?? String(err)}`);
145
+ return emptyResult();
146
+ }
147
+ const env = {};
148
+ if (parsed.env && typeof parsed.env === 'object') {
149
+ for (const [k, v] of Object.entries(parsed.env)) {
150
+ if (typeof v === 'string')
151
+ env[k] = v;
152
+ }
153
+ }
154
+ const reminders = [];
155
+ if (Array.isArray(parsed.reminders)) {
156
+ for (const item of parsed.reminders) {
157
+ if (typeof item['name'] !== 'string' || typeof item['scope'] !== 'string')
158
+ continue;
159
+ reminders.push({
160
+ name: item['name'],
161
+ scope: item['scope'],
162
+ expiresAt: typeof item['expiresAt'] === 'string' ? item['expiresAt'] : undefined,
163
+ overrodeScopes: Array.isArray(item['overrodeScopes'])
164
+ ? item['overrodeScopes'].filter(s => typeof s === 'string')
165
+ : undefined,
166
+ });
167
+ }
168
+ }
169
+ // Log only the size — never names or values.
170
+ logger.info(`secrets-client: fetched ${Object.keys(env).length} secret(s) for spawn`);
171
+ return {
172
+ env: new SpawnSecretsEnvelope(env),
173
+ reminders,
174
+ };
175
+ }
176
+ function emptyResult() {
177
+ return { env: new SpawnSecretsEnvelope({}), reminders: [] };
178
+ }
179
+ /**
180
+ * Render the system-reminder text from reminder metadata. This function
181
+ * intentionally has NO access to the env map — its inputs are name +
182
+ * scope + expiration only. A future code-reader who needs to add a value
183
+ * to the reminder body must change the function signature, which is a
184
+ * loud signal to think twice.
185
+ */
186
+ export function buildSecretsReminder(reminders) {
187
+ if (reminders.length === 0)
188
+ return '';
189
+ const now = Date.now();
190
+ const lines = ['## Available Secrets'];
191
+ lines.push('The following ENV variables are loaded into this session:');
192
+ for (const r of reminders) {
193
+ let suffix = '';
194
+ if (r.expiresAt) {
195
+ const expMs = Date.parse(r.expiresAt);
196
+ if (Number.isFinite(expMs)) {
197
+ const daysLeft = Math.floor((expMs - now) / (24 * 60 * 60 * 1000));
198
+ if (daysLeft < 1) {
199
+ suffix = ` — **EXPIRES IN <1d** :warning: please rotate via \`set_secret\``;
200
+ }
201
+ else if (daysLeft < 7) {
202
+ suffix = ` (expires in ${daysLeft}d — please rotate via \`set_secret\`)`;
203
+ }
204
+ }
205
+ }
206
+ const overrideNote = r.overrodeScopes && r.overrodeScopes.length > 0
207
+ ? `, overrides ${r.overrodeScopes.join('+')}`
208
+ : '';
209
+ lines.push(`- \`${r.name}\` (${r.scope} scope${overrideNote})${suffix}`);
210
+ }
211
+ lines.push('');
212
+ lines.push('Access via `process.env.{NAME}` or shell `$NAME`.');
213
+ lines.push('Values are a snapshot taken at spawn time; long sessions may see stale values until the next spawn.');
214
+ return lines.join('\n');
215
+ }
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface ControlMessage {
7
7
  export type QueueMessage = TeamVibeQueueMessage | ControlMessage;
8
8
  export interface TeamVibeQueueMessage {
9
9
  id: string;
10
- source: 'slack' | 'email' | 'cron' | 'heartbeat' | 'maintenance' | 'webhook';
10
+ source: 'slack' | 'email' | 'cron' | 'maintenance' | 'webhook';
11
11
  thread_id: string;
12
12
  sender: {
13
13
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "zod": "^3.22.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/bun": "^1.3.14",
32
33
  "@types/node": "^20.10.5",
33
34
  "tsx": "^4.7.0",
34
35
  "typescript": "^5.3.3"