@p4code/cli 0.1.25 → 0.1.26

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
@@ -236,7 +236,7 @@ const make$76 = () => {
236
236
  const layer$72 = Layer.sync(NetService, make$76);
237
237
  //#endregion
238
238
  //#region package.json
239
- var version = "0.1.25";
239
+ var version = "0.1.26";
240
240
  //#endregion
241
241
  //#region src/config.ts
242
242
  /**
@@ -5881,7 +5881,6 @@ const SourceControlWritingStyleSettings = Schema$1.Struct({
5881
5881
  followChangeRequestTemplates: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(true)))
5882
5882
  });
5883
5883
  const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
5884
- const TaskBoardSource = Schema$1.Literals(["local", "hub"]);
5885
5884
  const ServerSettings = Schema$1.Struct({
5886
5885
  enableAssistantStreaming: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(false))),
5887
5886
  enableProviderUpdateChecks: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(true))),
@@ -5956,23 +5955,19 @@ const ServerSettings = Schema$1.Struct({
5956
5955
  sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(Schema$1.withDecodingDefault(Effect.succeed({}))),
5957
5956
  sourceControlWriterModelSelection: Schema$1.NullOr(ModelSelection).pipe(Schema$1.withDecodingDefault(Effect.succeed(null))),
5958
5957
  /**
5959
- * Where this server's task board lives.
5958
+ * The Linear team new issues are filed into, by name or id.
5960
5959
  *
5961
- * `local` is this machine's own SQLite database, which is where every board
5962
- * has been until now. `hub` puts the board on the linked hub, so two servers
5963
- * read and write one board instead of two that silently diverge.
5960
+ * Needed because Linear requires a team to create an issue and has no notion
5961
+ * of a default one. Empty means unchosen, which is not an error until
5962
+ * somebody tries to file a task on the Linear board - reading it works fine
5963
+ * without one, and demanding the choice up front would block the common case
5964
+ * of looking at what is already there.
5964
5965
  *
5965
- * A setting rather than "hub if one is linked", because a hub can be linked
5966
- * for skills alone, and flipping a board's home is not something to have
5967
- * happen as a side effect of connecting. It is also the way back: the local
5968
- * rows are still there, untouched, when it is set to `local` again.
5969
- *
5970
- * **On `hub`, an unreachable hub means no board.** There is no local
5971
- * fallback by decision: answering from a stale local copy would recreate the
5972
- * per-machine divergence the hub exists to remove, and it would do it
5973
- * invisibly.
5966
+ * A setting rather than a board control, unlike the board source itself: the
5967
+ * team is a property of how this machine is set up, it is answered once, and
5968
+ * changing it retroactively re-homes nothing.
5974
5969
  */
5975
- taskBoardSource: TaskBoardSource.pipe(Schema$1.withDecodingDefault(Effect.succeed("local"))),
5970
+ linearTeam: Schema$1.String.pipe(Schema$1.withDecodingDefault(Effect.succeed(""))),
5976
5971
  /**
5977
5972
  * Skills the agents on this server must not see, by name.
5978
5973
  *
@@ -6095,7 +6090,7 @@ const ServerSettingsPatch = Schema$1.Struct({
6095
6090
  })),
6096
6091
  sourceControlWriterModelSelection: Schema$1.optionalKey(Schema$1.NullOr(ModelSelection)),
6097
6092
  disabledSkills: Schema$1.optionalKey(Schema$1.Array(TrimmedNonEmptyString)),
6098
- taskBoardSource: Schema$1.optionalKey(TaskBoardSource),
6093
+ linearTeam: Schema$1.optionalKey(TrimmedString),
6099
6094
  observability: Schema$1.optionalKey(Schema$1.Struct({
6100
6095
  otlpTracesUrl: Schema$1.optionalKey(TrimmedString),
6101
6096
  otlpMetricsUrl: Schema$1.optionalKey(TrimmedString)
@@ -7283,6 +7278,41 @@ const TaskPriority = Schema$1.Literals([
7283
7278
  "urgent"
7284
7279
  ]);
7285
7280
  /**
7281
+ * Which board a call is about.
7282
+ *
7283
+ * `board` is p4code's own: this machine's rows, mirrored to the hub when one is
7284
+ * linked. It is one board rather than two because the local store is a replica
7285
+ * of the hub and not a rival copy of it, which is why there is no `hub` member
7286
+ * here — asking for "the hub board" and "the local board" separately would be
7287
+ * asking for the same rows twice.
7288
+ *
7289
+ * `linear` is the user's Linear workspace, read and written through the Linear
7290
+ * MCP server they already registered. p4code holds no Linear credential of its
7291
+ * own; see `mcp/TicketResolver` for why that is the only way in.
7292
+ *
7293
+ * A per-call parameter rather than a setting: a person switches boards to look
7294
+ * at something and switches back, and a server-wide mode would make that a
7295
+ * configuration change with a blast radius across every open client.
7296
+ */
7297
+ const TaskSource = Schema$1.Literals(["board", "linear"]);
7298
+ /**
7299
+ * The board a call means when it does not say. Every existing caller predates
7300
+ * the parameter and means p4code's own board, so the default has to be `board`
7301
+ * for them to keep working unchanged.
7302
+ */
7303
+ const DEFAULT_TASK_SOURCE = "board";
7304
+ const TaskSourceField = TaskSource.pipe(Schema$1.annotateEncoded({ description: "Which board to act on: 'board' for p4code's own (the default), 'linear' for the linked Linear workspace." }));
7305
+ /**
7306
+ * Mixed into the RPC payloads rather than into `TaskCreateInput` and friends.
7307
+ *
7308
+ * Those inputs are also the hub's wire format and the MCP tools' argument
7309
+ * schemas, and a source belongs to neither: the hub *is* the board, so a
7310
+ * `source` field on its create route would be a field it must reject, and an
7311
+ * agent picking a tracker per call is a capability to add deliberately rather
7312
+ * than one to leak in by inheritance.
7313
+ */
7314
+ const TaskSourceSelector = Schema$1.Struct({ source: Schema$1.optional(TaskSourceField) });
7315
+ /**
7286
7316
  * A per-prefix sequence prefix, e.g. `T` or `P4`. Declared by a project in its
7287
7317
  * `p4.json` (`taskPrefix`); always stored and compared uppercase.
7288
7318
  */
@@ -7472,6 +7502,39 @@ const HubTaskCreateInput = Schema$1.Struct({
7472
7502
  orderKey: Schema$1.optional(OrderKeyField)
7473
7503
  });
7474
7504
  /**
7505
+ * A whole row, written to the hub at an id the sending machine chose.
7506
+ *
7507
+ * Every field is present and nullable rather than optional, because this is a
7508
+ * replace and not a patch: a task whose assignee was cleared locally has to
7509
+ * arrive as an explicit `null`, and under an optional field it would arrive as
7510
+ * an absence indistinguishable from "unchanged" — so the clear would never
7511
+ * propagate and the two machines would disagree forever about who owns it.
7512
+ *
7513
+ * `readableId` and `updatedAt` are absent for the opposite reason: they are
7514
+ * the hub's to assign. The hub allocates the readable id the first time it
7515
+ * sees an id and preserves it afterwards, which is what lets a task created
7516
+ * on an offline machine get its `P4-12` on the first push and keep it.
7517
+ *
7518
+ * `createdAt` does come from the sender. It is a fact about when the person
7519
+ * filed the task, and the hub learning about it late does not make it newer.
7520
+ */
7521
+ const HubTaskPutInput = Schema$1.Struct({
7522
+ projectId: Schema$1.NullOr(ProjectIdField),
7523
+ repositoryKey: Schema$1.NullOr(RepositoryKeyField),
7524
+ threadId: Schema$1.NullOr(ThreadIdField),
7525
+ externalRef: Schema$1.NullOr(ExternalRefField),
7526
+ parentTaskId: Schema$1.NullOr(ParentTaskIdField),
7527
+ title: TitleField,
7528
+ body: BodyField,
7529
+ status: StatusField,
7530
+ priority: PriorityField,
7531
+ assignee: Schema$1.NullOr(AssigneeField),
7532
+ labels: LabelsField,
7533
+ orderKey: Schema$1.NullOr(OrderKeyField),
7534
+ createdAt: IsoDateTime,
7535
+ readableIdPrefix: Schema$1.optional(TrimmedNonEmptyString)
7536
+ });
7537
+ /**
7475
7538
  * Every field is optional and an omitted field is left untouched. The nullable
7476
7539
  * fields accept an explicit `null` to clear them, which is why they are
7477
7540
  * `NullOr` inside `optional` rather than merely optional.
@@ -9213,23 +9276,44 @@ const WS_METHODS = {
9213
9276
  * client and useful only in the server log.
9214
9277
  */
9215
9278
  const TaskRpcError = Schema$1.Union([TaskStoreError, EnvironmentAuthorizationError]);
9279
+ /**
9280
+ * Every task method carries the board it means.
9281
+ *
9282
+ * Spread into each payload rather than declared once as a wrapper, because
9283
+ * these payloads are what the client sends verbatim and a nested `{ input,
9284
+ * source }` would rename every field at every call site for no gain. Absent
9285
+ * means `board`, which is what every caller written before this parameter
9286
+ * existed intends.
9287
+ */
9216
9288
  const WsTasksListRpc = Rpc.make(WS_METHODS.tasksList, {
9217
- payload: TaskListFilter,
9289
+ payload: Schema$1.Struct({
9290
+ ...TaskListFilter.fields,
9291
+ ...TaskSourceSelector.fields
9292
+ }),
9218
9293
  success: TaskListResult$1,
9219
9294
  error: TaskRpcError
9220
9295
  });
9221
9296
  const WsTasksGetRpc = Rpc.make(WS_METHODS.tasksGet, {
9222
- payload: TaskGetInput,
9297
+ payload: Schema$1.Struct({
9298
+ ...TaskGetInput.fields,
9299
+ ...TaskSourceSelector.fields
9300
+ }),
9223
9301
  success: TaskGetResult,
9224
9302
  error: TaskRpcError
9225
9303
  });
9226
9304
  const WsTasksCreateRpc = Rpc.make(WS_METHODS.tasksCreate, {
9227
- payload: TaskCreateInput,
9305
+ payload: Schema$1.Struct({
9306
+ ...TaskCreateInput.fields,
9307
+ ...TaskSourceSelector.fields
9308
+ }),
9228
9309
  success: TaskResult,
9229
9310
  error: TaskRpcError
9230
9311
  });
9231
9312
  const WsTasksUpdateRpc = Rpc.make(WS_METHODS.tasksUpdate, {
9232
- payload: TaskUpdateInput,
9313
+ payload: Schema$1.Struct({
9314
+ ...TaskUpdateInput.fields,
9315
+ ...TaskSourceSelector.fields
9316
+ }),
9233
9317
  success: TaskResult,
9234
9318
  error: Schema$1.Union([
9235
9319
  TaskNotFoundError,
@@ -9238,7 +9322,10 @@ const WsTasksUpdateRpc = Rpc.make(WS_METHODS.tasksUpdate, {
9238
9322
  ])
9239
9323
  });
9240
9324
  const WsTasksDeleteRpc = Rpc.make(WS_METHODS.tasksDelete, {
9241
- payload: TaskGetInput,
9325
+ payload: Schema$1.Struct({
9326
+ ...TaskGetInput.fields,
9327
+ ...TaskSourceSelector.fields
9328
+ }),
9242
9329
  success: Schema$1.Struct({}),
9243
9330
  error: TaskRpcError
9244
9331
  });
@@ -9256,7 +9343,10 @@ const WsTasksDeleteRpc = Rpc.make(WS_METHODS.tasksDelete, {
9256
9343
  * engine's own failure and already has a type.
9257
9344
  */
9258
9345
  const WsTasksStartThreadRpc = Rpc.make(WS_METHODS.tasksStartThread, {
9259
- payload: TaskStartThreadInput,
9346
+ payload: Schema$1.Struct({
9347
+ ...TaskStartThreadInput.fields,
9348
+ ...TaskSourceSelector.fields
9349
+ }),
9260
9350
  success: TaskStartThreadResult,
9261
9351
  error: Schema$1.Union([
9262
9352
  TaskNotFoundError,
@@ -9487,7 +9577,7 @@ const WsTicketsResolveRpc = Rpc.make(WS_METHODS.ticketsResolve, {
9487
9577
  * a write can be missed.
9488
9578
  */
9489
9579
  const WsSubscribeTasksRpc = Rpc.make(WS_METHODS.subscribeTasks, {
9490
- payload: Schema$1.Struct({}),
9580
+ payload: TaskSourceSelector,
9491
9581
  success: TaskStreamEvent,
9492
9582
  error: Schema$1.Union([TaskStoreError, EnvironmentAuthorizationError]),
9493
9583
  stream: true
@@ -13037,6 +13127,62 @@ var _042_ProjectionThreadsUnpromptedSubagents_default = Effect.gen(function* ()
13037
13127
  `;
13038
13128
  });
13039
13129
  //#endregion
13130
+ //#region src/persistence/Migrations/043_TaskSync.ts
13131
+ /**
13132
+ * What the local board still owes the hub.
13133
+ *
13134
+ * The board is no longer either local or remote: local SQLite is a replica of
13135
+ * the hub, so every row needs to say whether the hub has seen its current
13136
+ * version. `sync_state` is that flag, and it only ever has two values —
13137
+ * `pending` (this machine has a version the hub has not acknowledged) and
13138
+ * `synced` (the hub returned this exact row). There is no third state for "in
13139
+ * flight": a push that dies mid-request leaves the row `pending`, which is
13140
+ * true, and the next pass retries it.
13141
+ *
13142
+ * Existing rows are marked `pending` rather than `synced`, which is what makes
13143
+ * this a migration and not just a column. Every task on this machine predates
13144
+ * the mirror and the hub has never seen any of them, so they upload on the
13145
+ * first pass after a hub is linked. That is the behaviour the column exists
13146
+ * for; defaulting them to `synced` would silently strand every task already on
13147
+ * the board.
13148
+ *
13149
+ * `task_tombstones` is the other half. A row deleted here while the hub was
13150
+ * unreachable is gone from `tasks`, so the next pull — which sees the hub
13151
+ * still holding it — would put it straight back. The tombstone is what says
13152
+ * "this absence is a decision, not a gap", and it is dropped once the hub has
13153
+ * accepted the delete.
13154
+ *
13155
+ * @module Migrations/043_TaskSync
13156
+ */
13157
+ var _043_TaskSync_default = Effect.gen(function* () {
13158
+ const sql = yield* SqlClient.SqlClient;
13159
+ yield* sql`ALTER TABLE tasks ADD COLUMN sync_state TEXT NOT NULL DEFAULT 'pending'`;
13160
+ yield* sql`ALTER TABLE tasks ADD COLUMN readable_id_prefix TEXT`;
13161
+ yield* sql`
13162
+ UPDATE tasks
13163
+ SET readable_id_prefix = substr(readable_id, 1, instr(readable_id, '-') - 1)
13164
+ WHERE readable_id IS NOT NULL AND instr(readable_id, '-') > 1
13165
+ `;
13166
+ yield* sql`
13167
+ CREATE INDEX IF NOT EXISTS idx_tasks_sync_state
13168
+ ON tasks(sync_state)
13169
+ `;
13170
+ yield* sql`
13171
+ CREATE TABLE IF NOT EXISTS task_tombstones (
13172
+ task_id TEXT PRIMARY KEY,
13173
+ deleted_at TEXT NOT NULL
13174
+ )
13175
+ `;
13176
+ yield* sql`
13177
+ CREATE TABLE IF NOT EXISTS linear_task_links (
13178
+ issue_id TEXT PRIMARY KEY,
13179
+ thread_id TEXT,
13180
+ project_id TEXT,
13181
+ repository_key TEXT
13182
+ )
13183
+ `;
13184
+ });
13185
+ //#endregion
13040
13186
  //#region src/persistence/Migrations.ts
13041
13187
  /**
13042
13188
  * MigrationsLive - Migration runner with inline loader
@@ -13267,6 +13413,11 @@ const migrationEntries = [
13267
13413
  42,
13268
13414
  "ProjectionThreadsUnpromptedSubagents",
13269
13415
  _042_ProjectionThreadsUnpromptedSubagents_default
13416
+ ],
13417
+ [
13418
+ 43,
13419
+ "TaskSync",
13420
+ _043_TaskSync_default
13270
13421
  ]
13271
13422
  ];
13272
13423
  const makeMigrationLoader = (throughId) => Migrator.fromRecord(Object.fromEntries(migrationEntries.filter(([id]) => throughId === void 0 || id <= throughId).map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -14874,7 +15025,7 @@ const DeleteTaskInput = Schema$1.Struct({ taskId: TaskId });
14874
15025
  var TaskRepository = class extends Context.Service()("@p4code/cli/persistence/Services/Tasks/TaskRepository") {};
14875
15026
  //#endregion
14876
15027
  //#region src/persistence/Layers/Tasks.ts
14877
- const TaskDbRow = Task.mapFields(Struct.assign({
15028
+ const TaskDbRow$1 = Task.mapFields(Struct.assign({
14878
15029
  labels: Schema$1.fromJsonString(Schema$1.Array(TaskLabel)),
14879
15030
  externalRef: Schema$1.NullOr(Schema$1.fromJsonString(TaskExternalRef))
14880
15031
  }));
@@ -14891,7 +15042,7 @@ const TaskListQuery = Schema$1.Struct({
14891
15042
  status: Schema$1.NullOr(Schema$1.String),
14892
15043
  assignee: Schema$1.NullOr(Schema$1.String)
14893
15044
  });
14894
- const TASK_COLUMNS = `
15045
+ const TASK_COLUMNS$1 = `
14895
15046
  task_id AS "taskId",
14896
15047
  readable_id AS "readableId",
14897
15048
  project_id AS "projectId",
@@ -14973,13 +15124,13 @@ const makeTaskRepository = Effect.gen(function* () {
14973
15124
  });
14974
15125
  const getTaskRow = SqlSchema.findOneOption({
14975
15126
  Request: GetTaskInput,
14976
- Result: TaskDbRow,
14977
- execute: ({ taskId }) => sql`SELECT ${sql.literal(TASK_COLUMNS)} FROM tasks WHERE task_id = ${taskId}`
15127
+ Result: TaskDbRow$1,
15128
+ execute: ({ taskId }) => sql`SELECT ${sql.literal(TASK_COLUMNS$1)} FROM tasks WHERE task_id = ${taskId}`
14978
15129
  });
14979
15130
  const getTaskRowByReadableId = SqlSchema.findOneOption({
14980
15131
  Request: Schema$1.String,
14981
- Result: TaskDbRow,
14982
- execute: (readableId) => sql`SELECT ${sql.literal(TASK_COLUMNS)} FROM tasks WHERE readable_id = ${readableId}`
15132
+ Result: TaskDbRow$1,
15133
+ execute: (readableId) => sql`SELECT ${sql.literal(TASK_COLUMNS$1)} FROM tasks WHERE readable_id = ${readableId}`
14983
15134
  });
14984
15135
  /**
14985
15136
  * Take the next number for a prefix, creating the sequence at 1 on first
@@ -14998,9 +15149,9 @@ const makeTaskRepository = Effect.gen(function* () {
14998
15149
  });
14999
15150
  const listTaskRows = SqlSchema.findAll({
15000
15151
  Request: TaskListQuery,
15001
- Result: TaskDbRow,
15152
+ Result: TaskDbRow$1,
15002
15153
  execute: (query) => sql`
15003
- SELECT ${sql.literal(TASK_COLUMNS)}
15154
+ SELECT ${sql.literal(TASK_COLUMNS$1)}
15004
15155
  FROM tasks
15005
15156
  WHERE (${query.projectId} IS NULL OR project_id = ${query.projectId})
15006
15157
  AND (${query.repositoryKey} IS NULL OR repository_key = ${query.repositoryKey})
@@ -15248,6 +15399,60 @@ const createTaskRoute = HttpRouter.add("POST", "/tasks", respondToHubFailures(Ef
15248
15399
  const task = yield* tasks.create(row, input.readableIdPrefix ?? "T");
15249
15400
  return HttpServerResponse.jsonUnsafe(task, { status: 201 });
15250
15401
  })));
15402
+ /**
15403
+ * Write a whole row at an id the calling server chose.
15404
+ *
15405
+ * This is what makes a local mirror possible. A server writes the row to its
15406
+ * own database first — so the board answers instantly and keeps answering with
15407
+ * the hub down — and only then pushes it here. By that point the id is already
15408
+ * referenced by a thread link, a parent link and anything else that names the
15409
+ * task, so the hub has to accept it rather than mint a competing one. Ids are
15410
+ * UUIDs, so accepting the caller's costs nothing: two servers cannot collide.
15411
+ *
15412
+ * The readable id stays the hub's. It is allocated the first time an id is
15413
+ * seen and preserved on every write after, which is the arrangement that lets
15414
+ * a task filed on an offline laptop pick up its `P4-12` on first contact and
15415
+ * keep it everywhere.
15416
+ */
15417
+ const putTaskRoute = HttpRouter.add("PUT", "/tasks/:taskId", respondToHubFailures(Effect.gen(function* () {
15418
+ yield* authenticateHubRequest();
15419
+ const params = yield* HttpRouter.params;
15420
+ const taskId = TaskId.make(params.taskId ?? "");
15421
+ const input = yield* decodeOrInvalid(HubTaskPutInput, "Request body does not match HubTaskPutInput.")(yield* readJsonBody());
15422
+ const tasks = yield* TaskRepository;
15423
+ const timestamp = DateTime.formatIso(yield* DateTime.now);
15424
+ const fields = {
15425
+ projectId: input.projectId,
15426
+ repositoryKey: input.repositoryKey,
15427
+ threadId: input.threadId,
15428
+ externalRef: input.externalRef,
15429
+ parentTaskId: input.parentTaskId,
15430
+ title: input.title,
15431
+ body: input.body,
15432
+ status: input.status,
15433
+ priority: input.priority,
15434
+ assignee: input.assignee,
15435
+ labels: input.labels,
15436
+ orderKey: input.orderKey
15437
+ };
15438
+ const existing = yield* tasks.getById({ taskId });
15439
+ if (Option.isNone(existing)) {
15440
+ const created = yield* tasks.create({
15441
+ taskId,
15442
+ ...fields,
15443
+ createdAt: input.createdAt,
15444
+ updatedAt: timestamp
15445
+ }, input.readableIdPrefix ?? "T");
15446
+ return HttpServerResponse.jsonUnsafe(created, { status: 201 });
15447
+ }
15448
+ const replaced = {
15449
+ ...existing.value,
15450
+ ...fields,
15451
+ updatedAt: timestamp
15452
+ };
15453
+ yield* tasks.upsert(replaced);
15454
+ return HttpServerResponse.jsonUnsafe(replaced);
15455
+ })));
15251
15456
  const updateTaskRoute = HttpRouter.add("PATCH", "/tasks/:taskId", respondToHubFailures(Effect.gen(function* () {
15252
15457
  yield* authenticateHubRequest();
15253
15458
  const params = yield* HttpRouter.params;
@@ -15376,7 +15581,7 @@ const deleteAssetRoute = HttpRouter.add("DELETE", "/assets/:kind/:name", respond
15376
15581
  const outcome = yield* (yield* AgentAssetRepository).deleteByName(requested);
15377
15582
  return outcome._tag === "conflict" ? assetConflict(Option.getOrNull(outcome.current)) : HttpServerResponse.empty({ status: 204 });
15378
15583
  })));
15379
- const layer$62 = Layer.mergeAll(healthRoute, listTasksRoute, getTaskRoute, createTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute);
15584
+ const layer$62 = Layer.mergeAll(healthRoute, listTasksRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute);
15380
15585
  //#endregion
15381
15586
  //#region src/hub/Migrations/001_Tasks.ts
15382
15587
  /**
@@ -32555,7 +32760,7 @@ const NESTED_PAYLOAD_KEYS = [
32555
32760
  "operations"
32556
32761
  ];
32557
32762
  const MAX_COLLECT_DEPTH = 4;
32558
- function asRecord$4(value) {
32763
+ function asRecord$6(value) {
32559
32764
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
32560
32765
  }
32561
32766
  function pushChangedFilePath(target, value) {
@@ -32570,7 +32775,7 @@ function collectChangedFilePaths(value, target, depth) {
32570
32775
  for (const entry of value) collectChangedFilePaths(entry, target, depth + 1);
32571
32776
  return;
32572
32777
  }
32573
- const record = asRecord$4(value);
32778
+ const record = asRecord$6(value);
32574
32779
  if (!record) return;
32575
32780
  for (const field of CHANGED_FILE_FIELDS) pushChangedFilePath(target, record[field]);
32576
32781
  for (const nestedKey of NESTED_PAYLOAD_KEYS) if (nestedKey in record) collectChangedFilePaths(record[nestedKey], target, depth + 1);
@@ -32581,7 +32786,7 @@ function collectChangedFilePaths(value, target, depth) {
32581
32786
  */
32582
32787
  function collectActivityChangedFilePaths(payload) {
32583
32788
  const target = /* @__PURE__ */ new Set();
32584
- collectChangedFilePaths(asRecord$4(asRecord$4(payload)?.data), target, 0);
32789
+ collectChangedFilePaths(asRecord$6(asRecord$6(payload)?.data), target, 0);
32585
32790
  return target;
32586
32791
  }
32587
32792
  /**
@@ -35666,22 +35871,22 @@ function classifyToolCategory(input) {
35666
35871
  if (normalized.includes("image")) return "image_view";
35667
35872
  return "tool";
35668
35873
  }
35669
- function asRecord$3(value) {
35874
+ function asRecord$5(value) {
35670
35875
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
35671
35876
  }
35672
35877
  /** Classify from a runtime item payload's `data` (`{ toolName, input }`). */
35673
35878
  function classifyToolCategoryFromToolData(data) {
35674
- const record = asRecord$3(data);
35879
+ const record = asRecord$5(data);
35675
35880
  const toolName = record?.toolName;
35676
35881
  if (typeof toolName !== "string" || toolName.trim().length === 0) return;
35677
35882
  return classifyToolCategory({
35678
35883
  toolName,
35679
- toolInput: asRecord$3(record?.input)
35884
+ toolInput: asRecord$5(record?.input)
35680
35885
  });
35681
35886
  }
35682
35887
  //#endregion
35683
35888
  //#region src/orchestration/ActivityPayloadProjection.ts
35684
- function asRecord$2(value) {
35889
+ function asRecord$4(value) {
35685
35890
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
35686
35891
  }
35687
35892
  function asTrimmedString$1(value) {
@@ -35704,7 +35909,7 @@ function collectChangedFiles(value, target, seen, depth) {
35704
35909
  }
35705
35910
  return;
35706
35911
  }
35707
- const record = asRecord$2(value);
35912
+ const record = asRecord$4(value);
35708
35913
  if (!record) return;
35709
35914
  pushChangedFile(target, seen, record.path);
35710
35915
  pushChangedFile(target, seen, record.filePath);
@@ -35730,13 +35935,13 @@ function collectChangedFiles(value, target, seen, depth) {
35730
35935
  }
35731
35936
  }
35732
35937
  function projectCommandData(data) {
35733
- const item = asRecord$2(data.item);
35938
+ const item = asRecord$4(data.item);
35734
35939
  if (!item) return;
35735
35940
  const projectedItem = {};
35736
35941
  if ("command" in item) projectedItem.command = item.command;
35737
- const input = asRecord$2(item.input);
35942
+ const input = asRecord$4(item.input);
35738
35943
  if (input && "command" in input) projectedItem.input = { command: input.command };
35739
- const result = asRecord$2(item.result);
35944
+ const result = asRecord$4(item.result);
35740
35945
  if (result && "command" in result) projectedItem.result = { command: result.command };
35741
35946
  return Object.keys(projectedItem).length > 0 ? projectedItem : void 0;
35742
35947
  }
@@ -35752,7 +35957,7 @@ function summarizeToolTextOutput(value) {
35752
35957
  return null;
35753
35958
  }
35754
35959
  function projectRawOutput(value) {
35755
- const rawOutput = asRecord$2(value);
35960
+ const rawOutput = asRecord$4(value);
35756
35961
  if (!rawOutput) return;
35757
35962
  if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) return {
35758
35963
  totalFiles: rawOutput.totalFiles,
@@ -35774,14 +35979,14 @@ function projectRawOutput(value) {
35774
35979
  * the full payload in persistence and the event store.
35775
35980
  */
35776
35981
  function projectActivityPayload(activity) {
35777
- const payload = asRecord$2(activity.payload);
35778
- const data = asRecord$2(payload?.data);
35982
+ const payload = asRecord$4(activity.payload);
35983
+ const data = asRecord$4(payload?.data);
35779
35984
  if (!payload || !data || payload.itemType === "mcp_tool_call") return activity;
35780
35985
  const projectedData = {};
35781
35986
  const item = projectCommandData(data);
35782
35987
  if (item) projectedData.item = item;
35783
35988
  if ("command" in data) projectedData.command = data.command;
35784
- const input = asRecord$2(data.input);
35989
+ const input = asRecord$4(data.input);
35785
35990
  if (input && "command" in input) projectedData.input = { command: input.command };
35786
35991
  const changedFiles = [];
35787
35992
  collectChangedFiles(data, changedFiles, /* @__PURE__ */ new Set(), 0);
@@ -35808,7 +36013,7 @@ function projectActivityPayload(activity) {
35808
36013
  */
35809
36014
  function isResolvableContextWindowActivity(activity) {
35810
36015
  if (activity.kind !== "context-window.updated") return false;
35811
- const usedTokens = asRecord$2(activity.payload)?.usedTokens;
36016
+ const usedTokens = asRecord$4(activity.payload)?.usedTokens;
35812
36017
  return typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0;
35813
36018
  }
35814
36019
  /**
@@ -35824,7 +36029,7 @@ function isResolvableContextWindowActivity(activity) {
35824
36029
  * client.
35825
36030
  */
35826
36031
  function withoutContextWindowBreakdown$1(activity) {
35827
- const payload = asRecord$2(activity.payload);
36032
+ const payload = asRecord$4(activity.payload);
35828
36033
  if (!payload || payload.breakdown === void 0) return activity;
35829
36034
  const { breakdown: _breakdown, ...rest } = payload;
35830
36035
  return {
@@ -35839,7 +36044,7 @@ function dropStaleContextWindowActivities(activities) {
35839
36044
  const retainedIndexes = new Set(latestIndexByTurn.values());
35840
36045
  let breakdownIndex = null;
35841
36046
  for (const index of retainedIndexes) {
35842
- if (asRecord$2(activities[index].payload)?.breakdown === void 0) continue;
36047
+ if (asRecord$4(activities[index].payload)?.breakdown === void 0) continue;
35843
36048
  if (breakdownIndex === null || index > breakdownIndex) breakdownIndex = index;
35844
36049
  }
35845
36050
  return activities.flatMap((activity, index) => {
@@ -35937,6 +36142,1283 @@ const normalizeDispatchCommand = (command) => Effect.gen(function* () {
35937
36142
  };
35938
36143
  });
35939
36144
  //#endregion
36145
+ //#region src/mcp/McpToolClient.ts
36146
+ /**
36147
+ * Calling a tool on somebody else's MCP server.
36148
+ *
36149
+ * The rest of `apps/server/src/mcp` points the other way: it *exposes* p4code's
36150
+ * toolkit to an agent. This is the only place p4code is the client, and it
36151
+ * exists because one feature - recognizing a pasted ticket - needs an answer
36152
+ * before any agent turn runs, from a server the user has already registered.
36153
+ *
36154
+ * Deliberately not a general MCP client. There is no session reuse, no tool
36155
+ * listing, no notifications, no SSE stream held open: one request/response pair
36156
+ * per call, which is all streamable HTTP requires for a plain `tools/call`. A
36157
+ * real client belongs here only once something needs one.
36158
+ *
36159
+ * @module mcp/McpToolClient
36160
+ */
36161
+ /**
36162
+ * The version p4code speaks. Sent on `initialize` and echoed back as a header
36163
+ * on the follow-up calls, which is what the spec asks of a client that has
36164
+ * already negotiated.
36165
+ */
36166
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
36167
+ const REQUEST_TIMEOUT_MS$1 = 15e3;
36168
+ var McpToolCallError = class extends Schema$1.TaggedErrorClass()("McpToolCallError", {
36169
+ detail: Schema$1.String,
36170
+ status: Schema$1.NullOr(Schema$1.Number)
36171
+ }) {
36172
+ get message() {
36173
+ return this.detail;
36174
+ }
36175
+ };
36176
+ const unauthorized$1 = (status) => status === 401 || status === 403;
36177
+ /**
36178
+ * Read a JSON-RPC envelope out of a response that may be either JSON or SSE.
36179
+ *
36180
+ * A streamable-http server picks the encoding, and both are legal answers to a
36181
+ * single call. The SSE branch keeps the last `data:` payload that parses: a
36182
+ * server is free to emit progress notifications ahead of the result, and the
36183
+ * result is what a caller of this asked for.
36184
+ */
36185
+ const readEnvelope = (body) => {
36186
+ const direct = parseJsonObject(body);
36187
+ if (direct !== void 0) return direct;
36188
+ let latest;
36189
+ for (const line of body.split("\n")) {
36190
+ const trimmed = line.trim();
36191
+ if (!trimmed.startsWith("data:")) continue;
36192
+ const parsed = parseJsonObject(trimmed.slice(5).trim());
36193
+ if (parsed !== void 0) latest = parsed;
36194
+ }
36195
+ return latest;
36196
+ };
36197
+ const parseJsonObject = (text) => {
36198
+ if (text.length === 0) return void 0;
36199
+ try {
36200
+ const parsed = JSON.parse(text);
36201
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
36202
+ } catch {
36203
+ return;
36204
+ }
36205
+ };
36206
+ const textOf = (content) => {
36207
+ if (!Array.isArray(content)) return "";
36208
+ const parts = [];
36209
+ for (const block of content) {
36210
+ if (typeof block !== "object" || block === null) continue;
36211
+ const record = block;
36212
+ if (record["type"] === "text" && typeof record["text"] === "string") parts.push(record["text"]);
36213
+ }
36214
+ return parts.join("\n");
36215
+ };
36216
+ /**
36217
+ * One `tools/call`, preceded by the `initialize` handshake the transport
36218
+ * requires.
36219
+ *
36220
+ * The handshake is not optional and not cacheable here: a server may hand out a
36221
+ * session id, and this holds no session state between calls. Two round trips
36222
+ * for one answer is the price of not keeping a connection open for a feature
36223
+ * that fires when somebody pastes something.
36224
+ */
36225
+ const callMcpTool = Effect.fn("McpToolClient.callMcpTool")(function* (input) {
36226
+ const http = yield* HttpClient$1.HttpClient;
36227
+ const post = (body, extraHeaders) => http.execute(HttpClientRequest$1.bodyJsonUnsafe(HttpClientRequest$1.setHeaders(HttpClientRequest$1.post(input.url), {
36228
+ ...input.headers,
36229
+ ...extraHeaders,
36230
+ accept: "application/json, text/event-stream"
36231
+ }), body)).pipe(Effect.timeout(REQUEST_TIMEOUT_MS$1), Effect.catchCause((cause) => Effect.fail(new McpToolCallError({
36232
+ detail: `Could not reach ${input.url}: ${String(cause)}`,
36233
+ status: null
36234
+ }))));
36235
+ const initialize = yield* post({
36236
+ jsonrpc: "2.0",
36237
+ id: 1,
36238
+ method: "initialize",
36239
+ params: {
36240
+ protocolVersion: MCP_PROTOCOL_VERSION,
36241
+ capabilities: {},
36242
+ clientInfo: {
36243
+ name: "p4code",
36244
+ version: "0.0.0"
36245
+ }
36246
+ }
36247
+ }, {});
36248
+ if (initialize.status >= 400) return yield* new McpToolCallError({
36249
+ detail: unauthorized$1(initialize.status) ? "The server rejected p4code's credentials." : `The server answered ${initialize.status} to initialize.`,
36250
+ status: initialize.status
36251
+ });
36252
+ const sessionId = initialize.headers["mcp-session-id"];
36253
+ const sessionHeaders = {
36254
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
36255
+ ...sessionId === void 0 ? {} : { "mcp-session-id": sessionId }
36256
+ };
36257
+ yield* post({
36258
+ jsonrpc: "2.0",
36259
+ method: "notifications/initialized"
36260
+ }, sessionHeaders).pipe(Effect.ignore);
36261
+ const response = yield* post({
36262
+ jsonrpc: "2.0",
36263
+ id: 2,
36264
+ method: "tools/call",
36265
+ params: {
36266
+ name: input.toolName,
36267
+ arguments: input.arguments
36268
+ }
36269
+ }, sessionHeaders);
36270
+ const body = yield* response.text.pipe(Effect.catchCause(() => Effect.succeed("")));
36271
+ if (response.status >= 400) return yield* new McpToolCallError({
36272
+ detail: unauthorized$1(response.status) ? "The server rejected p4code's credentials." : `The server answered ${response.status} to ${input.toolName}.`,
36273
+ status: response.status
36274
+ });
36275
+ const envelope = readEnvelope(body);
36276
+ if (envelope === void 0) return yield* new McpToolCallError({
36277
+ detail: `The server's answer to ${input.toolName} was not JSON-RPC.`,
36278
+ status: response.status
36279
+ });
36280
+ const error = envelope["error"];
36281
+ if (typeof error === "object" && error !== null) {
36282
+ const detail = error["message"];
36283
+ return yield* new McpToolCallError({
36284
+ detail: typeof detail === "string" ? detail : `${input.toolName} failed.`,
36285
+ status: response.status
36286
+ });
36287
+ }
36288
+ const result = envelope["result"];
36289
+ if (typeof result !== "object" || result === null) return yield* new McpToolCallError({
36290
+ detail: `${input.toolName} returned no result.`,
36291
+ status: response.status
36292
+ });
36293
+ const resultRecord = result;
36294
+ const text = textOf(resultRecord["content"]);
36295
+ if (resultRecord["isError"] === true) return yield* new McpToolCallError({
36296
+ detail: text.length > 0 ? text : `${input.toolName} reported an error.`,
36297
+ status: response.status
36298
+ });
36299
+ return {
36300
+ structuredContent: resultRecord["structuredContent"],
36301
+ text
36302
+ };
36303
+ });
36304
+ /** Why a Linear call could not be made, in terms a person can act on. */
36305
+ var LinearUnavailable = class extends Schema$1.TaggedErrorClass()("LinearUnavailable", {
36306
+ reason: Schema$1.Literals([
36307
+ "not_configured",
36308
+ "not_authorized",
36309
+ "failed"
36310
+ ]),
36311
+ detail: Schema$1.String
36312
+ }) {
36313
+ get message() {
36314
+ return this.detail;
36315
+ }
36316
+ };
36317
+ const asRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
36318
+ const asArray = (value) => Array.isArray(value) ? value : void 0;
36319
+ /**
36320
+ * Find the payload inside whatever the tool returned.
36321
+ *
36322
+ * MCP leaves a tool's result shape to the tool, so this accepts the three
36323
+ * arrangements seen in the wild - the structured payload itself, a text block
36324
+ * that happens to be JSON, or nothing usable - rather than assuming the one
36325
+ * Linear happens to send today. Same reasoning as `TicketResolver.readIssue`,
36326
+ * generalized because this one reads lists as well as single objects.
36327
+ */
36328
+ const readPayload$1 = (result) => {
36329
+ if (result.structuredContent !== void 0 && result.structuredContent !== null) return result.structuredContent;
36330
+ try {
36331
+ return JSON.parse(result.text);
36332
+ } catch {
36333
+ return;
36334
+ }
36335
+ };
36336
+ /**
36337
+ * The rows out of a list result.
36338
+ *
36339
+ * A list tool may answer with a bare array or with the array under a key that
36340
+ * names the entity (`issues`, `teams`, `statuses`, `nodes`). Rather than
36341
+ * hard-coding each name, this takes the first array-valued property, which is
36342
+ * unambiguous in practice: these payloads carry one collection and some
36343
+ * pagination scalars beside it.
36344
+ */
36345
+ const readRows = (payload) => {
36346
+ const direct = asArray(payload);
36347
+ if (direct !== void 0) return direct.map(asRecord$3).filter((row) => row !== void 0);
36348
+ const record = asRecord$3(payload);
36349
+ if (record === void 0) return [];
36350
+ for (const value of Object.values(record)) {
36351
+ const rows = asArray(value);
36352
+ if (rows !== void 0) return rows.map(asRecord$3).filter((row) => row !== void 0);
36353
+ }
36354
+ return [];
36355
+ };
36356
+ /** The single object out of a result, unwrapping one level of nesting. */
36357
+ const readOne = (payload) => {
36358
+ const record = asRecord$3(payload);
36359
+ if (record === void 0) return;
36360
+ if (record["id"] !== void 0 || record["identifier"] !== void 0) return record;
36361
+ for (const value of Object.values(record)) {
36362
+ const nested = asRecord$3(value);
36363
+ if (nested?.["id"] !== void 0) return nested;
36364
+ }
36365
+ };
36366
+ var LinearMcpClient = class extends Context.Service()("@p4code/cli/mcp/LinearMcpClient") {};
36367
+ const makeLinearMcpClient = Effect.gen(function* () {
36368
+ const registry = yield* McpRegistry;
36369
+ const oauth = yield* McpOAuth;
36370
+ const http = yield* HttpClient$1.HttpClient;
36371
+ /**
36372
+ * The registration, already narrowed to one that can be reached over HTTP.
36373
+ *
36374
+ * A stdio Linear server is treated as absent rather than as an error: this
36375
+ * client speaks streamable HTTP only, and a machine that registered Linear
36376
+ * over stdio has no reachable Linear as far as the board is concerned.
36377
+ */
36378
+ const findServer = registry.list.pipe(Effect.map((servers) => {
36379
+ for (const candidate of servers) {
36380
+ const registration = candidate.registration;
36381
+ if (registration.name.toLowerCase() === "linear" && registration.enabled && registration.transport !== "stdio") return registration;
36382
+ }
36383
+ }), Effect.orElseSucceed(() => void 0));
36384
+ const isConfigured = findServer.pipe(Effect.map((registration) => registration !== void 0));
36385
+ const call = (toolName, args) => Effect.gen(function* () {
36386
+ const registration = yield* findServer;
36387
+ if (registration === void 0) return yield* new LinearUnavailable({
36388
+ reason: "not_configured",
36389
+ detail: "No Linear MCP server is registered on this machine. Add it in Settings to use the Linear board."
36390
+ });
36391
+ const token = yield* oauth.accessTokenFor(registration).pipe(Effect.orElseSucceed(() => Option.none()));
36392
+ if (Option.isNone(token) && Object.keys(registration.headers ?? {}).length === 0) return yield* new LinearUnavailable({
36393
+ reason: "not_authorized",
36394
+ detail: "Linear is registered but not signed in on this machine. Sign in from Settings."
36395
+ });
36396
+ const headers = {
36397
+ ...registration.headers,
36398
+ ...Option.isSome(token) ? { authorization: `Bearer ${token.value}` } : {}
36399
+ };
36400
+ const result = yield* callMcpTool({
36401
+ url: registration.url,
36402
+ headers,
36403
+ toolName,
36404
+ arguments: args
36405
+ }).pipe(Effect.provideService(HttpClient$1.HttpClient, http), Effect.mapError((error) => error.status === 401 || error.status === 403 ? new LinearUnavailable({
36406
+ reason: "not_authorized",
36407
+ detail: "Linear rejected p4code's sign-in. Sign in again from Settings."
36408
+ }) : new LinearUnavailable({
36409
+ reason: "failed",
36410
+ detail: error.detail
36411
+ })));
36412
+ return readPayload$1(result);
36413
+ });
36414
+ return {
36415
+ isConfigured,
36416
+ call
36417
+ };
36418
+ });
36419
+ const LinearMcpClientLive = Layer.effect(LinearMcpClient, makeLinearMcpClient);
36420
+ //#endregion
36421
+ //#region ../../packages/shared/src/ticketReference.ts
36422
+ /**
36423
+ * Recognizing a tracker reference in text somebody pasted.
36424
+ *
36425
+ * Shared because both ends need the same answer: the composer decides whether a
36426
+ * paste is worth a round trip, and the server decides what to ask the tracker.
36427
+ * Two copies of this rule would differ, and the difference would read as "it
36428
+ * works on my machine but the paste does nothing".
36429
+ *
36430
+ * @module ticketReference
36431
+ */
36432
+ /**
36433
+ * A Linear issue URL, e.g.
36434
+ * `https://linear.app/omnicasa/issue/MOBILE-12262/haptic-feedback`.
36435
+ *
36436
+ * The workspace slug is what makes the URL unguessable from the identifier
36437
+ * alone, which is why a resolved ticket keeps the tracker's own URL rather than
36438
+ * rebuilding one.
36439
+ */
36440
+ const LINEAR_ISSUE_URL_PATTERN = /https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/;
36441
+ /**
36442
+ * A bare identifier, e.g. `MOBILE-12262`.
36443
+ *
36444
+ * Anchored: only a paste that is *nothing but* an identifier counts. Scanning
36445
+ * prose for this shape would claim things that are not tickets - a date range,
36446
+ * a version, a p4code readable id like `P4-40` - and each false positive costs
36447
+ * a request and, worse, a wrong link.
36448
+ */
36449
+ const BARE_IDENTIFIER_PATTERN = /^([A-Za-z][A-Za-z0-9]*-\d+)$/;
36450
+ /**
36451
+ * The issue identifier a paste refers to, or `null` when it refers to none.
36452
+ *
36453
+ * Case is normalized up, the way every tracker prints it, so the same ticket
36454
+ * pasted two ways compares equal.
36455
+ */
36456
+ function parseTicketReference(text) {
36457
+ const trimmed = text.trim();
36458
+ if (trimmed.length === 0) return null;
36459
+ const url = LINEAR_ISSUE_URL_PATTERN.exec(trimmed);
36460
+ if (url?.[1] !== void 0) return url[1].toUpperCase();
36461
+ const bare = BARE_IDENTIFIER_PATTERN.exec(trimmed);
36462
+ if (bare?.[1] !== void 0) return bare[1].toUpperCase();
36463
+ return null;
36464
+ }
36465
+ //#endregion
36466
+ //#region src/persistence/Layers/linearTaskMapping.ts
36467
+ /**
36468
+ * Linear's issues and p4code's tasks, in both directions.
36469
+ *
36470
+ * Kept apart from the repository that calls it because this is the part with
36471
+ * the judgement calls in it - which Linear state a `todo` becomes, what an
36472
+ * issue with a state nobody anticipated reads as - and those are worth testing
36473
+ * without an MCP server in the room.
36474
+ *
36475
+ * The mapping is lossy in one direction and only one: p4code carries a thread,
36476
+ * a project and a repository key that Linear has nowhere to put, and those live
36477
+ * in the `linear_task_links` sidecar instead. Everything Linear carries has a
36478
+ * home here.
36479
+ *
36480
+ * @module persistence/Layers/linearTaskMapping
36481
+ */
36482
+ /**
36483
+ * Linear's numeric priority, which is not p4code's ordering and not anyone's
36484
+ * intuition: 0 is none and 1 is the *most* urgent, so the scale runs backwards
36485
+ * from the number line.
36486
+ */
36487
+ const PRIORITY_FROM_LINEAR = {
36488
+ 0: "none",
36489
+ 1: "urgent",
36490
+ 2: "high",
36491
+ 3: "medium",
36492
+ 4: "low"
36493
+ };
36494
+ const PRIORITY_TO_LINEAR = {
36495
+ none: 0,
36496
+ urgent: 1,
36497
+ high: 2,
36498
+ medium: 3,
36499
+ low: 4
36500
+ };
36501
+ /**
36502
+ * The name p4code asks for when it wants a review column.
36503
+ *
36504
+ * Linear has no `in_review` state *type* - review states are ordinary
36505
+ * `started` states that a team happened to name - so this is the one status
36506
+ * that has to travel as a name and the one that can fail to apply. A team
36507
+ * without a state by this name gets Linear's own error, which says the state
36508
+ * was not found, rather than a silent landing in `in_progress`.
36509
+ */
36510
+ const LINEAR_REVIEW_STATE_NAME = "In Review";
36511
+ /** How p4code names each Linear state type. */
36512
+ const STATUS_FROM_LINEAR_TYPE = {
36513
+ triage: "backlog",
36514
+ backlog: "backlog",
36515
+ unstarted: "todo",
36516
+ started: "in_progress",
36517
+ completed: "done",
36518
+ canceled: "cancelled"
36519
+ };
36520
+ const asRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
36521
+ const text = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
36522
+ /**
36523
+ * A person's name out of whatever Linear put in the field: an object when the
36524
+ * issue was read with expansions, a bare string when it was not.
36525
+ */
36526
+ const personName = (value) => {
36527
+ const direct = text(value);
36528
+ if (direct !== void 0) return direct;
36529
+ const record = asRecord$2(value);
36530
+ if (record === void 0) return null;
36531
+ return text(record["displayName"]) ?? text(record["name"]) ?? text(record["email"]) ?? null;
36532
+ };
36533
+ /** Label names, from either `["Bug"]` or `[{ name: "Bug" }]`. */
36534
+ const labelNames = (value) => {
36535
+ if (!Array.isArray(value)) return [];
36536
+ return value.map((entry) => text(entry) ?? text(asRecord$2(entry)?.["name"])).filter((name) => name !== void 0);
36537
+ };
36538
+ /**
36539
+ * The p4code status an issue is in.
36540
+ *
36541
+ * Read from the state *type* rather than its name, because a team can call its
36542
+ * started state anything at all and only the type is stable across workspaces.
36543
+ * The one exception is review, which has no type of its own and so is
36544
+ * recognized by name - a `started` state whose name mentions review is a review
36545
+ * state, and the alternative is a board where nothing ever reaches the review
36546
+ * column.
36547
+ */
36548
+ const statusFromLinear = (statusType, statusName) => {
36549
+ const type = text(statusType)?.toLowerCase();
36550
+ const name = text(statusName);
36551
+ if (type === "started" && name !== void 0 && /review/iu.test(name)) return "in_review";
36552
+ if (type === void 0) return UNKNOWN_TASK_STATUS_FALLBACK;
36553
+ return STATUS_FROM_LINEAR_TYPE[type] ?? "backlog";
36554
+ };
36555
+ /**
36556
+ * What to put in `save_issue`'s `state`, which accepts a type, a name or an id.
36557
+ *
36558
+ * Types are used wherever one exists, so the write lands in whatever the team
36559
+ * named its backlog rather than requiring p4code to know. Review is the
36560
+ * exception explained above.
36561
+ */
36562
+ const statusToLinearState = (status) => {
36563
+ switch (status) {
36564
+ case "backlog": return "backlog";
36565
+ case "todo": return "unstarted";
36566
+ case "in_progress": return "started";
36567
+ case "in_review": return LINEAR_REVIEW_STATE_NAME;
36568
+ case "done": return "completed";
36569
+ case "cancelled": return "canceled";
36570
+ }
36571
+ };
36572
+ const priorityFromLinear = (value) => typeof value === "number" ? PRIORITY_FROM_LINEAR[value] ?? "none" : "none";
36573
+ const priorityToLinear = (priority) => PRIORITY_TO_LINEAR[priority];
36574
+ const EMPTY_LINEAR_TASK_LINK = {
36575
+ threadId: null,
36576
+ projectId: null,
36577
+ repositoryKey: null
36578
+ };
36579
+ /**
36580
+ * One Linear issue as a board card.
36581
+ *
36582
+ * Returns `undefined` for an issue with no id or no title, which is not a
36583
+ * defensive flourish: `list_issues` takes a field selector, and a caller that
36584
+ * forgets to ask for `title` would otherwise fill the board with blank cards
36585
+ * rather than failing where the mistake is.
36586
+ */
36587
+ const taskFromLinearIssue = (issue, link = EMPTY_LINEAR_TASK_LINK) => {
36588
+ const id = text(issue["id"]);
36589
+ const title = text(issue["title"]);
36590
+ if (id === void 0 || title === void 0) return;
36591
+ const url = text(issue["url"]);
36592
+ const identifier = text(issue["identifier"]) ?? (url === void 0 ? void 0 : parseTicketReference(url) ?? void 0);
36593
+ const createdAt = text(issue["createdAt"]);
36594
+ const updatedAt = text(issue["updatedAt"]);
36595
+ const parentId = text(issue["parentId"]);
36596
+ return {
36597
+ taskId: TaskId.make(id),
36598
+ readableId: identifier ?? null,
36599
+ orderKey: null,
36600
+ projectId: link.projectId === null ? null : link.projectId,
36601
+ repositoryKey: link.repositoryKey,
36602
+ threadId: link.threadId === null ? null : link.threadId,
36603
+ externalRef: identifier === void 0 || url === void 0 ? null : {
36604
+ source: "linear",
36605
+ identifier,
36606
+ url
36607
+ },
36608
+ parentTaskId: parentId === void 0 ? null : TaskId.make(parentId),
36609
+ title,
36610
+ body: text(issue["description"]) ?? "",
36611
+ status: statusFromLinear(issue["statusType"], issue["status"]),
36612
+ priority: priorityFromLinear(issue["priority"]),
36613
+ assignee: personName(issue["assignee"]),
36614
+ labels: labelNames(issue["labels"]),
36615
+ createdAt: createdAt ?? updatedAt ?? "1970-01-01T00:00:00.000Z",
36616
+ updatedAt: updatedAt ?? createdAt ?? "1970-01-01T00:00:00.000Z"
36617
+ };
36618
+ };
36619
+ /**
36620
+ * The fields `list_issues` has to return for a card to be complete.
36621
+ *
36622
+ * Named here rather than at the call site because `taskFromLinearIssue` reads
36623
+ * exactly these, and the two drifting apart is how a board quietly loses its
36624
+ * assignees.
36625
+ */
36626
+ const LINEAR_ISSUE_FIELDS = [
36627
+ "id",
36628
+ "title",
36629
+ "description",
36630
+ "priority",
36631
+ "url",
36632
+ "createdAt",
36633
+ "updatedAt",
36634
+ "status",
36635
+ "statusType",
36636
+ "labels",
36637
+ "assignee",
36638
+ "parentId"
36639
+ ];
36640
+ //#endregion
36641
+ //#region src/persistence/Layers/LinearTasks.ts
36642
+ /**
36643
+ * How often an open board asks Linear what changed.
36644
+ *
36645
+ * Slower than the hub's poll, because this is somebody else's rate limit and
36646
+ * a Linear workspace is edited by people at human speed rather than by agents
36647
+ * at machine speed.
36648
+ */
36649
+ const POLL_INTERVAL$1 = Duration.seconds(30);
36650
+ /** Linear's cap on one page. Asking for more is an error, not a bigger page. */
36651
+ const LINEAR_PAGE_LIMIT = 250;
36652
+ const LINEAR_LIST_TOOL = "list_issues";
36653
+ const LINEAR_GET_TOOL = "get_issue";
36654
+ const LINEAR_SAVE_TOOL = "save_issue";
36655
+ const linearFailed = (operation, cause) => new PersistenceSqlError({
36656
+ operation: `LinearTaskRepository.${operation}`,
36657
+ detail: cause.detail,
36658
+ cause: null
36659
+ });
36660
+ const makeLinearTaskRepository = Effect.gen(function* () {
36661
+ const sql = yield* SqlClient.SqlClient;
36662
+ const linear = yield* LinearMcpClient;
36663
+ const settings = yield* ServerSettingsService;
36664
+ const changes = yield* PubSub.unbounded();
36665
+ const publish = (event) => PubSub.publish(changes, event).pipe(Effect.asVoid);
36666
+ const watchers = yield* Ref.make(0);
36667
+ const lastSeen = yield* Ref.make(null);
36668
+ const LinkRow = Schema$1.Struct({
36669
+ issueId: Schema$1.String,
36670
+ threadId: Schema$1.NullOr(Schema$1.String),
36671
+ projectId: Schema$1.NullOr(Schema$1.String),
36672
+ repositoryKey: Schema$1.NullOr(Schema$1.String)
36673
+ });
36674
+ const selectLinks = SqlSchema.findAll({
36675
+ Request: Schema$1.Struct({}),
36676
+ Result: LinkRow,
36677
+ execute: () => sql`
36678
+ SELECT issue_id AS "issueId",
36679
+ thread_id AS "threadId",
36680
+ project_id AS "projectId",
36681
+ repository_key AS "repositoryKey"
36682
+ FROM linear_task_links
36683
+ `
36684
+ });
36685
+ const upsertLink = SqlSchema.void({
36686
+ Request: LinkRow,
36687
+ execute: (row) => sql`
36688
+ INSERT INTO linear_task_links (issue_id, thread_id, project_id, repository_key)
36689
+ VALUES (${row.issueId}, ${row.threadId}, ${row.projectId}, ${row.repositoryKey})
36690
+ ON CONFLICT (issue_id) DO UPDATE SET
36691
+ thread_id = excluded.thread_id,
36692
+ project_id = excluded.project_id,
36693
+ repository_key = excluded.repository_key
36694
+ `
36695
+ });
36696
+ const readLinks = selectLinks({}).pipe(Effect.map((rows) => new Map(rows.map((row) => [row.issueId, {
36697
+ threadId: row.threadId,
36698
+ projectId: row.projectId,
36699
+ repositoryKey: row.repositoryKey
36700
+ }]))), Effect.mapError(toPersistenceSqlError("LinearTaskRepository.readLinks")));
36701
+ /**
36702
+ * Merge the p4code-only fields of a write into the sidecar.
36703
+ *
36704
+ * Absent means unchanged, matching every other patch in the codebase, which
36705
+ * is why this reads the existing row instead of writing three nulls over it.
36706
+ */
36707
+ const saveLink = (issueId, patch) => Effect.gen(function* () {
36708
+ const current = (yield* readLinks).get(issueId) ?? EMPTY_LINEAR_TASK_LINK;
36709
+ yield* upsertLink({
36710
+ issueId,
36711
+ threadId: patch.threadId === void 0 ? current.threadId : patch.threadId,
36712
+ projectId: patch.projectId === void 0 ? current.projectId : patch.projectId,
36713
+ repositoryKey: patch.repositoryKey === void 0 ? current.repositoryKey : patch.repositoryKey
36714
+ }).pipe(Effect.mapError(toPersistenceSqlError("LinearTaskRepository.saveLink")));
36715
+ });
36716
+ const call = (operation, tool, args) => linear.call(tool, args).pipe(Effect.mapError((cause) => linearFailed(operation, cause)));
36717
+ const configuredTeam = settings.getSettings.pipe(Effect.map((current) => current.linearTeam.trim()), Effect.orElseSucceed(() => ""));
36718
+ const list = (filter) => Effect.gen(function* () {
36719
+ const team = yield* configuredTeam;
36720
+ const payload = yield* call("list", LINEAR_LIST_TOOL, {
36721
+ includeArchived: false,
36722
+ limit: LINEAR_PAGE_LIMIT,
36723
+ fields: [...LINEAR_ISSUE_FIELDS],
36724
+ ...team === "" ? {} : { team },
36725
+ ...filter.status === void 0 ? {} : { state: statusToLinearState(filter.status) },
36726
+ ...filter.assignee === void 0 ? {} : { assignee: filter.assignee }
36727
+ });
36728
+ const links = yield* readLinks;
36729
+ return readRows(payload).map((issue) => taskFromLinearIssue(issue, links.get(String(issue["id"])) ?? void 0)).filter((task) => task !== void 0).filter((task) => (filter.projectId === void 0 || task.projectId === filter.projectId) && (filter.repositoryKey === void 0 || task.repositoryKey === filter.repositoryKey) && (filter.threadId === void 0 || task.threadId === filter.threadId));
36730
+ });
36731
+ const readIssue = (operation, reference) => Effect.gen(function* () {
36732
+ const issue = readOne(yield* call(operation, LINEAR_GET_TOOL, { id: reference }));
36733
+ if (issue === void 0) return Option.none();
36734
+ const task = taskFromLinearIssue(issue, (yield* readLinks).get(String(issue["id"])) ?? void 0);
36735
+ return task === void 0 ? Option.none() : Option.some(task);
36736
+ });
36737
+ const getById = ({ taskId }) => readIssue("getById", taskId);
36738
+ const getByReadableId = (readableId) => readIssue("getByReadableId", readableId);
36739
+ /**
36740
+ * The issue fields of a write, as `save_issue` wants them.
36741
+ *
36742
+ * Undefined entries are dropped rather than sent, because this tool treats
36743
+ * an absent key as "leave alone" and an explicit null as "clear" - and the
36744
+ * two are different answers for `assignee` and `parentId`.
36745
+ */
36746
+ const issueFields = (input) => ({
36747
+ ...input.title === void 0 ? {} : { title: input.title },
36748
+ ...input.body === void 0 ? {} : { description: input.body },
36749
+ ...input.status === void 0 ? {} : { state: statusToLinearState(input.status) },
36750
+ ...input.priority === void 0 ? {} : { priority: priorityToLinear(input.priority) },
36751
+ ...input.assignee === void 0 ? {} : { assignee: input.assignee },
36752
+ ...input.labels === void 0 ? {} : { labels: [...input.labels] },
36753
+ ...input.parentTaskId === void 0 ? {} : { parentId: input.parentTaskId }
36754
+ });
36755
+ const saveIssue = (operation, args) => Effect.gen(function* () {
36756
+ const issue = readOne(yield* call(operation, LINEAR_SAVE_TOOL, args));
36757
+ if (issue === void 0) return yield* new PersistenceSqlError({
36758
+ operation: `LinearTaskRepository.${operation}`,
36759
+ detail: "Linear accepted the write but returned no issue.",
36760
+ cause: null
36761
+ });
36762
+ const task = taskFromLinearIssue(issue, (yield* readLinks).get(String(issue["id"])) ?? void 0);
36763
+ if (task === void 0) return yield* new PersistenceSqlError({
36764
+ operation: `LinearTaskRepository.${operation}`,
36765
+ detail: "Linear returned an issue with no id or title.",
36766
+ cause: null
36767
+ });
36768
+ return task;
36769
+ });
36770
+ const create = (row) => Effect.gen(function* () {
36771
+ const team = yield* configuredTeam;
36772
+ if (team === "") return yield* new PersistenceSqlError({
36773
+ operation: "LinearTaskRepository.create",
36774
+ detail: "No Linear team is chosen, and Linear needs one to file an issue. Pick a team on the board.",
36775
+ cause: null
36776
+ });
36777
+ const created = yield* saveIssue("create", {
36778
+ team,
36779
+ ...issueFields({
36780
+ title: row.title,
36781
+ body: row.body,
36782
+ status: row.status,
36783
+ priority: row.priority,
36784
+ assignee: row.assignee,
36785
+ labels: row.labels,
36786
+ parentTaskId: row.parentTaskId
36787
+ })
36788
+ });
36789
+ yield* saveLink(created.taskId, {
36790
+ threadId: row.threadId,
36791
+ projectId: row.projectId,
36792
+ repositoryKey: row.repositoryKey
36793
+ });
36794
+ const task = {
36795
+ ...created,
36796
+ threadId: row.threadId,
36797
+ projectId: row.projectId,
36798
+ repositoryKey: row.repositoryKey
36799
+ };
36800
+ yield* publish({
36801
+ type: "upserted",
36802
+ task
36803
+ });
36804
+ return task;
36805
+ });
36806
+ const upsert = (row) => saveIssue("upsert", {
36807
+ id: row.taskId,
36808
+ ...issueFields(row)
36809
+ }).pipe(Effect.tap(() => saveLink(row.taskId, {
36810
+ threadId: row.threadId,
36811
+ projectId: row.projectId,
36812
+ repositoryKey: row.repositoryKey
36813
+ })), Effect.tap((task) => publish({
36814
+ type: "upserted",
36815
+ task: {
36816
+ ...task,
36817
+ threadId: row.threadId
36818
+ }
36819
+ })), Effect.asVoid);
36820
+ const patch = (input) => Effect.gen(function* () {
36821
+ if (!(input.title !== void 0 || input.body !== void 0 || input.status !== void 0 || input.priority !== void 0 || input.assignee !== void 0 || input.labels !== void 0 || input.parentTaskId !== void 0)) {
36822
+ yield* saveLink(input.taskId, {
36823
+ ...input.threadId === void 0 ? {} : { threadId: input.threadId },
36824
+ ...input.projectId === void 0 ? {} : { projectId: input.projectId },
36825
+ ...input.repositoryKey === void 0 ? {} : { repositoryKey: input.repositoryKey }
36826
+ });
36827
+ const reread = yield* getById({ taskId: input.taskId });
36828
+ yield* Option.match(reread, {
36829
+ onNone: () => Effect.void,
36830
+ onSome: (task) => publish({
36831
+ type: "upserted",
36832
+ task
36833
+ })
36834
+ });
36835
+ return reread;
36836
+ }
36837
+ const saved = yield* saveIssue("patch", {
36838
+ id: input.taskId,
36839
+ ...issueFields(input)
36840
+ });
36841
+ yield* saveLink(input.taskId, {
36842
+ ...input.threadId === void 0 ? {} : { threadId: input.threadId },
36843
+ ...input.projectId === void 0 ? {} : { projectId: input.projectId },
36844
+ ...input.repositoryKey === void 0 ? {} : { repositoryKey: input.repositoryKey }
36845
+ });
36846
+ const links = yield* readLinks;
36847
+ const task = {
36848
+ ...saved,
36849
+ ...links.get(input.taskId) ?? EMPTY_LINEAR_TASK_LINK
36850
+ };
36851
+ yield* publish({
36852
+ type: "upserted",
36853
+ task
36854
+ });
36855
+ return Option.some(task);
36856
+ });
36857
+ const patchIfStatus = (input, updatedAt) => Effect.gen(function* () {
36858
+ const current = yield* getById({ taskId: input.patch.taskId });
36859
+ if (Option.isNone(current)) return { outcome: "missing" };
36860
+ if (current.value.status !== input.expectedStatus) return {
36861
+ outcome: "conflict",
36862
+ current: current.value
36863
+ };
36864
+ const updated = yield* patch(input.patch, updatedAt);
36865
+ return Option.isNone(updated) ? { outcome: "missing" } : {
36866
+ outcome: "updated",
36867
+ task: updated.value
36868
+ };
36869
+ });
36870
+ const deleteById = () => new PersistenceSqlError({
36871
+ operation: "LinearTaskRepository.deleteById",
36872
+ detail: "Linear issues cannot be deleted from p4code. Cancel the issue instead, or delete it in Linear.",
36873
+ cause: null
36874
+ });
36875
+ /**
36876
+ * One pass: read the board, publish what differs from last time.
36877
+ *
36878
+ * Compared on `updatedAt` for the same reason the hub's poll is - it is the
36879
+ * field every write moves. A failed pass publishes nothing and keeps the old
36880
+ * snapshot, so a Linear blip does not empty an open board.
36881
+ */
36882
+ const pollOnce = Effect.gen(function* () {
36883
+ if ((yield* Ref.get(watchers)) === 0) return;
36884
+ const current = yield* list({});
36885
+ const previous = yield* Ref.getAndSet(lastSeen, current);
36886
+ if (previous === null) return;
36887
+ const previousById = new Map(previous.map((task) => [task.taskId, task]));
36888
+ for (const task of current) {
36889
+ const before = previousById.get(task.taskId);
36890
+ if (before === void 0 || before.updatedAt !== task.updatedAt) yield* publish({
36891
+ type: "upserted",
36892
+ task
36893
+ });
36894
+ previousById.delete(task.taskId);
36895
+ }
36896
+ for (const taskId of previousById.keys()) yield* publish({
36897
+ type: "deleted",
36898
+ taskId
36899
+ });
36900
+ }).pipe(Effect.ignoreCause({ log: true }));
36901
+ yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$1)))).pipe(Effect.forkScoped);
36902
+ return {
36903
+ create,
36904
+ upsert,
36905
+ patch,
36906
+ patchIfStatus,
36907
+ getById,
36908
+ getByReadableId,
36909
+ list,
36910
+ deleteById,
36911
+ streamChanges: Stream.fromPubSub(changes),
36912
+ streamWithSnapshot: Stream.unwrap(Effect.gen(function* () {
36913
+ yield* Effect.acquireRelease(Ref.update(watchers, (count) => count + 1), () => Ref.update(watchers, (count) => Math.max(0, count - 1)));
36914
+ const subscription = yield* PubSub.subscribe(changes);
36915
+ const snapshot = yield* list({});
36916
+ yield* Ref.set(lastSeen, snapshot);
36917
+ return Stream.concat(Stream.succeed({
36918
+ type: "snapshot",
36919
+ tasks: snapshot
36920
+ }), Stream.fromSubscription(subscription));
36921
+ }))
36922
+ };
36923
+ });
36924
+ //#endregion
36925
+ //#region src/persistence/Layers/RemoteTasks.ts
36926
+ /**
36927
+ * The hub's task API, as a client.
36928
+ *
36929
+ * This used to be a `TaskRepository` in its own right, and the board was
36930
+ * either this or the local database. It is now the transport underneath
36931
+ * `SyncedTaskRepository`: local SQLite is a replica of the hub, so nothing
36932
+ * reads from here directly and the board no longer goes dark when the hub is
36933
+ * unreachable. What changed with it is the failure story — an unreachable hub
36934
+ * used to mean no board, and now means a board that is behind.
36935
+ *
36936
+ * The four calls here are what a mirror needs and no more: read the hub's
36937
+ * rows, push a whole row, arbitrate a claim, and delete. There is no partial
36938
+ * patch, because a mirror always holds the full row it wants the hub to have
36939
+ * and sending a diff of it would only add a way for the two to disagree.
36940
+ *
36941
+ * @module persistence/Layers/RemoteTasks
36942
+ */
36943
+ const HUB_REQUEST_TIMEOUT_MS = 15e3;
36944
+ const TaskListPage = Schema$1.Struct({ tasks: Schema$1.Array(Task) });
36945
+ const ConflictBody = Schema$1.Struct({ current: Task });
36946
+ const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
36947
+ const decodeTask = Schema$1.decodeUnknownEffect(Task);
36948
+ const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
36949
+ /**
36950
+ * Every failure here is reported as the persistence error type.
36951
+ *
36952
+ * The mirror catches these and carries on — a failed push leaves the row
36953
+ * `pending` and the next pass retries it — so what matters is that the detail
36954
+ * says the hub was the store that would not answer, which is what shows up in
36955
+ * the log when someone asks why their two machines disagree.
36956
+ */
36957
+ const hubUnavailable = (operation, detail) => new PersistenceSqlError({
36958
+ operation: `HubTaskClient.${operation}`,
36959
+ detail: `The hub board is unavailable: ${detail}`,
36960
+ cause: null
36961
+ });
36962
+ var HubTaskClient = class extends Context.Service()("@p4code/cli/persistence/Layers/RemoteTasks/HubTaskClient") {};
36963
+ const makeHubTaskClient = Effect.gen(function* () {
36964
+ const http = yield* HttpClient.HttpClient;
36965
+ const link = yield* HubLink;
36966
+ const isLinked = link.current.pipe(Effect.map((state) => Option.isSome(state.settings)));
36967
+ const requireSettings = (operation) => Effect.gen(function* () {
36968
+ const state = yield* link.current;
36969
+ if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
36970
+ return state.settings.value;
36971
+ });
36972
+ 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"}`)));
36973
+ const list = (filter) => Effect.gen(function* () {
36974
+ const settings = yield* requireSettings("list");
36975
+ const query = new URLSearchParams();
36976
+ for (const key of [
36977
+ "projectId",
36978
+ "repositoryKey",
36979
+ "threadId",
36980
+ "status",
36981
+ "assignee"
36982
+ ]) {
36983
+ const value = filter[key];
36984
+ if (value !== void 0) query.set(key, value);
36985
+ }
36986
+ const suffix = query.size === 0 ? "" : `?${query.toString()}`;
36987
+ const response = yield* send("list", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks${suffix}`));
36988
+ if (response.status !== 200) return yield* hubUnavailable("list", `status ${response.status}`);
36989
+ return (yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("list", "the response did not match the contract")))).tasks;
36990
+ });
36991
+ const put = (row, readableIdPrefix) => Effect.gen(function* () {
36992
+ const settings = yield* requireSettings("put");
36993
+ const response = yield* send("put", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.put(`${settings.baseUrl}/tasks/${encodeURIComponent(row.taskId)}`), {
36994
+ projectId: row.projectId,
36995
+ repositoryKey: row.repositoryKey,
36996
+ threadId: row.threadId,
36997
+ externalRef: row.externalRef,
36998
+ parentTaskId: row.parentTaskId,
36999
+ title: row.title,
37000
+ body: row.body,
37001
+ status: row.status,
37002
+ priority: row.priority,
37003
+ assignee: row.assignee,
37004
+ labels: row.labels,
37005
+ orderKey: row.orderKey,
37006
+ createdAt: row.createdAt,
37007
+ readableIdPrefix
37008
+ }));
37009
+ if (response.status !== 200 && response.status !== 201) return yield* hubUnavailable("put", `status ${response.status}`);
37010
+ return yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("put", "the response did not match the contract")));
37011
+ });
37012
+ const patchIfStatus = (input) => Effect.gen(function* () {
37013
+ const settings = yield* requireSettings("patchIfStatus");
37014
+ const { taskId, ...fields } = input.patch;
37015
+ const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
37016
+ ...fields,
37017
+ expectedStatus: input.expectedStatus
37018
+ }));
37019
+ if (response.status === 404) return { outcome: "missing" };
37020
+ if (response.status === 409) return {
37021
+ outcome: "conflict",
37022
+ current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
37023
+ };
37024
+ if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
37025
+ return {
37026
+ outcome: "updated",
37027
+ task: yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")))
37028
+ };
37029
+ });
37030
+ const remove = (taskId) => Effect.gen(function* () {
37031
+ const settings = yield* requireSettings("remove");
37032
+ const response = yield* send("remove", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
37033
+ if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("remove", `status ${response.status}`);
37034
+ });
37035
+ return {
37036
+ isLinked,
37037
+ list,
37038
+ put,
37039
+ patchIfStatus,
37040
+ remove
37041
+ };
37042
+ });
37043
+ Layer.effect(HubTaskClient, makeHubTaskClient);
37044
+ //#endregion
37045
+ //#region src/persistence/Layers/TaskSyncStore.ts
37046
+ /**
37047
+ * The bookkeeping a mirror needs and a board does not.
37048
+ *
37049
+ * `TaskRepository` deliberately knows nothing about the hub: the same
37050
+ * interface backs the hub's own store, where "does the hub have this yet" is
37051
+ * not a question that can be asked. So the two extra facts the mirror needs —
37052
+ * which rows the hub has not acknowledged, and which absences are deletions
37053
+ * rather than gaps — live here, in their own small store over the same
37054
+ * database.
37055
+ *
37056
+ * Rows are pushed whole rather than as a queue of patches. Two edits to one
37057
+ * task while the hub was unreachable do not need to arrive as two writes; the
37058
+ * hub only ever needed the last one, and an ordered queue would buy replay
37059
+ * fidelity nobody reads at the cost of a table that can be half-drained.
37060
+ *
37061
+ * @module persistence/Layers/TaskSyncStore
37062
+ */
37063
+ const TaskDbRow = Task.mapFields(Struct.assign({
37064
+ labels: Schema$1.fromJsonString(Schema$1.Array(TaskLabel)),
37065
+ externalRef: Schema$1.NullOr(Schema$1.fromJsonString(TaskExternalRef))
37066
+ }));
37067
+ const TASK_COLUMNS = `
37068
+ task_id AS "taskId",
37069
+ readable_id AS "readableId",
37070
+ project_id AS "projectId",
37071
+ repository_key AS "repositoryKey",
37072
+ thread_id AS "threadId",
37073
+ external_ref_json AS "externalRef",
37074
+ parent_task_id AS "parentTaskId",
37075
+ title,
37076
+ body,
37077
+ status,
37078
+ priority,
37079
+ assignee,
37080
+ labels_json AS "labels",
37081
+ order_key AS "orderKey",
37082
+ created_at AS "createdAt",
37083
+ updated_at AS "updatedAt"
37084
+ `;
37085
+ const makeTaskSyncStore = Effect.gen(function* () {
37086
+ const sql = yield* SqlClient.SqlClient;
37087
+ const setState = SqlSchema.void({
37088
+ Request: Schema$1.Struct({
37089
+ taskId: TaskId,
37090
+ state: Schema$1.String
37091
+ }),
37092
+ execute: ({ taskId, state }) => sql`UPDATE tasks SET sync_state = ${state} WHERE task_id = ${taskId}`
37093
+ });
37094
+ const PendingRow = Schema$1.Struct({
37095
+ ...TaskDbRow.fields,
37096
+ readableIdPrefix: Schema$1.NullOr(Schema$1.String)
37097
+ });
37098
+ const selectPending = SqlSchema.findAll({
37099
+ Request: Schema$1.Struct({}),
37100
+ Result: PendingRow,
37101
+ execute: () => sql`
37102
+ SELECT ${sql.literal(TASK_COLUMNS)}, readable_id_prefix AS "readableIdPrefix"
37103
+ FROM tasks WHERE sync_state = 'pending'
37104
+ `
37105
+ });
37106
+ const advanceSequence = SqlSchema.void({
37107
+ Request: Schema$1.Struct({
37108
+ prefix: Schema$1.String,
37109
+ seq: Schema$1.Int
37110
+ }),
37111
+ execute: ({ prefix, seq }) => sql`
37112
+ INSERT INTO task_sequences (prefix, last_seq)
37113
+ VALUES (${prefix}, ${seq})
37114
+ ON CONFLICT (prefix) DO UPDATE SET last_seq = MAX(last_seq, excluded.last_seq)
37115
+ `
37116
+ });
37117
+ const selectPrefix = SqlSchema.findAll({
37118
+ Request: Schema$1.Struct({ taskId: TaskId }),
37119
+ Result: Schema$1.Struct({ readableIdPrefix: Schema$1.NullOr(Schema$1.String) }),
37120
+ execute: ({ taskId }) => sql`SELECT readable_id_prefix AS "readableIdPrefix" FROM tasks WHERE task_id = ${taskId}`
37121
+ });
37122
+ const setPrefix = SqlSchema.void({
37123
+ Request: Schema$1.Struct({
37124
+ taskId: TaskId,
37125
+ prefix: Schema$1.String
37126
+ }),
37127
+ execute: ({ taskId, prefix }) => sql`UPDATE tasks SET readable_id_prefix = ${prefix} WHERE task_id = ${taskId}`
37128
+ });
37129
+ const selectSyncedIds = SqlSchema.findAll({
37130
+ Request: Schema$1.Struct({}),
37131
+ Result: Schema$1.Struct({ taskId: TaskId }),
37132
+ execute: () => sql`SELECT task_id AS "taskId" FROM tasks WHERE sync_state = 'synced'`
37133
+ });
37134
+ const insertTombstone = SqlSchema.void({
37135
+ Request: Schema$1.Struct({
37136
+ taskId: TaskId,
37137
+ deletedAt: Schema$1.String
37138
+ }),
37139
+ execute: ({ taskId, deletedAt }) => sql`
37140
+ INSERT INTO task_tombstones (task_id, deleted_at)
37141
+ VALUES (${taskId}, ${deletedAt})
37142
+ ON CONFLICT (task_id) DO UPDATE SET deleted_at = excluded.deleted_at
37143
+ `
37144
+ });
37145
+ const removeTombstone = SqlSchema.void({
37146
+ Request: Schema$1.Struct({ taskId: TaskId }),
37147
+ execute: ({ taskId }) => sql`DELETE FROM task_tombstones WHERE task_id = ${taskId}`
37148
+ });
37149
+ const selectTombstones = SqlSchema.findAll({
37150
+ Request: Schema$1.Struct({}),
37151
+ Result: Schema$1.Struct({ taskId: TaskId }),
37152
+ execute: () => sql`SELECT task_id AS "taskId" FROM task_tombstones`
37153
+ });
37154
+ return {
37155
+ /** Flag a row as owing the hub a push. Every local write calls this. */
37156
+ markPending: (taskId) => setState({
37157
+ taskId,
37158
+ state: "pending"
37159
+ }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.markPending"))),
37160
+ /** Record that the hub returned this exact row. */
37161
+ markSynced: (taskId) => setState({
37162
+ taskId,
37163
+ state: "synced"
37164
+ }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.markSynced"))),
37165
+ /**
37166
+ * Remember which prefix this task should be numbered under, for a push
37167
+ * that happens long after the create that knew it.
37168
+ */
37169
+ rememberPrefix: (taskId, prefix) => setPrefix({
37170
+ taskId,
37171
+ prefix
37172
+ }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.rememberPrefix"))),
37173
+ /**
37174
+ * Move this machine's own sequence past a number the hub handed out.
37175
+ *
37176
+ * Without this the two counters drift apart in the one direction that
37177
+ * hurts: the local sequence is only bumped by local creates, so a machine
37178
+ * that has mirrored `P4-1` through `P4-40` from the hub still thinks its
37179
+ * next number is 1 - and the moment the hub link goes away and it mints
37180
+ * locally, the insert collides with a row it is already holding.
37181
+ *
37182
+ * Takes the maximum rather than assigning, so an out-of-order pull cannot
37183
+ * wind the counter backwards.
37184
+ */
37185
+ adoptSequence: (readableId) => {
37186
+ if (readableId === null) return Effect.void;
37187
+ const separator = readableId.lastIndexOf("-");
37188
+ if (separator <= 0) return Effect.void;
37189
+ const prefix = readableId.slice(0, separator).toUpperCase();
37190
+ const seq = Number.parseInt(readableId.slice(separator + 1), 10);
37191
+ if (!Number.isSafeInteger(seq) || seq <= 0) return Effect.void;
37192
+ return advanceSequence({
37193
+ prefix,
37194
+ seq
37195
+ }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.adoptSequence")));
37196
+ },
37197
+ /** The remembered prefix, or `null` for a row that predates the column. */
37198
+ readPrefix: (taskId) => selectPrefix({ taskId }).pipe(Effect.map((rows) => rows[0]?.readableIdPrefix ?? null), Effect.mapError(toPersistenceSqlError("TaskSyncStore.readPrefix"))),
37199
+ /** Whole rows, because a push sends the row and not a diff of it. */
37200
+ listPending: selectPending({}).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.listPending"))),
37201
+ /**
37202
+ * Ids the hub has confirmed. The pull compares against this rather than
37203
+ * against every local row: a row the hub has never seen is missing from
37204
+ * the hub's list for the obvious reason, and deleting it as "gone
37205
+ * elsewhere" would throw away a task filed while offline.
37206
+ */
37207
+ listSyncedIds: selectSyncedIds({}).pipe(Effect.map((rows) => rows.map((row) => row.taskId)), Effect.mapError(toPersistenceSqlError("TaskSyncStore.listSyncedIds"))),
37208
+ recordTombstone: (taskId, deletedAt) => insertTombstone({
37209
+ taskId,
37210
+ deletedAt
37211
+ }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.recordTombstone"))),
37212
+ clearTombstone: (taskId) => removeTombstone({ taskId }).pipe(Effect.mapError(toPersistenceSqlError("TaskSyncStore.clearTombstone"))),
37213
+ listTombstones: selectTombstones({}).pipe(Effect.map((rows) => rows.map((row) => row.taskId)), Effect.mapError(toPersistenceSqlError("TaskSyncStore.listTombstones")))
37214
+ };
37215
+ });
37216
+ //#endregion
37217
+ //#region src/persistence/Layers/SyncedTasks.ts
37218
+ /**
37219
+ * One board, replicated: local SQLite is the copy, the hub is the original.
37220
+ *
37221
+ * Every read is answered from this machine's database, so the board is instant
37222
+ * and keeps working with the hub unreachable. Every write lands locally first
37223
+ * and is then pushed; a push that fails leaves the row `pending` and the next
37224
+ * pass retries it. What a person sees is a board that never goes blank and
37225
+ * that agrees with the other machine within a poll interval.
37226
+ *
37227
+ * **This reverses the earlier decision, deliberately.** The hub board used to
37228
+ * fail loudly rather than answer from a local copy, on the grounds that a
37229
+ * stale answer recreates the per-machine divergence the hub exists to remove.
37230
+ * That argument was right about the risk and wrong about the cost: it traded a
37231
+ * working board for a guarantee that only holds while the network does. The
37232
+ * replacement keeps the guarantee where it actually matters — `patchIfStatus`,
37233
+ * the claim, still goes to the hub and still fails when the hub is unreachable,
37234
+ * because two machines each arbitrating against their own replica would both
37235
+ * win. Everything else is last-writer-wins, and a person retitling a task on
37236
+ * a plane is not a correctness problem.
37237
+ *
37238
+ * **With no hub linked this is just the local store**, plus rows marked as
37239
+ * owing a push that never comes. Linking a hub later uploads them.
37240
+ *
37241
+ * @module persistence/Layers/SyncedTasks
37242
+ */
37243
+ /**
37244
+ * How stale another machine's write may look here.
37245
+ *
37246
+ * Short enough that a board left open follows along, long enough that two
37247
+ * servers idling all day are not a steady stream of requests at a hosted
37248
+ * service. Only changes made elsewhere wait for it; a write made here is
37249
+ * already on screen.
37250
+ */
37251
+ const SYNC_INTERVAL = Duration.seconds(10);
37252
+ const makeSyncedTaskRepository = Effect.gen(function* () {
37253
+ const local = yield* makeTaskRepository;
37254
+ const sync = yield* makeTaskSyncStore;
37255
+ const hub = yield* makeHubTaskClient;
37256
+ const now = DateTime.now.pipe(Effect.map(DateTime.formatIso));
37257
+ const prefixFor = (taskId) => sync.readPrefix(taskId).pipe(Effect.map((prefix) => prefix ?? "T"));
37258
+ /**
37259
+ * Send one row and adopt what came back.
37260
+ *
37261
+ * Adopting matters more than sending: the hub's answer carries the readable
37262
+ * id it allocated and the timestamp it stamped, and a mirror that kept its
37263
+ * own versions of those would disagree with the other machine about what the
37264
+ * task is called.
37265
+ */
37266
+ /**
37267
+ * Take a row the hub confirmed: store it, mark it settled, and move this
37268
+ * machine's own readable-id sequence past the number the hub used.
37269
+ *
37270
+ * The sequence step is not optional bookkeeping. Local minting only ever
37271
+ * bumps the local counter, so a machine holding forty mirrored rows still
37272
+ * believes its next number is 1 - and the first task it files with the hub
37273
+ * gone would collide with a row it is already holding.
37274
+ */
37275
+ const adopt = (stored) => local.upsert(stored).pipe(Effect.andThen(sync.markSynced(stored.taskId)), Effect.andThen(sync.adoptSequence(stored.readableId)));
37276
+ const pushRow = (task, readableIdPrefix) => hub.put(task, readableIdPrefix).pipe(Effect.tap(adopt));
37277
+ /** A push whose failure is not the caller's problem: the retry loop owns it. */
37278
+ const pushInBackground = (task, readableIdPrefix) => pushRow(task, readableIdPrefix).pipe(Effect.ignoreCause({ log: true }), Effect.asVoid);
37279
+ const create = (row, readableIdPrefix) => Effect.gen(function* () {
37280
+ if (!(yield* hub.isLinked)) {
37281
+ const task = yield* local.create(row, readableIdPrefix);
37282
+ yield* sync.rememberPrefix(task.taskId, readableIdPrefix);
37283
+ return task;
37284
+ }
37285
+ const draft = {
37286
+ ...row,
37287
+ readableId: null
37288
+ };
37289
+ yield* local.upsert(draft);
37290
+ yield* sync.rememberPrefix(draft.taskId, readableIdPrefix);
37291
+ return yield* pushRow(draft, readableIdPrefix).pipe(Effect.catchCause((cause) => Effect.logDebug("task create could not reach the hub; queued", { cause }).pipe(Effect.as(draft))));
37292
+ });
37293
+ const upsert = (row) => Effect.gen(function* () {
37294
+ yield* local.upsert(row);
37295
+ yield* sync.markPending(row.taskId);
37296
+ if (yield* hub.isLinked) yield* pushInBackground(row, yield* prefixFor(row.taskId));
37297
+ });
37298
+ const patch = (input, updatedAt) => Effect.gen(function* () {
37299
+ const patched = yield* local.patch(input, updatedAt);
37300
+ if (Option.isNone(patched)) return patched;
37301
+ const task = patched.value;
37302
+ yield* sync.markPending(task.taskId);
37303
+ if (!(yield* hub.isLinked)) return patched;
37304
+ const prefix = yield* prefixFor(task.taskId);
37305
+ return yield* pushRow(task, prefix).pipe(Effect.map(Option.some), Effect.catchCause(() => Effect.succeed(patched)));
37306
+ });
37307
+ /**
37308
+ * The one call that does not go local-first.
37309
+ *
37310
+ * A claim is a question about who gets the task, and a replica cannot answer
37311
+ * it — both machines would compare against their own copy, both would see
37312
+ * `todo`, and both would start. So this goes to the hub and only writes what
37313
+ * the hub decided. With the hub unreachable it fails, which is the whole
37314
+ * point: an agent that cannot establish it won the task must not run it.
37315
+ */
37316
+ const patchIfStatus = (input, updatedAt) => Effect.gen(function* () {
37317
+ if (!(yield* hub.isLinked)) return yield* local.patchIfStatus(input, updatedAt);
37318
+ const outcome = yield* hub.patchIfStatus(input);
37319
+ if (outcome.outcome === "updated") yield* adopt(outcome.task);
37320
+ if (outcome.outcome === "conflict") yield* adopt(outcome.current);
37321
+ return outcome;
37322
+ });
37323
+ const deleteById = ({ taskId }) => Effect.gen(function* () {
37324
+ const linked = yield* hub.isLinked;
37325
+ yield* local.deleteById({ taskId });
37326
+ if (!linked) return;
37327
+ yield* sync.recordTombstone(taskId, yield* now);
37328
+ yield* hub.remove(taskId).pipe(Effect.andThen(sync.clearTombstone(taskId)), Effect.ignoreCause({ log: true }));
37329
+ });
37330
+ /**
37331
+ * One pass: drain what is owed, then take what is new.
37332
+ *
37333
+ * Deletes go first, pushes second, the pull last, and the order is the whole
37334
+ * correctness argument. A delete replayed after a push would re-upload a row
37335
+ * that was just removed; a pull run before either would see this machine's
37336
+ * own stale absence as the hub's opinion and undo local work.
37337
+ */
37338
+ const syncOnce = Effect.gen(function* () {
37339
+ if (!(yield* hub.isLinked)) return;
37340
+ for (const taskId of yield* sync.listTombstones) yield* hub.remove(taskId).pipe(Effect.andThen(sync.clearTombstone(taskId)), Effect.ignoreCause({ log: true }));
37341
+ for (const row of yield* sync.listPending) {
37342
+ const { readableIdPrefix, ...task } = row;
37343
+ yield* pushInBackground(task, readableIdPrefix ?? "T");
37344
+ }
37345
+ const remote = yield* hub.list({});
37346
+ const stillPending = new Set((yield* sync.listPending).map((row) => row.taskId));
37347
+ const tombstoned = new Set(yield* sync.listTombstones);
37348
+ const syncedIds = yield* sync.listSyncedIds;
37349
+ const held = new Map((yield* local.list({})).map((task) => [task.taskId, task]));
37350
+ const seen = /* @__PURE__ */ new Set();
37351
+ for (const task of remote) {
37352
+ seen.add(task.taskId);
37353
+ if (stillPending.has(task.taskId) || tombstoned.has(task.taskId)) continue;
37354
+ const current = held.get(task.taskId);
37355
+ if (current === void 0 || current.updatedAt !== task.updatedAt) yield* adopt(task);
37356
+ }
37357
+ for (const taskId of syncedIds) if (!seen.has(taskId)) yield* local.deleteById({ taskId });
37358
+ }).pipe(Effect.ignoreCause({ log: true }));
37359
+ yield* Effect.forever(syncOnce.pipe(Effect.andThen(Effect.sleep(SYNC_INTERVAL)))).pipe(Effect.forkScoped);
37360
+ return {
37361
+ create,
37362
+ upsert,
37363
+ patch,
37364
+ patchIfStatus,
37365
+ getById: local.getById,
37366
+ getByReadableId: local.getByReadableId,
37367
+ list: local.list,
37368
+ deleteById,
37369
+ streamChanges: local.streamChanges,
37370
+ streamWithSnapshot: local.streamWithSnapshot
37371
+ };
37372
+ });
37373
+ Layer.effect(TaskRepository, makeSyncedTaskRepository);
37374
+ //#endregion
37375
+ //#region src/persistence/Layers/TaskBoardSource.ts
37376
+ /**
37377
+ * Which board a call is about, resolved per call.
37378
+ *
37379
+ * This replaced a server-wide setting that chose between a local database and
37380
+ * the hub. Two things changed at once and each removed a reason for that
37381
+ * setting to exist: local and hub became one replicated board rather than two
37382
+ * rival ones, so there was nothing left to choose between; and a second real
37383
+ * board arrived - Linear - which a person switches to in order to look at
37384
+ * something and switches back a minute later. That is a control on the board,
37385
+ * not a configuration of the machine.
37386
+ *
37387
+ * Both repositories are built at startup and every call picks one. Building
37388
+ * lazily would mean the first call to a source paid for its construction, and
37389
+ * the Linear one starts a poll fiber that must exist before anyone subscribes.
37390
+ *
37391
+ * **A subscription keeps the source it opened with.** Switching boards in the
37392
+ * UI closes the stream and opens another, which is the honest implementation:
37393
+ * a stream that changed what it was a stream of would need a "your board
37394
+ * moved" event that no client knows how to render.
37395
+ *
37396
+ * @module persistence/Layers/TaskBoardSource
37397
+ */
37398
+ var TaskRepositoryRegistry = class extends Context.Service()("@p4code/cli/persistence/Layers/TaskBoardSource/TaskRepositoryRegistry") {};
37399
+ const makeRegistry = Effect.gen(function* () {
37400
+ const board = yield* makeSyncedTaskRepository;
37401
+ const linear = yield* makeLinearTaskRepository;
37402
+ return {
37403
+ forSource: (source) => source === "linear" ? linear : board,
37404
+ linearAvailable: (yield* LinearMcpClient).isConfigured
37405
+ };
37406
+ });
37407
+ const TaskRepositoryRegistryLive = Layer.effect(TaskRepositoryRegistry, makeRegistry);
37408
+ /**
37409
+ * The default board, for everything that names no source.
37410
+ *
37411
+ * Every consumer that predates the source parameter - the orchestration
37412
+ * engine's task lookups, the startup reconciliation - goes through this, so
37413
+ * adding the parameter did not become a change to all of them. It is the same
37414
+ * instance the registry hands out for `board`, which matters: the repository
37415
+ * owns the change PubSub, and a second instance would mean a task filed by an
37416
+ * agent never reaching an open board.
37417
+ */
37418
+ const RoutedTaskRepositoryLive = Layer.effect(TaskRepository, Effect.gen(function* () {
37419
+ return (yield* TaskRepositoryRegistry).forSource(DEFAULT_TASK_SOURCE);
37420
+ })).pipe(Layer.provideMerge(TaskRepositoryRegistryLive));
37421
+ //#endregion
35940
37422
  //#region src/taskBoard.ts
35941
37423
  /**
35942
37424
  * Server-side helpers shared by the task board's two front doors: the MCP
@@ -36813,7 +38295,7 @@ function toAssetFilePath(repositoryPath, root) {
36813
38295
  const REGISTRY_SEARCH_URL = "https://www.skills.sh/api/search";
36814
38296
  const GITHUB_API_URL = "https://api.github.com";
36815
38297
  const GITHUB_RAW_URL = "https://raw.githubusercontent.com";
36816
- const REQUEST_TIMEOUT_MS$1 = 15e3;
38298
+ const REQUEST_TIMEOUT_MS = 15e3;
36817
38299
  /** More than a person reads, few enough that the panel is a list and not a feed. */
36818
38300
  const MAX_SEARCH_RESULTS = 25;
36819
38301
  /**
@@ -36853,7 +38335,7 @@ const emptyFetch = (id, unavailable) => ({
36853
38335
  const make$37 = Effect.gen(function* () {
36854
38336
  const http = yield* HttpClient.HttpClient;
36855
38337
  const request = Effect.fn("SkillRegistry.request")(function* (url) {
36856
- return yield* http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader("accept", "application/json"), HttpClientRequest.setHeader("user-agent", "p4code"))).pipe(Effect.timeout(REQUEST_TIMEOUT_MS$1));
38338
+ return yield* http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader("accept", "application/json"), HttpClientRequest.setHeader("user-agent", "p4code"))).pipe(Effect.timeout(REQUEST_TIMEOUT_MS));
36857
38339
  });
36858
38340
  const search = (query) => Effect.gen(function* () {
36859
38341
  const response = yield* request(`${REGISTRY_SEARCH_URL}?q=${encodeURIComponent(query)}`).pipe(Effect.orElseSucceed(() => null));
@@ -36923,211 +38405,6 @@ const make$37 = Effect.gen(function* () {
36923
38405
  });
36924
38406
  const layer$32 = Layer.effect(SkillRegistry, make$37);
36925
38407
  //#endregion
36926
- //#region ../../packages/shared/src/ticketReference.ts
36927
- /**
36928
- * Recognizing a tracker reference in text somebody pasted.
36929
- *
36930
- * Shared because both ends need the same answer: the composer decides whether a
36931
- * paste is worth a round trip, and the server decides what to ask the tracker.
36932
- * Two copies of this rule would differ, and the difference would read as "it
36933
- * works on my machine but the paste does nothing".
36934
- *
36935
- * @module ticketReference
36936
- */
36937
- /**
36938
- * A Linear issue URL, e.g.
36939
- * `https://linear.app/omnicasa/issue/MOBILE-12262/haptic-feedback`.
36940
- *
36941
- * The workspace slug is what makes the URL unguessable from the identifier
36942
- * alone, which is why a resolved ticket keeps the tracker's own URL rather than
36943
- * rebuilding one.
36944
- */
36945
- const LINEAR_ISSUE_URL_PATTERN = /https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/;
36946
- /**
36947
- * A bare identifier, e.g. `MOBILE-12262`.
36948
- *
36949
- * Anchored: only a paste that is *nothing but* an identifier counts. Scanning
36950
- * prose for this shape would claim things that are not tickets - a date range,
36951
- * a version, a p4code readable id like `P4-40` - and each false positive costs
36952
- * a request and, worse, a wrong link.
36953
- */
36954
- const BARE_IDENTIFIER_PATTERN = /^([A-Za-z][A-Za-z0-9]*-\d+)$/;
36955
- /**
36956
- * The issue identifier a paste refers to, or `null` when it refers to none.
36957
- *
36958
- * Case is normalized up, the way every tracker prints it, so the same ticket
36959
- * pasted two ways compares equal.
36960
- */
36961
- function parseTicketReference(text) {
36962
- const trimmed = text.trim();
36963
- if (trimmed.length === 0) return null;
36964
- const url = LINEAR_ISSUE_URL_PATTERN.exec(trimmed);
36965
- if (url?.[1] !== void 0) return url[1].toUpperCase();
36966
- const bare = BARE_IDENTIFIER_PATTERN.exec(trimmed);
36967
- if (bare?.[1] !== void 0) return bare[1].toUpperCase();
36968
- return null;
36969
- }
36970
- //#endregion
36971
- //#region src/mcp/McpToolClient.ts
36972
- /**
36973
- * Calling a tool on somebody else's MCP server.
36974
- *
36975
- * The rest of `apps/server/src/mcp` points the other way: it *exposes* p4code's
36976
- * toolkit to an agent. This is the only place p4code is the client, and it
36977
- * exists because one feature - recognizing a pasted ticket - needs an answer
36978
- * before any agent turn runs, from a server the user has already registered.
36979
- *
36980
- * Deliberately not a general MCP client. There is no session reuse, no tool
36981
- * listing, no notifications, no SSE stream held open: one request/response pair
36982
- * per call, which is all streamable HTTP requires for a plain `tools/call`. A
36983
- * real client belongs here only once something needs one.
36984
- *
36985
- * @module mcp/McpToolClient
36986
- */
36987
- /**
36988
- * The version p4code speaks. Sent on `initialize` and echoed back as a header
36989
- * on the follow-up calls, which is what the spec asks of a client that has
36990
- * already negotiated.
36991
- */
36992
- const MCP_PROTOCOL_VERSION = "2025-06-18";
36993
- const REQUEST_TIMEOUT_MS = 15e3;
36994
- var McpToolCallError = class extends Schema$1.TaggedErrorClass()("McpToolCallError", {
36995
- detail: Schema$1.String,
36996
- status: Schema$1.NullOr(Schema$1.Number)
36997
- }) {
36998
- get message() {
36999
- return this.detail;
37000
- }
37001
- };
37002
- const unauthorized$1 = (status) => status === 401 || status === 403;
37003
- /**
37004
- * Read a JSON-RPC envelope out of a response that may be either JSON or SSE.
37005
- *
37006
- * A streamable-http server picks the encoding, and both are legal answers to a
37007
- * single call. The SSE branch keeps the last `data:` payload that parses: a
37008
- * server is free to emit progress notifications ahead of the result, and the
37009
- * result is what a caller of this asked for.
37010
- */
37011
- const readEnvelope = (body) => {
37012
- const direct = parseJsonObject(body);
37013
- if (direct !== void 0) return direct;
37014
- let latest;
37015
- for (const line of body.split("\n")) {
37016
- const trimmed = line.trim();
37017
- if (!trimmed.startsWith("data:")) continue;
37018
- const parsed = parseJsonObject(trimmed.slice(5).trim());
37019
- if (parsed !== void 0) latest = parsed;
37020
- }
37021
- return latest;
37022
- };
37023
- const parseJsonObject = (text) => {
37024
- if (text.length === 0) return void 0;
37025
- try {
37026
- const parsed = JSON.parse(text);
37027
- return typeof parsed === "object" && parsed !== null ? parsed : void 0;
37028
- } catch {
37029
- return;
37030
- }
37031
- };
37032
- const textOf = (content) => {
37033
- if (!Array.isArray(content)) return "";
37034
- const parts = [];
37035
- for (const block of content) {
37036
- if (typeof block !== "object" || block === null) continue;
37037
- const record = block;
37038
- if (record["type"] === "text" && typeof record["text"] === "string") parts.push(record["text"]);
37039
- }
37040
- return parts.join("\n");
37041
- };
37042
- /**
37043
- * One `tools/call`, preceded by the `initialize` handshake the transport
37044
- * requires.
37045
- *
37046
- * The handshake is not optional and not cacheable here: a server may hand out a
37047
- * session id, and this holds no session state between calls. Two round trips
37048
- * for one answer is the price of not keeping a connection open for a feature
37049
- * that fires when somebody pastes something.
37050
- */
37051
- const callMcpTool = Effect.fn("McpToolClient.callMcpTool")(function* (input) {
37052
- const http = yield* HttpClient$1.HttpClient;
37053
- const post = (body, extraHeaders) => http.execute(HttpClientRequest$1.bodyJsonUnsafe(HttpClientRequest$1.setHeaders(HttpClientRequest$1.post(input.url), {
37054
- ...input.headers,
37055
- ...extraHeaders,
37056
- accept: "application/json, text/event-stream"
37057
- }), body)).pipe(Effect.timeout(REQUEST_TIMEOUT_MS), Effect.catchCause((cause) => Effect.fail(new McpToolCallError({
37058
- detail: `Could not reach ${input.url}: ${String(cause)}`,
37059
- status: null
37060
- }))));
37061
- const initialize = yield* post({
37062
- jsonrpc: "2.0",
37063
- id: 1,
37064
- method: "initialize",
37065
- params: {
37066
- protocolVersion: MCP_PROTOCOL_VERSION,
37067
- capabilities: {},
37068
- clientInfo: {
37069
- name: "p4code",
37070
- version: "0.0.0"
37071
- }
37072
- }
37073
- }, {});
37074
- if (initialize.status >= 400) return yield* new McpToolCallError({
37075
- detail: unauthorized$1(initialize.status) ? "The server rejected p4code's credentials." : `The server answered ${initialize.status} to initialize.`,
37076
- status: initialize.status
37077
- });
37078
- const sessionId = initialize.headers["mcp-session-id"];
37079
- const sessionHeaders = {
37080
- "mcp-protocol-version": MCP_PROTOCOL_VERSION,
37081
- ...sessionId === void 0 ? {} : { "mcp-session-id": sessionId }
37082
- };
37083
- yield* post({
37084
- jsonrpc: "2.0",
37085
- method: "notifications/initialized"
37086
- }, sessionHeaders).pipe(Effect.ignore);
37087
- const response = yield* post({
37088
- jsonrpc: "2.0",
37089
- id: 2,
37090
- method: "tools/call",
37091
- params: {
37092
- name: input.toolName,
37093
- arguments: input.arguments
37094
- }
37095
- }, sessionHeaders);
37096
- const body = yield* response.text.pipe(Effect.catchCause(() => Effect.succeed("")));
37097
- if (response.status >= 400) return yield* new McpToolCallError({
37098
- detail: unauthorized$1(response.status) ? "The server rejected p4code's credentials." : `The server answered ${response.status} to ${input.toolName}.`,
37099
- status: response.status
37100
- });
37101
- const envelope = readEnvelope(body);
37102
- if (envelope === void 0) return yield* new McpToolCallError({
37103
- detail: `The server's answer to ${input.toolName} was not JSON-RPC.`,
37104
- status: response.status
37105
- });
37106
- const error = envelope["error"];
37107
- if (typeof error === "object" && error !== null) {
37108
- const detail = error["message"];
37109
- return yield* new McpToolCallError({
37110
- detail: typeof detail === "string" ? detail : `${input.toolName} failed.`,
37111
- status: response.status
37112
- });
37113
- }
37114
- const result = envelope["result"];
37115
- if (typeof result !== "object" || result === null) return yield* new McpToolCallError({
37116
- detail: `${input.toolName} returned no result.`,
37117
- status: response.status
37118
- });
37119
- const resultRecord = result;
37120
- const text = textOf(resultRecord["content"]);
37121
- if (resultRecord["isError"] === true) return yield* new McpToolCallError({
37122
- detail: text.length > 0 ? text : `${input.toolName} reported an error.`,
37123
- status: response.status
37124
- });
37125
- return {
37126
- structuredContent: resultRecord["structuredContent"],
37127
- text
37128
- };
37129
- });
37130
- //#endregion
37131
38408
  //#region src/mcp/TicketResolver.ts
37132
38409
  /**
37133
38410
  * The registered server name a Linear ticket is resolved through.
@@ -37441,7 +38718,7 @@ const COMMON_DEV_PORTS = Object.freeze([
37441
38718
  8888,
37442
38719
  9e3
37443
38720
  ]);
37444
- const POLL_INTERVAL$1 = Duration.seconds(3);
38721
+ const POLL_INTERVAL = Duration.seconds(3);
37445
38722
  const LSOF_TIMEOUT_MS = 5e3;
37446
38723
  const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
37447
38724
  const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
@@ -37616,7 +38893,7 @@ const make$35 = Effect.gen(function* PortDiscoveryMake() {
37616
38893
  lastSnapshot: next
37617
38894
  }])) yield* broadcast(next);
37618
38895
  }, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
37619
- yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$1))));
38896
+ yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
37620
38897
  const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
37621
38898
  if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
37622
38899
  ...state,
@@ -46549,7 +47826,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
46549
47826
  const orchestrationEngine = yield* OrchestrationEngineService;
46550
47827
  const checkpointDiffQuery = yield* CheckpointDiffQuery;
46551
47828
  const keybindings = yield* Keybindings;
46552
- const taskRepository = yield* TaskRepository;
47829
+ const taskRepositories = yield* TaskRepositoryRegistry;
46553
47830
  const projectionProjects = yield* ProjectionProjectRepository;
46554
47831
  const externalLauncher = yield* ExternalLauncher;
46555
47832
  const gitWorkflow = yield* GitWorkflowService;
@@ -47039,29 +48316,30 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
47039
48316
  issues: []
47040
48317
  };
47041
48318
  }), { "rpc.aggregate": "server" }),
47042
- [WS_METHODS.tasksList]: (filter) => observeRpcEffect$1(WS_METHODS.tasksList, taskRepository.list(filter).pipe(Effect.catch(toTaskStoreError("tasks.list")), Effect.map((tasks) => ({ tasks }))), { "rpc.aggregate": "tasks" }),
47043
- [WS_METHODS.tasksGet]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksGet, taskRepository.getById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.get")), Effect.map((found) => ({ task: Option.getOrNull(found) }))), { "rpc.aggregate": "tasks" }),
47044
- [WS_METHODS.tasksCreate]: (input) => observeRpcEffect$1(WS_METHODS.tasksCreate, Effect.gen(function* () {
48319
+ [WS_METHODS.tasksList]: ({ source, ...filter }) => observeRpcEffect$1(WS_METHODS.tasksList, taskRepositories.forSource(source).list(filter).pipe(Effect.catch(toTaskStoreError("tasks.list")), Effect.map((tasks) => ({ tasks }))), { "rpc.aggregate": "tasks" }),
48320
+ [WS_METHODS.tasksGet]: ({ taskId, source }) => observeRpcEffect$1(WS_METHODS.tasksGet, taskRepositories.forSource(source).getById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.get")), Effect.map((found) => ({ task: Option.getOrNull(found) }))), { "rpc.aggregate": "tasks" }),
48321
+ [WS_METHODS.tasksCreate]: ({ source, ...input }) => observeRpcEffect$1(WS_METHODS.tasksCreate, Effect.gen(function* () {
47045
48322
  const taskId = TaskId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie));
47046
48323
  const timestamp = yield* nowIso$5;
47047
48324
  const repositoryKey = yield* resolveTaskRepositoryKey(input.projectId);
47048
48325
  const readableIdPrefix = yield* resolveTaskReadableIdPrefix(input.projectId);
47049
48326
  const row = buildTaskRow(input, taskId, timestamp, repositoryKey);
47050
- return { task: yield* taskRepository.create(row, readableIdPrefix).pipe(Effect.catch(toTaskStoreError("tasks.create"))) };
48327
+ return { task: yield* taskRepositories.forSource(source).create(row, readableIdPrefix).pipe(Effect.catch(toTaskStoreError("tasks.create"))) };
47051
48328
  }), { "rpc.aggregate": "tasks" }),
47052
- [WS_METHODS.tasksUpdate]: (input) => observeRpcEffect$1(WS_METHODS.tasksUpdate, Effect.gen(function* () {
48329
+ [WS_METHODS.tasksUpdate]: ({ source, ...input }) => observeRpcEffect$1(WS_METHODS.tasksUpdate, Effect.gen(function* () {
47053
48330
  const timestamp = yield* nowIso$5;
47054
48331
  const patch = input.projectId === void 0 ? input : {
47055
48332
  ...input,
47056
48333
  repositoryKey: yield* resolveTaskRepositoryKey(input.projectId)
47057
48334
  };
47058
- const patched = yield* taskRepository.patch(patch, timestamp).pipe(Effect.catch(toTaskStoreError("tasks.update")));
48335
+ const patched = yield* taskRepositories.forSource(source).patch(patch, timestamp).pipe(Effect.catch(toTaskStoreError("tasks.update")));
47059
48336
  if (Option.isNone(patched)) return yield* new TaskNotFoundError({ taskId: input.taskId });
47060
48337
  return { task: patched.value };
47061
48338
  }), { "rpc.aggregate": "tasks" }),
47062
- [WS_METHODS.tasksDelete]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksDelete, taskRepository.deleteById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.delete")), Effect.as({})), { "rpc.aggregate": "tasks" }),
47063
- [WS_METHODS.tasksStartThread]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksStartThread, Effect.gen(function* () {
47064
- const found = yield* taskRepository.getById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.startThread")));
48339
+ [WS_METHODS.tasksDelete]: ({ taskId, source }) => observeRpcEffect$1(WS_METHODS.tasksDelete, taskRepositories.forSource(source).deleteById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.delete")), Effect.as({})), { "rpc.aggregate": "tasks" }),
48340
+ [WS_METHODS.tasksStartThread]: ({ taskId, source }) => observeRpcEffect$1(WS_METHODS.tasksStartThread, Effect.gen(function* () {
48341
+ const tasks = taskRepositories.forSource(source);
48342
+ const found = yield* tasks.getById({ taskId }).pipe(Effect.catch(toTaskStoreError("tasks.startThread")));
47065
48343
  if (Option.isNone(found)) return yield* new TaskNotFoundError({ taskId });
47066
48344
  const task = found.value;
47067
48345
  const plan = resolveTaskStart(task, task.projectId === null ? Option.none() : yield* projectionProjects.getById({ projectId: task.projectId }).pipe(Effect.catch(toTaskStoreError("tasks.startThread.project"))));
@@ -47111,7 +48389,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
47111
48389
  createdAt
47112
48390
  });
47113
48391
  yield* dispatchNormalizedCommand(normalizedCommand);
47114
- const patched = yield* taskRepository.patch({
48392
+ const patched = yield* tasks.patch({
47115
48393
  taskId,
47116
48394
  threadId,
47117
48395
  status: "in_progress"
@@ -47129,7 +48407,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
47129
48407
  * becomes a task, so an accidental paste costs one request and no row.
47130
48408
  */
47131
48409
  [WS_METHODS.ticketsResolve]: ({ reference }) => observeRpcEffect$1(WS_METHODS.ticketsResolve, ticketResolver.resolve(reference), { "rpc.aggregate": "tickets" }),
47132
- [WS_METHODS.subscribeTasks]: (_input) => observeRpcStreamEffect$1(WS_METHODS.subscribeTasks, Effect.succeed(taskRepository.streamWithSnapshot.pipe(Stream.catch((cause) => Stream.fromEffect(toTaskStoreError("tasks.subscribe")(cause))))), { "rpc.aggregate": "tasks" }),
48410
+ [WS_METHODS.subscribeTasks]: ({ source }) => observeRpcStreamEffect$1(WS_METHODS.subscribeTasks, Effect.succeed(taskRepositories.forSource(source).streamWithSnapshot.pipe(Stream.catch((cause) => Stream.fromEffect(toTaskStoreError("tasks.subscribe")(cause))))), { "rpc.aggregate": "tasks" }),
47133
48411
  [WS_METHODS.hubGetSyncStatus]: (_input) => observeRpcEffect$1(WS_METHODS.hubGetSyncStatus, assetSync.status, { "rpc.aggregate": "hub" }),
47134
48412
  [WS_METHODS.hubConnect]: (input) => observeRpcEffect$1(WS_METHODS.hubConnect, hubLink.connect(input).pipe(Effect.mapError((cause) => new HubLinkError({ detail: cause.message })), Effect.andThen(assetSync.status)), { "rpc.aggregate": "hub" }),
47135
48413
  [WS_METHODS.hubDisconnect]: (_input) => observeRpcEffect$1(WS_METHODS.hubDisconnect, hubLink.disconnect.pipe(Effect.mapError((cause) => new HubLinkError({ detail: cause.message })), Effect.andThen(assetSync.status)), { "rpc.aggregate": "hub" }),
@@ -47388,7 +48666,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
47388
48666
  const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
47389
48667
  const previewAutomationBroker = yield* PreviewAutomationBroker;
47390
48668
  const serverSelfUpdate = yield* ServerSelfUpdate;
47391
- const taskRepository = yield* TaskRepository;
48669
+ const taskRepositories = yield* TaskRepositoryRegistry;
47392
48670
  const projectionProjects = yield* ProjectionProjectRepository;
47393
48671
  const p4ProjectFileLoader = yield* P4ProjectFileLoader;
47394
48672
  return HttpRouter.add("GET", "/ws", Effect.gen(function* () {
@@ -47396,7 +48674,7 @@ const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
47396
48674
  const serverAuth = yield* EnvironmentAuth;
47397
48675
  const sessions = yield* SessionStore;
47398
48676
  const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(Effect.catchIf(isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(serverAuthCredentialReason(error))), Effect.catchIf(isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error)));
47399
- const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true }).pipe(Effect.provide(makeWsRpcLayer(session, previewAutomationBroker).pipe(Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(layer$34), Layer.provide(Layer.succeed(ServerSelfUpdate, serverSelfUpdate)), Layer.provide(Layer.succeed(TaskRepository, taskRepository)), Layer.provide(Layer.succeed(ProjectionProjectRepository, projectionProjects)), Layer.provide(Layer.succeed(P4ProjectFileLoader, p4ProjectFileLoader)), Layer.provide(layer$5.pipe(Layer.provide(layer$14.pipe(Layer.provide(Layer.mergeAll(layer$22, layer$20, layer$18, layer$16)), Layer.provideMerge(layer$39), Layer.provide(layer$37.pipe(Layer.provide(layer$38))))), Layer.provide(layer$40))))));
48677
+ const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true }).pipe(Effect.provide(makeWsRpcLayer(session, previewAutomationBroker).pipe(Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(layer$34), Layer.provide(Layer.succeed(ServerSelfUpdate, serverSelfUpdate)), Layer.provide(Layer.succeed(TaskRepositoryRegistry, taskRepositories)), Layer.provide(Layer.succeed(ProjectionProjectRepository, projectionProjects)), Layer.provide(Layer.succeed(P4ProjectFileLoader, p4ProjectFileLoader)), Layer.provide(layer$5.pipe(Layer.provide(layer$14.pipe(Layer.provide(Layer.mergeAll(layer$22, layer$20, layer$18, layer$16)), Layer.provideMerge(layer$39), Layer.provide(layer$37.pipe(Layer.provide(layer$38))))), Layer.provide(layer$40))))));
47400
48678
  return yield* Effect.acquireUseRelease(sessions.markConnected(session.sessionId), () => rpcWebSocketHttpEffect, () => sessions.markDisconnected(session.sessionId));
47401
48679
  }).pipe(Effect.catchTags({
47402
48680
  EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
@@ -47404,296 +48682,6 @@ const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
47404
48682
  })));
47405
48683
  }));
47406
48684
  //#endregion
47407
- //#region src/persistence/Layers/RemoteTasks.ts
47408
- /**
47409
- * The board, kept on the hub instead of in this machine's database.
47410
- *
47411
- * A drop-in `TaskRepository`: same interface, same events, so the MCP toolkit,
47412
- * the websocket RPCs and the board itself are unchanged and do not know which
47413
- * one they are talking to. That was the point of making it an interface.
47414
- *
47415
- * **An unreachable hub fails loudly, by decision.** There is no local fallback
47416
- * and no write queue. A server that answered from a local copy while the hub
47417
- * was down would recreate exactly the divergence the hub exists to remove —
47418
- * two Macs with two boards, silently — and the failure would be invisible until
47419
- * someone noticed a task missing on the other machine. So a hub that cannot be
47420
- * reached means the board says so and nothing is written. The cost is real and
47421
- * is the point: no board while the hub is down.
47422
- *
47423
- * **Changes are polled, because the hub has no push.** The interface promises
47424
- * a change stream, and the honest implementation of that against a plain HTTP
47425
- * API is a poll with a diff. A write made through *this* server publishes
47426
- * immediately, so the local board is live; the poll is what carries the other
47427
- * machine's writes, at up to `POLL_INTERVAL` of lag.
47428
- *
47429
- * @module persistence/Layers/RemoteTasks
47430
- */
47431
- const HUB_REQUEST_TIMEOUT_MS = 15e3;
47432
- /**
47433
- * How stale another machine's write may look here.
47434
- *
47435
- * Short enough that a board left open follows along, long enough that two
47436
- * servers idling all day are not a steady stream of requests at a hosted
47437
- * service. Only changes made elsewhere wait for it.
47438
- */
47439
- const POLL_INTERVAL = Duration.seconds(10);
47440
- const TaskListPage = Schema$1.Struct({ tasks: Schema$1.Array(Task) });
47441
- const ConflictBody = Schema$1.Struct({ current: Task });
47442
- const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
47443
- const decodeTask = Schema$1.decodeUnknownEffect(Task);
47444
- const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
47445
- /**
47446
- * Every failure here is reported as the interface's own error type.
47447
- *
47448
- * `PersistenceSqlError` names a store that would not answer, which is exactly
47449
- * what this is from a caller's point of view — the detail says the store is the
47450
- * hub. Inventing a second error type would mean touching every call site to
47451
- * handle a case they already handle.
47452
- */
47453
- const hubUnavailable = (operation, detail) => new PersistenceSqlError({
47454
- operation: `RemoteTaskRepository.${operation}`,
47455
- detail: `The hub board is unavailable: ${detail}`,
47456
- cause: null
47457
- });
47458
- /**
47459
- * @param isActive Whether this server is currently reading its board from the
47460
- * hub. The poll asks before every pass rather than being started once: a
47461
- * machine with the board set to `local` must not be sending a request to a
47462
- * hosted service every ten seconds for a board nobody is reading, and the
47463
- * setting can change while the server runs.
47464
- */
47465
- const makeRemoteTaskRepository = Effect.fn("makeRemoteTaskRepository")(function* (isActive) {
47466
- const http = yield* HttpClient.HttpClient;
47467
- const link = yield* HubLink;
47468
- const changes = yield* PubSub.unbounded();
47469
- const publish = (event) => PubSub.publish(changes, event).pipe(Effect.asVoid);
47470
- /** The board as of the last poll, for diffing the next one against. */
47471
- const lastSeen = yield* Ref.make(null);
47472
- const requireSettings = (operation) => Effect.gen(function* () {
47473
- const state = yield* link.current;
47474
- if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
47475
- return state.settings.value;
47476
- });
47477
- 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"}`)));
47478
- const list = (filter) => Effect.gen(function* () {
47479
- const settings = yield* requireSettings("list");
47480
- const query = new URLSearchParams();
47481
- for (const key of [
47482
- "projectId",
47483
- "repositoryKey",
47484
- "threadId",
47485
- "status",
47486
- "assignee"
47487
- ]) {
47488
- const value = filter[key];
47489
- if (value !== void 0) query.set(key, value);
47490
- }
47491
- const suffix = query.size === 0 ? "" : `?${query.toString()}`;
47492
- const response = yield* send("list", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks${suffix}`));
47493
- if (response.status !== 200) return yield* hubUnavailable("list", `status ${response.status}`);
47494
- return (yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("list", "the response did not match the contract")))).tasks;
47495
- });
47496
- const getById = ({ taskId }) => Effect.gen(function* () {
47497
- const settings = yield* requireSettings("getById");
47498
- const response = yield* send("getById", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
47499
- if (response.status === 404) return Option.none();
47500
- if (response.status !== 200) return yield* hubUnavailable("getById", `status ${response.status}`);
47501
- return Option.some(yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("getById", "the response did not match the contract"))));
47502
- });
47503
- /**
47504
- * Both writes here POST the hub's create route, and that is a real
47505
- * difference from the local store: the hub mints its own ids, timestamps and
47506
- * readable id, so a row written through this comes back with the hub's
47507
- * version of all three. The caller's `taskId` is not carried - honouring it
47508
- * would need a hub route that takes one, and inventing that quietly would
47509
- * let two servers mint colliding ids.
47510
- */
47511
- const postCreate = (operation, row, readableIdPrefix) => Effect.gen(function* () {
47512
- const settings = yield* requireSettings(operation);
47513
- const response = yield* send(operation, settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(`${settings.baseUrl}/tasks`), {
47514
- ...row.projectId === null ? {} : { projectId: row.projectId },
47515
- ...row.repositoryKey === null ? {} : { repositoryKey: row.repositoryKey },
47516
- ...row.threadId === null ? {} : { threadId: row.threadId },
47517
- ...row.externalRef === null ? {} : { externalRef: row.externalRef },
47518
- ...row.parentTaskId === null ? {} : { parentTaskId: row.parentTaskId },
47519
- title: row.title,
47520
- body: row.body,
47521
- status: row.status,
47522
- priority: row.priority,
47523
- ...row.assignee === null ? {} : { assignee: row.assignee },
47524
- labels: row.labels,
47525
- ...row.orderKey === null ? {} : { orderKey: row.orderKey },
47526
- ...readableIdPrefix === void 0 ? {} : { readableIdPrefix }
47527
- }));
47528
- if (response.status !== 201 && response.status !== 200) return yield* hubUnavailable(operation, `status ${response.status}`);
47529
- const created = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable(operation, "the response did not match the contract")));
47530
- yield* publish({
47531
- type: "upserted",
47532
- task: created
47533
- });
47534
- return created;
47535
- });
47536
- const create = (row, readableIdPrefix) => postCreate("create", row, readableIdPrefix);
47537
- const upsert = (row) => postCreate("upsert", row).pipe(Effect.asVoid);
47538
- /**
47539
- * Served by the same hub route as `getById`: readable ids and UUIDs are
47540
- * disjoint by shape, so the hub dispatches on what it is handed.
47541
- */
47542
- const getByReadableId = (readableId) => getById({ taskId: TaskId.make(readableId) });
47543
- const patch = (input) => Effect.gen(function* () {
47544
- const settings = yield* requireSettings("patch");
47545
- const { taskId, ...fields } = input;
47546
- const response = yield* send("patch", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), fields));
47547
- if (response.status === 404) return Option.none();
47548
- if (response.status !== 200) return yield* hubUnavailable("patch", `status ${response.status}`);
47549
- const updated = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patch", "the response did not match the contract")));
47550
- yield* publish({
47551
- type: "upserted",
47552
- task: updated
47553
- });
47554
- return Option.some(updated);
47555
- });
47556
- const patchIfStatus = (input) => Effect.gen(function* () {
47557
- const settings = yield* requireSettings("patchIfStatus");
47558
- const { taskId, ...fields } = input.patch;
47559
- const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
47560
- ...fields,
47561
- expectedStatus: input.expectedStatus
47562
- }));
47563
- if (response.status === 404) return { outcome: "missing" };
47564
- if (response.status === 409) return {
47565
- outcome: "conflict",
47566
- current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
47567
- };
47568
- if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
47569
- const updated = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")));
47570
- yield* publish({
47571
- type: "upserted",
47572
- task: updated
47573
- });
47574
- return {
47575
- outcome: "updated",
47576
- task: updated
47577
- };
47578
- });
47579
- const deleteById = ({ taskId }) => Effect.gen(function* () {
47580
- const settings = yield* requireSettings("deleteById");
47581
- const response = yield* send("deleteById", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
47582
- if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("deleteById", `status ${response.status}`);
47583
- yield* publish({
47584
- type: "deleted",
47585
- taskId
47586
- });
47587
- });
47588
- /**
47589
- * One poll: read the board, publish what differs from last time.
47590
- *
47591
- * Compared on `updatedAt` rather than deep equality, because that is the
47592
- * field every hub write moves and it makes the diff O(n) with no schema
47593
- * knowledge. A failed poll publishes nothing and leaves the last snapshot in
47594
- * place — a hub blip should not empty every open board.
47595
- */
47596
- const pollOnce = Effect.gen(function* () {
47597
- if (!(yield* isActive)) return;
47598
- const current = yield* list({});
47599
- const previous = yield* Ref.getAndSet(lastSeen, current);
47600
- if (previous === null) return;
47601
- const previousById = new Map(previous.map((task) => [task.taskId, task]));
47602
- for (const task of current) {
47603
- const before = previousById.get(task.taskId);
47604
- if (before === void 0 || before.updatedAt !== task.updatedAt) yield* publish({
47605
- type: "upserted",
47606
- task
47607
- });
47608
- previousById.delete(task.taskId);
47609
- }
47610
- for (const taskId of previousById.keys()) yield* publish({
47611
- type: "deleted",
47612
- taskId
47613
- });
47614
- }).pipe(Effect.ignoreCause({ log: true }));
47615
- yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL)))).pipe(Effect.forkScoped);
47616
- return {
47617
- create,
47618
- upsert,
47619
- patch,
47620
- patchIfStatus,
47621
- getById,
47622
- getByReadableId,
47623
- list,
47624
- deleteById,
47625
- streamChanges: Stream.fromPubSub(changes),
47626
- streamWithSnapshot: Stream.unwrap(Effect.gen(function* () {
47627
- const subscription = yield* PubSub.subscribe(changes);
47628
- const snapshot = yield* list({});
47629
- yield* Ref.set(lastSeen, snapshot);
47630
- return Stream.concat(Stream.succeed({
47631
- type: "snapshot",
47632
- tasks: snapshot
47633
- }), Stream.fromSubscription(subscription));
47634
- }))
47635
- };
47636
- });
47637
- Layer.effect(TaskRepository, makeRemoteTaskRepository(Effect.succeed(true)));
47638
- //#endregion
47639
- //#region src/persistence/Layers/TaskBoardSource.ts
47640
- /**
47641
- * Which board a call goes to: this machine's database, or the hub's.
47642
- *
47643
- * Both repositories are built, and every call reads the setting and delegates.
47644
- * That is deliberate rather than picking one at startup: `taskBoardSource` is
47645
- * an ordinary setting a person changes in a panel, and a setting that only
47646
- * takes effect after a restart is the same hazard the hub link already
47647
- * documents — you change it, nothing happens, and there is nothing on screen
47648
- * saying why.
47649
- *
47650
- * **Subscriptions pick their source when they subscribe.** A stream cannot
47651
- * change its mind mid-flight without inventing a "your board moved" event that
47652
- * nothing knows how to render, so an open board keeps reading the source it
47653
- * opened against and picks up the change when it reconnects. Switching is rare;
47654
- * a wrong row on screen is not worth a new event type.
47655
- *
47656
- * @module persistence/Layers/TaskBoardSource
47657
- */
47658
- const makeRoutedTaskRepository = Effect.gen(function* () {
47659
- const local = yield* makeTaskRepository;
47660
- const usingHub = (yield* ServerSettingsService).getSettings.pipe(Effect.map((current) => current.taskBoardSource === "hub"), Effect.orElseSucceed(() => false));
47661
- const remote = yield* makeRemoteTaskRepository(usingHub);
47662
- /**
47663
- * `local` on a settings read failure, and that direction is chosen.
47664
- *
47665
- * The alternative is refusing every board call because a settings file could
47666
- * not be read, which turns one broken file into a dead board. Falling back to
47667
- * the machine's own database is the answer that still works and cannot lose a
47668
- * hub write, because a write that never reached the hub was never accepted.
47669
- */
47670
- const pick = usingHub.pipe(Effect.map((hub) => hub ? remote : local));
47671
- return {
47672
- create: (row, readableIdPrefix) => pick.pipe(Effect.flatMap((repository) => repository.create(row, readableIdPrefix))),
47673
- upsert: (row) => pick.pipe(Effect.flatMap((repository) => repository.upsert(row))),
47674
- patch: (input, updatedAt) => pick.pipe(Effect.flatMap((repository) => repository.patch(input, updatedAt))),
47675
- patchIfStatus: (input, updatedAt) => pick.pipe(Effect.flatMap((repository) => repository.patchIfStatus(input, updatedAt))),
47676
- getById: (input) => pick.pipe(Effect.flatMap((repository) => repository.getById(input))),
47677
- getByReadableId: (readableId) => pick.pipe(Effect.flatMap((repository) => repository.getByReadableId(readableId))),
47678
- list: (filter) => pick.pipe(Effect.flatMap((repository) => repository.list(filter))),
47679
- deleteById: (input) => pick.pipe(Effect.flatMap((repository) => repository.deleteById(input))),
47680
- get streamChanges() {
47681
- return Stream.unwrap(pick.pipe(Effect.map((repository) => repository.streamChanges)));
47682
- },
47683
- get streamWithSnapshot() {
47684
- return Stream.unwrap(pick.pipe(Effect.map((repository) => repository.streamWithSnapshot)));
47685
- }
47686
- };
47687
- });
47688
- /**
47689
- * The board, wherever it lives.
47690
- *
47691
- * Replaces `TaskRepositoryLive` at the one place the route tree provides it, so
47692
- * the MCP toolkit and the websocket handlers keep sharing a single instance —
47693
- * which is what makes a task an agent files appear on an open board.
47694
- */
47695
- const RoutedTaskRepositoryLive = Layer.effect(TaskRepository, makeRoutedTaskRepository);
47696
- //#endregion
47697
48685
  //#region src/persistence/ProviderSessionRuntime.ts
47698
48686
  /**
47699
48687
  * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions.
@@ -91513,7 +92501,7 @@ const WorkspaceLayerLive = Layer.mergeAll(layer$44, WorkspaceEntriesLayerLive, W
91513
92501
  const ProjectFaviconResolverLayerLive = layer$42.pipe(Layer.provide(layer$44), Layer.provide(layer$43));
91514
92502
  const AuthLayerLive = layer$64.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$68));
91515
92503
  const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive));
91516
- const RuntimeDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(layer$52), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$61.pipe(Layer.provide(layer$68))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$53), Layer.provideMerge(layer$46), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$31.pipe(Layer.provideMerge(Layer.mergeAll(layer$58, layer$32).pipe(Layer.provideMerge(layer$59), Layer.provideMerge(layer$60))))), Layer.provideMerge(layer$68)).pipe(Layer.provideMerge(layer$8), Layer.provideMerge(layer$7), Layer.provideMerge(layer$6), Layer.provideMerge(layer$49), Layer.provideMerge(layer$51), Layer.provideMerge(layer$50), Layer.provide(layer$72));
92504
+ const RuntimeDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(layer$52), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$61.pipe(Layer.provide(layer$68))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$53), Layer.provideMerge(layer$46), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(Layer.mergeAll(layer$31, LinearMcpClientLive).pipe(Layer.provideMerge(Layer.mergeAll(layer$58, layer$32).pipe(Layer.provideMerge(layer$59), Layer.provideMerge(layer$60))))), Layer.provideMerge(layer$68)).pipe(Layer.provideMerge(layer$8), Layer.provideMerge(layer$7), Layer.provideMerge(layer$6), Layer.provideMerge(layer$49), Layer.provideMerge(layer$51), Layer.provideMerge(layer$50), Layer.provide(layer$72));
91517
92505
  /**
91518
92506
  * Hub asset sync.
91519
92507
  *