merge: dev into main
This commit is contained in:
commit
be95b564cf
15 changed files with 550 additions and 46 deletions
|
|
@ -53,8 +53,6 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
|
|||
|
||||
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
||||
|
||||
If you want the FRESHEST version, clone the repo and run it from the `dev` branch.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
|
@ -347,16 +345,16 @@ local packaging.
|
|||
- [ ] Planning mode to organize agent plans before execution
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
|
||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [x] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Slash commands
|
||||
- [x] Slash commands
|
||||
- [ ] Outgoing message queue — queue user messages while the lead (or agent) is busy; clear agent-busy status in the UI; flush to stdin or relay from inbox when idle (durable queue on disk for the lead inbox path)
|
||||
- [ ] `createTasksBatch` — IPC/service API to create many team tasks in one call (playbooks, markdown checklist import, scripts); complements single `createTask`
|
||||
- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow
|
||||
- [ ] Custom kanban columns
|
||||
- [ ] Run terminal commands
|
||||
- [ ] Monitor agents processes/stats
|
||||
- [x] Monitor agents processes/stats
|
||||
- [ ] Reusable agents with SOUL.md
|
||||
- [ ] Сommunicate via messenger
|
||||
- [ ] SDK to programmatically launch agents
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
|
|||
|
||||
export async function preflightOpenCodeLiveEnvironment(input) {
|
||||
const repoRoot = input.repoRoot;
|
||||
const requiredModels = Array.isArray(input.requiredModels)
|
||||
? input.requiredModels.map((model) => String(model).trim()).filter(Boolean)
|
||||
: [];
|
||||
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
|
||||
const xdgDataHome = path.join(tempRoot, 'xdg-data');
|
||||
|
|
@ -29,6 +32,14 @@ export async function preflightOpenCodeLiveEnvironment(input) {
|
|||
if (!models.ok) {
|
||||
return skip(`opencode models failed: ${models.output}`);
|
||||
}
|
||||
const missingModels = findMissingOpenCodeModels(models.output, requiredModels);
|
||||
if (missingModels.length > 0) {
|
||||
return skip(
|
||||
`opencode models missing selected model(s): ${missingModels.join(', ')}. Available: ${compactOutput(
|
||||
parseOpenCodeModels(models.output).join(', ') || 'none'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
|
||||
if (!agents.ok) {
|
||||
|
|
@ -68,7 +79,7 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
|
|||
maxBuffer: 256_000,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return { ok: true, output: '' };
|
||||
return { ok: true, output: result.stdout || '' };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -76,6 +87,19 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseOpenCodeModels(output) {
|
||||
return output
|
||||
.split(/\s+/)
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function findMissingOpenCodeModels(output, requiredModels) {
|
||||
if (requiredModels.length === 0) return [];
|
||||
const available = new Set(parseOpenCodeModels(output));
|
||||
return requiredModels.filter((model) => !available.has(model));
|
||||
}
|
||||
|
||||
function canBindLoopback() {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
|
@ -287,7 +311,9 @@ function compactOutput(value) {
|
|||
}
|
||||
|
||||
export const __opencodeLivePreflightTestHooks = {
|
||||
findMissingOpenCodeModels,
|
||||
isHealthyOpenCodeHostResponse,
|
||||
parseOpenCodeModels,
|
||||
stopChild,
|
||||
taskkillProcessTree,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.
|
|||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
|
||||
const requestedOrder =
|
||||
process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed';
|
||||
|
||||
|
|
@ -28,6 +29,8 @@ const env = {
|
|||
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000',
|
||||
CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS:
|
||||
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000',
|
||||
PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL:
|
||||
process.env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL?.trim() || DEFAULT_OPENCODE_MODEL,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
|
|
@ -47,7 +50,7 @@ console.log(`Anthropic auth: ${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH}`);
|
|||
console.log(
|
||||
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
|
||||
env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini'
|
||||
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL || 'openai/gpt-5.4-mini'}`
|
||||
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL}`
|
||||
);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
|
|
@ -109,7 +112,10 @@ async function preflightProviderLaunchStress(input) {
|
|||
anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true },
|
||||
codex: needs.codex ? preflightCodex() : { ok: true },
|
||||
opencode: needs.opencode
|
||||
? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot })
|
||||
? await preflightOpenCodeLiveEnvironment({
|
||||
repoRoot: input.repoRoot,
|
||||
requiredModels: [env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL],
|
||||
})
|
||||
: { ok: true },
|
||||
};
|
||||
const skipped = [];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import type {
|
|||
} from './ports';
|
||||
|
||||
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
|
||||
const CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS = 60_000;
|
||||
const CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS = 5_000;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface ClaudePtyWorkspaceTrustStrategyInput {
|
||||
claudePath: string;
|
||||
|
|
@ -45,6 +51,27 @@ function buildRawTail(snapshot: TerminalSnapshot | undefined): string | undefine
|
|||
return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT);
|
||||
}
|
||||
|
||||
async function waitForTrustedState(input: {
|
||||
stateProbe: ProviderStateProbe;
|
||||
workspace: WorkspaceTrustWorkspace;
|
||||
isCancelled(): boolean;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<Awaited<ReturnType<ProviderStateProbe['readTrustState']>>> {
|
||||
const pollIntervalMs = Math.max(1, input.pollIntervalMs);
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
let last = await input.stateProbe.readTrustState(input.workspace);
|
||||
while (last.status !== 'trusted' && !input.isCancelled()) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await sleep(Math.min(pollIntervalMs, remainingMs));
|
||||
last = await input.stateProbe.readTrustState(input.workspace);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function worseStatus(
|
||||
current: WorkspaceTrustDiagnosticStrategyResult['status'],
|
||||
next: WorkspaceTrustDiagnosticStrategyResult['status']
|
||||
|
|
@ -152,13 +179,22 @@ export class ClaudePtyWorkspaceTrustStrategy {
|
|||
session: spawnResult.session,
|
||||
detect: detectClaudeStartupState,
|
||||
isCancelled: input.isCancelled,
|
||||
timeoutMs: input.timeoutMs,
|
||||
timeoutMs: input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS,
|
||||
pollIntervalMs: input.pollIntervalMs,
|
||||
afterDialogAction: async ({ ruleId }) => {
|
||||
if (ruleId !== 'claude.workspace_trust') {
|
||||
return { action: 'continue' };
|
||||
}
|
||||
const after = await stateProbe.readTrustState(workspace);
|
||||
const after = await waitForTrustedState({
|
||||
stateProbe,
|
||||
workspace,
|
||||
isCancelled: input.isCancelled,
|
||||
timeoutMs: Math.min(
|
||||
CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS,
|
||||
input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS
|
||||
),
|
||||
pollIntervalMs: input.pollIntervalMs ?? 100,
|
||||
});
|
||||
if (after.status === 'trusted') {
|
||||
evidence.push(...after.evidence);
|
||||
return { action: 'stop', reason: 'workspace_trust_persisted' };
|
||||
|
|
@ -218,7 +254,7 @@ export class ClaudePtyWorkspaceTrustStrategy {
|
|||
workspaceIds,
|
||||
matchedRuleIds: [...new Set(matchedRuleIds)],
|
||||
actions,
|
||||
evidence,
|
||||
evidence: [...new Set(evidence)],
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createRequire } from 'node:module';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type {
|
||||
|
|
@ -13,6 +15,7 @@ import type * as NodePty from 'node-pty';
|
|||
|
||||
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
|
||||
const MAX_TRANSCRIPT_CHARS = 64 * 1024;
|
||||
const requireNativeAddon = createRequire(import.meta.url);
|
||||
|
||||
type NodePtyModule = typeof NodePty;
|
||||
|
||||
|
|
@ -23,8 +26,7 @@ function loadNodePty(): NodePtyModule | null {
|
|||
return nodePty;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
|
||||
nodePty = require('node-pty') as NodePtyModule;
|
||||
nodePty = requireNativeAddon('node-pty') as NodePtyModule;
|
||||
} catch (error) {
|
||||
logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`);
|
||||
nodePty = null;
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
|
|||
providerId: TeamProviderId;
|
||||
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
|
||||
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
|
||||
settingsDirectory?: string | null;
|
||||
}): Promise<TeamRuntimeSettingsBundle | null> {
|
||||
const fragments = [...(input.baseSettings ?? [])].filter(
|
||||
(fragment): fragment is TeamRuntimeSettingsJson =>
|
||||
|
|
@ -145,7 +146,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const baseDirectory = input.anthropicHelper?.directory;
|
||||
const baseDirectory = input.anthropicHelper?.directory ?? input.settingsDirectory;
|
||||
if (!baseDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1588,6 +1588,27 @@ function buildAnthropicSettingsArgs(
|
|||
return ['--settings', JSON.stringify(settings)];
|
||||
}
|
||||
|
||||
function sanitizeRuntimeSettingsTeamName(teamName: string): string {
|
||||
return teamName.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'team';
|
||||
}
|
||||
|
||||
function buildRuntimeSettingsTempDirectory(teamName: string): string {
|
||||
return path.join(
|
||||
os.tmpdir(),
|
||||
'agent-teams-runtime-settings',
|
||||
`${sanitizeRuntimeSettingsTeamName(teamName)}-${randomUUID()}`
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTeamRuntimeNodeEnv(env: NodeJS.ProcessEnv): void {
|
||||
// Vitest sets NODE_ENV=test in the desktop parent process. Real team runtime
|
||||
// children must run the CLI normally, otherwise source launches can take
|
||||
// test-only startup paths and exit before deterministic bootstrap starts.
|
||||
if (env.NODE_ENV === 'test') {
|
||||
env.NODE_ENV = 'development';
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderFastModeArgs(
|
||||
providerId: TeamProviderId,
|
||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||
|
|
@ -7124,7 +7145,7 @@ export class TeamProvisioningService {
|
|||
const rawProviderArgs = input.envResolution.providerArgs ?? [];
|
||||
const rawExtraArgs = input.extraArgs ?? [];
|
||||
|
||||
if (!helper) {
|
||||
if (!helper && resolvedProviderId !== 'anthropic') {
|
||||
return {
|
||||
settingsArgs: [],
|
||||
fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity),
|
||||
|
|
@ -7137,13 +7158,14 @@ export class TeamProvisioningService {
|
|||
|
||||
const providerArgsWithoutHelper = filterOutSettingsPathArgs(
|
||||
rawProviderArgs,
|
||||
helper.settingsPath
|
||||
helper?.settingsPath
|
||||
);
|
||||
const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper);
|
||||
const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs);
|
||||
if (
|
||||
hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
|
||||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)
|
||||
helper &&
|
||||
(hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
|
||||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs))
|
||||
) {
|
||||
throw new Error(
|
||||
`${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.`
|
||||
|
|
@ -7160,6 +7182,7 @@ export class TeamProvisioningService {
|
|||
...splitExtraArgs.settingsFragments,
|
||||
],
|
||||
anthropicHelper: helper,
|
||||
settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -22019,6 +22042,7 @@ export class TeamProvisioningService {
|
|||
contextLabel: 'Team create launch',
|
||||
});
|
||||
const spawnArgs = mergeJsonSettingsArgs([
|
||||
'--print',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
|
|
@ -23288,6 +23312,7 @@ export class TeamProvisioningService {
|
|||
throw error;
|
||||
}
|
||||
const launchArgs = [
|
||||
'--print',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
|
|
@ -36058,6 +36083,7 @@ export class TeamProvisioningService {
|
|||
: {}),
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
};
|
||||
normalizeTeamRuntimeNodeEnv(env);
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||
providerId,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application';
|
||||
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProviderStateProbe,
|
||||
|
|
@ -83,6 +82,10 @@ function workspace(cwd = '/tmp/project') {
|
|||
}
|
||||
|
||||
describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips PTY when the state probe already reports trusted', async () => {
|
||||
const pty = new FakePtyProcess();
|
||||
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
|
||||
|
|
@ -172,15 +175,17 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
|||
it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => {
|
||||
const pty = new FakePtyProcess();
|
||||
const tempStore = new FakeTempStore();
|
||||
const stateProbe = new FakeStateProbe([
|
||||
{ status: 'untrusted' },
|
||||
{ status: 'untrusted' },
|
||||
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
|
||||
]);
|
||||
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
|
||||
claudePath: '/usr/local/bin/claude',
|
||||
workspaces: [workspace()],
|
||||
env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined },
|
||||
ptyProcess: pty,
|
||||
stateProbe: new FakeStateProbe([
|
||||
{ status: 'untrusted' },
|
||||
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
|
||||
]),
|
||||
stateProbe,
|
||||
tempEmptyMcpConfigStore: tempStore,
|
||||
isCancelled: () => false,
|
||||
timeoutMs: 100,
|
||||
|
|
@ -190,6 +195,7 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
|||
expect(result.status).toBe('ok');
|
||||
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
|
||||
expect(result.actions).toEqual(['claude.workspace_trust:enter']);
|
||||
expect(result.evidence).toEqual(['trusted project key: /tmp/project']);
|
||||
expect(pty.spawnInputs[0]).toMatchObject({
|
||||
command: '/usr/local/bin/claude',
|
||||
cwd: '/tmp/project',
|
||||
|
|
@ -203,6 +209,33 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
|||
expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']);
|
||||
expect(pty.session?.killed).toBe(true);
|
||||
expect(tempStore.cleaned).toBe(true);
|
||||
expect(stateProbe.calls).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the default Claude preflight alive long enough for slow startup trust prompts', async () => {
|
||||
const pty = new FakePtyProcess();
|
||||
const session = new FakeSession(['Starting Claude...', 'Quick safety check\nYes, I trust this folder']);
|
||||
pty.spawnResult = { ok: true, session };
|
||||
const nowValues = [0, 0, 0, 0, 46_000, 46_000, 46_000];
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => nowValues.shift() ?? 46_000);
|
||||
|
||||
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
|
||||
claudePath: '/usr/local/bin/claude',
|
||||
workspaces: [workspace()],
|
||||
env: { HOME: '/Users/tester' },
|
||||
ptyProcess: pty,
|
||||
stateProbe: new FakeStateProbe([
|
||||
{ status: 'untrusted' },
|
||||
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
|
||||
]),
|
||||
tempEmptyMcpConfigStore: new FakeTempStore(),
|
||||
isCancelled: () => false,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
|
||||
expect(session.actions.map((action) => action.id)).toEqual(['enter']);
|
||||
});
|
||||
|
||||
it('soft-fails when node-pty is unavailable instead of throwing', async () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem
|
|||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||
import {
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
|
@ -51,7 +52,6 @@ const liveDescribe =
|
|||
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||
const DEFAULT_MODEL = 'sonnet';
|
||||
const DEFAULT_EFFORT = 'low' as const;
|
||||
|
||||
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
||||
|
||||
|
|
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
let previousHome: string | undefined;
|
||||
let previousHistFile: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
let claudeJsonConfigRoot: string;
|
||||
let previousClaudeJsonConfig: string | null | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let feature: MemberWorkSyncFeatureFacade | null;
|
||||
let controlServer: MemberWorkSyncLiveControlServer | null;
|
||||
let teamName: string | null;
|
||||
let usingConnectedClaudeAccount = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
|
||||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
|
|
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
previousHome = process.env.HOME;
|
||||
previousHistFile = process.env.HISTFILE;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousClaudeJsonConfig = undefined;
|
||||
|
||||
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||
tempHome = shouldUseConnectedAccountHome
|
||||
? resolveConnectedClaudeHome(previousHome)
|
||||
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||
tempHome = usingConnectedClaudeAccount
|
||||
? resolveConnectedClaudeHome()
|
||||
: path.join(tempDir, 'home');
|
||||
tempClaudeRoot = usingConnectedClaudeAccount
|
||||
? path.join(tempHome, '.claude')
|
||||
: path.join(tempDir, '.claude');
|
||||
claudeJsonConfigRoot = usingConnectedClaudeAccount ? tempHome : tempClaudeRoot;
|
||||
await fs.mkdir(tempHome, { recursive: true });
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
|
||||
if (usingConnectedClaudeAccount) {
|
||||
setClaudeBasePathOverride(null);
|
||||
} else {
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
}
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.HISTFILE = '/dev/null';
|
||||
|
|
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
|
||||
if (svc && teamName) {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
|
|
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
||||
await feature?.dispose().catch(() => undefined);
|
||||
await controlServer?.close().catch(() => undefined);
|
||||
discardKnownClaudeStopHookWarnings();
|
||||
if (!keepTemp && usingConnectedClaudeAccount && teamName) {
|
||||
await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true });
|
||||
await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true });
|
||||
}
|
||||
if (usingConnectedClaudeAccount && previousClaudeJsonConfig !== undefined) {
|
||||
await restoreClaudeJsonConfig(claudeJsonConfigRoot, previousClaudeJsonConfig);
|
||||
}
|
||||
|
||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
|
|
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
restoreEnv('HISTFILE', previousHistFile);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
|
||||
if (keepTemp) {
|
||||
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
||||
} else {
|
||||
await removeTempDirAfterLateShellWrites(tempDir);
|
||||
|
|
@ -186,7 +206,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||
if (usingConnectedClaudeAccount) {
|
||||
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
|
||||
claudeJsonConfigRoot,
|
||||
projectPath
|
||||
);
|
||||
} else {
|
||||
await writeTrustedClaudeConfig(claudeJsonConfigRoot, projectPath);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
|
|
@ -251,7 +278,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
cwd: projectPath,
|
||||
providerId: 'anthropic',
|
||||
model,
|
||||
effort: DEFAULT_EFFORT,
|
||||
...(process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS?.trim()
|
||||
? { extraCliArgs: process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS.trim() }
|
||||
: {}),
|
||||
skipPermissions: true,
|
||||
prompt: [
|
||||
'Keep launch work minimal and wait for the explicit live-test instruction.',
|
||||
|
|
@ -263,7 +292,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
role: 'Developer',
|
||||
providerId: 'anthropic',
|
||||
model,
|
||||
effort: DEFAULT_EFFORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude team launch',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude validation turn',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
await feature!.replayPendingReports([teamName!]);
|
||||
const [status, metrics, tasks] = await Promise.all([
|
||||
|
|
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude Stop hook turn',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
|
|
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
|
|||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
function discardKnownClaudeStopHookWarnings(): void {
|
||||
const warn = vi.mocked(console.warn);
|
||||
if (!warn.mock) return;
|
||||
const calls = warn.mock.calls;
|
||||
for (let index = calls.length - 1; index >= 0; index -= 1) {
|
||||
const text = calls[index]?.map((value) => String(value)).join(' ') ?? '';
|
||||
if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) {
|
||||
calls.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (text.includes('Failed to resolve interactive shell env: shell env resolve timeout')) {
|
||||
calls.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (text.includes('Failed to parse runtime model list for launch validation')) {
|
||||
calls.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
|
|
@ -589,14 +643,71 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
|
|||
);
|
||||
}
|
||||
|
||||
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
||||
async function upsertTrustedClaudeProjectConfig(
|
||||
configDir: string,
|
||||
projectPath: string
|
||||
): Promise<string | null> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
const existing = parseJsonObject(previous) ?? {};
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
const projects =
|
||||
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||
? { ...(existing.projects as Record<string, unknown>) }
|
||||
: {};
|
||||
const currentProject =
|
||||
projects[normalizedProjectPath] &&
|
||||
typeof projects[normalizedProjectPath] === 'object' &&
|
||||
!Array.isArray(projects[normalizedProjectPath])
|
||||
? (projects[normalizedProjectPath] as Record<string, unknown>)
|
||||
: {};
|
||||
projects[normalizedProjectPath] = {
|
||||
...currentProject,
|
||||
hasTrustDialogAccepted: true,
|
||||
};
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
projects,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return previous;
|
||||
}
|
||||
|
||||
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
if (previous === null) {
|
||||
await fs.rm(configPath, { force: true });
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(configPath, previous, 'utf8');
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveConnectedClaudeHome(): string {
|
||||
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||
if (explicit) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
const previous = previousHome?.trim();
|
||||
if (previous) {
|
||||
return path.resolve(previous);
|
||||
}
|
||||
return os.userInfo().homedir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const liveDescribe =
|
|||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
||||
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
|
||||
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
|
||||
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
|
||||
|
||||
liveDescribe('Mixed provider team launch live e2e', () => {
|
||||
let tempDir: string;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import * as path from 'node:path';
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createWorkspaceTrustCoordinator } from '../../../../src/features/workspace-trust/main';
|
||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||
import {
|
||||
getAutoDetectedClaudeBasePath,
|
||||
getClaudeBasePath,
|
||||
getHomeDir,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
|
|
@ -42,7 +46,7 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
|
|||
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
||||
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
|
||||
const DEFAULT_CODEX_EFFORT = 'low' as const;
|
||||
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
|
||||
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
|
||||
const DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed'];
|
||||
const MEMBER_NAMES = [
|
||||
'alice',
|
||||
|
|
@ -236,6 +240,7 @@ async function runProviderStressScenario(
|
|||
throw error;
|
||||
}
|
||||
const svc = harness?.svc ?? new TeamProvisioningService();
|
||||
configureWorkspaceTrustCoordinator(svc);
|
||||
const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false };
|
||||
activeScenarios.push(active);
|
||||
|
||||
|
|
@ -296,6 +301,20 @@ async function runProviderStressScenario(
|
|||
}
|
||||
}
|
||||
|
||||
function configureWorkspaceTrustCoordinator(svc: TeamProvisioningService): void {
|
||||
svc.setWorkspaceTrustCoordinator(
|
||||
createWorkspaceTrustCoordinator({
|
||||
claudeConfigDir: () => getClaudeBasePath(),
|
||||
globalConfigFilePath: () => {
|
||||
const claudeBasePath = getClaudeBasePath();
|
||||
return claudeBasePath !== getAutoDetectedClaudeBasePath()
|
||||
? path.join(claudeBasePath, '.claude.json')
|
||||
: path.join(getHomeDir(), '.claude.json');
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function runRestartStressChecks(
|
||||
active: ActiveScenario,
|
||||
expectedMembers: string[],
|
||||
|
|
|
|||
|
|
@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
||||
});
|
||||
|
||||
it('does not leak Vitest NODE_ENV into real team runtime children', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const buildProvisioningEnv = (
|
||||
svc as unknown as {
|
||||
buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>;
|
||||
}
|
||||
).buildProvisioningEnv.bind(svc);
|
||||
|
||||
const result = await buildProvisioningEnv();
|
||||
|
||||
expect(result.env.NODE_ENV).toBe('development');
|
||||
expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ NODE_ENV: 'development' }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||
|
|
@ -2864,6 +2892,42 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks');
|
||||
});
|
||||
|
||||
it('materializes Anthropic turn-settled hook settings instead of passing inline JSON', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeTurnSettledHookSettingsProvider(async ({ provider }) =>
|
||||
provider === 'claude'
|
||||
? {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [{ type: 'command', command: '/bin/true # test-hook' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({
|
||||
teamName: 'anthropic-hook-settings-team',
|
||||
providerId: 'anthropic',
|
||||
launchIdentity: null,
|
||||
envResolution: { providerArgs: [] },
|
||||
extraArgs: [],
|
||||
includeAnthropicHelper: false,
|
||||
contextLabel: 'Team launch',
|
||||
});
|
||||
|
||||
expect(result.fastModeArgs).toEqual([]);
|
||||
expect(result.runtimeTurnSettledHookArgs).toEqual([]);
|
||||
expect(result.settingsArgs[0]).toBe('--settings');
|
||||
const settingsPath = result.settingsArgs[1];
|
||||
expect(settingsPath).toContain('agent-teams-runtime-settings');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.hooks.Stop[0].hooks[0].command).toBe('/bin/true # test-hook');
|
||||
});
|
||||
|
||||
it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||
|
|
|
|||
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import { throwIfClaudeTranscriptApiError } from './memberWorkSyncLiveHarness';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('memberWorkSyncLiveHarness', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('scopes Claude API error checks to the current project transcripts', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||
tempDirs.push(claudeRoot);
|
||||
const projectPath = path.join(claudeRoot, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
|
||||
const projectTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath(await fs.realpath(projectPath))
|
||||
);
|
||||
const unrelatedTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath('/Users/example/other-project')
|
||||
);
|
||||
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||
await fs.mkdir(unrelatedTranscriptDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(unrelatedTranscriptDir, 'unrelated.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('wrong project'))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('right project'))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
})
|
||||
).rejects.toThrow(/right project/);
|
||||
});
|
||||
|
||||
it('ignores stale Claude API errors before the live check start time', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||
tempDirs.push(claudeRoot);
|
||||
const projectPath = path.join(claudeRoot, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
|
||||
const projectTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath(await fs.realpath(projectPath))
|
||||
);
|
||||
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||
const sinceMs = Date.now();
|
||||
await fs.writeFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('old error', sinceMs - 1_000))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
sinceMs,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await fs.appendFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('new error', sinceMs + 1_000))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
sinceMs,
|
||||
})
|
||||
).rejects.toThrow(/new error/);
|
||||
});
|
||||
});
|
||||
|
||||
function buildApiErrorRecord(text: string, timestampMs = Date.now()): Record<string, unknown> {
|
||||
return {
|
||||
isApiErrorMessage: true,
|
||||
error: 'unknown',
|
||||
timestamp: new Date(timestampMs).toISOString(),
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs';
|
|||
import * as http from 'node:http';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
|
||||
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
||||
|
||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
export class FatalWaitError extends Error {
|
||||
|
|
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
|||
export async function throwIfClaudeTranscriptApiError(input: {
|
||||
claudeRoot: string;
|
||||
context: string;
|
||||
projectPath?: string;
|
||||
sinceMs?: number;
|
||||
}): Promise<void> {
|
||||
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
|
||||
const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath);
|
||||
const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat();
|
||||
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||
for (const filePath of transcriptFiles) {
|
||||
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||
|
|
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
|||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -247,6 +254,46 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
|||
);
|
||||
}
|
||||
|
||||
async function resolveClaudeTranscriptRoots(
|
||||
claudeRoot: string,
|
||||
projectPath: string | undefined
|
||||
): Promise<string[]> {
|
||||
const projectsRoot = path.join(claudeRoot, 'projects');
|
||||
if (!projectPath) {
|
||||
return [projectsRoot];
|
||||
}
|
||||
|
||||
const candidateRoots = new Set<string>();
|
||||
const addCandidate = (candidatePath: string) => {
|
||||
candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath)));
|
||||
};
|
||||
addCandidate(path.resolve(projectPath));
|
||||
const realProjectPath = await fs.realpath(projectPath).catch(() => null);
|
||||
if (realProjectPath) {
|
||||
addCandidate(realProjectPath);
|
||||
}
|
||||
|
||||
const existingRoots: string[] = [];
|
||||
for (const candidateRoot of candidateRoots) {
|
||||
const stats = await fs.stat(candidateRoot).catch(() => null);
|
||||
if (stats?.isDirectory()) {
|
||||
existingRoots.push(candidateRoot);
|
||||
}
|
||||
}
|
||||
return existingRoots;
|
||||
}
|
||||
|
||||
function isTranscriptRecordBefore(record: Record<string, unknown>, sinceMs: number): boolean {
|
||||
const timestamp = record.timestamp;
|
||||
const timestampMs =
|
||||
typeof timestamp === 'string'
|
||||
? Date.parse(timestamp)
|
||||
: typeof timestamp === 'number'
|
||||
? timestamp
|
||||
: Number.NaN;
|
||||
return Number.isFinite(timestampMs) && timestampMs < sinceMs;
|
||||
}
|
||||
|
||||
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||
Array<{
|
||||
filePath: string;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { promises as fs } from 'fs';
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StopChildOptions {
|
||||
|
|
@ -17,7 +16,9 @@ interface StopChildOptions {
|
|||
|
||||
interface OpenCodeLivePreflightTestHooks {
|
||||
__opencodeLivePreflightTestHooks: {
|
||||
findMissingOpenCodeModels(output: string, requiredModels: string[]): string[];
|
||||
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
|
||||
parseOpenCodeModels(output: string): string[];
|
||||
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
|
||||
taskkillProcessTree(pid: number): Promise<void>;
|
||||
};
|
||||
|
|
@ -47,6 +48,21 @@ describe('opencode live preflight cleanup', () => {
|
|||
expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('detects selected OpenCode models missing from preflight output', async () => {
|
||||
const { findMissingOpenCodeModels, parseOpenCodeModels } = (await loadTestHooks())
|
||||
.__opencodeLivePreflightTestHooks;
|
||||
const output = 'opencode/big-pickle\nopencode/minimax-m2.5-free\n';
|
||||
|
||||
expect(parseOpenCodeModels(output)).toEqual([
|
||||
'opencode/big-pickle',
|
||||
'opencode/minimax-m2.5-free',
|
||||
]);
|
||||
expect(findMissingOpenCodeModels(output, ['opencode/big-pickle'])).toEqual([]);
|
||||
expect(findMissingOpenCodeModels(output, ['openai/gpt-5.4-mini'])).toEqual([
|
||||
'openai/gpt-5.4-mini',
|
||||
]);
|
||||
});
|
||||
|
||||
it('waits for child close after Windows process-tree cleanup', async () => {
|
||||
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
|
||||
const child = new FakeChild({ pid: 1234 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue