mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c750427ab8
A direct local `plannotator annotate --gate --json` waits for one authoritative decision. If every review surface disappears without approving, sending feedback, or exiting, the caller blocks forever: the server has no notion of whether a client ever connected, whether another tab is still open, or whether a disconnect is a reload. Page lifecycle events cannot answer that. `pagehide` and `beforeunload` also fire on reload and navigation, so dismissing from them ends reviews the user expects to resume. Use connection presence instead, which is exactly what the transport can observe. Local direct structured gates advertise a client lease in /api/plan and serve /api/annotate/client-lease as SSE. One open stream is one connected review surface. The server heartbeats every 5s and, only after at least one client has connected, starts a 30s reconnect grace when the last one disconnects. A reconnect inside the grace continues the same review; expiry resolves the gate through the same path as explicit Close, so it produces an ordinary `dismissed` decision and inherits the strict-result contract unchanged. Approve, feedback, explicit exit, and server stop all cancel a pending expiry. Presence lives in two runtime-independent pieces so Bun and Pi cannot drift. createAnnotateClientLeaseTracker owns first-client, active-count, reconnect, cancellation, and one-shot expiry. createAnnotateClientLease- StreamSession owns one connected client: acquire the slot, write the ready comment, heartbeat, release exactly once. Each server passes only its own write primitive (a ReadableStream controller for Bun, res.write for Pi). A write that fails closes the session, because a stream that can no longer be written to is a client that is no longer present; holding the slot there would make the gate un-dismissable for the rest of the run, which is reachable only through a half-open connection and so is covered by unit tests rather than an integration test. Scope is deliberately narrow. The capability stays off for remote and shared sessions, where tunnel disconnects would read as abandonment, and off for hook transport, legacy plaintext, archive, plan, review, and folder-picker sessions. A session that never receives its first client never auto-dismisses, so browser-launch failures still need a caller-side timeout. Decision settlement is explicit for the same reason: a connected surface and the lease can both try to settle the session, and the awaited promise ignoring the second resolve was not enough. The loser still deleted the reviewer's draft and answered ok, so a tab reported success for a decision the caller never received. createAnnotateDecisionSettler makes the winner explicit; a loser changes nothing and answers 409. Expiry deliberately keeps the saved draft, unlike explicit Close, so an abandoned review stays recoverable. Stopping the server closes live lease streams instead of only releasing their slots, so a long-lived host process does not retain a heartbeat timer and an open response for every finished session.
140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
ANNOTATE_CLIENT_LEASE_STREAM_PATH,
|
|
shouldConnectAnnotateClientLease,
|
|
openAnnotateClientLeaseStream,
|
|
type AnnotateClientLeaseConfig,
|
|
} from './annotateClientLease';
|
|
|
|
/** Minimal fake EventSource — just enough to observe construction and close(). */
|
|
class FakeEventSource {
|
|
static instances: FakeEventSource[] = [];
|
|
url: string;
|
|
closed = false;
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
FakeEventSource.instances.push(this);
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
describe('shouldConnectAnnotateClientLease', () => {
|
|
const enabled: AnnotateClientLeaseConfig = { enabled: true, reconnectGraceMs: 30_000 };
|
|
const disabled: AnnotateClientLeaseConfig = { enabled: false };
|
|
|
|
test('connects when annotate mode is active, not shared, not yet submitted, and the server enabled it', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted: null,
|
|
clientLease: enabled,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('treats an undefined decision the same as null: the session is still open', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted: undefined,
|
|
clientLease: enabled,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('does not connect when the server capability is disabled', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted: null,
|
|
clientLease: disabled,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('does not connect when clientLease is unavailable (undefined/null — e.g. plan review mode, or /api/plan fetch failed)', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted: null,
|
|
clientLease: undefined,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted: null,
|
|
clientLease: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('does not connect outside annotate mode (e.g. plan review)', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: false,
|
|
isSharedSession: false,
|
|
submitted: null,
|
|
clientLease: enabled,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('does not connect for a shared/static session (no live server to lease against)', () => {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: true,
|
|
submitted: null,
|
|
clientLease: enabled,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('does not connect once a decision has already been submitted', () => {
|
|
for (const submitted of ['approved', 'denied', 'exited'] as const) {
|
|
expect(
|
|
shouldConnectAnnotateClientLease({
|
|
annotateMode: true,
|
|
isSharedSession: false,
|
|
submitted,
|
|
clientLease: enabled,
|
|
}),
|
|
).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('openAnnotateClientLeaseStream', () => {
|
|
test('opens exactly one EventSource against the shared client-lease path', () => {
|
|
FakeEventSource.instances = [];
|
|
const stream = openAnnotateClientLeaseStream(FakeEventSource as unknown as typeof EventSource);
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
expect(FakeEventSource.instances[0]!.url).toBe(ANNOTATE_CLIENT_LEASE_STREAM_PATH);
|
|
|
|
stream.close();
|
|
});
|
|
|
|
test('close() is idempotent and actually closes the underlying EventSource', () => {
|
|
FakeEventSource.instances = [];
|
|
const stream = openAnnotateClientLeaseStream(FakeEventSource as unknown as typeof EventSource);
|
|
const instance = FakeEventSource.instances[0]!;
|
|
|
|
expect(instance.closed).toBe(false);
|
|
stream.close();
|
|
expect(instance.closed).toBe(true);
|
|
|
|
// Calling close() again must not throw or reopen/reconstruct anything.
|
|
expect(() => stream.close()).not.toThrow();
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
});
|
|
});
|