import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { GitManagerServiceError, VcsStatusInput, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; function boundedDiagnosticValue(value: string): string { return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); } function diagnosticValueTag(value: unknown): string { try { if ( typeof value === "object" && value !== null && "_tag" in value && typeof value._tag === "string" ) { return boundedDiagnosticValue(value._tag); } if (value instanceof Error) { return boundedDiagnosticValue(value.name); } return typeof value; } catch { return "Uninspectable"; } } function diagnosticFailureOperation(value: unknown): string | undefined { try { if ( typeof value === "object" && value !== null && "operation" in value && typeof value.operation === "string" ) { return boundedDiagnosticValue(value.operation); } } catch { return undefined; } return undefined; } function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { if ( value !== undefined && values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && !values.includes(value) ) { values.push(value); } } export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { const failureTags: Array = []; const failureOperations: Array = []; const defectTags: Array = []; let failureCount = 0; let defectCount = 0; let interruptionCount = 0; for (const reason of cause.reasons) { if (Cause.isFailReason(reason)) { failureCount += 1; addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); continue; } if (Cause.isDieReason(reason)) { defectCount += 1; addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); continue; } interruptionCount += 1; } return { reasonCount: cause.reasons.length, failureCount, failureTags, failureOperations, defectCount, defectTags, interruptionCount, }; } interface VcsStatusChange { readonly cwd: string; readonly event: VcsStatusStreamEvent; } interface CachedValue { readonly fingerprint: string; readonly value: T; } interface CachedVcsStatus { readonly local: CachedValue | null; readonly remote: CachedValue | null; } interface ActiveRemotePoller { readonly fiber: Fiber.Fiber; readonly subscriberCount: number; readonly demandCwds: Ref.Ref>; } interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } export class VcsAutoPullPolicy extends Context.Reference<{ readonly isEnabled: (cwd: string) => Effect.Effect; }>("t3/vcs/VcsAutoPullPolicy", { defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }), }) {} export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const serverSettings = yield* ServerSettings.ServerSettingsService; return { isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( function* (cwd: string) { const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); if (project._tag === "None") return false; const settings = yield* serverSettings.getSettings; return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); }, Effect.orElseSucceed(() => false), ), }; }), ); export function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, ) { const exponent = Math.max(0, consecutiveFailures - 1); const backoffMs = Duration.toMillis(VCS_STATUS_REFRESH_FAILURE_BASE_DELAY) * Math.pow(2, exponent); const cappedBackoff = Duration.min( Duration.millis(backoffMs), VCS_STATUS_REFRESH_FAILURE_MAX_DELAY, ); return Duration.max(configuredInterval, cappedBackoff); } export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, { readonly getStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; readonly refreshStatus: (cwd: string) => Effect.Effect; /** * Refresh a loaded cwd after a turn if background policy allows it. * GitManager retries missing PRs for the current branch and keeps known * PRs and failed lookup backoff cached. This does not fetch Git remotes. */ readonly refreshPullRequestStatus: ( cwd: string, ) => Effect.Effect; readonly streamStatus: ( input: VcsStatusInput, options?: StreamStatusOptions, ) => Stream.Stream; } >()("t3/vcs/VcsStatusBroadcaster") {} function fingerprintStatusPart(status: unknown): string { return JSON.stringify(status); } const normalizeCwd = (cwd: string) => Effect.service(FileSystem.FileSystem).pipe( Effect.flatMap((fs) => fs.realPath(cwd)), Effect.orElseSucceed(() => cwd), ); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), ); const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a // periodic poll that started before `gh pr create` can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. const remoteWriteLocks = new Map(); const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { let lock = remoteWriteLocks.get(cwd); if (lock === undefined) { lock = Semaphore.makeUnsafe(1); remoteWriteLocks.set(cwd, lock); } return lock.withPermits(1)(effect); }; const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, ) { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); }); const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { ...previous, local: nextLocal, }); return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { _tag: "localUpdated", local, }, }); } return local; }, ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { ...previous, remote: nextRemote, }); return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { _tag: "remoteUpdated", remote, }, }); } return remote; }, ); const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( cwd: string, local: VcsStatusLocalResult, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }, ) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, } satisfies CachedValue; const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { local: nextLocal, remote: nextRemote, }); return [ previous.local?.fingerprint !== nextLocal.fingerprint || previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache, ] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { _tag: "snapshot", local, remote, }, }); } return mergeGitStatusParts(local, remote); }); const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( cwd: string, ) { const local = yield* workflow.localStatus({ cwd }); return yield* updateCachedLocalStatus(cwd, local); }); const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( cwd: string, ) { const cached = yield* getCachedStatus(cwd); if (cached?.local) { return cached.local.value; } return yield* loadLocalStatus(cwd); }); const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const cached = yield* getCachedStatus(cwd); if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const latest = yield* getCachedStatus(cwd); const [local, remote] = yield* Effect.all( [ latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), ], { concurrency: "unbounded" }, ); return yield* updateCachedStatus(cwd, local, remote); }), ); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); return yield* updateCachedLocalStatus(cwd, local, { publish: true }); }, ); const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); return yield* refreshLocalStatusCore(cwd); }); const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( cwd: string, remote: VcsStatusRemoteResult | null, policyCwds: ReadonlyArray, ) { return yield* Effect.gen(function* () { const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { concurrency: "unbounded", })).some(Boolean); if ( remote === null || !remote.hasUpstream || remote.aheadCount > 0 || remote.behindCount <= 0 || !autoPullEnabled ) { return null; } yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; yield* workflow.pullCurrentBranch(cwd); yield* workflow.invalidateStatus(cwd); const [refreshedLocal, refreshedRemote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], { concurrency: "unbounded" }, ); yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); return { local: refreshedLocal, remote: refreshedRemote }; }).pipe( Effect.catch(() => Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)), ), ); }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, options?: { readonly refreshUpstream?: boolean; readonly policyCwds?: ReadonlyArray; }, ) { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); } const remote = yield* workflow.remoteStatus({ cwd }, options); const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, ); const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }), ); }); const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); if (cached?.remote?.value == null) return null; const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; const shouldRefresh = (yield* Effect.forEach( demandCwds, (demandCwd) => backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; // Resolve the checked-out branch again. A cached PR can belong to // the previous branch after an agent checks out another branch. const remote = yield* workflow.remoteStatus( { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); }); const makeRemoteRefreshLoop = ( cwd: string, demandCwdsRef: Ref.Ref>, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) => { return Effect.gen(function* () { const consecutiveFailuresRef = yield* Ref.make(0); const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); const refreshRemoteStatusIfEnabled = Effect.gen(function* () { const configuredInterval = yield* automaticRemoteRefreshInterval; const activeInterval = Duration.isZero(configuredInterval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : configuredInterval; const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { return activeInterval; } const demandCwds = yield* Ref.get(demandCwdsRef); const shouldRun = needsInitialRefresh || (yield* Effect.all( [...demandCwds.keys()].map((demandCwd) => backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd, }), ), { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRun) { return activeInterval; } const exit = yield* refreshRemoteStatus(cwd, { refreshUpstream: !Duration.isZero(configuredInterval), policyCwds: [...demandCwds.keys()], }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { yield* Ref.set(needsInitialRefreshRef, false); yield* Ref.set(consecutiveFailuresRef, 0); return activeInterval; } const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); if (interruptionReasons.length > 0) { return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); } const consecutiveFailures = yield* Ref.updateAndGet( consecutiveFailuresRef, (count) => count + 1, ); const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); yield* Effect.logWarning("VCS remote status refresh failed", { cwdLength: cwd.length, ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), }); return nextDelay; }); if (!refreshImmediately) { const configuredInterval = yield* automaticRemoteRefreshInterval; yield* Effect.sleep( Duration.isZero(configuredInterval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : configuredInterval, ); } return yield* refreshRemoteStatusIfEnabled.pipe( Effect.repeat( Schedule.identity().pipe( Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), ), ), Effect.asVoid, ); }); }; const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, demandCwd: string, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) { yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (existing) { return Ref.update(existing.demandCwds, (demandCwds) => { const next = new Map(demandCwds); next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1); return next; }).pipe( Effect.map(() => { const nextPollers = new Map(activePollers); nextPollers.set(cwd, { ...existing, subscriberCount: existing.subscriberCount + 1, }); return [undefined, nextPollers] as const; }), ); } return Ref.make>(new Map([[demandCwd, 1]])).pipe( Effect.flatMap((demandCwds) => makeRemoteRefreshLoop( cwd, demandCwds, automaticRemoteRefreshInterval, refreshImmediately, ).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); nextPollers.set(cwd, { fiber, subscriberCount: 1, demandCwds, }); return [undefined, nextPollers] as const; }), ), ), ); }); }); const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( cwd: string, demandCwd: string, ) { const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (!existing) { return Effect.succeed([null, activePollers] as const); } if (existing.subscriberCount > 1) { return Ref.update(existing.demandCwds, (demandCwds) => { const nextDemandCwds = new Map(demandCwds); const count = nextDemandCwds.get(demandCwd) ?? 0; if (count <= 1) { nextDemandCwds.delete(demandCwd); } else { nextDemandCwds.set(demandCwd, count - 1); } return nextDemandCwds; }).pipe( Effect.as([ null, new Map(activePollers).set(cwd, { ...existing, subscriberCount: existing.subscriberCount - 1, }), ] as const), ); } return Effect.succeed([ existing.fiber, new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)), ] as const); }); if (pollerToInterrupt) { yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); } }); const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; yield* retainRemotePoller( cwd, input.cwd, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ _tag: "snapshot" as const, local: initialLocal, remote: initialRemote, }), Stream.fromSubscription(subscription).pipe( Stream.filter((event) => event.cwd === cwd), Stream.map((event) => event.event), ), ).pipe(Stream.ensuring(release)); }), ); return VcsStatusBroadcaster.of({ getStatus, refreshLocalStatus, refreshStatus, refreshPullRequestStatus, streamStatus, }); }); export const layer = Layer.effect(VcsStatusBroadcaster, make);