@teamvibe/poller 0.8.6 → 0.8.7-rc.1
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/bin/tv-git-credential.mjs +39 -0
- package/dist/auth-provider.js +8 -1
- package/dist/bg-task-registry.d.ts +12 -0
- package/dist/bg-task-registry.js +65 -0
- package/dist/brain-manager.d.ts +4 -0
- package/dist/brain-manager.js +87 -14
- package/dist/brains-bootstrap.js +6 -4
- package/dist/claude-spawner.d.ts +3 -0
- package/dist/claude-spawner.js +54 -21
- package/dist/connections-client.d.ts +66 -0
- package/dist/connections-client.js +128 -0
- package/dist/git-credential-env.d.ts +3 -0
- package/dist/git-credential-env.js +37 -0
- package/dist/git-credential-session.d.ts +33 -0
- package/dist/git-credential-session.js +265 -0
- package/dist/idle-restart.d.ts +1 -1
- package/dist/idle-restart.js +7 -62
- package/dist/maintenance-cron.d.ts +6 -0
- package/dist/poller.js +3 -1
- package/dist/pool-telemetry.js +1 -0
- package/dist/redact-text.d.ts +3 -1
- package/dist/redact-text.js +17 -1
- package/dist/session-instance.d.ts +9 -0
- package/dist/session-instance.js +36 -0
- package/dist/worktree-manager.d.ts +7 -4
- package/dist/worktree-manager.js +94 -36
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
package/dist/pool-telemetry.js
CHANGED
|
@@ -139,6 +139,7 @@ export function emitPoolSelection(msg, selection) {
|
|
|
139
139
|
selectedAccountLabel: safeText(selection.account.label) ?? 'unlabeled',
|
|
140
140
|
decidedAt: lastSelectionAt,
|
|
141
141
|
candidates: selection.candidates ?? [],
|
|
142
|
+
...(selection.pinned ? { pinned: true } : {}),
|
|
142
143
|
...(selection.switch ? { switch: selection.switch } : {}),
|
|
143
144
|
});
|
|
144
145
|
}
|
package/dist/redact-text.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export declare function registerConfiguredToken(token: string): void;
|
|
2
|
+
export declare function redactRepoUrl(url: string): string;
|
|
3
|
+
export declare function redactGitDiagnostic(text: string): string;
|
|
2
4
|
export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
|
package/dist/redact-text.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
/** Redact credentials in diagnostics without initializing poller authentication. */
|
|
2
|
+
const issuedTokens = new Set();
|
|
3
|
+
export function registerConfiguredToken(token) { if (token)
|
|
4
|
+
issuedTokens.add(token); }
|
|
5
|
+
export function redactRepoUrl(url) {
|
|
6
|
+
return redactGitDiagnostic(url.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1<redacted>@'));
|
|
7
|
+
}
|
|
8
|
+
export function redactGitDiagnostic(text) {
|
|
9
|
+
let safe = text
|
|
10
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1<redacted>@')
|
|
11
|
+
.replace(/\b(?:x-access-token|oauth2):[^\s/@]+/gi, match => match.split(':')[0] + ':<redacted>')
|
|
12
|
+
.replace(/\bgh[pos]_[A-Za-z0-9]{20,}\b/g, '<redacted>');
|
|
13
|
+
for (const token of issuedTokens)
|
|
14
|
+
if (token)
|
|
15
|
+
safe = safe.split(token).join('<redacted>');
|
|
16
|
+
return safe;
|
|
17
|
+
}
|
|
2
18
|
export function redactStartupText(text, configuredTokens = []) {
|
|
3
19
|
let safe = text
|
|
4
20
|
.replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
|
|
@@ -7,7 +23,7 @@ export function redactStartupText(text, configuredTokens = []) {
|
|
|
7
23
|
// Cover both Claude's token name and the opt-in subprocess alias in
|
|
8
24
|
// shell assignments and JSON env diagnostics, even without a token list.
|
|
9
25
|
.replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
|
|
10
|
-
for (const token of configuredTokens) {
|
|
26
|
+
for (const token of [...issuedTokens, ...configuredTokens]) {
|
|
11
27
|
if (token)
|
|
12
28
|
safe = safe.split(token).join('[REDACTED]');
|
|
13
29
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SessionInstance {
|
|
2
|
+
sessionInstanceId: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createSessionInstance(): SessionInstance;
|
|
6
|
+
export declare function readSessionOwner(path: string): SessionInstance | undefined;
|
|
7
|
+
export declare function writeSessionOwner(path: string, owner: SessionInstance): void;
|
|
8
|
+
export declare function sameSessionInstance(a: SessionInstance | undefined, b: SessionInstance | undefined): boolean;
|
|
9
|
+
export declare function reportSessionOwnerMismatch(owner: SessionInstance, current?: SessionInstance): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
export function createSessionInstance() {
|
|
5
|
+
return { sessionInstanceId: randomUUID(), createdAt: new Date().toISOString() };
|
|
6
|
+
}
|
|
7
|
+
export function readSessionOwner(path) {
|
|
8
|
+
try {
|
|
9
|
+
const owner = JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
if (typeof owner.sessionInstanceId === 'string' && typeof owner.createdAt === 'string')
|
|
11
|
+
return owner;
|
|
12
|
+
}
|
|
13
|
+
catch { /* Missing or malformed ownership is never permission to delete. */ }
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
export function writeSessionOwner(path, owner) {
|
|
17
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(temporary, JSON.stringify({ sessionInstanceId: owner.sessionInstanceId, createdAt: owner.createdAt }), { mode: 0o600, flag: 'wx' });
|
|
20
|
+
chmodSync(temporary, 0o600);
|
|
21
|
+
renameSync(temporary, path);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
rmSync(temporary, { force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function sameSessionInstance(a, b) {
|
|
28
|
+
return a !== undefined && b !== undefined && a.sessionInstanceId === b.sessionInstanceId && a.createdAt === b.createdAt;
|
|
29
|
+
}
|
|
30
|
+
const reported = new Set();
|
|
31
|
+
export function reportSessionOwnerMismatch(owner, current) {
|
|
32
|
+
if (reported.has(owner.sessionInstanceId))
|
|
33
|
+
return;
|
|
34
|
+
reported.add(owner.sessionInstanceId);
|
|
35
|
+
logger.warn(`[GitCredential] Cleanup ownership changed; retaining successor worktree and credentials (expected ${owner.sessionInstanceId}, actual ${current?.sessionInstanceId ?? 'missing-or-malformed'})`);
|
|
36
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SessionInstance } from './session-instance.js';
|
|
1
2
|
/**
|
|
2
3
|
* Create a fresh worktree for a spawned session. Returns the absolute path of
|
|
3
4
|
* the worktree, which the caller should use as the spawned process's `cwd`.
|
|
@@ -5,17 +6,19 @@
|
|
|
5
6
|
* Idempotent against existing worktrees at the same path: if one is found,
|
|
6
7
|
* it's removed and recreated to guarantee a clean start.
|
|
7
8
|
*/
|
|
8
|
-
export declare function createWorktree(brainPath: string, sessionId: string, branch?: string): Promise<string>;
|
|
9
|
+
export declare function createWorktree(brainPath: string, sessionId: string, branch?: string, instance?: SessionInstance): Promise<string>;
|
|
9
10
|
/**
|
|
10
11
|
* Remove a worktree by session ID. Force-removes so dirty state from a crashed
|
|
11
12
|
* session doesn't block cleanup — the contract is that any session work the
|
|
12
13
|
* caller wanted to preserve has already been pushed to `origin/<branch>`.
|
|
13
14
|
*/
|
|
14
|
-
export declare function removeWorktree(brainPath: string, sessionId: string): Promise<void>;
|
|
15
|
+
export declare function removeWorktree(brainPath: string, sessionId: string, instance?: SessionInstance | null): Promise<void>;
|
|
15
16
|
/**
|
|
16
17
|
* Sweep orphan worktrees left behind by crashed sessions. Removes every
|
|
17
|
-
*
|
|
18
|
-
* than `maxAgeMs`.
|
|
18
|
+
* known-dead instance immediately, or an unregistered/legacy orphan older
|
|
19
|
+
* than `maxAgeMs`. Live sessions and registered tasks always retain their
|
|
20
|
+
* worktrees, including those with missing, malformed or changed markers.
|
|
21
|
+
* Intended to run once at poller startup.
|
|
19
22
|
*/
|
|
20
23
|
export declare function cleanupOrphanWorktrees(brainPath: string, maxAgeMs?: number): Promise<{
|
|
21
24
|
removed: string[];
|
package/dist/worktree-manager.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'fs';
|
|
2
|
+
import { isGitCredentialInstanceRetained, isGitCredentialInstanceKnownDead, registeredWorktreeOwner } from './git-credential-session.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { createSessionInstance, readSessionOwner, writeSessionOwner, sameSessionInstance, reportSessionOwnerMismatch } from './session-instance.js';
|
|
2
5
|
import { mkdir, readdir, stat, appendFile, readFile } from 'fs/promises';
|
|
3
6
|
import { join } from 'path';
|
|
4
7
|
import { execFile } from 'child_process';
|
|
@@ -6,8 +9,24 @@ import { promisify } from 'util';
|
|
|
6
9
|
import { validateBrainId, resolveBrainDir } from './brain-path.js';
|
|
7
10
|
import { readInboxRuntime } from './inbox-manager.js';
|
|
8
11
|
const execFileAsync = promisify(execFile);
|
|
12
|
+
// Serialize creation and deletion at a reusable thread path: ownership must
|
|
13
|
+
// stay stable between the marker check and completion of git worktree remove.
|
|
14
|
+
const worktreeOperations = new Map();
|
|
15
|
+
async function withWorktreeLock(path, operation) {
|
|
16
|
+
const previous = worktreeOperations.get(path) ?? Promise.resolve();
|
|
17
|
+
const current = previous.catch(() => { }).then(operation);
|
|
18
|
+
worktreeOperations.set(path, current);
|
|
19
|
+
try {
|
|
20
|
+
return await current;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
if (worktreeOperations.get(path) === current)
|
|
24
|
+
worktreeOperations.delete(path);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
9
27
|
const WORKTREES_SUBDIR = '.worktrees';
|
|
10
|
-
const
|
|
28
|
+
const OWNER_FILE = '.teamvibe-session-owner.json';
|
|
29
|
+
const EXCLUDE_ENTRY = `${WORKTREES_SUBDIR}/\n${OWNER_FILE}\n`;
|
|
11
30
|
/**
|
|
12
31
|
* Spawned (non-foreground) Claude sessions run in isolated git worktrees off
|
|
13
32
|
* the brain repo to avoid the sync-watchdog race in teamvibe.ai#120.
|
|
@@ -32,7 +51,7 @@ async function ensureExclude(brainPath) {
|
|
|
32
51
|
catch {
|
|
33
52
|
// .git/info/exclude may not exist (rare); fall back to creating it
|
|
34
53
|
}
|
|
35
|
-
if (!current.split('\n').includes(
|
|
54
|
+
if (!current.split('\n').includes(OWNER_FILE)) {
|
|
36
55
|
await appendFile(excludePath, EXCLUDE_ENTRY);
|
|
37
56
|
}
|
|
38
57
|
}
|
|
@@ -43,46 +62,76 @@ async function ensureExclude(brainPath) {
|
|
|
43
62
|
* Idempotent against existing worktrees at the same path: if one is found,
|
|
44
63
|
* it's removed and recreated to guarantee a clean start.
|
|
45
64
|
*/
|
|
46
|
-
export async function createWorktree(brainPath, sessionId, branch = 'main') {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
export async function createWorktree(brainPath, sessionId, branch = 'main', instance = createSessionInstance()) {
|
|
66
|
+
return withWorktreeLock(join(brainPath, WORKTREES_SUBDIR, sessionId), async () => {
|
|
67
|
+
await mkdir(join(brainPath, WORKTREES_SUBDIR), { recursive: true });
|
|
68
|
+
await ensureExclude(brainPath);
|
|
69
|
+
const worktreePath = join(brainPath, WORKTREES_SUBDIR, sessionId);
|
|
70
|
+
// Defensive cleanup in case a previous attempt left an orphan
|
|
71
|
+
try {
|
|
72
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Not registered as a worktree — fine
|
|
76
|
+
}
|
|
77
|
+
await execFileAsync('git', ['fetch', 'origin', branch, '--quiet'], { cwd: brainPath });
|
|
78
|
+
await execFileAsync('git', ['worktree', 'add', '--no-checkout', '--detach', worktreePath, `origin/${branch}`], { cwd: brainPath });
|
|
79
|
+
// Register without checkout first: no usable checkout exists until its
|
|
80
|
+
// atomic 0600 ownership marker is installed. A crash before that leaves
|
|
81
|
+
// an incomplete, age-gated orphan rather than an unmarked live worktree.
|
|
82
|
+
writeSessionOwner(join(worktreePath, OWNER_FILE), instance);
|
|
83
|
+
await execFileAsync('git', ['reset', '--hard', `origin/${branch}`], { cwd: worktreePath });
|
|
84
|
+
return worktreePath;
|
|
85
|
+
});
|
|
60
86
|
}
|
|
61
87
|
/**
|
|
62
88
|
* Remove a worktree by session ID. Force-removes so dirty state from a crashed
|
|
63
89
|
* session doesn't block cleanup — the contract is that any session work the
|
|
64
90
|
* caller wanted to preserve has already been pushed to `origin/<branch>`.
|
|
65
91
|
*/
|
|
66
|
-
export async function removeWorktree(brainPath, sessionId) {
|
|
92
|
+
export async function removeWorktree(brainPath, sessionId, instance) {
|
|
93
|
+
return removeOwnedWorktree(brainPath, sessionId, instance);
|
|
94
|
+
}
|
|
95
|
+
async function removeOwnedWorktree(brainPath, sessionId, instance, orphanCutoff) {
|
|
67
96
|
const worktreePath = join(brainPath, WORKTREES_SUBDIR, sessionId);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
const expected = instance === undefined ? readSessionOwner(join(worktreePath, OWNER_FILE)) : instance;
|
|
98
|
+
return withWorktreeLock(worktreePath, async () => {
|
|
99
|
+
const current = readSessionOwner(join(worktreePath, OWNER_FILE));
|
|
100
|
+
if (orphanCutoff !== undefined) {
|
|
101
|
+
// Recheck under the same lock as creation, including retention before age.
|
|
102
|
+
if (isGitCredentialInstanceRetained(current, undefined, sessionId))
|
|
103
|
+
return;
|
|
104
|
+
if (!(current && isGitCredentialInstanceKnownDead(current)) && (await stat(worktreePath)).mtimeMs >= orphanCutoff)
|
|
105
|
+
return;
|
|
106
|
+
if (expected ? !sameSessionInstance(current, expected) : current !== undefined)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
else if (!expected || !sameSessionInstance(current, expected)) {
|
|
110
|
+
if (expected)
|
|
111
|
+
reportSessionOwnerMismatch(expected, current);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Already gone, or never registered — fine
|
|
119
|
+
}
|
|
120
|
+
// Prune any stale administrative bookkeeping in .git/worktrees/
|
|
121
|
+
try {
|
|
122
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: brainPath });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// best-effort
|
|
126
|
+
}
|
|
127
|
+
});
|
|
81
128
|
}
|
|
82
129
|
/**
|
|
83
130
|
* Sweep orphan worktrees left behind by crashed sessions. Removes every
|
|
84
|
-
*
|
|
85
|
-
* than `maxAgeMs`.
|
|
131
|
+
* known-dead instance immediately, or an unregistered/legacy orphan older
|
|
132
|
+
* than `maxAgeMs`. Live sessions and registered tasks always retain their
|
|
133
|
+
* worktrees, including those with missing, malformed or changed markers.
|
|
134
|
+
* Intended to run once at poller startup.
|
|
86
135
|
*/
|
|
87
136
|
export async function cleanupOrphanWorktrees(brainPath, maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
88
137
|
const worktreesDir = join(brainPath, WORKTREES_SUBDIR);
|
|
@@ -97,15 +146,24 @@ export async function cleanupOrphanWorktrees(brainPath, maxAgeMs = 24 * 60 * 60
|
|
|
97
146
|
const cutoffMs = Date.now() - maxAgeMs;
|
|
98
147
|
for (const entry of entries) {
|
|
99
148
|
const path = join(worktreesDir, entry);
|
|
149
|
+
const instance = readSessionOwner(join(path, OWNER_FILE));
|
|
100
150
|
const pending = readInboxRuntime(brainPath, entry);
|
|
101
151
|
if (pending?.pin.resumePending && pending.pin.effectiveCwd === path)
|
|
102
152
|
continue;
|
|
103
153
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
154
|
+
// Retain by thread metadata as well as marker: a corrupt marker cannot
|
|
155
|
+
// hide a live session or registered task from the shared predicate.
|
|
156
|
+
if (isGitCredentialInstanceRetained(instance, undefined, entry)) {
|
|
157
|
+
// Report damaged ownership with both IDs without reading credentials.
|
|
158
|
+
const expected = registeredWorktreeOwner(entry);
|
|
159
|
+
if (expected && !sameSessionInstance(instance, expected))
|
|
160
|
+
reportSessionOwnerMismatch(expected, instance);
|
|
161
|
+
logger.info(`[Worktree] Retained ${path}: live session or registered task`);
|
|
162
|
+
continue;
|
|
108
163
|
}
|
|
164
|
+
await removeOwnedWorktree(brainPath, entry, instance ?? null, cutoffMs);
|
|
165
|
+
if (!existsSync(path))
|
|
166
|
+
removed.push(entry);
|
|
109
167
|
}
|
|
110
168
|
catch {
|
|
111
169
|
// skip entries we can't stat
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7-rc.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@teamvibe/poller",
|
|
9
|
-
"version": "0.8.
|
|
9
|
+
"version": "0.8.7-rc.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@aws-sdk/client-dynamodb": "3.1108.0",
|
|
12
12
|
"@aws-sdk/client-sqs": "3.1108.0",
|
package/package.json
CHANGED