diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42cb2ec..6e6ed27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,78 @@ jobs: - name: Check packaging contract run: pnpm test:packaging + legacy-bridge-continuity: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out exact T4 source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install continuity prerequisites + run: sudo apt-get update && sudo apt-get install --yes expect + + - name: Install T4 dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve pinned OMP authority source + id: authority + shell: bash + run: | + set -euo pipefail + source_repository="$(jq -er '.sourceRepository' provenance/omp-host-migration.json)" + test "$source_repository" = "https://github.com/lyc-aon/oh-my-pi" + sha="$(jq -er '.inputs.operationsContinuity' provenance/omp-host-migration.json)" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] + echo "repository=lyc-aon/oh-my-pi" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Check out pinned OMP authority source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: ${{ steps.authority.outputs.repository }} + ref: ${{ steps.authority.outputs.sha }} + path: .continuity/omp + + - name: Install OMP authority dependencies + working-directory: .continuity/omp + run: bun install --frozen-lockfile + + - name: Build OMP native addon + working-directory: .continuity/omp + run: bun run build:native + + - name: Run legacy bridge continuity gate + env: + T4_OMP_SOURCE_DIR: ${{ github.workspace }}/.continuity/omp + run: pnpm test:legacy-bridge-continuity + + - name: Upload continuity evidence + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: legacy-bridge-continuity-${{ github.run_id }} + path: artifacts/legacy-bridge-continuity/ + if-no-files-found: error + retention-days: 14 + tooling: runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -124,7 +196,7 @@ jobs: verify: name: verify if: ${{ always() }} - needs: [core, tooling, android-debug] + needs: [core, legacy-bridge-continuity, tooling, android-debug] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -132,10 +204,12 @@ jobs: shell: bash env: CORE_RESULT: ${{ needs.core.result }} + CONTINUITY_RESULT: ${{ needs.legacy-bridge-continuity.result }} TOOLING_RESULT: ${{ needs.tooling.result }} ANDROID_RESULT: ${{ needs.android-debug.result }} run: | set -euo pipefail test "$CORE_RESULT" = success + test "$CONTINUITY_RESULT" = success test "$TOOLING_RESULT" = success test "$ANDROID_RESULT" = success diff --git a/.gitignore b/.gitignore index ad5f758..e2d729c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ dist-electron/ !.env.example # Test and runtime output -/artifacts/operations-continuity/ +/artifacts/legacy-bridge-continuity/ +/.continuity/ /evidence/wire/ coverage/ playwright-report/ diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index 898f29d..0b6a9f5 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -50,6 +50,7 @@ import { pendingPromptsFromRef } from "./pending-prompts.ts"; import { createTranscriptArtifactSource, type TranscriptImageAvailability, + type TranscriptMediaReference, } from "./transcript-images.ts"; import { commandSupport, @@ -300,24 +301,30 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu const transcriptImageAvailability = ( runtime: DesktopRuntimeSnapshot, + reference: TranscriptMediaReference, ): TranscriptImageAvailability => { if (runtime.connections.get(targetId) !== "connected") { - return { available: false, reason: "Reconnect to this host to load transcript images." }; + return { available: false, reason: "Reconnect to this host to load transcript media." }; } const host = runtime.hosts.get(options.hostId); + const noun = "source" in reference ? "artifact" : "transcript image"; if (host === undefined || !host.grantedCapabilities.includes("sessions.read")) { return { available: false, - reason: "This target does not grant transcript image access.", + reason: `This target does not grant ${noun} access.`, }; } if ( - !host.grantedFeatures.includes("transcript.images") && - !host.grantedFeatures.includes("artifacts.read") + "source" in reference + ? !host.grantedFeatures.includes("artifacts.read") + : !host.grantedFeatures.includes("transcript.images") ) { return { available: false, - reason: "This OMP host does not offer transcript image reads.", + reason: + "source" in reference + ? "This host does not offer artifact reads." + : "This OMP host does not offer transcript image reads.", }; } if (!transcriptImagesAttached) { @@ -327,9 +334,10 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu }; const syncTranscriptImageAvailability = (runtime: DesktopRuntimeSnapshot) => { - transcriptImages.setAvailability(transcriptImageAvailability(runtime)); + transcriptImages.setAvailability((reference) => transcriptImageAvailability(runtime, reference)); }; + const warmSession = (runtime: DesktopRuntimeSnapshot): SessionProjection | undefined => runtime.projection.sessions.get(projectionKey); diff --git a/apps/web/src/features/session-runtime/transcript-images.ts b/apps/web/src/features/session-runtime/transcript-images.ts index 02013f4..51bfb40 100644 --- a/apps/web/src/features/session-runtime/transcript-images.ts +++ b/apps/web/src/features/session-runtime/transcript-images.ts @@ -34,6 +34,10 @@ export type TranscriptImageAvailability = | { readonly available: true } | { readonly available: false; readonly reason: string }; +export type TranscriptMediaAvailability = + | TranscriptImageAvailability + | ((reference: TranscriptMediaReference) => TranscriptImageAvailability); + export type TranscriptImageSnapshot = | { readonly status: "loading" } | { @@ -74,7 +78,7 @@ interface TranscriptMediaSourceOptions Promise; - readonly availability?: TranscriptImageAvailability; + readonly availability?: TranscriptMediaAvailability; readonly hostId?: string; readonly sessionId?: string; readonly maxCacheBytes?: number; @@ -622,9 +626,9 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { private readonly sessionId: string | undefined; private readonly entries = new Map(); private readonly pendingLoads: CacheEntry[] = []; - private availability: TranscriptImageAvailability; - private unavailable: TranscriptImageSnapshot | null; + private availability: TranscriptMediaAvailability; private readonly pausedSnapshot = unavailableSnapshot(TRANSCRIPT_IMAGE_PAUSED_REASON); + private readonly unavailableSnapshots = new Map(); private paused = false; private disposedReason: string | null = null; private disposedSnapshot: TranscriptImageSnapshot | null = null; @@ -660,9 +664,6 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { available: false, reason: TRANSCRIPT_IMAGE_FIXTURE_REASON, }; - this.unavailable = this.availability.available - ? null - : unavailableSnapshot(this.availability.reason); this.hostId = options.hostId; this.sessionId = options.sessionId; if (this.hostId !== undefined && this.sessionId !== undefined) { @@ -670,18 +671,14 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } } - setAvailability(availability: TranscriptImageAvailability): void { + setAvailability(availability: TranscriptMediaAvailability): void { if (this.disposedReason !== null) return; - const unchanged = - this.availability.available === availability.available && - (this.availability.available || - (!availability.available && this.availability.reason === availability.reason)); - if (unchanged) return; - const becameAvailable = !this.availability.available && availability.available; + const previous = this.availability; this.availability = availability; - this.unavailable = availability.available ? null : unavailableSnapshot(availability.reason); for (const entry of this.entries.values()) { - if (!availability.available) { + const before = this.availabilityFor(entry.reference, previous); + const after = this.availabilityFor(entry.reference); + if (!after.available) { if (entry.state === "queued") this.cancelQueued(entry); if (entry.state === "waiting") { entry.state = "idle"; @@ -689,28 +686,37 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } if (entry.state === "loading") this.cancelActive(entry); } - if (becameAvailable && entry.state === "error" && entry.retryable) { + if (!before.available && after.available && entry.state === "error" && entry.retryable) { entry.state = "idle"; entry.snapshot = null; } this.notify(entry); - if (!this.paused && availability.available && entry.refCount > 0 && entry.state === "idle") { + if (!this.paused && after.available && entry.refCount > 0 && entry.state === "idle") { this.start(entry); } } - if (!this.paused && availability.available) this.drainQueue(); + if (!this.paused) this.drainQueue(); } getSnapshot(reference: TranscriptMediaReference): TranscriptImageSnapshot { if (this.disposedSnapshot !== null) return this.disposedSnapshot; if (this.paused) return this.pausedSnapshot; const entry = this.entries.get(mediaKey(reference)); + const availability = this.availabilityFor(reference); + if (!availability.available) return this.unavailableSnapshot(availability.reason); if (entry?.state === "ready" && entry.snapshot !== null) return entry.snapshot; - if (this.unavailable !== null) return this.unavailable; if (entry?.state === "error" && entry.snapshot !== null) return entry.snapshot; return LOADING_SNAPSHOT; } + private unavailableSnapshot(reason: string): TranscriptImageSnapshot { + const existing = this.unavailableSnapshots.get(reason); + if (existing !== undefined) return existing; + const snapshot = unavailableSnapshot(reason); + this.unavailableSnapshots.set(reason, snapshot); + return snapshot; + } + subscribe(reference: TranscriptMediaReference, listener: () => void): () => void { if (this.disposedReason !== null) return () => undefined; const entry = this.ensureEntry(reference); @@ -732,7 +738,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { entry.snapshot = null; this.notify(entry); } - if (this.availability.available && entry.state === "idle") this.start(entry); + if (this.availabilityFor(entry.reference).available && entry.state === "idle") this.start(entry); let retained = true; return () => { if (!retained) return; @@ -810,11 +816,15 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.paused = false; for (const entry of this.entries.values()) { this.notify(entry); - if (this.availability.available && entry.refCount > 0 && entry.state === "idle") { + if ( + this.availabilityFor(entry.reference).available && + entry.refCount > 0 && + entry.state === "idle" + ) { this.start(entry); } } - if (this.availability.available) this.drainQueue(); + this.drainQueue(); } dispose(reason = "Transcript image cache was closed."): void { @@ -870,6 +880,12 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { this.entries.set(key, entry); return entry; } + private availabilityFor( + reference: TranscriptMediaReference, + availability = this.availability, + ): TranscriptImageAvailability { + return typeof availability === "function" ? availability(reference) : availability; + } private touch(entry: CacheEntry): void { this.clock += 1; @@ -881,9 +897,13 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } private retryCapacityWaiters(): void { - if (this.disposedReason !== null || this.paused || !this.availability.available) return; + if (this.disposedReason !== null || this.paused) return; for (const entry of this.entries.values()) { - if (entry.refCount > 0 && entry.state === "waiting") { + if ( + this.availabilityFor(entry.reference).available && + entry.refCount > 0 && + entry.state === "waiting" + ) { entry.state = "idle"; entry.snapshot = null; this.notify(entry); @@ -896,7 +916,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { if ( this.disposedReason !== null || this.paused || - !this.availability.available || + !this.availabilityFor(entry.reference).available || entry.refCount <= 0 || entry.state !== "idle" || entry.promise !== null || @@ -914,12 +934,13 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { } private drainQueue(): void { - if (this.disposedReason !== null || this.paused || !this.availability.available) return; + if (this.disposedReason !== null || this.paused) return; while (this.activeLoads < this.maxConcurrentLoads && this.pendingLoads.length > 0) { const entry = this.pendingLoads.shift(); if ( entry === undefined || this.entries.get(entry.key) !== entry || + !this.availabilityFor(entry.reference).available || entry.refCount <= 0 || entry.state !== "queued" || !entry.queued @@ -941,7 +962,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { if ( this.disposedReason === null && !this.paused && - this.availability.available && + this.availabilityFor(entry.reference).available && this.entries.get(entry.key) === entry && entry.refCount > 0 && entry.state === "idle" @@ -1096,8 +1117,7 @@ export class ManagedTranscriptImageSource implements TranscriptImageSource { if (chunks > TRANSCRIPT_IMAGE_MAX_CHUNKS) { throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_PROTOCOL_ERROR, false); } - this.assertCurrent(entry, signal); - if (!this.availability.available) { + if (!this.availabilityFor(entry.reference).available) { throw new TranscriptImageFailure(TRANSCRIPT_IMAGE_LOAD_ERROR, true); } let response: TranscriptImageCommandResult; diff --git a/apps/web/src/features/transcript/TranscriptArtifacts.tsx b/apps/web/src/features/transcript/TranscriptArtifacts.tsx index e2ece74..645d0a1 100644 --- a/apps/web/src/features/transcript/TranscriptArtifacts.tsx +++ b/apps/web/src/features/transcript/TranscriptArtifacts.tsx @@ -17,6 +17,14 @@ function labelFor(artifact: TranscriptArtifactReference): string { return artifact.name ?? `${artifact.kind} artifact`; } +export function artifactOpensInline(artifact: TranscriptArtifactReference): boolean { + return artifact.kind === "image" && artifact.disposition === "inline"; +} + +export function artifactDownloadLabel(artifact: TranscriptArtifactReference): string { + return artifact.sha256 === undefined ? "Download artifact" : "Download verified artifact"; +} + function ArtifactCard({ artifact, source, @@ -24,7 +32,7 @@ function ArtifactCard({ readonly artifact: TranscriptArtifactReference; readonly source: TranscriptImageSource; }) { - const [open, setOpen] = useState(artifact.kind === "image"); + const [open, setOpen] = useState(() => artifactOpensInline(artifact)); const [text, setText] = useState(null); const snapshot = useSyncExternalStore( (listener) => source.subscribe(artifact, listener), @@ -78,7 +86,7 @@ function ArtifactCard({ {labelFor(artifact)} {artifact.kind} - {artifact.kind === "image" && snapshot.status === "ready" && ( + {artifact.kind === "image" && open && snapshot.status === "ready" && ( source.reportDecodeFailure(artifact)} /> )} + {artifact.kind === "image" && artifact.disposition === "attachment" && ( + + )} {snapshot.status === "loading" && open && (