Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

196 lines
5.8 KiB
TypeScript

import type {
ComposerSettingsContext,
ComposerSettingsCorePorts,
ComposerSettingsCoreSnapshot,
ComposerSettingsDraft,
ComposerSettingsState
} from "./types.ts";
import {
applyContextChange,
applyDraftPatch,
applyFetchFailed,
applyFetchResolved,
applyFetchResolvedEmpty,
createComposerSettingsState,
issueFetch,
projectComposerSettingsSnapshot,
resolveComposerSettings
} from "./composerSettingsState.ts";
/**
* Host-agnostic composer settings policy: owns the sparse settings draft and
* the fenced options lifecycle, seeds display from the daemon's
* defaults-merged effectiveSettings, and writes explicit picks back to the
* canonical per-target defaults ledger through a port. Views bind through the
* external-store contract (stable subscribe/getSnapshot references).
*/
export class ComposerSettingsCore {
private readonly ports: ComposerSettingsCorePorts;
private state: ComposerSettingsState;
private snapshot: ComposerSettingsCoreSnapshot;
private readonly listeners = new Set<() => void>();
private disposed = false;
private pendingDefaults: {
agentTargetId: string;
patch: ComposerSettingsDraft;
} | null = null;
private defaultsWriteInFlight = false;
constructor(
ports: ComposerSettingsCorePorts,
context: ComposerSettingsContext
) {
this.ports = ports;
this.state = createComposerSettingsState(context);
this.snapshot = projectComposerSettingsSnapshot(this.state);
}
readonly getSnapshot = (): ComposerSettingsCoreSnapshot => this.snapshot;
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/**
* Target changes reset draft and options (per-target facts) and drop any
* unwritten defaults patch for the previous target; cwd-only changes keep
* the draft. Either way the current catalog is re-fetched and any in-flight
* response is fenced out.
*/
setContext(context: ComposerSettingsContext): void {
const previousTargetId = this.state.agentTargetId;
const next = applyContextChange(this.state, context);
if (next === this.state) {
return;
}
this.state = next;
if (this.state.agentTargetId !== previousTargetId) {
this.pendingDefaults = null;
}
this.startFetch();
}
/** Merge explicit picks, refresh against them, and persist them. */
setSettings(patch: ComposerSettingsDraft): void {
this.state = applyDraftPatch(this.state, patch);
this.queueRememberDefaults(patch);
this.startFetch();
}
refresh(): void {
this.startFetch();
}
/**
* The exact values the composer currently displays; submissions must carry
* them verbatim so empty fields are never re-interpreted downstream.
*/
resolveSubmitSettings(): ComposerSettingsDraft {
return resolveComposerSettings(this.state.draft, this.state.options);
}
dispose(): void {
this.disposed = true;
this.listeners.clear();
this.pendingDefaults = null;
}
private startFetch(): void {
this.state = issueFetch(this.state);
const revision = this.state.fetchRevision;
const { agentTargetId, cwd, draft } = this.state;
if (!agentTargetId) {
// No target yet (e.g. before the launcher resolves one): settle
// silently so the fence stays consistent without surfacing an error.
this.state = applyFetchResolvedEmpty(this.state, revision);
this.emit();
return;
}
this.emit();
void this.ports
.fetchOptions({
agentTargetId,
cwd,
settings: Object.keys(draft).length > 0 ? draft : null
})
.then(
(options) => {
if (this.disposed) {
return;
}
this.state = applyFetchResolved(this.state, revision, options);
this.emit();
},
(error: unknown) => {
if (this.disposed) {
return;
}
this.state = applyFetchFailed(
this.state,
revision,
error instanceof Error ? error.message : String(error)
);
this.emit();
}
);
}
/**
* Trailing-edge coalescing: one write in flight, later picks merge into a
* single pending patch, and the pending patch is re-drained after the
* in-flight write settles. Write failures are dropped — persistence must
* never block or fail the composer.
*/
private queueRememberDefaults(patch: ComposerSettingsDraft): void {
if (!this.ports.rememberDefaults) {
return;
}
const agentTargetId = this.state.agentTargetId;
if (!agentTargetId) {
return;
}
this.pendingDefaults =
this.pendingDefaults?.agentTargetId === agentTargetId
? { agentTargetId, patch: { ...this.pendingDefaults.patch, ...patch } }
: { agentTargetId, patch };
void this.drainRememberDefaults();
}
private async drainRememberDefaults(): Promise<void> {
if (this.defaultsWriteInFlight) {
return;
}
this.defaultsWriteInFlight = true;
try {
while (this.pendingDefaults && !this.disposed) {
const { agentTargetId, patch } = this.pendingDefaults;
this.pendingDefaults = null;
try {
await this.ports.rememberDefaults?.(agentTargetId, patch);
} catch (error: unknown) {
// Non-blocking by contract; the pick still applies to this session.
this.ports.reportDiagnostic?.(
"composer_settings.defaults_write_failed",
{
agentTargetId,
error: error instanceof Error ? error.message : String(error)
}
);
}
}
} finally {
this.defaultsWriteInFlight = false;
}
}
private emit(): void {
this.snapshot = projectComposerSettingsSnapshot(this.state);
for (const listener of this.listeners) {
listener();
}
}
}