@p4code/cli 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -8,7 +8,7 @@ import * as Effect from "effect/Effect";
8
8
  import * as Layer from "effect/Layer";
9
9
  import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
10
10
  import * as CliError from "effect/unstable/cli/CliError";
11
- import { FetchHttpClient, Headers as Headers$1, HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
11
+ import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
12
12
  import * as Context from "effect/Context";
13
13
  import * as NodeOS from "node:os";
14
14
  import * as NodeNet from "node:net";
@@ -63,6 +63,8 @@ import * as NodeReadline from "node:readline";
63
63
  import * as NodeFSP from "node:fs/promises";
64
64
  import { access } from "node:fs/promises";
65
65
  import * as Clock from "effect/Clock";
66
+ import * as Headers$1 from "effect/unstable/http/Headers";
67
+ import * as HttpRouter$1 from "effect/unstable/http/HttpRouter";
66
68
  import * as NodeStream from "@effect/platform-node/NodeStream";
67
69
  import * as NodeHttp from "node:http";
68
70
  import * as Deferred from "effect/Deferred";
@@ -266,7 +268,7 @@ const make$124 = () => {
266
268
  const layer$124 = Layer.sync(NetService, make$124);
267
269
  //#endregion
268
270
  //#region package.json
269
- var version$1 = "0.6.13";
271
+ var version$1 = "0.6.14";
270
272
  //#endregion
271
273
  //#region src/config.ts
272
274
  /**
@@ -24244,6 +24246,49 @@ function isLoopbackHost(host) {
24244
24246
  return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.");
24245
24247
  }
24246
24248
  //#endregion
24249
+ //#region src/httpCompression/HttpResponseCompression.ts
24250
+ const GZIP_MIN_BYTES = 1024;
24251
+ var HttpResponseCompression = class extends Context.Service()("@p4code/cli/httpCompression/HttpResponseCompression") {};
24252
+ const layerNode = Layer.effect(HttpResponseCompression, Effect.gen(function* () {
24253
+ const NodeZlib = yield* Effect.promise(() => import("node:zlib"));
24254
+ return { gzip: (body, options) => {
24255
+ const stream = NodeZlib.createGzip();
24256
+ stream.end(body);
24257
+ return HttpServerResponse$1.raw(stream, options);
24258
+ } };
24259
+ }));
24260
+ const layerBun = Layer.succeed(HttpResponseCompression, { gzip: (body, options) => HttpServerResponse$1.raw(new Response(body).body.pipeThrough(new CompressionStream("gzip")), options) });
24261
+ function acceptsGzip(value) {
24262
+ if (!value) return false;
24263
+ const accepted = new Map(value.split(",").map((entry) => {
24264
+ const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";");
24265
+ const quality = parameters.map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]).find((parameter) => parameter !== void 0);
24266
+ return [coding, quality === void 0 ? 1 : Number(quality)];
24267
+ }));
24268
+ return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0;
24269
+ }
24270
+ function varyByAcceptEncoding(value) {
24271
+ if (!value) return "Accept-Encoding";
24272
+ const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase()));
24273
+ return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
24274
+ }
24275
+ const compressHttpResponse = Effect.fnUntraced(function* (response, acceptEncoding) {
24276
+ const body = response.body;
24277
+ if (body._tag !== "Uint8Array" || body.contentLength < GZIP_MIN_BYTES || !body.contentType.startsWith("application/json") || response.headers["content-encoding"]) return response;
24278
+ const variedResponse = HttpServerResponse$1.setHeader(response, "vary", varyByAcceptEncoding(response.headers.vary));
24279
+ if (!acceptsGzip(acceptEncoding)) return variedResponse;
24280
+ const compression = yield* HttpResponseCompression;
24281
+ const headers = Headers$1.set(Headers$1.remove(variedResponse.headers, "content-length"), "content-encoding", "gzip");
24282
+ return compression.gzip(body.body, {
24283
+ status: response.status,
24284
+ statusText: response.statusText,
24285
+ headers,
24286
+ cookies: response.cookies,
24287
+ contentType: body.contentType
24288
+ });
24289
+ });
24290
+ const httpCompressionLayer = HttpRouter$1.middleware((httpEffect) => Effect.flatMap(Effect.all([httpEffect, HttpServerRequest$1.HttpServerRequest]), ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"])), { global: true });
24291
+ //#endregion
24247
24292
  //#region src/persistence/Services/AgentAssets.ts
24248
24293
  var AgentAssetRepository = class extends Context.Service()("@p4code/cli/persistence/Services/AgentAssets/AgentAssetRepository") {};
24249
24294
  //#endregion
@@ -28795,6 +28840,321 @@ const hubTunnelClientSocketRoute = Effect.gen(function* () {
28795
28840
  })));
28796
28841
  const layer$96 = HttpRouter.add("GET", `${HUB_TUNNEL_CLIENT_SOCKET_PATH}/*`, hubTunnelClientSocketRoute);
28797
28842
  //#endregion
28843
+ //#region src/sync/HubLink.ts
28844
+ /**
28845
+ * Where this server's hub is, what it authenticates with, and whether it syncs
28846
+ * on its own.
28847
+ *
28848
+ * Absent by default. A server with no hub configured runs exactly as it does
28849
+ * today — the sync is skipped rather than failing, because "no hub" is a
28850
+ * complete and normal configuration, not a broken one.
28851
+ *
28852
+ * The record lives in `ServerSecretStore`, which already writes with
28853
+ * restrictive permissions, rather than in the settings file or the database.
28854
+ * The sync mode rides along in that record even though it is not a secret:
28855
+ * connect, disconnect and pause are then one write and one read, and the
28856
+ * settings panel reads one thing instead of joining two. Environment variables
28857
+ * override the link so a container or a service unit can be configured without
28858
+ * a first-run interactive step — the same trade the hub process itself makes
28859
+ * with `P4CODE_HUB_TOKEN_HASHES` — while the mode always comes from the stored
28860
+ * record, so a server configured by environment can still be paused.
28861
+ *
28862
+ * Mutable, deliberately. Resolving this once at layer construction would mean
28863
+ * connecting to a hub did nothing until the server restarted, which is a
28864
+ * miserable thing to discover after typing a token into a form.
28865
+ *
28866
+ * @module sync/HubLink
28867
+ */
28868
+ /** Secret name holding the hub record as JSON. */
28869
+ const HUB_LINK_SECRET_NAME = "hub-link";
28870
+ var HubLink = class extends Context.Service()("@p4code/cli/sync/HubLink") {};
28871
+ /** The submitted URL or token could not make a usable link. */
28872
+ var InvalidHubLink = class extends Schema$1.TaggedErrorClass()("InvalidHubLink", { detail: Schema$1.String }) {
28873
+ get message() {
28874
+ return this.detail;
28875
+ }
28876
+ };
28877
+ const trimTrailingSlash = (value) => value.replace(/\/+$/u, "");
28878
+ /**
28879
+ * Both halves must be present and the URL parseable. A half-configured link is
28880
+ * treated as no link at all, because a server that thinks it has a hub and
28881
+ * cannot form a request would report every sync as a failure rather than as the
28882
+ * "not configured" it actually is.
28883
+ */
28884
+ const validateHubLink = (rawBaseUrl, rawToken) => {
28885
+ const trimmed = trimTrailingSlash(rawBaseUrl.trim());
28886
+ const token = rawToken.trim();
28887
+ if (trimmed.length === 0 || token.length === 0) return Option.none();
28888
+ const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;
28889
+ if (!URL.canParse(baseUrl)) return Option.none();
28890
+ const parsed = new URL(baseUrl);
28891
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return Option.none();
28892
+ return Option.some({
28893
+ baseUrl,
28894
+ token
28895
+ });
28896
+ };
28897
+ const StoredHubLink = Schema$1.fromJsonString(Schema$1.Struct({
28898
+ baseUrl: Schema$1.String,
28899
+ token: Schema$1.String,
28900
+ syncMode: Schema$1.optional(Schema$1.Literals(["auto", "paused"])),
28901
+ shareMode: Schema$1.optional(Schema$1.Literals(["all", "selected"]))
28902
+ }));
28903
+ const decodeStoredHubLink = Schema$1.decodeUnknownExit(StoredHubLink);
28904
+ const encodeStoredHubLink = Schema$1.encodeSync(StoredHubLink);
28905
+ /**
28906
+ * Parse a stored record, tolerating anything that is not the shape we wrote.
28907
+ *
28908
+ * A corrupt secret disables the sync rather than failing the server's start.
28909
+ * Losing sync is recoverable by reconnecting; refusing to boot over it is not
28910
+ * proportionate to what has gone wrong.
28911
+ */
28912
+ const parseHubLink = (raw) => {
28913
+ const decoded = decodeStoredHubLink(raw);
28914
+ if (decoded._tag !== "Success") return {
28915
+ settings: Option.none(),
28916
+ syncMode: "auto",
28917
+ shareMode: "all"
28918
+ };
28919
+ return {
28920
+ settings: validateHubLink(decoded.value.baseUrl, decoded.value.token),
28921
+ syncMode: decoded.value.syncMode ?? "auto",
28922
+ shareMode: decoded.value.shareMode ?? "all"
28923
+ };
28924
+ };
28925
+ const formatHubLink = (input) => encodeStoredHubLink({
28926
+ baseUrl: Option.match(input.settings, {
28927
+ onNone: () => "",
28928
+ onSome: (settings) => trimTrailingSlash(settings.baseUrl)
28929
+ }),
28930
+ token: Option.match(input.settings, {
28931
+ onNone: () => "",
28932
+ onSome: (s) => s.token
28933
+ }),
28934
+ syncMode: input.syncMode,
28935
+ shareMode: input.shareMode
28936
+ });
28937
+ const fromEnvironment = (environment) => validateHubLink(environment.P4CODE_HUB_URL ?? "", environment.P4CODE_HUB_TOKEN ?? "");
28938
+ const make$113 = Effect.fn("HubLink.make")(function* (environment) {
28939
+ const secrets = yield* ServerSecretStore;
28940
+ const env = environment ?? process.env;
28941
+ const fromEnv = fromEnvironment(env);
28942
+ const readStored = Effect.fn("HubLink.readStored")(function* () {
28943
+ const stored = yield* secrets.get(HUB_LINK_SECRET_NAME).pipe(Effect.orElseSucceed(() => Option.none()));
28944
+ return Option.match(stored, {
28945
+ onNone: () => ({
28946
+ settings: Option.none(),
28947
+ syncMode: "auto",
28948
+ shareMode: "all"
28949
+ }),
28950
+ onSome: (bytes) => parseHubLink(new TextDecoder().decode(bytes))
28951
+ });
28952
+ });
28953
+ const toState = (record) => Option.isSome(fromEnv) ? {
28954
+ settings: fromEnv,
28955
+ syncMode: record.syncMode,
28956
+ shareMode: record.shareMode,
28957
+ source: "environment"
28958
+ } : {
28959
+ settings: record.settings,
28960
+ syncMode: record.syncMode,
28961
+ shareMode: record.shareMode,
28962
+ source: Option.isSome(record.settings) ? "stored" : "none"
28963
+ };
28964
+ const cache = yield* Ref.make(yield* readStored());
28965
+ const updates = yield* PubSub.sliding({
28966
+ capacity: 1,
28967
+ replay: 1
28968
+ });
28969
+ yield* PubSub.publish(updates, toState(yield* Ref.get(cache)));
28970
+ const persist = Effect.fn("HubLink.persist")(function* (record) {
28971
+ yield* secrets.set(HUB_LINK_SECRET_NAME, new TextEncoder().encode(formatHubLink(record)));
28972
+ yield* Ref.set(cache, record);
28973
+ yield* PubSub.publish(updates, toState(record));
28974
+ return toState(record);
28975
+ });
28976
+ return {
28977
+ current: Ref.get(cache).pipe(Effect.map(toState)),
28978
+ changes: Stream.fromPubSub(updates),
28979
+ connect: (settings) => Effect.gen(function* () {
28980
+ const validated = validateHubLink(settings.baseUrl, settings.token);
28981
+ if (Option.isNone(validated)) return yield* new InvalidHubLink({ detail: "A hub needs an http or https URL and a non-empty token." });
28982
+ const record = yield* Ref.get(cache);
28983
+ return yield* persist({
28984
+ ...record,
28985
+ settings: validated
28986
+ });
28987
+ }),
28988
+ disconnect: Effect.gen(function* () {
28989
+ const record = yield* Ref.get(cache);
28990
+ return yield* persist({
28991
+ ...record,
28992
+ settings: Option.none()
28993
+ });
28994
+ }),
28995
+ setSyncMode: (mode) => Effect.gen(function* () {
28996
+ const record = yield* Ref.get(cache);
28997
+ return yield* persist({
28998
+ ...record,
28999
+ syncMode: mode
29000
+ });
29001
+ }),
29002
+ setShareMode: (mode) => Effect.gen(function* () {
29003
+ const record = yield* Ref.get(cache);
29004
+ return yield* persist({
29005
+ ...record,
29006
+ shareMode: mode
29007
+ });
29008
+ })
29009
+ };
29010
+ });
29011
+ const layer$95 = Layer.effect(HubLink, make$113());
29012
+ //#endregion
29013
+ //#region src/persistence/Layers/RemoteTasks.ts
29014
+ /**
29015
+ * The hub's task API, as a client.
29016
+ *
29017
+ * This used to be a `TaskRepository` in its own right, and the board was
29018
+ * either this or the local database. It is now the transport underneath
29019
+ * `SyncedTaskRepository`: local SQLite is a replica of the hub, so nothing
29020
+ * reads from here directly and the board no longer goes dark when the hub is
29021
+ * unreachable. What changed with it is the failure story — an unreachable hub
29022
+ * used to mean no board, and now means a board that is behind.
29023
+ *
29024
+ * The four calls here are what a mirror needs and no more: pull what changed
29025
+ * on the hub, push a whole row, arbitrate a claim, and delete. There is no partial
29026
+ * patch, because a mirror always holds the full row it wants the hub to have
29027
+ * and sending a diff of it would only add a way for the two to disagree.
29028
+ *
29029
+ * @module persistence/Layers/RemoteTasks
29030
+ */
29031
+ const HUB_REQUEST_TIMEOUT_MS$1 = 15e3;
29032
+ /**
29033
+ * How often the hub writes to an idle `/v1/task-changes` stream. The client
29034
+ * treats two missed beats as a dead connection. Under ten seconds because Bun
29035
+ * closes a connection idle for ten, and proxies in front of a hosted hub are
29036
+ * rarely more patient.
29037
+ */
29038
+ const HUB_TASK_CHANGES_HEARTBEAT_MS = 8e3;
29039
+ /**
29040
+ * `cursor`, `deletedTaskIds` and `full` are absent from a hub that predates
29041
+ * cursors, which answers every pull with the whole board.
29042
+ */
29043
+ const TaskListPage = Schema$1.Struct({
29044
+ tasks: Schema$1.Array(Task),
29045
+ deletedTaskIds: Schema$1.optional(Schema$1.Array(TaskId)),
29046
+ cursor: Schema$1.optional(Schema$1.String),
29047
+ full: Schema$1.optional(Schema$1.Boolean)
29048
+ });
29049
+ const ConflictBody$1 = Schema$1.Struct({ current: Task });
29050
+ const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
29051
+ const decodeTask = Schema$1.decodeUnknownEffect(Task);
29052
+ const decodeConflictBody$1 = Schema$1.decodeUnknownEffect(ConflictBody$1);
29053
+ /**
29054
+ * Every failure here is reported as the persistence error type.
29055
+ *
29056
+ * The mirror catches these and carries on — a failed push leaves the row
29057
+ * `pending` and the next pass retries it — so what matters is that the detail
29058
+ * says the hub was the store that would not answer, which is what shows up in
29059
+ * the log when someone asks why their two machines disagree.
29060
+ */
29061
+ const hubUnavailable = (operation, detail) => new PersistenceSqlError({
29062
+ operation: `HubTaskClient.${operation}`,
29063
+ detail: `The hub board is unavailable: ${detail}`,
29064
+ cause: null
29065
+ });
29066
+ var HubTaskClient = class extends Context.Service()("@p4code/cli/persistence/Layers/RemoteTasks/HubTaskClient") {};
29067
+ const makeHubTaskClient = Effect.gen(function* () {
29068
+ const http = yield* HttpClient.HttpClient;
29069
+ const link = yield* HubLink;
29070
+ const isLinked = link.current.pipe(Effect.map((state) => Option.isSome(state.settings)));
29071
+ const requireSettings = (operation) => Effect.gen(function* () {
29072
+ const state = yield* link.current;
29073
+ if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
29074
+ return state.settings.value;
29075
+ });
29076
+ const send = (operation, settings, request) => http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS$1), Effect.mapError((cause) => hubUnavailable(operation, `${cause._tag ?? "request failed"}`)));
29077
+ const pull = (from) => Effect.gen(function* () {
29078
+ const settings = yield* requireSettings("pull");
29079
+ const resume = from?.baseUrl === settings.baseUrl ? from : void 0;
29080
+ const request = HttpClientRequest.get(`${settings.baseUrl}/tasks${resume === void 0 ? "" : `?since=${encodeURIComponent(resume.cursor)}`}`);
29081
+ const response = yield* send("pull", settings, resume?.etag === void 0 ? request : HttpClientRequest.setHeader(request, "if-none-match", resume.etag));
29082
+ if (response.status === 304) return Option.none();
29083
+ if (response.status !== 200) return yield* hubUnavailable("pull", `status ${response.status}`);
29084
+ const body = yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("pull", "the response did not match the contract")));
29085
+ return Option.some({
29086
+ full: body.full ?? true,
29087
+ tasks: body.tasks,
29088
+ deletedTaskIds: body.deletedTaskIds ?? [],
29089
+ next: body.cursor === void 0 ? void 0 : {
29090
+ baseUrl: settings.baseUrl,
29091
+ cursor: body.cursor,
29092
+ etag: response.headers.etag
29093
+ }
29094
+ });
29095
+ });
29096
+ const watch = Stream.unwrap(Effect.gen(function* () {
29097
+ const settings = yield* requireSettings("watch");
29098
+ const response = yield* http.execute(HttpClientRequest.get(`${settings.baseUrl}/v1/task-changes`).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`))).pipe(Effect.mapError((cause) => hubUnavailable("watch", `${cause._tag ?? "request failed"}`)));
29099
+ if (response.status !== 200) return yield* hubUnavailable("watch", `status ${response.status}`);
29100
+ const relinked = link.changes.pipe(Stream.filter((state) => Option.isNone(state.settings) || state.settings.value.baseUrl !== settings.baseUrl || state.settings.value.token !== settings.token), Stream.runHead);
29101
+ return response.stream.pipe(Stream.mapError(() => hubUnavailable("watch", "the change stream failed")), Stream.decodeText, Stream.splitLines, Stream.timeout(HUB_TASK_CHANGES_HEARTBEAT_MS * 2), Stream.filter((line) => line.includes("\"ready\"") || line.includes("\"changed\"")), Stream.map(() => void 0), Stream.interruptWhen(relinked));
29102
+ }));
29103
+ const put = (row, readableIdPrefix) => Effect.gen(function* () {
29104
+ const settings = yield* requireSettings("put");
29105
+ const response = yield* send("put", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.put(`${settings.baseUrl}/tasks/${encodeURIComponent(row.taskId)}`), {
29106
+ projectId: row.projectId,
29107
+ repositoryKey: row.repositoryKey,
29108
+ threadId: row.threadId,
29109
+ parentTaskId: row.parentTaskId,
29110
+ title: row.title,
29111
+ body: row.body,
29112
+ status: row.status,
29113
+ priority: row.priority,
29114
+ assignee: row.assignee,
29115
+ labels: row.labels,
29116
+ orderKey: row.orderKey,
29117
+ createdAt: row.createdAt,
29118
+ statusChangedAt: row.statusChangedAt ?? row.updatedAt,
29119
+ readableIdPrefix
29120
+ }));
29121
+ if (response.status !== 200 && response.status !== 201) return yield* hubUnavailable("put", `status ${response.status}`);
29122
+ return yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("put", "the response did not match the contract")));
29123
+ });
29124
+ const patchIfStatus = (input) => Effect.gen(function* () {
29125
+ const settings = yield* requireSettings("patchIfStatus");
29126
+ const { taskId, ...fields } = input.patch;
29127
+ const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
29128
+ ...fields,
29129
+ expectedStatus: input.expectedStatus
29130
+ }));
29131
+ if (response.status === 404) return { outcome: "missing" };
29132
+ if (response.status === 409) return {
29133
+ outcome: "conflict",
29134
+ current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody$1), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
29135
+ };
29136
+ if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
29137
+ return {
29138
+ outcome: "updated",
29139
+ task: yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")))
29140
+ };
29141
+ });
29142
+ const remove = (taskId) => Effect.gen(function* () {
29143
+ const settings = yield* requireSettings("remove");
29144
+ const response = yield* send("remove", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
29145
+ if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("remove", `status ${response.status}`);
29146
+ });
29147
+ return {
29148
+ isLinked,
29149
+ pull,
29150
+ watch,
29151
+ put,
29152
+ patchIfStatus,
29153
+ remove
29154
+ };
29155
+ });
29156
+ Layer.effect(HubTaskClient, makeHubTaskClient);
29157
+ //#endregion
28798
29158
  //#region src/hub/HubHttpServer.ts
28799
29159
  /**
28800
29160
  * The hub's HTTP surface.
@@ -28857,10 +29217,66 @@ const identityRoute = HttpRouter.add("GET", "/v1/identity", respondToHubFailures
28857
29217
  environmentId: principal.environmentId
28858
29218
  }, { headers: { "cache-control": "no-store" } });
28859
29219
  })));
29220
+ /**
29221
+ * Minted per process, not per database: a database restored from a backup
29222
+ * carries its old sequence numbers, and a cursor past the restore point would
29223
+ * otherwise skip every write it lost. The cost is one full pull per server
29224
+ * after each hub restart.
29225
+ */
29226
+ const TASK_CURSOR_EPOCH = NodeCrypto.randomBytes(8).toString("hex");
29227
+ /**
29228
+ * The board, or what changed on it since a cursor.
29229
+ *
29230
+ * `?since=<cursor>` returns rows changed after that cursor plus the ids
29231
+ * deleted since, so a server polling an idle board downloads almost nothing.
29232
+ * An unfiltered list also carries the cursor to start from. A cursor this
29233
+ * process never issued gets the whole board back with `full: true`. Cursors
29234
+ * are `<epoch>-<seq>`; callers treat them as opaque.
29235
+ */
29236
+ const readTaskPage = Effect.fn("HubHttpServer.readTaskPage")(function* (filter, since) {
29237
+ const tasks = yield* TaskRepository;
29238
+ if (since === void 0 && Object.keys(filter).length > 0) return { tasks: yield* tasks.list(filter) };
29239
+ const sql = yield* SqlClient.SqlClient;
29240
+ const [head] = yield* sql`SELECT COALESCE(MAX(seq), 0) AS seq FROM task_change_log`;
29241
+ const seq = head?.seq ?? 0;
29242
+ const epoch = TASK_CURSOR_EPOCH;
29243
+ const cursor = `${epoch}-${seq}`;
29244
+ const [sinceEpoch, sinceSeqText] = since?.split("-") ?? [];
29245
+ const sinceSeq = Number(sinceSeqText);
29246
+ if (since === void 0 || sinceEpoch !== epoch || sinceSeq > seq) return {
29247
+ tasks: yield* tasks.list({}),
29248
+ deletedTaskIds: [],
29249
+ cursor,
29250
+ full: true
29251
+ };
29252
+ const changes = yield* sql`
29253
+ SELECT task_id AS "taskId", deleted FROM task_change_log
29254
+ WHERE seq > ${sinceSeq} AND seq <= ${seq}
29255
+ ORDER BY seq
29256
+ `;
29257
+ const changed = [];
29258
+ const deletedTaskIds = [];
29259
+ for (const change of changes) {
29260
+ if (change.deleted === 1) {
29261
+ deletedTaskIds.push(change.taskId);
29262
+ continue;
29263
+ }
29264
+ const task = yield* tasks.getById({ taskId: change.taskId });
29265
+ if (Option.isSome(task)) changed.push(task.value);
29266
+ }
29267
+ return {
29268
+ tasks: changed,
29269
+ deletedTaskIds,
29270
+ cursor,
29271
+ full: false
29272
+ };
29273
+ });
29274
+ /** Weak, because gzip may re-encode the same representation on the way out. */
29275
+ const taskPageEtag = (body) => `W/"${NodeCrypto.createHash("sha256").update(body).digest("base64url")}"`;
29276
+ const matchesEtag = (ifNoneMatch, etag) => ifNoneMatch !== void 0 && ifNoneMatch.split(",").map((candidate) => candidate.trim().replace(/^W\//, "")).some((candidate) => candidate === "*" || candidate === etag.replace(/^W\//, ""));
28860
29277
  const listTasksRoute = HttpRouter.add("GET", "/tasks", respondToHubFailures(Effect.gen(function* () {
28861
29278
  yield* authenticateHubRequest();
28862
29279
  const request = yield* HttpServerRequest.HttpServerRequest;
28863
- const tasks = yield* TaskRepository;
28864
29280
  const url = new URL(request.url, "http://hub.invalid");
28865
29281
  const filter = yield* decodeOrInvalid(TaskListFilter, "Query string does not match TaskListFilter.")(Object.fromEntries([
28866
29282
  "projectId",
@@ -28869,8 +29285,39 @@ const listTasksRoute = HttpRouter.add("GET", "/tasks", respondToHubFailures(Effe
28869
29285
  "assignee",
28870
29286
  "query"
28871
29287
  ].map((key) => [key, url.searchParams.get(key)]).filter(([, value]) => value !== null)));
28872
- const listed = yield* tasks.list(filter);
28873
- return HttpServerResponse.jsonUnsafe({ tasks: listed });
29288
+ const sinceParam = url.searchParams.get("since");
29289
+ if (sinceParam !== null && (!/^[0-9a-f]{1,32}-\d{1,15}$/.test(sinceParam) || Object.keys(filter).length > 0)) return yield* new InvalidHubRequest({ detail: "`since` must be a cursor from an earlier page and cannot be combined with filters." });
29290
+ const page = yield* readTaskPage(filter, sinceParam ?? void 0);
29291
+ const body = JSON.stringify(page);
29292
+ const etag = taskPageEtag(body);
29293
+ if (matchesEtag(request.headers["if-none-match"], etag)) return HttpServerResponse.empty({
29294
+ status: 304,
29295
+ headers: { etag }
29296
+ });
29297
+ return HttpServerResponse.text(body, {
29298
+ contentType: "application/json",
29299
+ headers: { etag }
29300
+ });
29301
+ })));
29302
+ const taskChangeLine = (type) => new TextEncoder().encode(`{"type":"${type}"}\n`);
29303
+ /**
29304
+ * A nudge per board write, so linked servers pull when something changed
29305
+ * instead of on a timer.
29306
+ *
29307
+ * Carries no rows: the server answers a nudge with the same cursor pull it
29308
+ * always makes, so a nudge that is lost or duplicated costs nothing but
29309
+ * latency. `ready` doubles as a nudge, covering whatever changed while the
29310
+ * server was not listening.
29311
+ */
29312
+ const taskChangesRoute = HttpRouter.add("GET", "/v1/task-changes", respondToHubFailures(Effect.gen(function* () {
29313
+ yield* authenticateHubRequest();
29314
+ const tasks = yield* TaskRepository;
29315
+ const events = Stream.merge(tasks.streamWithSnapshot.pipe(Stream.map((event) => taskChangeLine(event.type === "snapshot" ? "ready" : "changed"))), Stream.tick(HUB_TASK_CHANGES_HEARTBEAT_MS).pipe(Stream.drop(1), Stream.map(() => taskChangeLine("heartbeat"))));
29316
+ return HttpServerResponse.stream(events, { headers: {
29317
+ "content-type": "application/x-ndjson",
29318
+ "cache-control": "no-store",
29319
+ "x-accel-buffering": "no"
29320
+ } });
28874
29321
  })));
28875
29322
  const getTaskRoute = HttpRouter.add("GET", "/tasks/:taskId", respondToHubFailures(Effect.gen(function* () {
28876
29323
  yield* authenticateHubRequest();
@@ -29292,7 +29739,7 @@ const releaseFeedLeaseRoute = HttpRouter.add("DELETE", "/feed/lease", respondToH
29292
29739
  })));
29293
29740
  /** Provide around HttpRouter.serve so request fibers share these process-owned services. */
29294
29741
  const routingServices = Layer.mergeAll(layer$113, layer$111, layer$102, layer$101).pipe(Layer.provideMerge(layer$112));
29295
- const layer$95 = Layer.mergeAll(layer$100, layer$99, layer$103, layer$97, layer$96, layer$110, layer$98, healthRoute, identityRoute, listTasksRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, putFeedEngagementRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, renewFeedLeaseRoute, releaseFeedLeaseRoute).pipe(Layer.provideMerge(layer$113), Layer.provideMerge(layer$111), Layer.provideMerge(layer$102));
29742
+ const layer$94 = Layer.mergeAll(layer$100, layer$99, layer$103, layer$97, layer$96, layer$110, layer$98, healthRoute, identityRoute, listTasksRoute, taskChangesRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, putFeedEngagementRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, renewFeedLeaseRoute, releaseFeedLeaseRoute).pipe(Layer.provideMerge(layer$113), Layer.provideMerge(layer$111), Layer.provideMerge(layer$102));
29296
29743
  //#endregion
29297
29744
  //#region src/hub/Migrations/001_Tasks.ts
29298
29745
  /**
@@ -29789,6 +30236,57 @@ var _022_AnalyticsFacts_default = Effect.gen(function* () {
29789
30236
  ON hub_analytics_facts(account_id, kind, occurred_at DESC, event_id DESC)`;
29790
30237
  });
29791
30238
  //#endregion
30239
+ //#region src/hub/Migrations/023_TaskChangeLog.ts
30240
+ /**
30241
+ * One row per task that has ever existed, stamped with the order of its last
30242
+ * change, so a server can ask for "everything since cursor N" instead of the
30243
+ * whole board every poll.
30244
+ *
30245
+ * The sequence is the hub's, not `updated_at`: that timestamp is written by
30246
+ * whichever machine made the edit, and a machine with a slow clock would land
30247
+ * behind a cursor that already moved past it. `AUTOINCREMENT` never reuses a
30248
+ * value, and SQLite serializes writers, so a reader never sees N+1 before N.
30249
+ *
30250
+ * Triggers rather than repository code, because the hub shares
30251
+ * `TaskRepository` with every environment server and only the hub needs this.
30252
+ * A delete keeps its row with `deleted = 1`, which is how another machine
30253
+ * learns the task is gone. The triggers delete then insert rather than
30254
+ * `INSERT OR REPLACE`, because the repository writes with an upsert and its
30255
+ * conflict policy overrides the trigger's, failing the whole write.
30256
+ */
30257
+ var _023_TaskChangeLog_default = Effect.gen(function* () {
30258
+ const sql = yield* SqlClient.SqlClient;
30259
+ yield* sql`
30260
+ CREATE TABLE task_change_log (
30261
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
30262
+ task_id TEXT NOT NULL UNIQUE,
30263
+ deleted INTEGER NOT NULL DEFAULT 0
30264
+ )
30265
+ `;
30266
+ yield* sql`
30267
+ INSERT INTO task_change_log (task_id)
30268
+ SELECT task_id FROM tasks ORDER BY updated_at, task_id
30269
+ `;
30270
+ yield* sql`
30271
+ CREATE TRIGGER task_change_log_insert AFTER INSERT ON tasks BEGIN
30272
+ DELETE FROM task_change_log WHERE task_id = NEW.task_id;
30273
+ INSERT INTO task_change_log (task_id, deleted) VALUES (NEW.task_id, 0);
30274
+ END
30275
+ `;
30276
+ yield* sql`
30277
+ CREATE TRIGGER task_change_log_update AFTER UPDATE ON tasks BEGIN
30278
+ DELETE FROM task_change_log WHERE task_id = NEW.task_id;
30279
+ INSERT INTO task_change_log (task_id, deleted) VALUES (NEW.task_id, 0);
30280
+ END
30281
+ `;
30282
+ yield* sql`
30283
+ CREATE TRIGGER task_change_log_delete AFTER DELETE ON tasks BEGIN
30284
+ DELETE FROM task_change_log WHERE task_id = OLD.task_id;
30285
+ INSERT INTO task_change_log (task_id, deleted) VALUES (OLD.task_id, 1);
30286
+ END
30287
+ `;
30288
+ });
30289
+ //#endregion
29792
30290
  //#region src/hub/Migrations.ts
29793
30291
  /**
29794
30292
  * Hub migrations.
@@ -29909,6 +30407,11 @@ const hubMigrationEntries = [
29909
30407
  22,
29910
30408
  "AnalyticsFacts",
29911
30409
  _022_AnalyticsFacts_default
30410
+ ],
30411
+ [
30412
+ 23,
30413
+ "TaskChangeLog",
30414
+ _023_TaskChangeLog_default
29912
30415
  ]
29913
30416
  ];
29914
30417
  const hubMigrationLoader = Migrator.fromRecord(Object.fromEntries(hubMigrationEntries.map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -30000,7 +30503,7 @@ const announce = Layer.effectDiscard(Effect.gen(function* () {
30000
30503
  });
30001
30504
  if (isRemoteReachableHost(config.host)) yield* Effect.logWarning("Hub is reachable beyond loopback. The bearer token is the only control — repeated auth failures are not rate limited.", { host: config.host });
30002
30505
  }));
30003
- const makeHubLayer = HttpRouter.serve(Layer.mergeAll(layer$95, layer$107, layer$109)).pipe(Layer.provideMerge(routingServices), Layer.provideMerge(layer$105), Layer.provideMerge(layer$106), Layer.provideMerge(layer$104), Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(layer$115), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
30506
+ const makeHubLayer = HttpRouter.serve(Layer.mergeAll(layer$94, layer$107, layer$109).pipe(Layer.provide(httpCompressionLayer))).pipe(Layer.provideMerge(typeof Bun !== "undefined" ? layerBun : layerNode), Layer.provideMerge(routingServices), Layer.provideMerge(layer$105), Layer.provideMerge(layer$106), Layer.provideMerge(layer$104), Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(layer$115), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
30004
30507
  const runHub = Layer.launch(makeHubLayer);
30005
30508
  //#endregion
30006
30509
  //#region src/atomicWrite.ts
@@ -30157,7 +30660,7 @@ function stripDefaultServerSettings(current, defaults) {
30157
30660
  }
30158
30661
  return Object.is(current, defaults) ? void 0 : current;
30159
30662
  }
30160
- const make$113 = Effect.gen(function* () {
30663
+ const make$112 = Effect.gen(function* () {
30161
30664
  const { settingsPath } = yield* ServerConfig$1;
30162
30665
  const fs = yield* FileSystem.FileSystem;
30163
30666
  const pathService = yield* Path$1.Path;
@@ -30380,7 +30883,7 @@ const make$113 = Effect.gen(function* () {
30380
30883
  }
30381
30884
  };
30382
30885
  });
30383
- const layer$94 = Layer.effect(ServerSettingsService, make$113);
30886
+ const layer$93 = Layer.effect(ServerSettingsService, make$112);
30384
30887
  //#endregion
30385
30888
  //#region src/provider/skillWorkspace.ts
30386
30889
  /** Ancestors inside the nearest Git checkout, ordered from root to cwd. */
@@ -31124,7 +31627,7 @@ function entriesOf(config) {
31124
31627
  return found;
31125
31628
  }
31126
31629
  var ClaudeMcpFiles = class extends Context.Service()("@p4code/cli/mcp/ClaudeMcpFiles") {};
31127
- const make$112 = Effect.gen(function* () {
31630
+ const make$111 = Effect.gen(function* () {
31128
31631
  const fileSystem = yield* FileSystem.FileSystem;
31129
31632
  const path = yield* Path$1.Path;
31130
31633
  const services = yield* Effect.context();
@@ -31195,7 +31698,7 @@ const make$112 = Effect.gen(function* () {
31195
31698
  removeProject: (projectDir, name) => removeAt(Effect.succeed(projectFile(projectDir)))(name)
31196
31699
  };
31197
31700
  });
31198
- const layer$93 = Layer.effect(ClaudeMcpFiles, make$112);
31701
+ const layer$92 = Layer.effect(ClaudeMcpFiles, make$111);
31199
31702
  Layer.succeed(ClaudeMcpFiles, {
31200
31703
  readUser: Effect.succeed([]),
31201
31704
  readUserStrict: Effect.succeed([]),
@@ -31369,7 +31872,7 @@ const decodeClientRegistration = Schema$1.decodeUnknownExit(ClientRegistrationRe
31369
31872
  const decodeTokenResponse = Schema$1.decodeUnknownExit(TokenResponse);
31370
31873
  var McpOAuth = class extends Context.Service()("@p4code/cli/mcp/McpOAuth") {};
31371
31874
  const registryError = (detail) => new McpRegistryError({ detail });
31372
- const make$111 = Effect.gen(function* () {
31875
+ const make$110 = Effect.gen(function* () {
31373
31876
  const config = yield* ServerConfig$1;
31374
31877
  const secrets = yield* ServerSecretStore;
31375
31878
  const http = yield* HttpClient.HttpClient;
@@ -31774,7 +32277,7 @@ const make$111 = Effect.gen(function* () {
31774
32277
  grantIdentityFor
31775
32278
  };
31776
32279
  });
31777
- const layer$92 = Layer.effect(McpOAuth, make$111);
32280
+ const layer$91 = Layer.effect(McpOAuth, make$110);
31778
32281
  Layer.succeed(McpOAuth, {
31779
32282
  statusFor: () => Effect.succeed(Option.none()),
31780
32283
  begin: () => Effect.fail(new McpRegistryError({ detail: "OAuth sign-in is not available." })),
@@ -31829,7 +32332,7 @@ const slotsOf = (registration) => registration.secrets ?? [];
31829
32332
  */
31830
32333
  const OMITTED_FOR_SIGN_IN = "mcp server omitted: sign-in required";
31831
32334
  const SIGN_IN_ACTION = "Connect it in Settings > MCP servers.";
31832
- const make$110 = Effect.gen(function* () {
32335
+ const make$109 = Effect.gen(function* () {
31833
32336
  const config = yield* ServerConfig$1;
31834
32337
  const secrets = yield* ServerSecretStore;
31835
32338
  const oauth = yield* McpOAuth;
@@ -32109,7 +32612,7 @@ const make$110 = Effect.gen(function* () {
32109
32612
  resolveForSessionAtClaudeUserConfigPath
32110
32613
  };
32111
32614
  });
32112
- const layer$91 = Layer.effect(McpRegistry, make$110);
32615
+ const layer$90 = Layer.effect(McpRegistry, make$109);
32113
32616
  //#endregion
32114
32617
  //#region src/sync/skillDirectory.ts
32115
32618
  /**
@@ -32393,176 +32896,6 @@ const removeAgentDefinition = Effect.fn("agentDefinitionFiles.remove")(function*
32393
32896
  yield* fileSystem.remove(path.join(input.agentsRoot, input.name), { force: true });
32394
32897
  });
32395
32898
  //#endregion
32396
- //#region src/sync/HubLink.ts
32397
- /**
32398
- * Where this server's hub is, what it authenticates with, and whether it syncs
32399
- * on its own.
32400
- *
32401
- * Absent by default. A server with no hub configured runs exactly as it does
32402
- * today — the sync is skipped rather than failing, because "no hub" is a
32403
- * complete and normal configuration, not a broken one.
32404
- *
32405
- * The record lives in `ServerSecretStore`, which already writes with
32406
- * restrictive permissions, rather than in the settings file or the database.
32407
- * The sync mode rides along in that record even though it is not a secret:
32408
- * connect, disconnect and pause are then one write and one read, and the
32409
- * settings panel reads one thing instead of joining two. Environment variables
32410
- * override the link so a container or a service unit can be configured without
32411
- * a first-run interactive step — the same trade the hub process itself makes
32412
- * with `P4CODE_HUB_TOKEN_HASHES` — while the mode always comes from the stored
32413
- * record, so a server configured by environment can still be paused.
32414
- *
32415
- * Mutable, deliberately. Resolving this once at layer construction would mean
32416
- * connecting to a hub did nothing until the server restarted, which is a
32417
- * miserable thing to discover after typing a token into a form.
32418
- *
32419
- * @module sync/HubLink
32420
- */
32421
- /** Secret name holding the hub record as JSON. */
32422
- const HUB_LINK_SECRET_NAME = "hub-link";
32423
- var HubLink = class extends Context.Service()("@p4code/cli/sync/HubLink") {};
32424
- /** The submitted URL or token could not make a usable link. */
32425
- var InvalidHubLink = class extends Schema$1.TaggedErrorClass()("InvalidHubLink", { detail: Schema$1.String }) {
32426
- get message() {
32427
- return this.detail;
32428
- }
32429
- };
32430
- const trimTrailingSlash = (value) => value.replace(/\/+$/u, "");
32431
- /**
32432
- * Both halves must be present and the URL parseable. A half-configured link is
32433
- * treated as no link at all, because a server that thinks it has a hub and
32434
- * cannot form a request would report every sync as a failure rather than as the
32435
- * "not configured" it actually is.
32436
- */
32437
- const validateHubLink = (rawBaseUrl, rawToken) => {
32438
- const trimmed = trimTrailingSlash(rawBaseUrl.trim());
32439
- const token = rawToken.trim();
32440
- if (trimmed.length === 0 || token.length === 0) return Option.none();
32441
- const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;
32442
- if (!URL.canParse(baseUrl)) return Option.none();
32443
- const parsed = new URL(baseUrl);
32444
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return Option.none();
32445
- return Option.some({
32446
- baseUrl,
32447
- token
32448
- });
32449
- };
32450
- const StoredHubLink = Schema$1.fromJsonString(Schema$1.Struct({
32451
- baseUrl: Schema$1.String,
32452
- token: Schema$1.String,
32453
- syncMode: Schema$1.optional(Schema$1.Literals(["auto", "paused"])),
32454
- shareMode: Schema$1.optional(Schema$1.Literals(["all", "selected"]))
32455
- }));
32456
- const decodeStoredHubLink = Schema$1.decodeUnknownExit(StoredHubLink);
32457
- const encodeStoredHubLink = Schema$1.encodeSync(StoredHubLink);
32458
- /**
32459
- * Parse a stored record, tolerating anything that is not the shape we wrote.
32460
- *
32461
- * A corrupt secret disables the sync rather than failing the server's start.
32462
- * Losing sync is recoverable by reconnecting; refusing to boot over it is not
32463
- * proportionate to what has gone wrong.
32464
- */
32465
- const parseHubLink = (raw) => {
32466
- const decoded = decodeStoredHubLink(raw);
32467
- if (decoded._tag !== "Success") return {
32468
- settings: Option.none(),
32469
- syncMode: "auto",
32470
- shareMode: "all"
32471
- };
32472
- return {
32473
- settings: validateHubLink(decoded.value.baseUrl, decoded.value.token),
32474
- syncMode: decoded.value.syncMode ?? "auto",
32475
- shareMode: decoded.value.shareMode ?? "all"
32476
- };
32477
- };
32478
- const formatHubLink = (input) => encodeStoredHubLink({
32479
- baseUrl: Option.match(input.settings, {
32480
- onNone: () => "",
32481
- onSome: (settings) => trimTrailingSlash(settings.baseUrl)
32482
- }),
32483
- token: Option.match(input.settings, {
32484
- onNone: () => "",
32485
- onSome: (s) => s.token
32486
- }),
32487
- syncMode: input.syncMode,
32488
- shareMode: input.shareMode
32489
- });
32490
- const fromEnvironment = (environment) => validateHubLink(environment.P4CODE_HUB_URL ?? "", environment.P4CODE_HUB_TOKEN ?? "");
32491
- const make$109 = Effect.fn("HubLink.make")(function* (environment) {
32492
- const secrets = yield* ServerSecretStore;
32493
- const env = environment ?? process.env;
32494
- const fromEnv = fromEnvironment(env);
32495
- const readStored = Effect.fn("HubLink.readStored")(function* () {
32496
- const stored = yield* secrets.get(HUB_LINK_SECRET_NAME).pipe(Effect.orElseSucceed(() => Option.none()));
32497
- return Option.match(stored, {
32498
- onNone: () => ({
32499
- settings: Option.none(),
32500
- syncMode: "auto",
32501
- shareMode: "all"
32502
- }),
32503
- onSome: (bytes) => parseHubLink(new TextDecoder().decode(bytes))
32504
- });
32505
- });
32506
- const toState = (record) => Option.isSome(fromEnv) ? {
32507
- settings: fromEnv,
32508
- syncMode: record.syncMode,
32509
- shareMode: record.shareMode,
32510
- source: "environment"
32511
- } : {
32512
- settings: record.settings,
32513
- syncMode: record.syncMode,
32514
- shareMode: record.shareMode,
32515
- source: Option.isSome(record.settings) ? "stored" : "none"
32516
- };
32517
- const cache = yield* Ref.make(yield* readStored());
32518
- const updates = yield* PubSub.sliding({
32519
- capacity: 1,
32520
- replay: 1
32521
- });
32522
- yield* PubSub.publish(updates, toState(yield* Ref.get(cache)));
32523
- const persist = Effect.fn("HubLink.persist")(function* (record) {
32524
- yield* secrets.set(HUB_LINK_SECRET_NAME, new TextEncoder().encode(formatHubLink(record)));
32525
- yield* Ref.set(cache, record);
32526
- yield* PubSub.publish(updates, toState(record));
32527
- return toState(record);
32528
- });
32529
- return {
32530
- current: Ref.get(cache).pipe(Effect.map(toState)),
32531
- changes: Stream.fromPubSub(updates),
32532
- connect: (settings) => Effect.gen(function* () {
32533
- const validated = validateHubLink(settings.baseUrl, settings.token);
32534
- if (Option.isNone(validated)) return yield* new InvalidHubLink({ detail: "A hub needs an http or https URL and a non-empty token." });
32535
- const record = yield* Ref.get(cache);
32536
- return yield* persist({
32537
- ...record,
32538
- settings: validated
32539
- });
32540
- }),
32541
- disconnect: Effect.gen(function* () {
32542
- const record = yield* Ref.get(cache);
32543
- return yield* persist({
32544
- ...record,
32545
- settings: Option.none()
32546
- });
32547
- }),
32548
- setSyncMode: (mode) => Effect.gen(function* () {
32549
- const record = yield* Ref.get(cache);
32550
- return yield* persist({
32551
- ...record,
32552
- syncMode: mode
32553
- });
32554
- }),
32555
- setShareMode: (mode) => Effect.gen(function* () {
32556
- const record = yield* Ref.get(cache);
32557
- return yield* persist({
32558
- ...record,
32559
- shareMode: mode
32560
- });
32561
- })
32562
- };
32563
- });
32564
- const layer$90 = Layer.effect(HubLink, make$109());
32565
- //#endregion
32566
32899
  //#region src/sync/HubAssetClient.ts
32567
32900
  /**
32568
32901
  * The server's half of the hub's asset API.
@@ -32576,7 +32909,7 @@ const layer$90 = Layer.effect(HubLink, make$109());
32576
32909
  *
32577
32910
  * @module sync/HubAssetClient
32578
32911
  */
32579
- const HUB_REQUEST_TIMEOUT_MS$1 = 15e3;
32912
+ const HUB_REQUEST_TIMEOUT_MS = 15e3;
32580
32913
  /**
32581
32914
  * The hub could not be reached, or answered something unusable.
32582
32915
  *
@@ -32589,9 +32922,9 @@ var HubAssetConflict = class extends Data.TaggedError("HubAssetConflict") {};
32589
32922
  /** No hub is configured on this server, so there is nothing to talk to. */
32590
32923
  var HubNotConfigured = class extends Data.TaggedError("HubNotConfigured") {};
32591
32924
  const AssetListPage = Schema$1.Struct({ assets: Schema$1.Array(AgentAsset) });
32592
- const ConflictBody$1 = Schema$1.Struct({ current: Schema$1.NullOr(AgentAsset) });
32925
+ const ConflictBody = Schema$1.Struct({ current: Schema$1.NullOr(AgentAsset) });
32593
32926
  const decodeAssetListPage = Schema$1.decodeUnknownEffect(AssetListPage);
32594
- const decodeConflictBody$1 = Schema$1.decodeUnknownEffect(ConflictBody$1);
32927
+ const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
32595
32928
  const decodeAsset = Schema$1.decodeUnknownEffect(AgentAsset);
32596
32929
  var HubAssetClient = class extends Context.Service()("@p4code/cli/sync/HubAssetClient") {};
32597
32930
  const make$108 = Effect.gen(function* () {
@@ -32603,7 +32936,7 @@ const make$108 = Effect.gen(function* () {
32603
32936
  return state.settings.value;
32604
32937
  });
32605
32938
  const send = Effect.fn("HubAssetClient.send")(function* (operation, settings, request) {
32606
- return yield* http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS$1), Effect.mapError((cause) => new HubUnavailable({
32939
+ return yield* http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS), Effect.mapError((cause) => new HubUnavailable({
32607
32940
  operation,
32608
32941
  detail: `${cause._tag ?? "error"}`
32609
32942
  })));
@@ -32623,7 +32956,7 @@ const make$108 = Effect.gen(function* () {
32623
32956
  * which is what lets a caller show both sides instead of only saying no.
32624
32957
  */
32625
32958
  const conflictFrom = Effect.fn("HubAssetClient.conflictFrom")(function* (name, response) {
32626
- const body = yield* decodeBody("conflict", decodeConflictBody$1, response).pipe(Effect.orElseSucceed(() => ({ current: null })));
32959
+ const body = yield* decodeBody("conflict", decodeConflictBody, response).pipe(Effect.orElseSucceed(() => ({ current: null })));
32627
32960
  return new HubAssetConflict({
32628
32961
  name,
32629
32962
  current: body.current === null ? Option.none() : Option.some(body.current)
@@ -34849,7 +35182,7 @@ const findAgentPreset = (requested) => {
34849
35182
  };
34850
35183
  //#endregion
34851
35184
  //#region src/cli/assets.ts
34852
- const SyncServicesLive = layer$88.pipe(Layer.provideMerge(layer$89), Layer.provideMerge(layer$90), Layer.provideMerge(layer$94.pipe(Layer.provide(layer$120))), Layer.provideMerge(layer$120), Layer.provideMerge(FetchHttpClient.layer));
35185
+ const SyncServicesLive = layer$88.pipe(Layer.provideMerge(layer$89), Layer.provideMerge(layer$95), Layer.provideMerge(layer$93.pipe(Layer.provide(layer$120))), Layer.provideMerge(layer$120), Layer.provideMerge(FetchHttpClient.layer));
34853
35186
  const runWithSync = (flags, run) => Effect.gen(function* () {
34854
35187
  const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel);
34855
35188
  return yield* Effect.gen(function* () {
@@ -58879,18 +59212,6 @@ const acceptBunDeviceSocket = Effect.fn("DeviceHubBunSocket.accept")(function* (
58879
59212
  }))), MAX_DEVICE_INPUT_BYTES, MAX_DEVICE_OUTPUT_BYTES);
58880
59213
  });
58881
59214
  //#endregion
58882
- //#region src/httpCompression/HttpResponseCompression.ts
58883
- var HttpResponseCompression = class extends Context.Service()("@p4code/cli/httpCompression/HttpResponseCompression") {};
58884
- const layerNode = Layer.effect(HttpResponseCompression, Effect.gen(function* () {
58885
- const NodeZlib = yield* Effect.promise(() => import("node:zlib"));
58886
- return { gzip: (body, options) => {
58887
- const stream = NodeZlib.createGzip();
58888
- stream.end(body);
58889
- return HttpServerResponse$1.raw(stream, options);
58890
- } };
58891
- }));
58892
- const layerBun = Layer.succeed(HttpResponseCompression, { gzip: (body, options) => HttpServerResponse$1.raw(new Response(body).body.pipeThrough(new CompressionStream("gzip")), options) });
58893
- //#endregion
58894
59215
  //#region ../../packages/shared/src/devProxy.ts
58895
59216
  /**
58896
59217
  * Backend paths the web dev server proxies in single-origin browser dev.
@@ -60562,7 +60883,6 @@ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
60562
60883
  "localhost"
60563
60884
  ]);
60564
60885
  const DESKTOP_RENDERER_ORIGINS = ["p4code://app", "p4code-dev://app"];
60565
- const GZIP_MIN_BYTES = 1024;
60566
60886
  const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
60567
60887
  function assetResponseHeaders(filePath) {
60568
60888
  return {
@@ -60571,36 +60891,6 @@ function assetResponseHeaders(filePath) {
60571
60891
  ...filePath.toLowerCase().endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}
60572
60892
  };
60573
60893
  }
60574
- function acceptsGzip(value) {
60575
- if (!value) return false;
60576
- const accepted = new Map(value.split(",").map((entry) => {
60577
- const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";");
60578
- const quality = parameters.map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]).find((parameter) => parameter !== void 0);
60579
- return [coding, quality === void 0 ? 1 : Number(quality)];
60580
- }));
60581
- return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0;
60582
- }
60583
- function varyByAcceptEncoding(value) {
60584
- if (!value) return "Accept-Encoding";
60585
- const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase()));
60586
- return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
60587
- }
60588
- const compressHttpResponse = Effect.fnUntraced(function* (response, acceptEncoding) {
60589
- const body = response.body;
60590
- if (body._tag !== "Uint8Array" || body.contentLength < GZIP_MIN_BYTES || !body.contentType.startsWith("application/json") || response.headers["content-encoding"]) return response;
60591
- const variedResponse = HttpServerResponse.setHeader(response, "vary", varyByAcceptEncoding(response.headers.vary));
60592
- if (!acceptsGzip(acceptEncoding)) return variedResponse;
60593
- const compression = yield* HttpResponseCompression;
60594
- const headers = Headers$1.set(Headers$1.remove(variedResponse.headers, "content-length"), "content-encoding", "gzip");
60595
- return compression.gzip(body.body, {
60596
- status: response.status,
60597
- statusText: response.statusText,
60598
- headers,
60599
- cookies: response.cookies,
60600
- contentType: body.contentType
60601
- });
60602
- });
60603
- const httpCompressionLayer = HttpRouter.middleware((httpEffect) => Effect.flatMap(Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]), ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"])), { global: true });
60604
60894
  const browserApiCorsLayer = Layer.unwrap(Effect.gen(function* () {
60605
60895
  const config = yield* ServerConfig$1;
60606
60896
  const devOrigin = config.devUrl?.origin;
@@ -109238,7 +109528,7 @@ const COMMON_DEV_PORTS = Object.freeze([
109238
109528
  8888,
109239
109529
  9e3
109240
109530
  ]);
109241
- const POLL_INTERVAL$2 = Duration.seconds(3);
109531
+ const POLL_INTERVAL$3 = Duration.seconds(3);
109242
109532
  const LSOF_TIMEOUT_MS = 5e3;
109243
109533
  const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
109244
109534
  const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
@@ -109413,7 +109703,7 @@ const make$61 = Effect.gen(function* PortDiscoveryMake() {
109413
109703
  lastSnapshot: next
109414
109704
  }])) yield* broadcast(next);
109415
109705
  }, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
109416
- yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$2))));
109706
+ yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$3))));
109417
109707
  const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
109418
109708
  if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
109419
109709
  ...state,
@@ -124922,7 +125212,7 @@ const LINEAR_ISSUE_FIELDS = [
124922
125212
  * a Linear workspace is edited by people at human speed rather than by agents
124923
125213
  * at machine speed.
124924
125214
  */
124925
- const POLL_INTERVAL$1 = Duration.seconds(30);
125215
+ const POLL_INTERVAL$2 = Duration.seconds(30);
124926
125216
  /** Linear's cap on one page. Asking for more is an error, not a bigger page. */
124927
125217
  const LINEAR_PAGE_LIMIT = 250;
124928
125218
  const LINEAR_LIST_TOOL = "list_issues";
@@ -125175,7 +125465,7 @@ const makeLinearTaskRepository = Effect.gen(function* () {
125175
125465
  taskId
125176
125466
  });
125177
125467
  }).pipe(Effect.ignoreCause({ log: true }));
125178
- yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$1)))).pipe(Effect.forkScoped);
125468
+ yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$2)))).pipe(Effect.forkScoped);
125179
125469
  return {
125180
125470
  create,
125181
125471
  upsert,
@@ -125199,127 +125489,6 @@ const makeLinearTaskRepository = Effect.gen(function* () {
125199
125489
  };
125200
125490
  });
125201
125491
  //#endregion
125202
- //#region src/persistence/Layers/RemoteTasks.ts
125203
- /**
125204
- * The hub's task API, as a client.
125205
- *
125206
- * This used to be a `TaskRepository` in its own right, and the board was
125207
- * either this or the local database. It is now the transport underneath
125208
- * `SyncedTaskRepository`: local SQLite is a replica of the hub, so nothing
125209
- * reads from here directly and the board no longer goes dark when the hub is
125210
- * unreachable. What changed with it is the failure story — an unreachable hub
125211
- * used to mean no board, and now means a board that is behind.
125212
- *
125213
- * The four calls here are what a mirror needs and no more: read the hub's
125214
- * rows, push a whole row, arbitrate a claim, and delete. There is no partial
125215
- * patch, because a mirror always holds the full row it wants the hub to have
125216
- * and sending a diff of it would only add a way for the two to disagree.
125217
- *
125218
- * @module persistence/Layers/RemoteTasks
125219
- */
125220
- const HUB_REQUEST_TIMEOUT_MS = 15e3;
125221
- const TaskListPage = Schema$1.Struct({ tasks: Schema$1.Array(Task) });
125222
- const ConflictBody = Schema$1.Struct({ current: Task });
125223
- const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
125224
- const decodeTask = Schema$1.decodeUnknownEffect(Task);
125225
- const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
125226
- /**
125227
- * Every failure here is reported as the persistence error type.
125228
- *
125229
- * The mirror catches these and carries on — a failed push leaves the row
125230
- * `pending` and the next pass retries it — so what matters is that the detail
125231
- * says the hub was the store that would not answer, which is what shows up in
125232
- * the log when someone asks why their two machines disagree.
125233
- */
125234
- const hubUnavailable = (operation, detail) => new PersistenceSqlError({
125235
- operation: `HubTaskClient.${operation}`,
125236
- detail: `The hub board is unavailable: ${detail}`,
125237
- cause: null
125238
- });
125239
- var HubTaskClient = class extends Context.Service()("@p4code/cli/persistence/Layers/RemoteTasks/HubTaskClient") {};
125240
- const makeHubTaskClient = Effect.gen(function* () {
125241
- const http = yield* HttpClient.HttpClient;
125242
- const link = yield* HubLink;
125243
- const isLinked = link.current.pipe(Effect.map((state) => Option.isSome(state.settings)));
125244
- const requireSettings = (operation) => Effect.gen(function* () {
125245
- const state = yield* link.current;
125246
- if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
125247
- return state.settings.value;
125248
- });
125249
- const send = (operation, settings, request) => http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS), Effect.mapError((cause) => hubUnavailable(operation, `${cause._tag ?? "request failed"}`)));
125250
- const list = (filter) => Effect.gen(function* () {
125251
- const settings = yield* requireSettings("list");
125252
- const query = new URLSearchParams();
125253
- for (const key of [
125254
- "projectId",
125255
- "repositoryKey",
125256
- "threadId",
125257
- "status",
125258
- "assignee",
125259
- "query"
125260
- ]) {
125261
- const value = filter[key];
125262
- if (value !== void 0) query.set(key, value);
125263
- }
125264
- const suffix = query.size === 0 ? "" : `?${query.toString()}`;
125265
- const response = yield* send("list", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks${suffix}`));
125266
- if (response.status !== 200) return yield* hubUnavailable("list", `status ${response.status}`);
125267
- return (yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("list", "the response did not match the contract")))).tasks;
125268
- });
125269
- const put = (row, readableIdPrefix) => Effect.gen(function* () {
125270
- const settings = yield* requireSettings("put");
125271
- const response = yield* send("put", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.put(`${settings.baseUrl}/tasks/${encodeURIComponent(row.taskId)}`), {
125272
- projectId: row.projectId,
125273
- repositoryKey: row.repositoryKey,
125274
- threadId: row.threadId,
125275
- parentTaskId: row.parentTaskId,
125276
- title: row.title,
125277
- body: row.body,
125278
- status: row.status,
125279
- priority: row.priority,
125280
- assignee: row.assignee,
125281
- labels: row.labels,
125282
- orderKey: row.orderKey,
125283
- createdAt: row.createdAt,
125284
- statusChangedAt: row.statusChangedAt ?? row.updatedAt,
125285
- readableIdPrefix
125286
- }));
125287
- if (response.status !== 200 && response.status !== 201) return yield* hubUnavailable("put", `status ${response.status}`);
125288
- return yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("put", "the response did not match the contract")));
125289
- });
125290
- const patchIfStatus = (input) => Effect.gen(function* () {
125291
- const settings = yield* requireSettings("patchIfStatus");
125292
- const { taskId, ...fields } = input.patch;
125293
- const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
125294
- ...fields,
125295
- expectedStatus: input.expectedStatus
125296
- }));
125297
- if (response.status === 404) return { outcome: "missing" };
125298
- if (response.status === 409) return {
125299
- outcome: "conflict",
125300
- current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
125301
- };
125302
- if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
125303
- return {
125304
- outcome: "updated",
125305
- task: yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")))
125306
- };
125307
- });
125308
- const remove = (taskId) => Effect.gen(function* () {
125309
- const settings = yield* requireSettings("remove");
125310
- const response = yield* send("remove", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
125311
- if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("remove", `status ${response.status}`);
125312
- });
125313
- return {
125314
- isLinked,
125315
- list,
125316
- put,
125317
- patchIfStatus,
125318
- remove
125319
- };
125320
- });
125321
- Layer.effect(HubTaskClient, makeHubTaskClient);
125322
- //#endregion
125323
125492
  //#region src/persistence/Layers/TaskSyncStore.ts
125324
125493
  /**
125325
125494
  * The bookkeeping a mirror needs and a board does not.
@@ -125516,14 +125685,17 @@ const makeTaskSyncStore = Effect.gen(function* () {
125516
125685
  * @module persistence/Layers/SyncedTasks
125517
125686
  */
125518
125687
  /**
125519
- * How stale another machine's write may look here.
125688
+ * How stale another machine's write may look here when the hub cannot push.
125520
125689
  *
125521
- * Short enough that a board left open follows along, long enough that two
125522
- * servers idling all day are not a steady stream of requests at a hosted
125523
- * service. Only changes made elsewhere wait for it; a write made here is
125524
- * already on screen.
125690
+ * With the hub's change stream connected, a write elsewhere triggers a pull
125691
+ * right away and the timer is only a safety net, so it can be slow. Without
125692
+ * it (an older hub, or the stream is reconnecting) the timer is the only
125693
+ * signal, and a board left open should still follow along.
125525
125694
  */
125526
- const SYNC_INTERVAL = Duration.seconds(10);
125695
+ const POLL_INTERVAL$1 = Duration.seconds(10);
125696
+ const PUSHED_POLL_INTERVAL = Duration.minutes(5);
125697
+ /** Between change-stream attempts, so an older hub's 404 is not hammered. */
125698
+ const WATCH_RETRY_DELAY = Duration.seconds(30);
125527
125699
  const makeSyncedTaskRepository = Effect.gen(function* () {
125528
125700
  const local = yield* makeTaskRepository;
125529
125701
  const sync = yield* makeTaskSyncStore;
@@ -125610,6 +125782,11 @@ const makeSyncedTaskRepository = Effect.gen(function* () {
125610
125782
  * that was just removed; a pull run before either would see this machine's
125611
125783
  * own stale absence as the hub's opinion and undo local work.
125612
125784
  */
125785
+ /**
125786
+ * Where the last applied pull left off. Held in memory only: a restart pulls
125787
+ * the whole board once, then follows changes again.
125788
+ */
125789
+ let position;
125613
125790
  const syncOnce = Effect.gen(function* () {
125614
125791
  if (!(yield* hub.isLinked)) return;
125615
125792
  for (const taskId of yield* sync.listTombstones) yield* hub.remove(taskId).pipe(Effect.andThen(sync.clearTombstone(taskId)), Effect.ignoreCause({ log: true }));
@@ -125617,21 +125794,36 @@ const makeSyncedTaskRepository = Effect.gen(function* () {
125617
125794
  const { readableIdPrefix, ...task } = row;
125618
125795
  yield* pushInBackground(task, readableIdPrefix ?? "T");
125619
125796
  }
125620
- const remote = yield* hub.list({});
125797
+ const pulled = yield* hub.pull(position);
125798
+ if (Option.isNone(pulled)) return;
125799
+ const page = pulled.value;
125621
125800
  const stillPending = new Set((yield* sync.listPending).map((row) => row.taskId));
125622
125801
  const tombstoned = new Set(yield* sync.listTombstones);
125623
125802
  const syncedIds = yield* sync.listSyncedIds;
125624
125803
  const held = new Map((yield* local.list({})).map((task) => [task.taskId, task]));
125625
125804
  const seen = /* @__PURE__ */ new Set();
125626
- for (const task of remote) {
125805
+ for (const task of page.tasks) {
125627
125806
  seen.add(task.taskId);
125628
125807
  if (stillPending.has(task.taskId) || tombstoned.has(task.taskId)) continue;
125629
125808
  const current = held.get(task.taskId);
125630
125809
  if (current === void 0 || current.updatedAt !== task.updatedAt) yield* adopt(task);
125631
125810
  }
125632
- for (const taskId of syncedIds) if (!seen.has(taskId)) yield* local.deleteById({ taskId });
125811
+ const gone = page.full ? syncedIds.filter((taskId) => !seen.has(taskId)) : page.deletedTaskIds.filter((taskId) => syncedIds.includes(taskId));
125812
+ for (const taskId of gone) yield* local.deleteById({ taskId });
125813
+ position = page.next;
125633
125814
  }).pipe(Effect.ignoreCause({ log: true }));
125634
- yield* Effect.forever(syncOnce.pipe(Effect.andThen(Effect.sleep(SYNC_INTERVAL)))).pipe(Effect.forkScoped);
125815
+ const nudges = yield* Queue.sliding(1);
125816
+ let watching = false;
125817
+ yield* hub.watch.pipe(Stream.runForEach(() => Effect.sync(() => {
125818
+ watching = true;
125819
+ }).pipe(Effect.andThen(Queue.offer(nudges, void 0)))), Effect.ignoreCause({ log: "Debug" }), Effect.andThen(Effect.sync(() => {
125820
+ watching = false;
125821
+ }).pipe(Effect.andThen(Queue.offer(nudges, void 0)))), Effect.andThen(Effect.sleep(WATCH_RETRY_DELAY)), Effect.forever, Effect.forkScoped);
125822
+ const nextPass = Effect.gen(function* () {
125823
+ const owed = (yield* sync.listPending).length > 0 || (yield* sync.listTombstones).length > 0;
125824
+ return yield* Queue.take(nudges).pipe(Effect.timeoutOption(watching && !owed ? PUSHED_POLL_INTERVAL : POLL_INTERVAL$1));
125825
+ }).pipe(Effect.catch(() => Effect.sleep(POLL_INTERVAL$1)));
125826
+ yield* Effect.forever(syncOnce.pipe(Effect.andThen(nextPass))).pipe(Effect.forkScoped);
125635
125827
  return {
125636
125828
  create,
125637
125829
  upsert,
@@ -148286,8 +148478,8 @@ const WorkspaceLayerLive = Layer.mergeAll(layer$71, WorkspaceEntriesLayerLive, W
148286
148478
  const ProjectFaviconResolverLayerLive = layer$66.pipe(Layer.provide(layer$71), Layer.provide(layer$67));
148287
148479
  const AuthLayerLive = layer$116.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$120));
148288
148480
  const ProviderRuntimeLayerLive = Layer.mergeAll(ProviderSessionReaperLive, ProviderHealthRefresherLive).pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive));
148289
- const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(layer$28), Layer.provideMerge(Layer.mergeAll(PullRequestServiceLive, CheckpointingLayerLive, SourceControlProviderRegistryLayerLive)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderAuthServiceLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, RoutedTaskRepositoryLive)), Layer.provideMerge(Layer.mergeAll(PersistenceLayerLive, layer$90)), Layer.provideMerge(layer$84), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(layer$61), Layer.provideMerge(Layer.mergeAll(layer$60, layer$73.pipe(Layer.provide(layer$86)), layer$75, layer$70)), Layer.provideMerge(layer$55), Layer.provideMerge(layer$56), Layer.provideMerge(layer$57), Layer.provideMerge(layer$58), Layer.provideMerge(layer$59)).pipe(Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$63), Layer.provideMerge(JevWorkspaceAdvisorLive.pipe(Layer.provide(TypeSafeClientLive))), Layer.provideMerge(layer$94.pipe(Layer.provide(layer$120))), Layer.provideMerge(SupervisorSecretsLive.pipe(Layer.provide(layer$120))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$85), Layer.provideMerge(layer$69), Layer.provideMerge(remoteComputerUseBindingsLayer.pipe(Layer.provide(layer$90))), Layer.provideMerge(layer$78), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$5.pipe(Layer.provideMerge(LinearClientLive), Layer.provideMerge(Layer.mergeAll(LinearMcpClientLive, TrackerSecretsLive)), Layer.provideMerge(Layer.mergeAll(layer$91, layer$21).pipe(Layer.provideMerge(layer$92), Layer.provideMerge(layer$93))))), Layer.provideMerge(layer$120));
148290
- const UsageLayerLive = layer$25.pipe(Layer.provide(layer$94.pipe(Layer.provide(layer$120))));
148481
+ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(layer$28), Layer.provideMerge(Layer.mergeAll(PullRequestServiceLive, CheckpointingLayerLive, SourceControlProviderRegistryLayerLive)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderAuthServiceLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, RoutedTaskRepositoryLive)), Layer.provideMerge(Layer.mergeAll(PersistenceLayerLive, layer$95)), Layer.provideMerge(layer$84), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(layer$61), Layer.provideMerge(Layer.mergeAll(layer$60, layer$73.pipe(Layer.provide(layer$86)), layer$75, layer$70)), Layer.provideMerge(layer$55), Layer.provideMerge(layer$56), Layer.provideMerge(layer$57), Layer.provideMerge(layer$58), Layer.provideMerge(layer$59)).pipe(Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$63), Layer.provideMerge(JevWorkspaceAdvisorLive.pipe(Layer.provide(TypeSafeClientLive))), Layer.provideMerge(layer$93.pipe(Layer.provide(layer$120))), Layer.provideMerge(SupervisorSecretsLive.pipe(Layer.provide(layer$120))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$85), Layer.provideMerge(layer$69), Layer.provideMerge(remoteComputerUseBindingsLayer.pipe(Layer.provide(layer$95))), Layer.provideMerge(layer$78), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$5.pipe(Layer.provideMerge(LinearClientLive), Layer.provideMerge(Layer.mergeAll(LinearMcpClientLive, TrackerSecretsLive)), Layer.provideMerge(Layer.mergeAll(layer$90, layer$21).pipe(Layer.provideMerge(layer$91), Layer.provideMerge(layer$92))))), Layer.provideMerge(layer$120));
148482
+ const UsageLayerLive = layer$25.pipe(Layer.provide(layer$93.pipe(Layer.provide(layer$120))));
148291
148483
  const RuntimeDependenciesLive = layer$24.pipe(Layer.provideMerge(RuntimeCoreDependenciesLive)).pipe(Layer.provideMerge(UsageLayerLive), Layer.provideMerge(layer$12), Layer.provideMerge(layer$11), Layer.provideMerge(layer$10), Layer.provideMerge(layer$81), Layer.provideMerge(layer$83), Layer.provideMerge(layer$82), Layer.provide(layer$124));
148292
148484
  /**
148293
148485
  * Hub asset sync.
@@ -148296,7 +148488,7 @@ const RuntimeDependenciesLive = layer$24.pipe(Layer.provideMerge(RuntimeCoreDepe
148296
148488
  * not-configured shape, and every call reports that rather than failing — so
148297
148489
  * this costs a server with no hub nothing but a layer.
148298
148490
  */
148299
- const AssetSyncLive = layer$88.pipe(Layer.provideMerge(layer$77), Layer.provideMerge(layer$89), Layer.provideMerge(layer$90));
148491
+ const AssetSyncLive = layer$88.pipe(Layer.provideMerge(layer$77), Layer.provideMerge(layer$89), Layer.provideMerge(layer$95));
148300
148492
  const RuntimeServicesLive = layer$72.pipe(Layer.provideMerge(layer$1), Layer.provideMerge(AssetSyncLive), Layer.provideMerge(layer$23), Layer.provideMerge(layer$22.pipe(Layer.provide(layer$53), Layer.provide(ProviderSessionDirectoryLayerLive), Layer.provide(ProjectionProjectRepositoryLive))), Layer.provideMerge(RuntimeDependenciesLive));
148301
148493
  const makeRoutesLayer = Layer.mergeAll(Layer.mergeAll(HttpApiBuilder.layer(EnvironmentHttpApi).pipe(Layer.provide(authHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer)), otlpTracesProxyRouteLayer, assetRouteLayer, mcpOAuthCallbackRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer), layer$4.pipe(Layer.provide(layer$6)), layer$68, layer).pipe(Layer.provide(ProjectionProjectRepositoryLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provide(ProjectionThreadRepositoryLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provide(layer$67), Layer.provide(layer$28), Layer.provide(PullRequestServiceLive), Layer.provide(layer$20), Layer.provide(layer$79), Layer.provide(layer$26), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer));
148302
148494
  const makeServerLayer = Layer.unwrap(Effect.gen(function* () {