234 lines
6.5 KiB
TypeScript
234 lines
6.5 KiB
TypeScript
import { promises as fs } from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { stableHash } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
|
import {
|
|
createOpenCodeBridgeCommandLeaseStore,
|
|
createOpenCodeBridgeCommandLedgerStore,
|
|
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
|
|
|
describe('OpenCodeBridgeCommandLedgerStore', () => {
|
|
let tempDir: string;
|
|
let now: Date;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-ledger-'));
|
|
now = new Date('2026-04-21T12:00:00.000Z');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('blocks idempotency key reuse with a different payload', async () => {
|
|
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
|
filePath: path.join(tempDir, 'ledger.json'),
|
|
clock: () => now,
|
|
});
|
|
|
|
await expect(
|
|
ledger.begin({
|
|
idempotencyKey: 'same',
|
|
requestId: 'req-1',
|
|
command: 'opencode.launchTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash: stableHash({ prompt: 'first' }),
|
|
})
|
|
).resolves.toBe('started');
|
|
|
|
await expect(
|
|
ledger.begin({
|
|
idempotencyKey: 'same',
|
|
requestId: 'req-2',
|
|
command: 'opencode.launchTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash: stableHash({ prompt: 'second' }),
|
|
})
|
|
).rejects.toThrow('OpenCode bridge idempotency key reused with different payload');
|
|
});
|
|
|
|
it('marks timeout as unknown outcome and blocks retry until recovery', async () => {
|
|
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
|
filePath: path.join(tempDir, 'ledger.json'),
|
|
clock: () => now,
|
|
});
|
|
const requestHash = stableHash({ teamName: 'team-a', runId: 'run-1' });
|
|
|
|
await ledger.begin({
|
|
idempotencyKey: 'launch:team-a:run-1',
|
|
requestId: 'req-1',
|
|
command: 'opencode.launchTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash,
|
|
});
|
|
await ledger.markUnknownAfterTimeout({
|
|
idempotencyKey: 'launch:team-a:run-1',
|
|
error: 'timeout',
|
|
});
|
|
|
|
await expect(
|
|
ledger.begin({
|
|
idempotencyKey: 'launch:team-a:run-1',
|
|
requestId: 'req-2',
|
|
command: 'opencode.launchTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash,
|
|
})
|
|
).rejects.toThrow('OpenCode bridge command outcome must be reconciled before retry');
|
|
|
|
await expect(ledger.getByIdempotencyKey('launch:team-a:run-1')).resolves.toMatchObject({
|
|
status: 'unknown_after_timeout',
|
|
retryable: false,
|
|
lastError: 'timeout',
|
|
});
|
|
});
|
|
|
|
it('allows same-payload duplicate only after a completed command', async () => {
|
|
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
|
filePath: path.join(tempDir, 'ledger.json'),
|
|
clock: () => now,
|
|
});
|
|
const requestHash = stableHash({ body: 'same' });
|
|
|
|
await ledger.begin({
|
|
idempotencyKey: 'key-1',
|
|
requestId: 'req-1',
|
|
command: 'opencode.stopTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash,
|
|
});
|
|
|
|
await expect(
|
|
ledger.begin({
|
|
idempotencyKey: 'key-1',
|
|
requestId: 'req-2',
|
|
command: 'opencode.stopTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash,
|
|
})
|
|
).rejects.toThrow('OpenCode bridge command already started');
|
|
|
|
await ledger.markCompleted({
|
|
idempotencyKey: 'key-1',
|
|
response: { ok: true, runId: 'run-1' },
|
|
});
|
|
|
|
await expect(
|
|
ledger.begin({
|
|
idempotencyKey: 'key-1',
|
|
requestId: 'req-3',
|
|
command: 'opencode.stopTeam',
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
requestHash,
|
|
})
|
|
).resolves.toBe('duplicate_same_payload_completed');
|
|
});
|
|
});
|
|
|
|
describe('OpenCodeBridgeCommandLeaseStore', () => {
|
|
let tempDir: string;
|
|
let now: Date;
|
|
let nextId: number;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-lease-'));
|
|
now = new Date('2026-04-21T12:00:00.000Z');
|
|
nextId = 1;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('serializes state-changing commands per team through an active lease', async () => {
|
|
const leaseStore = createOpenCodeBridgeCommandLeaseStore({
|
|
filePath: path.join(tempDir, 'leases.json'),
|
|
idFactory: () => `lease-${nextId++}`,
|
|
clock: () => now,
|
|
});
|
|
|
|
const first = await leaseStore.acquire({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
command: 'opencode.launchTeam',
|
|
ttlMs: 10_000,
|
|
});
|
|
|
|
expect(first).toMatchObject({
|
|
leaseId: 'lease-1',
|
|
state: 'active',
|
|
expiresAt: '2026-04-21T12:00:10.000Z',
|
|
});
|
|
|
|
await expect(
|
|
leaseStore.acquire({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
command: 'opencode.stopTeam',
|
|
ttlMs: 10_000,
|
|
})
|
|
).rejects.toThrow('OpenCode bridge command lease already active: lease-1');
|
|
|
|
await leaseStore.release('lease-1');
|
|
await expect(
|
|
leaseStore.acquire({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
command: 'opencode.stopTeam',
|
|
ttlMs: 10_000,
|
|
})
|
|
).resolves.toMatchObject({
|
|
leaseId: 'lease-2',
|
|
command: 'opencode.stopTeam',
|
|
state: 'active',
|
|
});
|
|
});
|
|
|
|
it('expires stale active leases before acquiring a new one', async () => {
|
|
const leaseStore = createOpenCodeBridgeCommandLeaseStore({
|
|
filePath: path.join(tempDir, 'leases.json'),
|
|
idFactory: () => `lease-${nextId++}`,
|
|
clock: () => now,
|
|
});
|
|
|
|
await leaseStore.acquire({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
command: 'opencode.launchTeam',
|
|
ttlMs: 1000,
|
|
});
|
|
|
|
now = new Date('2026-04-21T12:00:02.000Z');
|
|
await expect(
|
|
leaseStore.acquire({
|
|
teamName: 'team-a',
|
|
runId: 'run-1',
|
|
command: 'opencode.reconcileTeam',
|
|
ttlMs: 1000,
|
|
})
|
|
).resolves.toMatchObject({
|
|
leaseId: 'lease-2',
|
|
state: 'active',
|
|
});
|
|
|
|
const persisted = JSON.parse(
|
|
await fs.readFile(path.join(tempDir, 'leases.json'), 'utf8')
|
|
) as {
|
|
data: Array<{ leaseId: string; state: string }>;
|
|
};
|
|
expect(persisted.data).toEqual([
|
|
expect.objectContaining({ leaseId: 'lease-1', state: 'expired' }),
|
|
expect.objectContaining({ leaseId: 'lease-2', state: 'active' }),
|
|
]);
|
|
});
|
|
});
|