@p4code/cli 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +1730 -567
- package/dist/client/assets/{DiffPanel-DeU85qmp.js → DiffPanel-BwQlPbUS.js} +2 -2
- package/dist/client/assets/{FilePreviewPanel-Ck-1V54G.js → FilePreviewPanel-BQQLaLpK.js} +2 -2
- package/dist/client/assets/{PreviewPanel-C9f2_i6J.js → PreviewPanel-Coh4ziog.js} +2 -2
- package/dist/client/assets/arrow-right-CZ5hDV1Y.js +2 -0
- package/dist/client/assets/{fileCommentAnnotations-BZURe-eK.js → fileCommentAnnotations-CRFAhzRA.js} +2 -2
- package/dist/client/assets/index-C93D7bsA.js +875 -0
- package/dist/client/assets/index-CVtJ86EL.css +1 -0
- package/dist/client/assets/terminal-links-D-5oUNjJ.js +46 -0
- package/dist/client/index.html +3 -3
- package/package.json +1 -1
- package/dist/client/assets/arrow-right-DdiaD_l3.js +0 -2
- package/dist/client/assets/index-2NU5L8X3.css +0 -1
- package/dist/client/assets/index-coKrfdge.js +0 -859
- 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.26";
|
|
240
240
|
//#endregion
|
|
241
241
|
//#region src/config.ts
|
|
242
242
|
/**
|
|
@@ -5881,7 +5881,6 @@ const SourceControlWritingStyleSettings = Schema$1.Struct({
|
|
|
5881
5881
|
followChangeRequestTemplates: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(true)))
|
|
5882
5882
|
});
|
|
5883
5883
|
const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
|
|
5884
|
-
const TaskBoardSource = Schema$1.Literals(["local", "hub"]);
|
|
5885
5884
|
const ServerSettings = Schema$1.Struct({
|
|
5886
5885
|
enableAssistantStreaming: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(false))),
|
|
5887
5886
|
enableProviderUpdateChecks: Schema$1.Boolean.pipe(Schema$1.withDecodingDefault(Effect.succeed(true))),
|
|
@@ -5956,23 +5955,19 @@ const ServerSettings = Schema$1.Struct({
|
|
|
5956
5955
|
sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(Schema$1.withDecodingDefault(Effect.succeed({}))),
|
|
5957
5956
|
sourceControlWriterModelSelection: Schema$1.NullOr(ModelSelection).pipe(Schema$1.withDecodingDefault(Effect.succeed(null))),
|
|
5958
5957
|
/**
|
|
5959
|
-
*
|
|
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
|
/**
|
|
@@ -16158,6 +16363,36 @@ function buildSkillOverrides(disabled, discovered) {
|
|
|
16158
16363
|
* @module provider/Drivers/ClaudeSkills
|
|
16159
16364
|
*/
|
|
16160
16365
|
const FRONTMATTER_PATTERN$1 = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
16366
|
+
/** The `true` spellings the CLI accepts for a boolean frontmatter field. */
|
|
16367
|
+
const TRUTHY_FRONTMATTER_VALUES = /* @__PURE__ */ new Set([
|
|
16368
|
+
"true",
|
|
16369
|
+
"yes",
|
|
16370
|
+
"on",
|
|
16371
|
+
"1"
|
|
16372
|
+
]);
|
|
16373
|
+
/**
|
|
16374
|
+
* A frontmatter boolean, spelled any of the ways the CLI allows.
|
|
16375
|
+
*
|
|
16376
|
+
* YAML already decodes an unquoted `true` to a boolean, but a quoted `"yes"`
|
|
16377
|
+
* arrives as a string, and the CLI accepts both. Reading only the boolean would
|
|
16378
|
+
* make a skill that works there silently model-invocable here.
|
|
16379
|
+
*/
|
|
16380
|
+
function readFrontmatterBoolean(value) {
|
|
16381
|
+
if (typeof value === "boolean") return value;
|
|
16382
|
+
if (typeof value === "number") return value === 1;
|
|
16383
|
+
if (typeof value === "string") return TRUTHY_FRONTMATTER_VALUES.has(value.trim().toLowerCase());
|
|
16384
|
+
}
|
|
16385
|
+
/**
|
|
16386
|
+
* The document with its frontmatter block removed.
|
|
16387
|
+
*
|
|
16388
|
+
* The body is what a skill actually instructs; the header is metadata about
|
|
16389
|
+
* how it is selected. Exported for the expansion that inlines a skill into a
|
|
16390
|
+
* turn, which must send the instructions without the routing header.
|
|
16391
|
+
*/
|
|
16392
|
+
function stripMarkdownFrontmatter(contents) {
|
|
16393
|
+
const match = FRONTMATTER_PATTERN$1.exec(contents);
|
|
16394
|
+
return (match ? contents.slice(match[0].length) : contents).trim();
|
|
16395
|
+
}
|
|
16161
16396
|
/**
|
|
16162
16397
|
* The `name`/`description` header a skill and a subagent definition both carry.
|
|
16163
16398
|
*
|
|
@@ -16179,10 +16414,12 @@ function parseMarkdownFrontmatter(contents) {
|
|
|
16179
16414
|
const record = parsed;
|
|
16180
16415
|
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
16181
16416
|
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
16417
|
+
const disableModelInvocation = readFrontmatterBoolean(record["disable-model-invocation"]);
|
|
16182
16418
|
return {
|
|
16183
16419
|
kind: "parsed",
|
|
16184
16420
|
...name ? { name } : {},
|
|
16185
|
-
...description ? { description } : {}
|
|
16421
|
+
...description ? { description } : {},
|
|
16422
|
+
...disableModelInvocation === void 0 ? {} : { disableModelInvocation }
|
|
16186
16423
|
};
|
|
16187
16424
|
}
|
|
16188
16425
|
/**
|
|
@@ -32523,7 +32760,7 @@ const NESTED_PAYLOAD_KEYS = [
|
|
|
32523
32760
|
"operations"
|
|
32524
32761
|
];
|
|
32525
32762
|
const MAX_COLLECT_DEPTH = 4;
|
|
32526
|
-
function asRecord$
|
|
32763
|
+
function asRecord$6(value) {
|
|
32527
32764
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
32528
32765
|
}
|
|
32529
32766
|
function pushChangedFilePath(target, value) {
|
|
@@ -32538,7 +32775,7 @@ function collectChangedFilePaths(value, target, depth) {
|
|
|
32538
32775
|
for (const entry of value) collectChangedFilePaths(entry, target, depth + 1);
|
|
32539
32776
|
return;
|
|
32540
32777
|
}
|
|
32541
|
-
const record = asRecord$
|
|
32778
|
+
const record = asRecord$6(value);
|
|
32542
32779
|
if (!record) return;
|
|
32543
32780
|
for (const field of CHANGED_FILE_FIELDS) pushChangedFilePath(target, record[field]);
|
|
32544
32781
|
for (const nestedKey of NESTED_PAYLOAD_KEYS) if (nestedKey in record) collectChangedFilePaths(record[nestedKey], target, depth + 1);
|
|
@@ -32549,7 +32786,7 @@ function collectChangedFilePaths(value, target, depth) {
|
|
|
32549
32786
|
*/
|
|
32550
32787
|
function collectActivityChangedFilePaths(payload) {
|
|
32551
32788
|
const target = /* @__PURE__ */ new Set();
|
|
32552
|
-
collectChangedFilePaths(asRecord$
|
|
32789
|
+
collectChangedFilePaths(asRecord$6(asRecord$6(payload)?.data), target, 0);
|
|
32553
32790
|
return target;
|
|
32554
32791
|
}
|
|
32555
32792
|
/**
|
|
@@ -35634,22 +35871,22 @@ function classifyToolCategory(input) {
|
|
|
35634
35871
|
if (normalized.includes("image")) return "image_view";
|
|
35635
35872
|
return "tool";
|
|
35636
35873
|
}
|
|
35637
|
-
function asRecord$
|
|
35874
|
+
function asRecord$5(value) {
|
|
35638
35875
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
35639
35876
|
}
|
|
35640
35877
|
/** Classify from a runtime item payload's `data` (`{ toolName, input }`). */
|
|
35641
35878
|
function classifyToolCategoryFromToolData(data) {
|
|
35642
|
-
const record = asRecord$
|
|
35879
|
+
const record = asRecord$5(data);
|
|
35643
35880
|
const toolName = record?.toolName;
|
|
35644
35881
|
if (typeof toolName !== "string" || toolName.trim().length === 0) return;
|
|
35645
35882
|
return classifyToolCategory({
|
|
35646
35883
|
toolName,
|
|
35647
|
-
toolInput: asRecord$
|
|
35884
|
+
toolInput: asRecord$5(record?.input)
|
|
35648
35885
|
});
|
|
35649
35886
|
}
|
|
35650
35887
|
//#endregion
|
|
35651
35888
|
//#region src/orchestration/ActivityPayloadProjection.ts
|
|
35652
|
-
function asRecord$
|
|
35889
|
+
function asRecord$4(value) {
|
|
35653
35890
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
35654
35891
|
}
|
|
35655
35892
|
function asTrimmedString$1(value) {
|
|
@@ -35672,7 +35909,7 @@ function collectChangedFiles(value, target, seen, depth) {
|
|
|
35672
35909
|
}
|
|
35673
35910
|
return;
|
|
35674
35911
|
}
|
|
35675
|
-
const record = asRecord$
|
|
35912
|
+
const record = asRecord$4(value);
|
|
35676
35913
|
if (!record) return;
|
|
35677
35914
|
pushChangedFile(target, seen, record.path);
|
|
35678
35915
|
pushChangedFile(target, seen, record.filePath);
|
|
@@ -35698,13 +35935,13 @@ function collectChangedFiles(value, target, seen, depth) {
|
|
|
35698
35935
|
}
|
|
35699
35936
|
}
|
|
35700
35937
|
function projectCommandData(data) {
|
|
35701
|
-
const item = asRecord$
|
|
35938
|
+
const item = asRecord$4(data.item);
|
|
35702
35939
|
if (!item) return;
|
|
35703
35940
|
const projectedItem = {};
|
|
35704
35941
|
if ("command" in item) projectedItem.command = item.command;
|
|
35705
|
-
const input = asRecord$
|
|
35942
|
+
const input = asRecord$4(item.input);
|
|
35706
35943
|
if (input && "command" in input) projectedItem.input = { command: input.command };
|
|
35707
|
-
const result = asRecord$
|
|
35944
|
+
const result = asRecord$4(item.result);
|
|
35708
35945
|
if (result && "command" in result) projectedItem.result = { command: result.command };
|
|
35709
35946
|
return Object.keys(projectedItem).length > 0 ? projectedItem : void 0;
|
|
35710
35947
|
}
|
|
@@ -35720,7 +35957,7 @@ function summarizeToolTextOutput(value) {
|
|
|
35720
35957
|
return null;
|
|
35721
35958
|
}
|
|
35722
35959
|
function projectRawOutput(value) {
|
|
35723
|
-
const rawOutput = asRecord$
|
|
35960
|
+
const rawOutput = asRecord$4(value);
|
|
35724
35961
|
if (!rawOutput) return;
|
|
35725
35962
|
if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) return {
|
|
35726
35963
|
totalFiles: rawOutput.totalFiles,
|
|
@@ -35742,14 +35979,14 @@ function projectRawOutput(value) {
|
|
|
35742
35979
|
* the full payload in persistence and the event store.
|
|
35743
35980
|
*/
|
|
35744
35981
|
function projectActivityPayload(activity) {
|
|
35745
|
-
const payload = asRecord$
|
|
35746
|
-
const data = asRecord$
|
|
35982
|
+
const payload = asRecord$4(activity.payload);
|
|
35983
|
+
const data = asRecord$4(payload?.data);
|
|
35747
35984
|
if (!payload || !data || payload.itemType === "mcp_tool_call") return activity;
|
|
35748
35985
|
const projectedData = {};
|
|
35749
35986
|
const item = projectCommandData(data);
|
|
35750
35987
|
if (item) projectedData.item = item;
|
|
35751
35988
|
if ("command" in data) projectedData.command = data.command;
|
|
35752
|
-
const input = asRecord$
|
|
35989
|
+
const input = asRecord$4(data.input);
|
|
35753
35990
|
if (input && "command" in input) projectedData.input = { command: input.command };
|
|
35754
35991
|
const changedFiles = [];
|
|
35755
35992
|
collectChangedFiles(data, changedFiles, /* @__PURE__ */ new Set(), 0);
|
|
@@ -35776,7 +36013,7 @@ function projectActivityPayload(activity) {
|
|
|
35776
36013
|
*/
|
|
35777
36014
|
function isResolvableContextWindowActivity(activity) {
|
|
35778
36015
|
if (activity.kind !== "context-window.updated") return false;
|
|
35779
|
-
const usedTokens = asRecord$
|
|
36016
|
+
const usedTokens = asRecord$4(activity.payload)?.usedTokens;
|
|
35780
36017
|
return typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0;
|
|
35781
36018
|
}
|
|
35782
36019
|
/**
|
|
@@ -35792,7 +36029,7 @@ function isResolvableContextWindowActivity(activity) {
|
|
|
35792
36029
|
* client.
|
|
35793
36030
|
*/
|
|
35794
36031
|
function withoutContextWindowBreakdown$1(activity) {
|
|
35795
|
-
const payload = asRecord$
|
|
36032
|
+
const payload = asRecord$4(activity.payload);
|
|
35796
36033
|
if (!payload || payload.breakdown === void 0) return activity;
|
|
35797
36034
|
const { breakdown: _breakdown, ...rest } = payload;
|
|
35798
36035
|
return {
|
|
@@ -35807,7 +36044,7 @@ function dropStaleContextWindowActivities(activities) {
|
|
|
35807
36044
|
const retainedIndexes = new Set(latestIndexByTurn.values());
|
|
35808
36045
|
let breakdownIndex = null;
|
|
35809
36046
|
for (const index of retainedIndexes) {
|
|
35810
|
-
if (asRecord$
|
|
36047
|
+
if (asRecord$4(activities[index].payload)?.breakdown === void 0) continue;
|
|
35811
36048
|
if (breakdownIndex === null || index > breakdownIndex) breakdownIndex = index;
|
|
35812
36049
|
}
|
|
35813
36050
|
return activities.flatMap((activity, index) => {
|
|
@@ -35905,6 +36142,1283 @@ const normalizeDispatchCommand = (command) => Effect.gen(function* () {
|
|
|
35905
36142
|
};
|
|
35906
36143
|
});
|
|
35907
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
|
|
35908
37422
|
//#region src/taskBoard.ts
|
|
35909
37423
|
/**
|
|
35910
37424
|
* Server-side helpers shared by the task board's two front doors: the MCP
|
|
@@ -36781,7 +38295,7 @@ function toAssetFilePath(repositoryPath, root) {
|
|
|
36781
38295
|
const REGISTRY_SEARCH_URL = "https://www.skills.sh/api/search";
|
|
36782
38296
|
const GITHUB_API_URL = "https://api.github.com";
|
|
36783
38297
|
const GITHUB_RAW_URL = "https://raw.githubusercontent.com";
|
|
36784
|
-
const REQUEST_TIMEOUT_MS
|
|
38298
|
+
const REQUEST_TIMEOUT_MS = 15e3;
|
|
36785
38299
|
/** More than a person reads, few enough that the panel is a list and not a feed. */
|
|
36786
38300
|
const MAX_SEARCH_RESULTS = 25;
|
|
36787
38301
|
/**
|
|
@@ -36821,7 +38335,7 @@ const emptyFetch = (id, unavailable) => ({
|
|
|
36821
38335
|
const make$37 = Effect.gen(function* () {
|
|
36822
38336
|
const http = yield* HttpClient.HttpClient;
|
|
36823
38337
|
const request = Effect.fn("SkillRegistry.request")(function* (url) {
|
|
36824
|
-
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));
|
|
36825
38339
|
});
|
|
36826
38340
|
const search = (query) => Effect.gen(function* () {
|
|
36827
38341
|
const response = yield* request(`${REGISTRY_SEARCH_URL}?q=${encodeURIComponent(query)}`).pipe(Effect.orElseSucceed(() => null));
|
|
@@ -36891,211 +38405,6 @@ const make$37 = Effect.gen(function* () {
|
|
|
36891
38405
|
});
|
|
36892
38406
|
const layer$32 = Layer.effect(SkillRegistry, make$37);
|
|
36893
38407
|
//#endregion
|
|
36894
|
-
//#region ../../packages/shared/src/ticketReference.ts
|
|
36895
|
-
/**
|
|
36896
|
-
* Recognizing a tracker reference in text somebody pasted.
|
|
36897
|
-
*
|
|
36898
|
-
* Shared because both ends need the same answer: the composer decides whether a
|
|
36899
|
-
* paste is worth a round trip, and the server decides what to ask the tracker.
|
|
36900
|
-
* Two copies of this rule would differ, and the difference would read as "it
|
|
36901
|
-
* works on my machine but the paste does nothing".
|
|
36902
|
-
*
|
|
36903
|
-
* @module ticketReference
|
|
36904
|
-
*/
|
|
36905
|
-
/**
|
|
36906
|
-
* A Linear issue URL, e.g.
|
|
36907
|
-
* `https://linear.app/omnicasa/issue/MOBILE-12262/haptic-feedback`.
|
|
36908
|
-
*
|
|
36909
|
-
* The workspace slug is what makes the URL unguessable from the identifier
|
|
36910
|
-
* alone, which is why a resolved ticket keeps the tracker's own URL rather than
|
|
36911
|
-
* rebuilding one.
|
|
36912
|
-
*/
|
|
36913
|
-
const LINEAR_ISSUE_URL_PATTERN = /https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/;
|
|
36914
|
-
/**
|
|
36915
|
-
* A bare identifier, e.g. `MOBILE-12262`.
|
|
36916
|
-
*
|
|
36917
|
-
* Anchored: only a paste that is *nothing but* an identifier counts. Scanning
|
|
36918
|
-
* prose for this shape would claim things that are not tickets - a date range,
|
|
36919
|
-
* a version, a p4code readable id like `P4-40` - and each false positive costs
|
|
36920
|
-
* a request and, worse, a wrong link.
|
|
36921
|
-
*/
|
|
36922
|
-
const BARE_IDENTIFIER_PATTERN = /^([A-Za-z][A-Za-z0-9]*-\d+)$/;
|
|
36923
|
-
/**
|
|
36924
|
-
* The issue identifier a paste refers to, or `null` when it refers to none.
|
|
36925
|
-
*
|
|
36926
|
-
* Case is normalized up, the way every tracker prints it, so the same ticket
|
|
36927
|
-
* pasted two ways compares equal.
|
|
36928
|
-
*/
|
|
36929
|
-
function parseTicketReference(text) {
|
|
36930
|
-
const trimmed = text.trim();
|
|
36931
|
-
if (trimmed.length === 0) return null;
|
|
36932
|
-
const url = LINEAR_ISSUE_URL_PATTERN.exec(trimmed);
|
|
36933
|
-
if (url?.[1] !== void 0) return url[1].toUpperCase();
|
|
36934
|
-
const bare = BARE_IDENTIFIER_PATTERN.exec(trimmed);
|
|
36935
|
-
if (bare?.[1] !== void 0) return bare[1].toUpperCase();
|
|
36936
|
-
return null;
|
|
36937
|
-
}
|
|
36938
|
-
//#endregion
|
|
36939
|
-
//#region src/mcp/McpToolClient.ts
|
|
36940
|
-
/**
|
|
36941
|
-
* Calling a tool on somebody else's MCP server.
|
|
36942
|
-
*
|
|
36943
|
-
* The rest of `apps/server/src/mcp` points the other way: it *exposes* p4code's
|
|
36944
|
-
* toolkit to an agent. This is the only place p4code is the client, and it
|
|
36945
|
-
* exists because one feature - recognizing a pasted ticket - needs an answer
|
|
36946
|
-
* before any agent turn runs, from a server the user has already registered.
|
|
36947
|
-
*
|
|
36948
|
-
* Deliberately not a general MCP client. There is no session reuse, no tool
|
|
36949
|
-
* listing, no notifications, no SSE stream held open: one request/response pair
|
|
36950
|
-
* per call, which is all streamable HTTP requires for a plain `tools/call`. A
|
|
36951
|
-
* real client belongs here only once something needs one.
|
|
36952
|
-
*
|
|
36953
|
-
* @module mcp/McpToolClient
|
|
36954
|
-
*/
|
|
36955
|
-
/**
|
|
36956
|
-
* The version p4code speaks. Sent on `initialize` and echoed back as a header
|
|
36957
|
-
* on the follow-up calls, which is what the spec asks of a client that has
|
|
36958
|
-
* already negotiated.
|
|
36959
|
-
*/
|
|
36960
|
-
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
36961
|
-
const REQUEST_TIMEOUT_MS = 15e3;
|
|
36962
|
-
var McpToolCallError = class extends Schema$1.TaggedErrorClass()("McpToolCallError", {
|
|
36963
|
-
detail: Schema$1.String,
|
|
36964
|
-
status: Schema$1.NullOr(Schema$1.Number)
|
|
36965
|
-
}) {
|
|
36966
|
-
get message() {
|
|
36967
|
-
return this.detail;
|
|
36968
|
-
}
|
|
36969
|
-
};
|
|
36970
|
-
const unauthorized$1 = (status) => status === 401 || status === 403;
|
|
36971
|
-
/**
|
|
36972
|
-
* Read a JSON-RPC envelope out of a response that may be either JSON or SSE.
|
|
36973
|
-
*
|
|
36974
|
-
* A streamable-http server picks the encoding, and both are legal answers to a
|
|
36975
|
-
* single call. The SSE branch keeps the last `data:` payload that parses: a
|
|
36976
|
-
* server is free to emit progress notifications ahead of the result, and the
|
|
36977
|
-
* result is what a caller of this asked for.
|
|
36978
|
-
*/
|
|
36979
|
-
const readEnvelope = (body) => {
|
|
36980
|
-
const direct = parseJsonObject(body);
|
|
36981
|
-
if (direct !== void 0) return direct;
|
|
36982
|
-
let latest;
|
|
36983
|
-
for (const line of body.split("\n")) {
|
|
36984
|
-
const trimmed = line.trim();
|
|
36985
|
-
if (!trimmed.startsWith("data:")) continue;
|
|
36986
|
-
const parsed = parseJsonObject(trimmed.slice(5).trim());
|
|
36987
|
-
if (parsed !== void 0) latest = parsed;
|
|
36988
|
-
}
|
|
36989
|
-
return latest;
|
|
36990
|
-
};
|
|
36991
|
-
const parseJsonObject = (text) => {
|
|
36992
|
-
if (text.length === 0) return void 0;
|
|
36993
|
-
try {
|
|
36994
|
-
const parsed = JSON.parse(text);
|
|
36995
|
-
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
36996
|
-
} catch {
|
|
36997
|
-
return;
|
|
36998
|
-
}
|
|
36999
|
-
};
|
|
37000
|
-
const textOf = (content) => {
|
|
37001
|
-
if (!Array.isArray(content)) return "";
|
|
37002
|
-
const parts = [];
|
|
37003
|
-
for (const block of content) {
|
|
37004
|
-
if (typeof block !== "object" || block === null) continue;
|
|
37005
|
-
const record = block;
|
|
37006
|
-
if (record["type"] === "text" && typeof record["text"] === "string") parts.push(record["text"]);
|
|
37007
|
-
}
|
|
37008
|
-
return parts.join("\n");
|
|
37009
|
-
};
|
|
37010
|
-
/**
|
|
37011
|
-
* One `tools/call`, preceded by the `initialize` handshake the transport
|
|
37012
|
-
* requires.
|
|
37013
|
-
*
|
|
37014
|
-
* The handshake is not optional and not cacheable here: a server may hand out a
|
|
37015
|
-
* session id, and this holds no session state between calls. Two round trips
|
|
37016
|
-
* for one answer is the price of not keeping a connection open for a feature
|
|
37017
|
-
* that fires when somebody pastes something.
|
|
37018
|
-
*/
|
|
37019
|
-
const callMcpTool = Effect.fn("McpToolClient.callMcpTool")(function* (input) {
|
|
37020
|
-
const http = yield* HttpClient$1.HttpClient;
|
|
37021
|
-
const post = (body, extraHeaders) => http.execute(HttpClientRequest$1.bodyJsonUnsafe(HttpClientRequest$1.setHeaders(HttpClientRequest$1.post(input.url), {
|
|
37022
|
-
...input.headers,
|
|
37023
|
-
...extraHeaders,
|
|
37024
|
-
accept: "application/json, text/event-stream"
|
|
37025
|
-
}), body)).pipe(Effect.timeout(REQUEST_TIMEOUT_MS), Effect.catchCause((cause) => Effect.fail(new McpToolCallError({
|
|
37026
|
-
detail: `Could not reach ${input.url}: ${String(cause)}`,
|
|
37027
|
-
status: null
|
|
37028
|
-
}))));
|
|
37029
|
-
const initialize = yield* post({
|
|
37030
|
-
jsonrpc: "2.0",
|
|
37031
|
-
id: 1,
|
|
37032
|
-
method: "initialize",
|
|
37033
|
-
params: {
|
|
37034
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
37035
|
-
capabilities: {},
|
|
37036
|
-
clientInfo: {
|
|
37037
|
-
name: "p4code",
|
|
37038
|
-
version: "0.0.0"
|
|
37039
|
-
}
|
|
37040
|
-
}
|
|
37041
|
-
}, {});
|
|
37042
|
-
if (initialize.status >= 400) return yield* new McpToolCallError({
|
|
37043
|
-
detail: unauthorized$1(initialize.status) ? "The server rejected p4code's credentials." : `The server answered ${initialize.status} to initialize.`,
|
|
37044
|
-
status: initialize.status
|
|
37045
|
-
});
|
|
37046
|
-
const sessionId = initialize.headers["mcp-session-id"];
|
|
37047
|
-
const sessionHeaders = {
|
|
37048
|
-
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
37049
|
-
...sessionId === void 0 ? {} : { "mcp-session-id": sessionId }
|
|
37050
|
-
};
|
|
37051
|
-
yield* post({
|
|
37052
|
-
jsonrpc: "2.0",
|
|
37053
|
-
method: "notifications/initialized"
|
|
37054
|
-
}, sessionHeaders).pipe(Effect.ignore);
|
|
37055
|
-
const response = yield* post({
|
|
37056
|
-
jsonrpc: "2.0",
|
|
37057
|
-
id: 2,
|
|
37058
|
-
method: "tools/call",
|
|
37059
|
-
params: {
|
|
37060
|
-
name: input.toolName,
|
|
37061
|
-
arguments: input.arguments
|
|
37062
|
-
}
|
|
37063
|
-
}, sessionHeaders);
|
|
37064
|
-
const body = yield* response.text.pipe(Effect.catchCause(() => Effect.succeed("")));
|
|
37065
|
-
if (response.status >= 400) return yield* new McpToolCallError({
|
|
37066
|
-
detail: unauthorized$1(response.status) ? "The server rejected p4code's credentials." : `The server answered ${response.status} to ${input.toolName}.`,
|
|
37067
|
-
status: response.status
|
|
37068
|
-
});
|
|
37069
|
-
const envelope = readEnvelope(body);
|
|
37070
|
-
if (envelope === void 0) return yield* new McpToolCallError({
|
|
37071
|
-
detail: `The server's answer to ${input.toolName} was not JSON-RPC.`,
|
|
37072
|
-
status: response.status
|
|
37073
|
-
});
|
|
37074
|
-
const error = envelope["error"];
|
|
37075
|
-
if (typeof error === "object" && error !== null) {
|
|
37076
|
-
const detail = error["message"];
|
|
37077
|
-
return yield* new McpToolCallError({
|
|
37078
|
-
detail: typeof detail === "string" ? detail : `${input.toolName} failed.`,
|
|
37079
|
-
status: response.status
|
|
37080
|
-
});
|
|
37081
|
-
}
|
|
37082
|
-
const result = envelope["result"];
|
|
37083
|
-
if (typeof result !== "object" || result === null) return yield* new McpToolCallError({
|
|
37084
|
-
detail: `${input.toolName} returned no result.`,
|
|
37085
|
-
status: response.status
|
|
37086
|
-
});
|
|
37087
|
-
const resultRecord = result;
|
|
37088
|
-
const text = textOf(resultRecord["content"]);
|
|
37089
|
-
if (resultRecord["isError"] === true) return yield* new McpToolCallError({
|
|
37090
|
-
detail: text.length > 0 ? text : `${input.toolName} reported an error.`,
|
|
37091
|
-
status: response.status
|
|
37092
|
-
});
|
|
37093
|
-
return {
|
|
37094
|
-
structuredContent: resultRecord["structuredContent"],
|
|
37095
|
-
text
|
|
37096
|
-
};
|
|
37097
|
-
});
|
|
37098
|
-
//#endregion
|
|
37099
38408
|
//#region src/mcp/TicketResolver.ts
|
|
37100
38409
|
/**
|
|
37101
38410
|
* The registered server name a Linear ticket is resolved through.
|
|
@@ -37409,7 +38718,7 @@ const COMMON_DEV_PORTS = Object.freeze([
|
|
|
37409
38718
|
8888,
|
|
37410
38719
|
9e3
|
|
37411
38720
|
]);
|
|
37412
|
-
const POLL_INTERVAL
|
|
38721
|
+
const POLL_INTERVAL = Duration.seconds(3);
|
|
37413
38722
|
const LSOF_TIMEOUT_MS = 5e3;
|
|
37414
38723
|
const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
|
|
37415
38724
|
const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
|
|
@@ -37584,7 +38893,7 @@ const make$35 = Effect.gen(function* PortDiscoveryMake() {
|
|
|
37584
38893
|
lastSnapshot: next
|
|
37585
38894
|
}])) yield* broadcast(next);
|
|
37586
38895
|
}, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
|
|
37587
|
-
yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL
|
|
38896
|
+
yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
|
|
37588
38897
|
const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
|
|
37589
38898
|
if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
|
|
37590
38899
|
...state,
|
|
@@ -46517,7 +47826,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
|
|
|
46517
47826
|
const orchestrationEngine = yield* OrchestrationEngineService;
|
|
46518
47827
|
const checkpointDiffQuery = yield* CheckpointDiffQuery;
|
|
46519
47828
|
const keybindings = yield* Keybindings;
|
|
46520
|
-
const
|
|
47829
|
+
const taskRepositories = yield* TaskRepositoryRegistry;
|
|
46521
47830
|
const projectionProjects = yield* ProjectionProjectRepository;
|
|
46522
47831
|
const externalLauncher = yield* ExternalLauncher;
|
|
46523
47832
|
const gitWorkflow = yield* GitWorkflowService;
|
|
@@ -47007,29 +48316,30 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
|
|
|
47007
48316
|
issues: []
|
|
47008
48317
|
};
|
|
47009
48318
|
}), { "rpc.aggregate": "server" }),
|
|
47010
|
-
[WS_METHODS.tasksList]: (filter) => observeRpcEffect$1(WS_METHODS.tasksList,
|
|
47011
|
-
[WS_METHODS.tasksGet]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksGet,
|
|
47012
|
-
[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* () {
|
|
47013
48322
|
const taskId = TaskId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie));
|
|
47014
48323
|
const timestamp = yield* nowIso$5;
|
|
47015
48324
|
const repositoryKey = yield* resolveTaskRepositoryKey(input.projectId);
|
|
47016
48325
|
const readableIdPrefix = yield* resolveTaskReadableIdPrefix(input.projectId);
|
|
47017
48326
|
const row = buildTaskRow(input, taskId, timestamp, repositoryKey);
|
|
47018
|
-
return { task: yield*
|
|
48327
|
+
return { task: yield* taskRepositories.forSource(source).create(row, readableIdPrefix).pipe(Effect.catch(toTaskStoreError("tasks.create"))) };
|
|
47019
48328
|
}), { "rpc.aggregate": "tasks" }),
|
|
47020
|
-
[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* () {
|
|
47021
48330
|
const timestamp = yield* nowIso$5;
|
|
47022
48331
|
const patch = input.projectId === void 0 ? input : {
|
|
47023
48332
|
...input,
|
|
47024
48333
|
repositoryKey: yield* resolveTaskRepositoryKey(input.projectId)
|
|
47025
48334
|
};
|
|
47026
|
-
const patched = yield*
|
|
48335
|
+
const patched = yield* taskRepositories.forSource(source).patch(patch, timestamp).pipe(Effect.catch(toTaskStoreError("tasks.update")));
|
|
47027
48336
|
if (Option.isNone(patched)) return yield* new TaskNotFoundError({ taskId: input.taskId });
|
|
47028
48337
|
return { task: patched.value };
|
|
47029
48338
|
}), { "rpc.aggregate": "tasks" }),
|
|
47030
|
-
[WS_METHODS.tasksDelete]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksDelete,
|
|
47031
|
-
[WS_METHODS.tasksStartThread]: ({ taskId }) => observeRpcEffect$1(WS_METHODS.tasksStartThread, Effect.gen(function* () {
|
|
47032
|
-
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")));
|
|
47033
48343
|
if (Option.isNone(found)) return yield* new TaskNotFoundError({ taskId });
|
|
47034
48344
|
const task = found.value;
|
|
47035
48345
|
const plan = resolveTaskStart(task, task.projectId === null ? Option.none() : yield* projectionProjects.getById({ projectId: task.projectId }).pipe(Effect.catch(toTaskStoreError("tasks.startThread.project"))));
|
|
@@ -47079,7 +48389,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
|
|
|
47079
48389
|
createdAt
|
|
47080
48390
|
});
|
|
47081
48391
|
yield* dispatchNormalizedCommand(normalizedCommand);
|
|
47082
|
-
const patched = yield*
|
|
48392
|
+
const patched = yield* tasks.patch({
|
|
47083
48393
|
taskId,
|
|
47084
48394
|
threadId,
|
|
47085
48395
|
status: "in_progress"
|
|
@@ -47097,7 +48407,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
|
|
|
47097
48407
|
* becomes a task, so an accidental paste costs one request and no row.
|
|
47098
48408
|
*/
|
|
47099
48409
|
[WS_METHODS.ticketsResolve]: ({ reference }) => observeRpcEffect$1(WS_METHODS.ticketsResolve, ticketResolver.resolve(reference), { "rpc.aggregate": "tickets" }),
|
|
47100
|
-
[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" }),
|
|
47101
48411
|
[WS_METHODS.hubGetSyncStatus]: (_input) => observeRpcEffect$1(WS_METHODS.hubGetSyncStatus, assetSync.status, { "rpc.aggregate": "hub" }),
|
|
47102
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" }),
|
|
47103
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" }),
|
|
@@ -47356,7 +48666,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
|
|
|
47356
48666
|
const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
|
|
47357
48667
|
const previewAutomationBroker = yield* PreviewAutomationBroker;
|
|
47358
48668
|
const serverSelfUpdate = yield* ServerSelfUpdate;
|
|
47359
|
-
const
|
|
48669
|
+
const taskRepositories = yield* TaskRepositoryRegistry;
|
|
47360
48670
|
const projectionProjects = yield* ProjectionProjectRepository;
|
|
47361
48671
|
const p4ProjectFileLoader = yield* P4ProjectFileLoader;
|
|
47362
48672
|
return HttpRouter.add("GET", "/ws", Effect.gen(function* () {
|
|
@@ -47364,7 +48674,7 @@ const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
|
|
|
47364
48674
|
const serverAuth = yield* EnvironmentAuth;
|
|
47365
48675
|
const sessions = yield* SessionStore;
|
|
47366
48676
|
const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(Effect.catchIf(isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(serverAuthCredentialReason(error))), Effect.catchIf(isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error)));
|
|
47367
|
-
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))))));
|
|
47368
48678
|
return yield* Effect.acquireUseRelease(sessions.markConnected(session.sessionId), () => rpcWebSocketHttpEffect, () => sessions.markDisconnected(session.sessionId));
|
|
47369
48679
|
}).pipe(Effect.catchTags({
|
|
47370
48680
|
EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
|
|
@@ -47372,296 +48682,6 @@ const websocketRpcRouteLayer = Layer.unwrap(Effect.gen(function* () {
|
|
|
47372
48682
|
})));
|
|
47373
48683
|
}));
|
|
47374
48684
|
//#endregion
|
|
47375
|
-
//#region src/persistence/Layers/RemoteTasks.ts
|
|
47376
|
-
/**
|
|
47377
|
-
* The board, kept on the hub instead of in this machine's database.
|
|
47378
|
-
*
|
|
47379
|
-
* A drop-in `TaskRepository`: same interface, same events, so the MCP toolkit,
|
|
47380
|
-
* the websocket RPCs and the board itself are unchanged and do not know which
|
|
47381
|
-
* one they are talking to. That was the point of making it an interface.
|
|
47382
|
-
*
|
|
47383
|
-
* **An unreachable hub fails loudly, by decision.** There is no local fallback
|
|
47384
|
-
* and no write queue. A server that answered from a local copy while the hub
|
|
47385
|
-
* was down would recreate exactly the divergence the hub exists to remove —
|
|
47386
|
-
* two Macs with two boards, silently — and the failure would be invisible until
|
|
47387
|
-
* someone noticed a task missing on the other machine. So a hub that cannot be
|
|
47388
|
-
* reached means the board says so and nothing is written. The cost is real and
|
|
47389
|
-
* is the point: no board while the hub is down.
|
|
47390
|
-
*
|
|
47391
|
-
* **Changes are polled, because the hub has no push.** The interface promises
|
|
47392
|
-
* a change stream, and the honest implementation of that against a plain HTTP
|
|
47393
|
-
* API is a poll with a diff. A write made through *this* server publishes
|
|
47394
|
-
* immediately, so the local board is live; the poll is what carries the other
|
|
47395
|
-
* machine's writes, at up to `POLL_INTERVAL` of lag.
|
|
47396
|
-
*
|
|
47397
|
-
* @module persistence/Layers/RemoteTasks
|
|
47398
|
-
*/
|
|
47399
|
-
const HUB_REQUEST_TIMEOUT_MS = 15e3;
|
|
47400
|
-
/**
|
|
47401
|
-
* How stale another machine's write may look here.
|
|
47402
|
-
*
|
|
47403
|
-
* Short enough that a board left open follows along, long enough that two
|
|
47404
|
-
* servers idling all day are not a steady stream of requests at a hosted
|
|
47405
|
-
* service. Only changes made elsewhere wait for it.
|
|
47406
|
-
*/
|
|
47407
|
-
const POLL_INTERVAL = Duration.seconds(10);
|
|
47408
|
-
const TaskListPage = Schema$1.Struct({ tasks: Schema$1.Array(Task) });
|
|
47409
|
-
const ConflictBody = Schema$1.Struct({ current: Task });
|
|
47410
|
-
const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
|
|
47411
|
-
const decodeTask = Schema$1.decodeUnknownEffect(Task);
|
|
47412
|
-
const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
|
|
47413
|
-
/**
|
|
47414
|
-
* Every failure here is reported as the interface's own error type.
|
|
47415
|
-
*
|
|
47416
|
-
* `PersistenceSqlError` names a store that would not answer, which is exactly
|
|
47417
|
-
* what this is from a caller's point of view — the detail says the store is the
|
|
47418
|
-
* hub. Inventing a second error type would mean touching every call site to
|
|
47419
|
-
* handle a case they already handle.
|
|
47420
|
-
*/
|
|
47421
|
-
const hubUnavailable = (operation, detail) => new PersistenceSqlError({
|
|
47422
|
-
operation: `RemoteTaskRepository.${operation}`,
|
|
47423
|
-
detail: `The hub board is unavailable: ${detail}`,
|
|
47424
|
-
cause: null
|
|
47425
|
-
});
|
|
47426
|
-
/**
|
|
47427
|
-
* @param isActive Whether this server is currently reading its board from the
|
|
47428
|
-
* hub. The poll asks before every pass rather than being started once: a
|
|
47429
|
-
* machine with the board set to `local` must not be sending a request to a
|
|
47430
|
-
* hosted service every ten seconds for a board nobody is reading, and the
|
|
47431
|
-
* setting can change while the server runs.
|
|
47432
|
-
*/
|
|
47433
|
-
const makeRemoteTaskRepository = Effect.fn("makeRemoteTaskRepository")(function* (isActive) {
|
|
47434
|
-
const http = yield* HttpClient.HttpClient;
|
|
47435
|
-
const link = yield* HubLink;
|
|
47436
|
-
const changes = yield* PubSub.unbounded();
|
|
47437
|
-
const publish = (event) => PubSub.publish(changes, event).pipe(Effect.asVoid);
|
|
47438
|
-
/** The board as of the last poll, for diffing the next one against. */
|
|
47439
|
-
const lastSeen = yield* Ref.make(null);
|
|
47440
|
-
const requireSettings = (operation) => Effect.gen(function* () {
|
|
47441
|
-
const state = yield* link.current;
|
|
47442
|
-
if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
|
|
47443
|
-
return state.settings.value;
|
|
47444
|
-
});
|
|
47445
|
-
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"}`)));
|
|
47446
|
-
const list = (filter) => Effect.gen(function* () {
|
|
47447
|
-
const settings = yield* requireSettings("list");
|
|
47448
|
-
const query = new URLSearchParams();
|
|
47449
|
-
for (const key of [
|
|
47450
|
-
"projectId",
|
|
47451
|
-
"repositoryKey",
|
|
47452
|
-
"threadId",
|
|
47453
|
-
"status",
|
|
47454
|
-
"assignee"
|
|
47455
|
-
]) {
|
|
47456
|
-
const value = filter[key];
|
|
47457
|
-
if (value !== void 0) query.set(key, value);
|
|
47458
|
-
}
|
|
47459
|
-
const suffix = query.size === 0 ? "" : `?${query.toString()}`;
|
|
47460
|
-
const response = yield* send("list", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks${suffix}`));
|
|
47461
|
-
if (response.status !== 200) return yield* hubUnavailable("list", `status ${response.status}`);
|
|
47462
|
-
return (yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("list", "the response did not match the contract")))).tasks;
|
|
47463
|
-
});
|
|
47464
|
-
const getById = ({ taskId }) => Effect.gen(function* () {
|
|
47465
|
-
const settings = yield* requireSettings("getById");
|
|
47466
|
-
const response = yield* send("getById", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
|
|
47467
|
-
if (response.status === 404) return Option.none();
|
|
47468
|
-
if (response.status !== 200) return yield* hubUnavailable("getById", `status ${response.status}`);
|
|
47469
|
-
return Option.some(yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("getById", "the response did not match the contract"))));
|
|
47470
|
-
});
|
|
47471
|
-
/**
|
|
47472
|
-
* Both writes here POST the hub's create route, and that is a real
|
|
47473
|
-
* difference from the local store: the hub mints its own ids, timestamps and
|
|
47474
|
-
* readable id, so a row written through this comes back with the hub's
|
|
47475
|
-
* version of all three. The caller's `taskId` is not carried - honouring it
|
|
47476
|
-
* would need a hub route that takes one, and inventing that quietly would
|
|
47477
|
-
* let two servers mint colliding ids.
|
|
47478
|
-
*/
|
|
47479
|
-
const postCreate = (operation, row, readableIdPrefix) => Effect.gen(function* () {
|
|
47480
|
-
const settings = yield* requireSettings(operation);
|
|
47481
|
-
const response = yield* send(operation, settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(`${settings.baseUrl}/tasks`), {
|
|
47482
|
-
...row.projectId === null ? {} : { projectId: row.projectId },
|
|
47483
|
-
...row.repositoryKey === null ? {} : { repositoryKey: row.repositoryKey },
|
|
47484
|
-
...row.threadId === null ? {} : { threadId: row.threadId },
|
|
47485
|
-
...row.externalRef === null ? {} : { externalRef: row.externalRef },
|
|
47486
|
-
...row.parentTaskId === null ? {} : { parentTaskId: row.parentTaskId },
|
|
47487
|
-
title: row.title,
|
|
47488
|
-
body: row.body,
|
|
47489
|
-
status: row.status,
|
|
47490
|
-
priority: row.priority,
|
|
47491
|
-
...row.assignee === null ? {} : { assignee: row.assignee },
|
|
47492
|
-
labels: row.labels,
|
|
47493
|
-
...row.orderKey === null ? {} : { orderKey: row.orderKey },
|
|
47494
|
-
...readableIdPrefix === void 0 ? {} : { readableIdPrefix }
|
|
47495
|
-
}));
|
|
47496
|
-
if (response.status !== 201 && response.status !== 200) return yield* hubUnavailable(operation, `status ${response.status}`);
|
|
47497
|
-
const created = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable(operation, "the response did not match the contract")));
|
|
47498
|
-
yield* publish({
|
|
47499
|
-
type: "upserted",
|
|
47500
|
-
task: created
|
|
47501
|
-
});
|
|
47502
|
-
return created;
|
|
47503
|
-
});
|
|
47504
|
-
const create = (row, readableIdPrefix) => postCreate("create", row, readableIdPrefix);
|
|
47505
|
-
const upsert = (row) => postCreate("upsert", row).pipe(Effect.asVoid);
|
|
47506
|
-
/**
|
|
47507
|
-
* Served by the same hub route as `getById`: readable ids and UUIDs are
|
|
47508
|
-
* disjoint by shape, so the hub dispatches on what it is handed.
|
|
47509
|
-
*/
|
|
47510
|
-
const getByReadableId = (readableId) => getById({ taskId: TaskId.make(readableId) });
|
|
47511
|
-
const patch = (input) => Effect.gen(function* () {
|
|
47512
|
-
const settings = yield* requireSettings("patch");
|
|
47513
|
-
const { taskId, ...fields } = input;
|
|
47514
|
-
const response = yield* send("patch", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), fields));
|
|
47515
|
-
if (response.status === 404) return Option.none();
|
|
47516
|
-
if (response.status !== 200) return yield* hubUnavailable("patch", `status ${response.status}`);
|
|
47517
|
-
const updated = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patch", "the response did not match the contract")));
|
|
47518
|
-
yield* publish({
|
|
47519
|
-
type: "upserted",
|
|
47520
|
-
task: updated
|
|
47521
|
-
});
|
|
47522
|
-
return Option.some(updated);
|
|
47523
|
-
});
|
|
47524
|
-
const patchIfStatus = (input) => Effect.gen(function* () {
|
|
47525
|
-
const settings = yield* requireSettings("patchIfStatus");
|
|
47526
|
-
const { taskId, ...fields } = input.patch;
|
|
47527
|
-
const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
|
|
47528
|
-
...fields,
|
|
47529
|
-
expectedStatus: input.expectedStatus
|
|
47530
|
-
}));
|
|
47531
|
-
if (response.status === 404) return { outcome: "missing" };
|
|
47532
|
-
if (response.status === 409) return {
|
|
47533
|
-
outcome: "conflict",
|
|
47534
|
-
current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
|
|
47535
|
-
};
|
|
47536
|
-
if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
|
|
47537
|
-
const updated = yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")));
|
|
47538
|
-
yield* publish({
|
|
47539
|
-
type: "upserted",
|
|
47540
|
-
task: updated
|
|
47541
|
-
});
|
|
47542
|
-
return {
|
|
47543
|
-
outcome: "updated",
|
|
47544
|
-
task: updated
|
|
47545
|
-
};
|
|
47546
|
-
});
|
|
47547
|
-
const deleteById = ({ taskId }) => Effect.gen(function* () {
|
|
47548
|
-
const settings = yield* requireSettings("deleteById");
|
|
47549
|
-
const response = yield* send("deleteById", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
|
|
47550
|
-
if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("deleteById", `status ${response.status}`);
|
|
47551
|
-
yield* publish({
|
|
47552
|
-
type: "deleted",
|
|
47553
|
-
taskId
|
|
47554
|
-
});
|
|
47555
|
-
});
|
|
47556
|
-
/**
|
|
47557
|
-
* One poll: read the board, publish what differs from last time.
|
|
47558
|
-
*
|
|
47559
|
-
* Compared on `updatedAt` rather than deep equality, because that is the
|
|
47560
|
-
* field every hub write moves and it makes the diff O(n) with no schema
|
|
47561
|
-
* knowledge. A failed poll publishes nothing and leaves the last snapshot in
|
|
47562
|
-
* place — a hub blip should not empty every open board.
|
|
47563
|
-
*/
|
|
47564
|
-
const pollOnce = Effect.gen(function* () {
|
|
47565
|
-
if (!(yield* isActive)) return;
|
|
47566
|
-
const current = yield* list({});
|
|
47567
|
-
const previous = yield* Ref.getAndSet(lastSeen, current);
|
|
47568
|
-
if (previous === null) return;
|
|
47569
|
-
const previousById = new Map(previous.map((task) => [task.taskId, task]));
|
|
47570
|
-
for (const task of current) {
|
|
47571
|
-
const before = previousById.get(task.taskId);
|
|
47572
|
-
if (before === void 0 || before.updatedAt !== task.updatedAt) yield* publish({
|
|
47573
|
-
type: "upserted",
|
|
47574
|
-
task
|
|
47575
|
-
});
|
|
47576
|
-
previousById.delete(task.taskId);
|
|
47577
|
-
}
|
|
47578
|
-
for (const taskId of previousById.keys()) yield* publish({
|
|
47579
|
-
type: "deleted",
|
|
47580
|
-
taskId
|
|
47581
|
-
});
|
|
47582
|
-
}).pipe(Effect.ignoreCause({ log: true }));
|
|
47583
|
-
yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL)))).pipe(Effect.forkScoped);
|
|
47584
|
-
return {
|
|
47585
|
-
create,
|
|
47586
|
-
upsert,
|
|
47587
|
-
patch,
|
|
47588
|
-
patchIfStatus,
|
|
47589
|
-
getById,
|
|
47590
|
-
getByReadableId,
|
|
47591
|
-
list,
|
|
47592
|
-
deleteById,
|
|
47593
|
-
streamChanges: Stream.fromPubSub(changes),
|
|
47594
|
-
streamWithSnapshot: Stream.unwrap(Effect.gen(function* () {
|
|
47595
|
-
const subscription = yield* PubSub.subscribe(changes);
|
|
47596
|
-
const snapshot = yield* list({});
|
|
47597
|
-
yield* Ref.set(lastSeen, snapshot);
|
|
47598
|
-
return Stream.concat(Stream.succeed({
|
|
47599
|
-
type: "snapshot",
|
|
47600
|
-
tasks: snapshot
|
|
47601
|
-
}), Stream.fromSubscription(subscription));
|
|
47602
|
-
}))
|
|
47603
|
-
};
|
|
47604
|
-
});
|
|
47605
|
-
Layer.effect(TaskRepository, makeRemoteTaskRepository(Effect.succeed(true)));
|
|
47606
|
-
//#endregion
|
|
47607
|
-
//#region src/persistence/Layers/TaskBoardSource.ts
|
|
47608
|
-
/**
|
|
47609
|
-
* Which board a call goes to: this machine's database, or the hub's.
|
|
47610
|
-
*
|
|
47611
|
-
* Both repositories are built, and every call reads the setting and delegates.
|
|
47612
|
-
* That is deliberate rather than picking one at startup: `taskBoardSource` is
|
|
47613
|
-
* an ordinary setting a person changes in a panel, and a setting that only
|
|
47614
|
-
* takes effect after a restart is the same hazard the hub link already
|
|
47615
|
-
* documents — you change it, nothing happens, and there is nothing on screen
|
|
47616
|
-
* saying why.
|
|
47617
|
-
*
|
|
47618
|
-
* **Subscriptions pick their source when they subscribe.** A stream cannot
|
|
47619
|
-
* change its mind mid-flight without inventing a "your board moved" event that
|
|
47620
|
-
* nothing knows how to render, so an open board keeps reading the source it
|
|
47621
|
-
* opened against and picks up the change when it reconnects. Switching is rare;
|
|
47622
|
-
* a wrong row on screen is not worth a new event type.
|
|
47623
|
-
*
|
|
47624
|
-
* @module persistence/Layers/TaskBoardSource
|
|
47625
|
-
*/
|
|
47626
|
-
const makeRoutedTaskRepository = Effect.gen(function* () {
|
|
47627
|
-
const local = yield* makeTaskRepository;
|
|
47628
|
-
const usingHub = (yield* ServerSettingsService).getSettings.pipe(Effect.map((current) => current.taskBoardSource === "hub"), Effect.orElseSucceed(() => false));
|
|
47629
|
-
const remote = yield* makeRemoteTaskRepository(usingHub);
|
|
47630
|
-
/**
|
|
47631
|
-
* `local` on a settings read failure, and that direction is chosen.
|
|
47632
|
-
*
|
|
47633
|
-
* The alternative is refusing every board call because a settings file could
|
|
47634
|
-
* not be read, which turns one broken file into a dead board. Falling back to
|
|
47635
|
-
* the machine's own database is the answer that still works and cannot lose a
|
|
47636
|
-
* hub write, because a write that never reached the hub was never accepted.
|
|
47637
|
-
*/
|
|
47638
|
-
const pick = usingHub.pipe(Effect.map((hub) => hub ? remote : local));
|
|
47639
|
-
return {
|
|
47640
|
-
create: (row, readableIdPrefix) => pick.pipe(Effect.flatMap((repository) => repository.create(row, readableIdPrefix))),
|
|
47641
|
-
upsert: (row) => pick.pipe(Effect.flatMap((repository) => repository.upsert(row))),
|
|
47642
|
-
patch: (input, updatedAt) => pick.pipe(Effect.flatMap((repository) => repository.patch(input, updatedAt))),
|
|
47643
|
-
patchIfStatus: (input, updatedAt) => pick.pipe(Effect.flatMap((repository) => repository.patchIfStatus(input, updatedAt))),
|
|
47644
|
-
getById: (input) => pick.pipe(Effect.flatMap((repository) => repository.getById(input))),
|
|
47645
|
-
getByReadableId: (readableId) => pick.pipe(Effect.flatMap((repository) => repository.getByReadableId(readableId))),
|
|
47646
|
-
list: (filter) => pick.pipe(Effect.flatMap((repository) => repository.list(filter))),
|
|
47647
|
-
deleteById: (input) => pick.pipe(Effect.flatMap((repository) => repository.deleteById(input))),
|
|
47648
|
-
get streamChanges() {
|
|
47649
|
-
return Stream.unwrap(pick.pipe(Effect.map((repository) => repository.streamChanges)));
|
|
47650
|
-
},
|
|
47651
|
-
get streamWithSnapshot() {
|
|
47652
|
-
return Stream.unwrap(pick.pipe(Effect.map((repository) => repository.streamWithSnapshot)));
|
|
47653
|
-
}
|
|
47654
|
-
};
|
|
47655
|
-
});
|
|
47656
|
-
/**
|
|
47657
|
-
* The board, wherever it lives.
|
|
47658
|
-
*
|
|
47659
|
-
* Replaces `TaskRepositoryLive` at the one place the route tree provides it, so
|
|
47660
|
-
* the MCP toolkit and the websocket handlers keep sharing a single instance —
|
|
47661
|
-
* which is what makes a task an agent files appear on an open board.
|
|
47662
|
-
*/
|
|
47663
|
-
const RoutedTaskRepositoryLive = Layer.effect(TaskRepository, makeRoutedTaskRepository);
|
|
47664
|
-
//#endregion
|
|
47665
48685
|
//#region src/persistence/ProviderSessionRuntime.ts
|
|
47666
48686
|
/**
|
|
47667
48687
|
* ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions.
|
|
@@ -89374,6 +90394,126 @@ const make$3 = Effect.gen(function* () {
|
|
|
89374
90394
|
});
|
|
89375
90395
|
const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make$3).pipe(Layer.provide(ProjectionTurnRepositoryLive));
|
|
89376
90396
|
//#endregion
|
|
90397
|
+
//#region src/provider/userInvokedSkills.ts
|
|
90398
|
+
/**
|
|
90399
|
+
* Running a skill the model is not allowed to reach.
|
|
90400
|
+
*
|
|
90401
|
+
* A skill carrying `disable-model-invocation: true` is withheld from the model
|
|
90402
|
+
* entirely — description included — so only a person typing `/name` can start
|
|
90403
|
+
* it. That works in the interactive CLI, which expands the slash command before
|
|
90404
|
+
* the model ever sees it. It does not work here: p4code drives the Agent SDK,
|
|
90405
|
+
* whose non-interactive path forwards the typed text verbatim, and p4code's own
|
|
90406
|
+
* fallback is to ask the model to call the `Skill` tool — which is the one door
|
|
90407
|
+
* this flag locks. The result is a skill that is unreachable from both sides,
|
|
90408
|
+
* with nothing in the transcript to say why.
|
|
90409
|
+
*
|
|
90410
|
+
* So the expansion happens here, server-side, before the turn leaves: the
|
|
90411
|
+
* skill's own instructions are inlined into the message the provider receives.
|
|
90412
|
+
* The transcript keeps what the person typed, the same split compression
|
|
90413
|
+
* already uses.
|
|
90414
|
+
*
|
|
90415
|
+
* **Only skills the model may not invoke are expanded.** Everything else is
|
|
90416
|
+
* left alone deliberately — those already work through the `Skill` tool, and
|
|
90417
|
+
* inlining them too would mean two mechanisms racing to run the same skill.
|
|
90418
|
+
*
|
|
90419
|
+
* @module provider/userInvokedSkills
|
|
90420
|
+
*/
|
|
90421
|
+
/**
|
|
90422
|
+
* A leading `/name`, with whatever followed it.
|
|
90423
|
+
*
|
|
90424
|
+
* Anchored at the start: a slash somewhere inside a sentence is a path, a date
|
|
90425
|
+
* or an "and/or", and treating those as commands would rewrite ordinary
|
|
90426
|
+
* messages. The name pattern matches what a skill directory can be called,
|
|
90427
|
+
* including the `plugin:skill` form.
|
|
90428
|
+
*/
|
|
90429
|
+
const SLASH_INVOCATION = /^\/([A-Za-z0-9][A-Za-z0-9_:-]*)[ \t]*([\s\S]*)$/;
|
|
90430
|
+
/** `SKILL.md` is the only file a skill is required to have. */
|
|
90431
|
+
const SKILL_FILENAME = "SKILL.md";
|
|
90432
|
+
const parseSlashInvocation = (text) => {
|
|
90433
|
+
const match = SLASH_INVOCATION.exec(text.trim());
|
|
90434
|
+
const name = match?.[1];
|
|
90435
|
+
if (name === void 0) return void 0;
|
|
90436
|
+
return {
|
|
90437
|
+
name,
|
|
90438
|
+
args: (match?.[2] ?? "").trim()
|
|
90439
|
+
};
|
|
90440
|
+
};
|
|
90441
|
+
/**
|
|
90442
|
+
* The message the provider receives in place of `/name args`.
|
|
90443
|
+
*
|
|
90444
|
+
* Tagged rather than pasted bare so the instructions cannot be mistaken for the
|
|
90445
|
+
* person's own words, and attributed to its file so an agent that needs the
|
|
90446
|
+
* skill's siblings — a reference, a template — knows where to look.
|
|
90447
|
+
*/
|
|
90448
|
+
const buildUserInvokedSkillInput = (input) => [
|
|
90449
|
+
`<skill name="${input.name}" path="${input.path}">`,
|
|
90450
|
+
input.body,
|
|
90451
|
+
"</skill>",
|
|
90452
|
+
"",
|
|
90453
|
+
`The user invoked the ${input.name} skill${input.args ? ` with: ${input.args}` : ""}. Follow the instructions above.`
|
|
90454
|
+
].join("\n");
|
|
90455
|
+
/**
|
|
90456
|
+
* The `SKILL.md` a typed name refers to, searched across the given roots.
|
|
90457
|
+
*
|
|
90458
|
+
* Both names a skill answers to are matched — the frontmatter `name` and the
|
|
90459
|
+
* directory it lives in — because either is what a person sees depending on the
|
|
90460
|
+
* surface they read it from, and matching one only would leave a skill whose
|
|
90461
|
+
* two names disagree quietly unreachable. Later roots win, which is how the
|
|
90462
|
+
* CLI resolves a project skill over a user one.
|
|
90463
|
+
*/
|
|
90464
|
+
const findSkillFile = Effect.fnUntraced(function* (input) {
|
|
90465
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
90466
|
+
const path = yield* Path.Path;
|
|
90467
|
+
let found;
|
|
90468
|
+
for (const root of input.roots) {
|
|
90469
|
+
const entries = yield* fileSystem.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
|
|
90470
|
+
for (const entry of [...entries].sort()) {
|
|
90471
|
+
const skillPath = path.join(root, entry, SKILL_FILENAME);
|
|
90472
|
+
const contents = yield* fileSystem.readFileString(skillPath).pipe(Effect.orElseSucceed(() => void 0));
|
|
90473
|
+
if (contents === void 0) continue;
|
|
90474
|
+
const frontmatter = parseMarkdownFrontmatter(contents);
|
|
90475
|
+
const frontmatterName = frontmatter.kind === "parsed" ? frontmatter.name : void 0;
|
|
90476
|
+
if (entry !== input.name && frontmatterName !== input.name) continue;
|
|
90477
|
+
found = {
|
|
90478
|
+
path: skillPath,
|
|
90479
|
+
contents
|
|
90480
|
+
};
|
|
90481
|
+
}
|
|
90482
|
+
}
|
|
90483
|
+
return found;
|
|
90484
|
+
});
|
|
90485
|
+
/**
|
|
90486
|
+
* The turn's text, with a user-invoked skill inlined when there is one.
|
|
90487
|
+
*
|
|
90488
|
+
* `undefined` means "nothing to do", which covers every ordinary message: no
|
|
90489
|
+
* leading slash, a name no skill answers to, a skill the model can invoke for
|
|
90490
|
+
* itself, or a body that is empty once the frontmatter comes off. Reading the
|
|
90491
|
+
* filesystem is best-effort throughout — an unreadable root or file leaves the
|
|
90492
|
+
* message exactly as it was typed, because failing a turn over a skill lookup
|
|
90493
|
+
* would be a worse answer than sending the text.
|
|
90494
|
+
*
|
|
90495
|
+
* @param roots - Skill directories, least specific first.
|
|
90496
|
+
*/
|
|
90497
|
+
const expandUserInvokedSkill = Effect.fnUntraced(function* (input) {
|
|
90498
|
+
const invocation = parseSlashInvocation(input.text);
|
|
90499
|
+
if (invocation === void 0) return void 0;
|
|
90500
|
+
const skill = yield* findSkillFile({
|
|
90501
|
+
name: invocation.name,
|
|
90502
|
+
roots: input.roots
|
|
90503
|
+
});
|
|
90504
|
+
if (skill === void 0) return void 0;
|
|
90505
|
+
const frontmatter = parseMarkdownFrontmatter(skill.contents);
|
|
90506
|
+
if (frontmatter.kind !== "parsed" || frontmatter.disableModelInvocation !== true) return;
|
|
90507
|
+
const body = stripMarkdownFrontmatter(skill.contents);
|
|
90508
|
+
if (body.length === 0) return void 0;
|
|
90509
|
+
return buildUserInvokedSkillInput({
|
|
90510
|
+
name: invocation.name,
|
|
90511
|
+
path: skill.path,
|
|
90512
|
+
body,
|
|
90513
|
+
args: invocation.args
|
|
90514
|
+
});
|
|
90515
|
+
});
|
|
90516
|
+
//#endregion
|
|
89377
90517
|
//#region src/orchestration/Layers/ProviderCommandReactor.ts
|
|
89378
90518
|
const isProviderAdapterRequestError = Schema$1.is(ProviderAdapterRequestError);
|
|
89379
90519
|
const isProviderDriverKind = Schema$1.is(ProviderDriverKind);
|
|
@@ -89570,6 +90710,25 @@ const make$2 = Effect.gen(function* () {
|
|
|
89570
90710
|
const resolveProject = Effect.fnUntraced(function* (projectId) {
|
|
89571
90711
|
return yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe(Effect.map(Option.getOrUndefined));
|
|
89572
90712
|
});
|
|
90713
|
+
/**
|
|
90714
|
+
* Where a typed `/name` is looked up: the user's skills, then the
|
|
90715
|
+
* workspace's.
|
|
90716
|
+
*
|
|
90717
|
+
* Project last so it wins, matching the CLI's most-specific-wins resolution.
|
|
90718
|
+
* The user root is resolved with an empty `homePath` on purpose — this runs
|
|
90719
|
+
* before a provider instance is chosen, and the config-dir override that a
|
|
90720
|
+
* single Claude instance may carry is not a fact about the person's skills.
|
|
90721
|
+
*/
|
|
90722
|
+
const resolveUserInvokedSkillRoots = Effect.fnUntraced(function* (thread) {
|
|
90723
|
+
const path = yield* Path.Path;
|
|
90724
|
+
const userSkillsDir = yield* resolveClaudeUserSkillsDir({ homePath: "" });
|
|
90725
|
+
const project = yield* resolveProject(thread.projectId);
|
|
90726
|
+
const cwd = resolveThreadWorkspaceCwd({
|
|
90727
|
+
thread,
|
|
90728
|
+
projects: project ? [project] : []
|
|
90729
|
+
});
|
|
90730
|
+
return [userSkillsDir, ...cwd ? [path.join(cwd, ".claude", "skills")] : []];
|
|
90731
|
+
});
|
|
89573
90732
|
const resolveThread = Effect.fnUntraced(function* (threadId) {
|
|
89574
90733
|
return yield* projectionSnapshotQuery.getThreadDetailById(threadId).pipe(Effect.map(Option.getOrUndefined));
|
|
89575
90734
|
});
|
|
@@ -89756,6 +90915,10 @@ const make$2 = Effect.gen(function* () {
|
|
|
89756
90915
|
});
|
|
89757
90916
|
if (input.modelSelection !== void 0) threadModelSelections.set(input.threadId, input.modelSelection);
|
|
89758
90917
|
const normalizedInput = toNonEmptyProviderInput(input.messageText);
|
|
90918
|
+
const expandedInput = normalizedInput === void 0 ? void 0 : (yield* expandUserInvokedSkill({
|
|
90919
|
+
text: normalizedInput,
|
|
90920
|
+
roots: yield* resolveUserInvokedSkillRoots(thread)
|
|
90921
|
+
})) ?? normalizedInput;
|
|
89759
90922
|
const normalizedAttachments = input.attachments ?? [];
|
|
89760
90923
|
const activeSession = yield* providerService.listSessions().pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)));
|
|
89761
90924
|
const sessionModelSwitch = activeSession === void 0 ? "in-session" : activeSession.providerInstanceId === void 0 ? yield* new ProviderAdapterRequestError({
|
|
@@ -89776,7 +90939,7 @@ const make$2 = Effect.gen(function* () {
|
|
|
89776
90939
|
injectRuleset: !hadActiveSession && !hasSessionLevelRuleset,
|
|
89777
90940
|
staleRulesetMode: hadActiveSession && !rebuildsRulesetEachTurn ? threadSessionRulesetModes.get(input.threadId) : void 0
|
|
89778
90941
|
});
|
|
89779
|
-
const inputWithCompressPrefix =
|
|
90942
|
+
const inputWithCompressPrefix = expandedInput !== void 0 && compressPrefix !== void 0 ? `${compressPrefix}\n\n${expandedInput}` : expandedInput;
|
|
89780
90943
|
return {
|
|
89781
90944
|
threadId: input.threadId,
|
|
89782
90945
|
...inputWithCompressPrefix ? { input: inputWithCompressPrefix } : {},
|
|
@@ -91338,7 +92501,7 @@ const WorkspaceLayerLive = Layer.mergeAll(layer$44, WorkspaceEntriesLayerLive, W
|
|
|
91338
92501
|
const ProjectFaviconResolverLayerLive = layer$42.pipe(Layer.provide(layer$44), Layer.provide(layer$43));
|
|
91339
92502
|
const AuthLayerLive = layer$64.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$68));
|
|
91340
92503
|
const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive));
|
|
91341
|
-
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));
|
|
91342
92505
|
/**
|
|
91343
92506
|
* Hub asset sync.
|
|
91344
92507
|
*
|