feat(fleet): HARNESS-HOMES fleet MVP — profile-driven seat composition and launch (#1209) #1213
@@ -94,6 +94,36 @@ describe('three-layer settings merge', () => {
|
||||
).toEqual({ nested: { keep: true } });
|
||||
});
|
||||
|
||||
it('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => {
|
||||
const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
|
||||
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
|
||||
const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
|
||||
expect(
|
||||
deepMergeSettings(
|
||||
{ hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
|
||||
{ hooks: { Stop: [leaseStop] } },
|
||||
),
|
||||
).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } });
|
||||
});
|
||||
|
||||
it('still deletes a whole hook event via the null tombstone', () => {
|
||||
expect(
|
||||
deepMergeSettings(
|
||||
{ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
|
||||
{ hooks: { Stop: null } },
|
||||
),
|
||||
).toEqual({ hooks: {} });
|
||||
});
|
||||
|
||||
it('keeps replace semantics for arrays outside the top-level hooks object', () => {
|
||||
expect(
|
||||
deepMergeSettings(
|
||||
{ plugins: ['base'], nested: { hooks: { Stop: ['base'] } } },
|
||||
{ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } },
|
||||
),
|
||||
).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } });
|
||||
});
|
||||
|
||||
it('deep-merges all three layers in precedence order', () => {
|
||||
expect(
|
||||
deepMergeSettings(
|
||||
|
||||
@@ -253,9 +253,12 @@ function cloneValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
type MergeContext = 'root' | 'hooks' | 'nested';
|
||||
|
||||
function mergeObject(
|
||||
lower: Record<string, unknown>,
|
||||
higher: Record<string, unknown>,
|
||||
context: MergeContext = 'nested',
|
||||
): Record<string, unknown> {
|
||||
const result = cloneValue(lower) as Record<string, unknown>;
|
||||
for (const [key, highValue] of Object.entries(higher)) {
|
||||
@@ -264,20 +267,36 @@ function mergeObject(
|
||||
continue;
|
||||
}
|
||||
const lowValue = result[key];
|
||||
if (context === 'hooks' && Array.isArray(lowValue) && Array.isArray(highValue)) {
|
||||
result[key] = [...(lowValue as unknown[]), ...(cloneValue(highValue) as unknown[])];
|
||||
continue;
|
||||
}
|
||||
result[key] =
|
||||
isPlainObject(lowValue) && isPlainObject(highValue)
|
||||
? mergeObject(lowValue, highValue)
|
||||
? mergeObject(
|
||||
lowValue,
|
||||
highValue,
|
||||
context === 'root' && key === 'hooks' ? 'hooks' : 'nested',
|
||||
)
|
||||
: cloneValue(highValue);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Deep object merge. Scalars and arrays replace; null in a higher layer deletes. */
|
||||
/**
|
||||
* Deep object merge. Scalars and arrays replace; null in a higher layer
|
||||
* deletes. Exception: hook event arrays directly under the top-level `hooks`
|
||||
* key concatenate (base entries first), so an overlay ADDS gating without
|
||||
* erasing the base QA hooks that share an event — replacing them would make
|
||||
* the gap-7 base/overlay split unimplementable without duplicating base
|
||||
* hooks inside the lease overlay. Removing an event entirely still works via
|
||||
* the null tombstone.
|
||||
*/
|
||||
export function deepMergeSettings(
|
||||
...layers: ReadonlyArray<Record<string, unknown> | undefined>
|
||||
): Record<string, unknown> {
|
||||
return layers.reduce<Record<string, unknown>>(
|
||||
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)),
|
||||
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'agent',
|
||||
'apply',
|
||||
'backlog',
|
||||
'create',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||
import { deepMergeSettings } from '../commands/fleet-launch-command.js';
|
||||
import {
|
||||
observeAndPromoteReceiptChallenge,
|
||||
requestBrokerReply,
|
||||
@@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py')
|
||||
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
|
||||
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
|
||||
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||
const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json');
|
||||
|
||||
// The gated seat contract is the COMPOSITION of the ungated base and the
|
||||
// lease overlay (gap-7 split); assertions about lease wiring must read that
|
||||
// composed view, produced by the same merge the launcher uses.
|
||||
async function readGatedClaudeSettings(): Promise<Record<string, unknown>> {
|
||||
const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record<string, unknown>;
|
||||
const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return deepMergeSettings(base, overlay);
|
||||
}
|
||||
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
||||
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
||||
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||
@@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
expect(parserResult.status).toBe(0);
|
||||
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
|
||||
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
||||
};
|
||||
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
||||
@@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
});
|
||||
|
||||
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
||||
};
|
||||
expect(
|
||||
@@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
||||
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||
};
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user