@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.
- package/dist/brain-manager.d.ts +5 -0
- package/dist/brain-manager.js +40 -2
- package/dist/classify-failed-iteration.d.ts +16 -0
- package/dist/classify-failed-iteration.js +53 -0
- package/dist/claude-spawner.d.ts +15 -1
- package/dist/claude-spawner.js +122 -25
- package/dist/config.d.ts +3 -7
- package/dist/config.js +8 -8
- package/dist/cron-telemetry.d.ts +5 -9
- package/dist/cron-telemetry.js +5 -17
- package/dist/{heartbeat-manager.d.ts → maintenance-cron.d.ts} +6 -17
- package/dist/{heartbeat-manager.js → maintenance-cron.js} +33 -54
- package/dist/metrics/collector.d.ts +0 -4
- package/dist/metrics/collector.js +7 -20
- package/dist/poller.d.ts +1 -1
- package/dist/poller.js +17 -12
- package/dist/secrets-client.d.ts +82 -0
- package/dist/secrets-client.js +215 -0
- package/dist/types.d.ts +1 -1
- package/package.json +2 -1
|
@@ -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 {
|
|
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
|
|
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
|
|
23
|
-
if (msg.source === '
|
|
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(`[
|
|
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(
|
|
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(`[
|
|
60
|
+
logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
|
|
62
61
|
continue;
|
|
63
62
|
}
|
|
64
|
-
logger.info(`[
|
|
63
|
+
logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
|
|
65
64
|
const syntheticMessage = {
|
|
66
|
-
id:
|
|
67
|
-
source,
|
|
68
|
-
thread_id:
|
|
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:
|
|
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(`[
|
|
98
|
-
emitError('session',
|
|
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
|
|
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
|
|
107
|
+
export function startMaintenanceLoop(processMessageFn) {
|
|
112
108
|
if (!config.MAINTENANCE_ENABLED) {
|
|
113
|
-
logger.info('[
|
|
109
|
+
logger.info('[Maintenance] Maintenance disabled via config');
|
|
114
110
|
return;
|
|
115
111
|
}
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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(
|
|
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
|
|
141
|
+
* Stop the maintenance cron (for shutdown).
|
|
159
142
|
*/
|
|
160
|
-
export function
|
|
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('[
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 !== '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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' | '
|
|
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.
|
|
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"
|