Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export const make = Effect.gen(function* () {
threadPriority: true,
// T3-CUSTOM(expbkt3): durable manual Linear tags.
threadLinearIssue: true,
// T3-CUSTOM(expbkt3): durable Mattermost conversation link.
threadMattermostLink: true,
threadExternalSessionAttach: true,
// T3-CUSTOM(expbkt3): high-level durable bootstrap and defaults hierarchy.
durableThreadBootstrap: true,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
priority: event.payload.priority ?? null,
// T3-CUSTOM(expbkt3): no manual Linear tag at thread creation.
linearIssueUrl: null,
// T3-CUSTOM(expbkt3): the Mattermost link is bound after creation.
mattermostThreadUrl: null,
// T3-CUSTOM(expbkt3): session lineage stamped at creation.
parentThreadId: event.payload.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — no work summary until one is requested.
Expand Down Expand Up @@ -1040,6 +1042,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
...(event.payload.linearIssueUrl !== undefined
? { linearIssueUrl: event.payload.linearIssueUrl }
: {}),
// T3-CUSTOM(expbkt3): durable Mattermost conversation link.
...(event.payload.mattermostThreadUrl !== undefined
? { mattermostThreadUrl: event.payload.mattermostThreadUrl }
: {}),
// T3-CUSTOM(expbkt3): session lineage re-parent / detach.
...(event.payload.parentThreadId !== undefined
? { parentThreadId: event.payload.parentThreadId }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
priority: null,
// T3-CUSTOM(expbkt3): no manual Linear tag on this fixture.
linearIssueUrl: null,
// T3-CUSTOM(expbkt3): no Mattermost conversation on this fixture.
mattermostThreadUrl: null,
// T3-CUSTOM(expbkt3): session lineage.
parentThreadId: null,
// T3-CUSTOM(expbkt3): no work summary was ever requested here.
Expand Down Expand Up @@ -501,6 +503,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
priority: null,
// T3-CUSTOM(expbkt3): no manual Linear tag on this fixture.
linearIssueUrl: null,
// T3-CUSTOM(expbkt3): no Mattermost conversation on this fixture.
mattermostThreadUrl: null,
// T3-CUSTOM(expbkt3): session lineage.
parentThreadId: null,
// T3-CUSTOM(expbkt3): no work summary was ever requested here.
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob).
work_summary AS "workSummary",
Expand Down Expand Up @@ -680,6 +681,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob).
work_summary AS "workSummary",
Expand Down Expand Up @@ -731,6 +733,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob).
work_summary AS "workSummary",
Expand Down Expand Up @@ -1191,6 +1194,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob).
work_summary AS "workSummary",
Expand Down Expand Up @@ -1263,6 +1267,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob).
work_summary AS "workSummary",
Expand Down Expand Up @@ -2228,6 +2233,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: row.snoozedAt,
priority: row.priority,
linearIssueUrl: row.linearIssueUrl ?? null,
mattermostThreadUrl: row.mattermostThreadUrl ?? null,
parentThreadId: row.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(row.workSummary),
Expand Down Expand Up @@ -2548,6 +2554,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: row.snoozedAt,
priority: row.priority,
linearIssueUrl: row.linearIssueUrl ?? null,
mattermostThreadUrl: row.mattermostThreadUrl ?? null,
parentThreadId: row.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(row.workSummary),
Expand Down Expand Up @@ -2733,6 +2740,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: row.snoozedAt,
priority: row.priority,
linearIssueUrl: row.linearIssueUrl ?? null,
mattermostThreadUrl: row.mattermostThreadUrl ?? null,
parentThreadId: row.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(row.workSummary),
Expand Down Expand Up @@ -2924,6 +2932,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: row.snoozedAt,
priority: row.priority,
linearIssueUrl: row.linearIssueUrl ?? null,
mattermostThreadUrl: row.mattermostThreadUrl ?? null,
parentThreadId: row.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(row.workSummary),
Expand Down Expand Up @@ -3257,6 +3266,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: threadRow.value.snoozedAt,
priority: threadRow.value.priority,
linearIssueUrl: threadRow.value.linearIssueUrl ?? null,
mattermostThreadUrl: threadRow.value.mattermostThreadUrl ?? null,
parentThreadId: threadRow.value.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(threadRow.value.workSummary),
Expand Down Expand Up @@ -3492,6 +3502,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedAt: threadRow.value.snoozedAt,
priority: threadRow.value.priority,
linearIssueUrl: threadRow.value.linearIssueUrl ?? null,
mattermostThreadUrl: threadRow.value.mattermostThreadUrl ?? null,
parentThreadId: threadRow.value.parentThreadId ?? null,
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary.
workSummary: mapWorkSummary(threadRow.value.workSummary),
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/orchestration/decider.priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,60 @@ it.layer(NodeServices.layer)("thread priority decider", (it) => {
).toBeNull();
}),
);

// T3-CUSTOM(expbkt3): the Mattermost conversation a session is bound to.
it.effect("sets and clears a Mattermost link through thread metadata", () =>
Effect.gen(function* () {
const linked = yield* decideOrchestrationCommand({
command: {
type: "thread.meta.update",
commandId: CommandId.make("cmd-link-mattermost"),
threadId: ThreadId.make("thread-1"),
mattermostThreadUrl: "https://chat.example.com/beknown/pl/abc123",
},
readModel: makeReadModel(),
});
const linkedEvents = Array.isArray(linked) ? linked : [linked];
expect(
linkedEvents[0]?.type === "thread.meta-updated"
? linkedEvents[0].payload.mattermostThreadUrl
: undefined,
).toBe("https://chat.example.com/beknown/pl/abc123");

const cleared = yield* decideOrchestrationCommand({
command: {
type: "thread.meta.update",
commandId: CommandId.make("cmd-clear-mattermost"),
threadId: ThreadId.make("thread-1"),
mattermostThreadUrl: null,
},
readModel: makeReadModel(),
});
const clearedEvents = Array.isArray(cleared) ? cleared : [cleared];
expect(
clearedEvents[0]?.type === "thread.meta-updated"
? clearedEvents[0].payload.mattermostThreadUrl
: undefined,
).toBeNull();
}),
);

it.effect("leaves the Mattermost link untouched when the command omits it", () =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
type: "thread.meta.update",
commandId: CommandId.make("cmd-rename-only-mattermost"),
threadId: ThreadId.make("thread-1"),
title: "Renamed",
},
readModel: makeReadModel(),
});
const events = Array.isArray(event) ? event : [event];
if (events[0]?.type === "thread.meta-updated") {
// undefined, not null: an omitted field must not clear a live binding.
expect(events[0].payload.mattermostThreadUrl).toBe(undefined);
}
}),
);
});
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.linearIssueUrl !== undefined
? { linearIssueUrl: command.linearIssueUrl }
: {}),
// T3-CUSTOM(expbkt3): undefined leaves the Mattermost link unchanged.
...(command.mattermostThreadUrl !== undefined
? { mattermostThreadUrl: command.mattermostThreadUrl }
: {}),
// T3-CUSTOM(expbkt3): undefined leaves lineage unchanged; null detaches.
...(command.parentThreadId !== undefined
? { parentThreadId: command.parentThreadId }
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ describe("orchestration projector", () => {
priority: null,
// T3-CUSTOM(expbkt3): no manual Linear tag on a new thread.
linearIssueUrl: null,
// T3-CUSTOM(expbkt3): no Mattermost conversation bound to a new thread.
mattermostThreadUrl: null,
// T3-CUSTOM(expbkt3): session lineage.
parentThreadId: null,
deletedAt: null,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ export function projectEvent(
priority: payload.priority ?? null,
// T3-CUSTOM(expbkt3): no manual Linear tag at thread creation.
linearIssueUrl: null,
// T3-CUSTOM(expbkt3): the Mattermost link is bound after creation.
mattermostThreadUrl: null,
// T3-CUSTOM(expbkt3): session lineage stamped at creation.
parentThreadId: payload.parentThreadId ?? null,
deletedAt: null,
Expand Down Expand Up @@ -528,6 +530,10 @@ export function projectEvent(
...(payload.linearIssueUrl !== undefined
? { linearIssueUrl: payload.linearIssueUrl }
: {}),
// T3-CUSTOM(expbkt3): durable Mattermost conversation link.
...(payload.mattermostThreadUrl !== undefined
? { mattermostThreadUrl: payload.mattermostThreadUrl }
: {}),
// T3-CUSTOM(expbkt3): session lineage re-parent / detach.
...(payload.parentThreadId !== undefined
? { parentThreadId: payload.parentThreadId }
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at,
priority,
linear_issue_url,
mattermost_thread_url,
parent_thread_id,
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON).
work_summary,
Expand Down Expand Up @@ -94,6 +95,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.snoozedAt},
${row.priority},
${row.linearIssueUrl ?? null},
${row.mattermostThreadUrl ?? null},
${row.parentThreadId ?? null},
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON).
${row.workSummary ?? null},
Expand Down Expand Up @@ -134,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at = excluded.snoozed_at,
priority = excluded.priority,
linear_issue_url = excluded.linear_issue_url,
mattermost_thread_url = excluded.mattermost_thread_url,
parent_thread_id = excluded.parent_thread_id,
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON).
work_summary = excluded.work_summary,
Expand Down Expand Up @@ -181,6 +184,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON).
work_summary AS "workSummary",
Expand Down Expand Up @@ -230,6 +234,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
-- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON).
work_summary AS "workSummary",
Expand Down Expand Up @@ -288,6 +293,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
priority,
linear_issue_url AS "linearIssueUrl",
mattermost_thread_url AS "mattermostThreadUrl",
parent_thread_id AS "parentThreadId",
work_summary AS "workSummary",
pinned_at AS "pinnedAt",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import Migration1005 from "./Migrations/036_ProjectionThreadsPinned.ts";
// T3-CUSTOM(expbkt3): fork migrations, numbered 1000+.
import Migration1000 from "./Migrations/1000_ProjectionThreadsPriority.ts";
import Migration1022 from "./Migrations/1022_AgentUiRenders.ts";
import Migration1023 from "./Migrations/1023_ProjectionThreadsMattermostLink.ts";
import Migration1001 from "./Migrations/1001_SessionRecoveryState.ts";
import Migration1002 from "./Migrations/1002_ThreadBootstrapAndCreationDefaults.ts";
// T3-CUSTOM(expbkt3): exact durable work items and guarded recovery audit.
Expand Down Expand Up @@ -226,6 +227,8 @@ const migrationEntries = [
[1021, "ProjectionThreadsUnsettledAt", Migration1021],
// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
[1022, "AgentUiRenders", Migration1022],
// T3-CUSTOM(expbkt3): durable Mattermost conversation link on a thread.
[1023, "ProjectionThreadsMattermostLink", Migration1023],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// T3-CUSTOM(expbkt3): durable Mattermost conversation link migration coverage.
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";

it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))(
"1023_ProjectionThreadsMattermostLink",
(it) => {
it.effect("adds the nullable Mattermost conversation URL", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 1023 });

const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
assert.isTrue(columns.some((column) => column.name === "mattermost_thread_url"));
}),
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// T3-CUSTOM(expbkt3): durable Mattermost conversation permalink on a thread.
// Written by the Linear/Mattermost bridge when it binds a session to a
// Mattermost thread, so the sidebar can mark sessions a human is watching
// from chat. Nullable: most threads have no Mattermost conversation.
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;

if (!columns.some((column) => column.name === "mattermost_thread_url")) {
yield* sql`
ALTER TABLE projection_threads
ADD COLUMN mattermost_thread_url TEXT
`;
}
});
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Services/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const ProjectionThread = Schema.Struct({
priority: Schema.NullOr(ThreadPriority),
// T3-CUSTOM(expbkt3): durable manual Linear issue URL.
linearIssueUrl: Schema.optional(Schema.NullOr(Schema.String)),
// T3-CUSTOM(expbkt3): durable Mattermost conversation permalink.
mattermostThreadUrl: Schema.optional(Schema.NullOr(Schema.String)),
// T3-CUSTOM(expbkt3): session lineage; null means this is a root session.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
// T3-CUSTOM(expbkt3): BEGIN — JSON-encoded ThreadWorkSummary for the bulk session
Expand Down
Loading
Loading