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.
|
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">
|
<table align="center">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
|
|
@ -347,16 +345,16 @@ local packaging.
|
||||||
- [ ] Planning mode to organize agent plans before execution
|
- [ ] 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
|
- [ ] 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)
|
- [ ] 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)
|
- [ ] 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)
|
- [ ] 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)
|
- [ ] 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`
|
- [ ] `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
|
- [ ] 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
|
- [ ] Custom kanban columns
|
||||||
- [ ] Run terminal commands
|
- [ ] Run terminal commands
|
||||||
- [ ] Monitor agents processes/stats
|
- [x] Monitor agents processes/stats
|
||||||
- [ ] Reusable agents with SOUL.md
|
- [ ] Reusable agents with SOUL.md
|
||||||
- [ ] Сommunicate via messenger
|
- [ ] Сommunicate via messenger
|
||||||
- [ ] SDK to programmatically launch agents
|
- [ ] SDK to programmatically launch agents
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
export async function preflightOpenCodeLiveEnvironment(input) {
|
export async function preflightOpenCodeLiveEnvironment(input) {
|
||||||
const repoRoot = input.repoRoot;
|
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 opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
|
||||||
const xdgDataHome = path.join(tempRoot, 'xdg-data');
|
const xdgDataHome = path.join(tempRoot, 'xdg-data');
|
||||||
|
|
@ -29,6 +32,14 @@ export async function preflightOpenCodeLiveEnvironment(input) {
|
||||||
if (!models.ok) {
|
if (!models.ok) {
|
||||||
return skip(`opencode models failed: ${models.output}`);
|
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);
|
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
|
||||||
if (!agents.ok) {
|
if (!agents.ok) {
|
||||||
|
|
@ -68,7 +79,7 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
|
||||||
maxBuffer: 256_000,
|
maxBuffer: 256_000,
|
||||||
});
|
});
|
||||||
if (result.status === 0) {
|
if (result.status === 0) {
|
||||||
return { ok: true, output: '' };
|
return { ok: true, output: result.stdout || '' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
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() {
|
function canBindLoopback() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
|
|
@ -287,7 +311,9 @@ function compactOutput(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __opencodeLivePreflightTestHooks = {
|
export const __opencodeLivePreflightTestHooks = {
|
||||||
|
findMissingOpenCodeModels,
|
||||||
isHealthyOpenCodeHostResponse,
|
isHealthyOpenCodeHostResponse,
|
||||||
|
parseOpenCodeModels,
|
||||||
stopChild,
|
stopChild,
|
||||||
taskkillProcessTree,
|
taskkillProcessTree,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
|
||||||
const requestedOrder =
|
const requestedOrder =
|
||||||
process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed';
|
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',
|
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000',
|
||||||
CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS:
|
CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS:
|
||||||
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000',
|
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: '1',
|
||||||
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
|
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
|
||||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '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(
|
console.log(
|
||||||
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
|
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
|
||||||
env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini'
|
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}`);
|
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 },
|
anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true },
|
||||||
codex: needs.codex ? preflightCodex() : { ok: true },
|
codex: needs.codex ? preflightCodex() : { ok: true },
|
||||||
opencode: needs.opencode
|
opencode: needs.opencode
|
||||||
? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot })
|
? await preflightOpenCodeLiveEnvironment({
|
||||||
|
repoRoot: input.repoRoot,
|
||||||
|
requiredModels: [env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL],
|
||||||
|
})
|
||||||
: { ok: true },
|
: { ok: true },
|
||||||
};
|
};
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ import type {
|
||||||
} from './ports';
|
} from './ports';
|
||||||
|
|
||||||
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
|
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 {
|
export interface ClaudePtyWorkspaceTrustStrategyInput {
|
||||||
claudePath: string;
|
claudePath: string;
|
||||||
|
|
@ -45,6 +51,27 @@ function buildRawTail(snapshot: TerminalSnapshot | undefined): string | undefine
|
||||||
return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT);
|
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(
|
function worseStatus(
|
||||||
current: WorkspaceTrustDiagnosticStrategyResult['status'],
|
current: WorkspaceTrustDiagnosticStrategyResult['status'],
|
||||||
next: WorkspaceTrustDiagnosticStrategyResult['status']
|
next: WorkspaceTrustDiagnosticStrategyResult['status']
|
||||||
|
|
@ -152,13 +179,22 @@ export class ClaudePtyWorkspaceTrustStrategy {
|
||||||
session: spawnResult.session,
|
session: spawnResult.session,
|
||||||
detect: detectClaudeStartupState,
|
detect: detectClaudeStartupState,
|
||||||
isCancelled: input.isCancelled,
|
isCancelled: input.isCancelled,
|
||||||
timeoutMs: input.timeoutMs,
|
timeoutMs: input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS,
|
||||||
pollIntervalMs: input.pollIntervalMs,
|
pollIntervalMs: input.pollIntervalMs,
|
||||||
afterDialogAction: async ({ ruleId }) => {
|
afterDialogAction: async ({ ruleId }) => {
|
||||||
if (ruleId !== 'claude.workspace_trust') {
|
if (ruleId !== 'claude.workspace_trust') {
|
||||||
return { action: 'continue' };
|
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') {
|
if (after.status === 'trusted') {
|
||||||
evidence.push(...after.evidence);
|
evidence.push(...after.evidence);
|
||||||
return { action: 'stop', reason: 'workspace_trust_persisted' };
|
return { action: 'stop', reason: 'workspace_trust_persisted' };
|
||||||
|
|
@ -218,7 +254,7 @@ export class ClaudePtyWorkspaceTrustStrategy {
|
||||||
workspaceIds,
|
workspaceIds,
|
||||||
matchedRuleIds: [...new Set(matchedRuleIds)],
|
matchedRuleIds: [...new Set(matchedRuleIds)],
|
||||||
actions,
|
actions,
|
||||||
evidence,
|
evidence: [...new Set(evidence)],
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
errorCode,
|
errorCode,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -13,6 +15,7 @@ import type * as NodePty from 'node-pty';
|
||||||
|
|
||||||
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
|
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
|
||||||
const MAX_TRANSCRIPT_CHARS = 64 * 1024;
|
const MAX_TRANSCRIPT_CHARS = 64 * 1024;
|
||||||
|
const requireNativeAddon = createRequire(import.meta.url);
|
||||||
|
|
||||||
type NodePtyModule = typeof NodePty;
|
type NodePtyModule = typeof NodePty;
|
||||||
|
|
||||||
|
|
@ -23,8 +26,7 @@ function loadNodePty(): NodePtyModule | null {
|
||||||
return nodePty;
|
return nodePty;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
|
nodePty = requireNativeAddon('node-pty') as NodePtyModule;
|
||||||
nodePty = require('node-pty') as NodePtyModule;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`);
|
logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`);
|
||||||
nodePty = null;
|
nodePty = null;
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
|
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
|
||||||
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
|
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
|
||||||
|
settingsDirectory?: string | null;
|
||||||
}): Promise<TeamRuntimeSettingsBundle | null> {
|
}): Promise<TeamRuntimeSettingsBundle | null> {
|
||||||
const fragments = [...(input.baseSettings ?? [])].filter(
|
const fragments = [...(input.baseSettings ?? [])].filter(
|
||||||
(fragment): fragment is TeamRuntimeSettingsJson =>
|
(fragment): fragment is TeamRuntimeSettingsJson =>
|
||||||
|
|
@ -145,7 +146,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDirectory = input.anthropicHelper?.directory;
|
const baseDirectory = input.anthropicHelper?.directory ?? input.settingsDirectory;
|
||||||
if (!baseDirectory) {
|
if (!baseDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1588,6 +1588,27 @@ function buildAnthropicSettingsArgs(
|
||||||
return ['--settings', JSON.stringify(settings)];
|
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(
|
function buildProviderFastModeArgs(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||||
|
|
@ -7124,7 +7145,7 @@ export class TeamProvisioningService {
|
||||||
const rawProviderArgs = input.envResolution.providerArgs ?? [];
|
const rawProviderArgs = input.envResolution.providerArgs ?? [];
|
||||||
const rawExtraArgs = input.extraArgs ?? [];
|
const rawExtraArgs = input.extraArgs ?? [];
|
||||||
|
|
||||||
if (!helper) {
|
if (!helper && resolvedProviderId !== 'anthropic') {
|
||||||
return {
|
return {
|
||||||
settingsArgs: [],
|
settingsArgs: [],
|
||||||
fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity),
|
fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity),
|
||||||
|
|
@ -7137,13 +7158,14 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const providerArgsWithoutHelper = filterOutSettingsPathArgs(
|
const providerArgsWithoutHelper = filterOutSettingsPathArgs(
|
||||||
rawProviderArgs,
|
rawProviderArgs,
|
||||||
helper.settingsPath
|
helper?.settingsPath
|
||||||
);
|
);
|
||||||
const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper);
|
const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper);
|
||||||
const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs);
|
const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs);
|
||||||
if (
|
if (
|
||||||
hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
|
helper &&
|
||||||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)
|
(hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
|
||||||
|
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs))
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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.`
|
`${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,
|
...splitExtraArgs.settingsFragments,
|
||||||
],
|
],
|
||||||
anthropicHelper: helper,
|
anthropicHelper: helper,
|
||||||
|
settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -22019,6 +22042,7 @@ export class TeamProvisioningService {
|
||||||
contextLabel: 'Team create launch',
|
contextLabel: 'Team create launch',
|
||||||
});
|
});
|
||||||
const spawnArgs = mergeJsonSettingsArgs([
|
const spawnArgs = mergeJsonSettingsArgs([
|
||||||
|
'--print',
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
|
|
@ -23288,6 +23312,7 @@ export class TeamProvisioningService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
|
'--print',
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
|
|
@ -36058,6 +36083,7 @@ export class TeamProvisioningService {
|
||||||
: {}),
|
: {}),
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
};
|
};
|
||||||
|
normalizeTeamRuntimeNodeEnv(env);
|
||||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application';
|
import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application';
|
||||||
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain';
|
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ProviderStateProbe,
|
ProviderStateProbe,
|
||||||
|
|
@ -83,6 +82,10 @@ function workspace(cwd = '/tmp/project') {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('skips PTY when the state probe already reports trusted', async () => {
|
it('skips PTY when the state probe already reports trusted', async () => {
|
||||||
const pty = new FakePtyProcess();
|
const pty = new FakePtyProcess();
|
||||||
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
|
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 () => {
|
it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => {
|
||||||
const pty = new FakePtyProcess();
|
const pty = new FakePtyProcess();
|
||||||
const tempStore = new FakeTempStore();
|
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({
|
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
|
||||||
claudePath: '/usr/local/bin/claude',
|
claudePath: '/usr/local/bin/claude',
|
||||||
workspaces: [workspace()],
|
workspaces: [workspace()],
|
||||||
env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined },
|
env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined },
|
||||||
ptyProcess: pty,
|
ptyProcess: pty,
|
||||||
stateProbe: new FakeStateProbe([
|
stateProbe,
|
||||||
{ status: 'untrusted' },
|
|
||||||
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
|
|
||||||
]),
|
|
||||||
tempEmptyMcpConfigStore: tempStore,
|
tempEmptyMcpConfigStore: tempStore,
|
||||||
isCancelled: () => false,
|
isCancelled: () => false,
|
||||||
timeoutMs: 100,
|
timeoutMs: 100,
|
||||||
|
|
@ -190,6 +195,7 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
||||||
expect(result.status).toBe('ok');
|
expect(result.status).toBe('ok');
|
||||||
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
|
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
|
||||||
expect(result.actions).toEqual(['claude.workspace_trust:enter']);
|
expect(result.actions).toEqual(['claude.workspace_trust:enter']);
|
||||||
|
expect(result.evidence).toEqual(['trusted project key: /tmp/project']);
|
||||||
expect(pty.spawnInputs[0]).toMatchObject({
|
expect(pty.spawnInputs[0]).toMatchObject({
|
||||||
command: '/usr/local/bin/claude',
|
command: '/usr/local/bin/claude',
|
||||||
cwd: '/tmp/project',
|
cwd: '/tmp/project',
|
||||||
|
|
@ -203,6 +209,33 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
|
||||||
expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']);
|
expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']);
|
||||||
expect(pty.session?.killed).toBe(true);
|
expect(pty.session?.killed).toBe(true);
|
||||||
expect(tempStore.cleaned).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 () => {
|
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 { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||||
import {
|
import {
|
||||||
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
setClaudeBasePathOverride,
|
setClaudeBasePathOverride,
|
||||||
} from '../../../../src/main/utils/pathDecoder';
|
} 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_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||||
const DEFAULT_MODEL = 'sonnet';
|
const DEFAULT_MODEL = 'sonnet';
|
||||||
const DEFAULT_EFFORT = 'low' as const;
|
|
||||||
|
|
||||||
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
||||||
|
|
||||||
|
|
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
let previousHome: string | undefined;
|
let previousHome: string | undefined;
|
||||||
let previousHistFile: string | undefined;
|
let previousHistFile: string | undefined;
|
||||||
let previousUserProfile: string | undefined;
|
let previousUserProfile: string | undefined;
|
||||||
|
let claudeJsonConfigRoot: string;
|
||||||
|
let previousClaudeJsonConfig: string | null | undefined;
|
||||||
let svc: TeamProvisioningService | null;
|
let svc: TeamProvisioningService | null;
|
||||||
let feature: MemberWorkSyncFeatureFacade | null;
|
let feature: MemberWorkSyncFeatureFacade | null;
|
||||||
let controlServer: MemberWorkSyncLiveControlServer | null;
|
let controlServer: MemberWorkSyncLiveControlServer | null;
|
||||||
let teamName: string | null;
|
let teamName: string | null;
|
||||||
|
let usingConnectedClaudeAccount = false;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
|
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;
|
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
|
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
previousHome = process.env.HOME;
|
previousHome = process.env.HOME;
|
||||||
previousHistFile = process.env.HISTFILE;
|
previousHistFile = process.env.HISTFILE;
|
||||||
previousUserProfile = process.env.USERPROFILE;
|
previousUserProfile = process.env.USERPROFILE;
|
||||||
|
previousClaudeJsonConfig = undefined;
|
||||||
|
|
||||||
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||||
tempHome = shouldUseConnectedAccountHome
|
tempHome = usingConnectedClaudeAccount
|
||||||
? resolveConnectedClaudeHome(previousHome)
|
? resolveConnectedClaudeHome()
|
||||||
: path.join(tempDir, 'home');
|
: 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(tempHome, { recursive: true });
|
||||||
|
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||||
|
|
||||||
|
if (usingConnectedClaudeAccount) {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
} else {
|
||||||
|
setClaudeBasePathOverride(tempClaudeRoot);
|
||||||
|
}
|
||||||
|
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
process.env.HISTFILE = '/dev/null';
|
process.env.HISTFILE = '/dev/null';
|
||||||
|
|
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
|
||||||
if (svc && teamName) {
|
if (svc && teamName) {
|
||||||
await svc.stopTeam(teamName).catch(() => undefined);
|
await svc.stopTeam(teamName).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
||||||
await feature?.dispose().catch(() => undefined);
|
await feature?.dispose().catch(() => undefined);
|
||||||
await controlServer?.close().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_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||||
|
|
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
restoreEnv('HISTFILE', previousHistFile);
|
restoreEnv('HISTFILE', previousHistFile);
|
||||||
restoreEnv('USERPROFILE', previousUserProfile);
|
restoreEnv('USERPROFILE', previousUserProfile);
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
|
if (keepTemp) {
|
||||||
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
||||||
} else {
|
} else {
|
||||||
await removeTempDirAfterLateShellWrites(tempDir);
|
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}`;
|
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
||||||
const projectPath = path.join(tempDir, 'project');
|
const projectPath = path.join(tempDir, 'project');
|
||||||
await fs.mkdir(projectPath, { recursive: true });
|
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(
|
await fs.writeFile(
|
||||||
path.join(projectPath, 'README.md'),
|
path.join(projectPath, 'README.md'),
|
||||||
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
'# 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,
|
cwd: projectPath,
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
model,
|
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,
|
skipPermissions: true,
|
||||||
prompt: [
|
prompt: [
|
||||||
'Keep launch work minimal and wait for the explicit live-test instruction.',
|
'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',
|
role: 'Developer',
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
model,
|
model,
|
||||||
effort: DEFAULT_EFFORT,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude team launch',
|
context: 'Claude team launch',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude validation turn',
|
context: 'Claude validation turn',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
await feature!.replayPendingReports([teamName!]);
|
await feature!.replayPendingReports([teamName!]);
|
||||||
const [status, metrics, tasks] = await Promise.all([
|
const [status, metrics, tasks] = await Promise.all([
|
||||||
|
|
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude Stop hook turn',
|
context: 'Claude Stop hook turn',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
await feature!.drainRuntimeTurnSettledEvents();
|
await feature!.drainRuntimeTurnSettledEvents();
|
||||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||||
|
|
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
|
||||||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
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> {
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
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();
|
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return path.resolve(explicit);
|
return path.resolve(explicit);
|
||||||
}
|
}
|
||||||
const previous = previousHome?.trim();
|
|
||||||
if (previous) {
|
|
||||||
return path.resolve(previous);
|
|
||||||
}
|
|
||||||
return os.userInfo().homedir;
|
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_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||||
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
||||||
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
|
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', () => {
|
liveDescribe('Mixed provider team launch live e2e', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ import * as path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||||
import {
|
import {
|
||||||
|
getAutoDetectedClaudeBasePath,
|
||||||
|
getClaudeBasePath,
|
||||||
|
getHomeDir,
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
setClaudeBasePathOverride,
|
setClaudeBasePathOverride,
|
||||||
|
|
@ -42,7 +46,7 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
|
||||||
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
|
||||||
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
|
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
|
||||||
const DEFAULT_CODEX_EFFORT = 'low' as const;
|
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 DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed'];
|
||||||
const MEMBER_NAMES = [
|
const MEMBER_NAMES = [
|
||||||
'alice',
|
'alice',
|
||||||
|
|
@ -236,6 +240,7 @@ async function runProviderStressScenario(
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const svc = harness?.svc ?? new TeamProvisioningService();
|
const svc = harness?.svc ?? new TeamProvisioningService();
|
||||||
|
configureWorkspaceTrustCoordinator(svc);
|
||||||
const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false };
|
const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false };
|
||||||
activeScenarios.push(active);
|
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(
|
async function runRestartStressChecks(
|
||||||
active: ActiveScenario,
|
active: ActiveScenario,
|
||||||
expectedMembers: string[],
|
expectedMembers: string[],
|
||||||
|
|
|
||||||
|
|
@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
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 () => {
|
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
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');
|
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 () => {
|
it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
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 http from 'node:http';
|
||||||
import * as path from 'node:path';
|
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 { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
|
||||||
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
||||||
|
|
||||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||||
|
|
||||||
export class FatalWaitError extends Error {
|
export class FatalWaitError extends Error {
|
||||||
|
|
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
||||||
export async function throwIfClaudeTranscriptApiError(input: {
|
export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
claudeRoot: string;
|
claudeRoot: string;
|
||||||
context: string;
|
context: string;
|
||||||
|
projectPath?: string;
|
||||||
|
sinceMs?: number;
|
||||||
}): Promise<void> {
|
}): 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 }> = [];
|
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||||
for (const filePath of transcriptFiles) {
|
for (const filePath of transcriptFiles) {
|
||||||
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||||
|
|
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||||
continue;
|
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<
|
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
interface StopChildOptions {
|
interface StopChildOptions {
|
||||||
|
|
@ -17,7 +16,9 @@ interface StopChildOptions {
|
||||||
|
|
||||||
interface OpenCodeLivePreflightTestHooks {
|
interface OpenCodeLivePreflightTestHooks {
|
||||||
__opencodeLivePreflightTestHooks: {
|
__opencodeLivePreflightTestHooks: {
|
||||||
|
findMissingOpenCodeModels(output: string, requiredModels: string[]): string[];
|
||||||
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
|
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
|
||||||
|
parseOpenCodeModels(output: string): string[];
|
||||||
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
|
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
|
||||||
taskkillProcessTree(pid: number): Promise<void>;
|
taskkillProcessTree(pid: number): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
@ -47,6 +48,21 @@ describe('opencode live preflight cleanup', () => {
|
||||||
expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false);
|
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 () => {
|
it('waits for child close after Windows process-tree cleanup', async () => {
|
||||||
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
|
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
|
||||||
const child = new FakeChild({ pid: 1234 });
|
const child = new FakeChild({ pid: 1234 });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue