@p4code/cli 0.1.25 → 0.1.27
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 +1553 -565
- package/dist/client/assets/DiffPanel-BAF3cYyw.js +98 -0
- package/dist/client/assets/{FilePreviewPanel-CgFwP53a.js → FilePreviewPanel-9KNhEGOM.js} +4 -4
- package/dist/client/assets/PreviewPanel-Cf84rh6P.js +2 -0
- package/dist/client/assets/arrow-right-BTLkCknE.js +2 -0
- package/dist/client/assets/{fileCommentAnnotations-BDZMQFe-.js → fileCommentAnnotations-CpiZEOW7.js} +2 -2
- package/dist/client/assets/index-D1PfDaQW.js +891 -0
- package/dist/client/assets/index-Xpg1fQAg.css +1 -0
- package/dist/client/assets/terminal-links-wSVIDKqM.js +46 -0
- package/dist/client/index.html +3 -3
- package/package.json +1 -1
- package/dist/client/assets/DiffPanel-C6ILyHye.js +0 -98
- package/dist/client/assets/PreviewPanel-CBlKzh6g.js +0 -2
- package/dist/client/assets/arrow-right-DdiaD_l3.js +0 -2
- package/dist/client/assets/index-CCDCJ1fx.js +0 -867
- package/dist/client/assets/index-CVtJ86EL.css +0 -1
- package/dist/client/assets/terminal-links-McQ4PMJM.js +0 -46
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.
|
|
239
|
+
var version = "0.1.27";
|
|
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
|
-
*
|
|
5958
|
+
* The Linear team new issues are filed into, by name or id.
|
|
5960
5959
|
*
|
|
5961
|
-
*
|
|
5962
|
-
*
|
|
5963
|
-
*
|
|
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
|
|
5966
|
-
*
|
|
5967
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
35884
|
+
toolInput: asRecord$5(record?.input)
|
|
35680
35885
|
});
|
|
35681
35886
|
}
|
|
35682
35887
|
//#endregion
|
|
35683
35888
|
//#region src/orchestration/ActivityPayloadProjection.ts
|
|
35684
|
-
function asRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
35942
|
+
const input = asRecord$4(item.input);
|
|
35738
35943
|
if (input && "command" in input) projectedItem.input = { command: input.command };
|
|
35739
|
-
const result = asRecord$
|
|
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$
|
|
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$
|
|
35778
|
-
const data = asRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
47043
|
-
[WS_METHODS.tasksGet]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksGet,
|
|
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*
|
|
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*
|
|
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,
|
|
47063
|
-
[WS_METHODS.tasksStartThread]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksStartThread, Effect.gen(function* () {
|
|
47064
|
-
const
|
|
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*
|
|
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]: (
|
|
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
|
|
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(
|
|
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
|
*
|