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
196 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|