Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
4f5a7d4
fix(integrations): preserve rapid legitimate edits
tinsever Sep 29, 2026
95478d8
perf(board): load pages incrementally and patch live changes
tinsever Sep 29, 2026
4e846bb
merge: include formatted integration scope fix
tinsever Sep 29, 2026
232b714
merge: include verified integration ownership fixes
tinsever Sep 29, 2026
df37bca
fix(integrations): identify older outbound echoes and stale events
tinsever Sep 29, 2026
dd614c1
fix(board): preserve bounded cache updates across pagination races
tinsever Sep 29, 2026
5af0a82
fix(integrations): confirm historical text echoes with provider state
tinsever Sep 29, 2026
e469f24
merge: preserve compatible destination integration links
tinsever Sep 29, 2026
8c325f1
fix: address verified delivery and concurrency review findings
tinsever Sep 29, 2026
8e77668
fix: address verified delivery and concurrency review findings
tinsever Sep 29, 2026
1b027ab
merge: inherit guarded integration status lookups
tinsever Sep 29, 2026
1e89b83
fix: bound realtime task refreshes and show public board progress
tinsever Sep 29, 2026
1777ae9
fix: merge outbound history under a link row lock
tinsever Sep 29, 2026
95d9006
merge: inherit current position allocation fixes
tinsever Sep 29, 2026
a4a3d2c
fix: preserve concurrent outbound history during inbound edits
tinsever Sep 29, 2026
0c46a5c
Preserve remote board changes during live local mutations
tinsever Sep 30, 2026
dcd5c75
Merge canonical reorder snapshots into incremental boards
tinsever Sep 30, 2026
55d2435
fix(integrations): classify echoes from locked metadata without block…
tinsever Sep 30, 2026
4267b60
merge: propagate virtual bucket reorders through live boards
tinsever Sep 30, 2026
9503882
merge: preserve scoped PR transitions alongside echo history
tinsever Sep 30, 2026
bd12c1f
merge: preserve atomic reorder guards in incremental boards
tinsever Sep 30, 2026
043214f
fix(integrations): repair late writes and retain guarded links
tinsever Sep 30, 2026
25d5a12
fix(board): reconcile page boundaries and remote label edits
tinsever Sep 30, 2026
f5ff90c
fix(board): guard partial list drags and complete live updates
tinsever Sep 30, 2026
8899e63
fix(board): preserve cached views and drain fallback detail events
tinsever Sep 30, 2026
7039659
merge: propagate committed integration notifications into echo handling
tinsever Sep 30, 2026
d97981a
merge: include committed provider updates in incremental boards
tinsever Sep 30, 2026
a84284f
fix(integrations): repair out-of-order provider state responses
tinsever Sep 30, 2026
acd36e3
fix(board): publish atomic column reorders and imported detail updates
tinsever Sep 30, 2026
08e67d2
merge: propagate complete task creation notifications into echo handling
tinsever Sep 30, 2026
4af43fc
merge: propagate complete task creation notifications into live boards
tinsever Sep 30, 2026
84e7a55
fix(integrations): persist pending outbound intents before provider w…
tinsever Sep 30, 2026
bcb8584
fix(board): wait for complete pages before archive-all
tinsever Sep 30, 2026
198b17c
fix(integrations): correlate pending echoes with provider versions
tinsever Sep 30, 2026
17236ef
merge ownership fixes into echo handling
tinsever Sep 30, 2026
52eb2d1
fix(board): preserve labels across fetches and remote renames
tinsever Sep 30, 2026
c7711aa
fix(integrations): preserve timestamp collisions and bound pending we…
tinsever Sep 30, 2026
d96566c
perf(board): bound progress snapshot work during pagination
tinsever Sep 30, 2026
667e442
fix(board): preserve edits during initial pagination
tinsever Sep 30, 2026
7faaa96
fix(integrations): recover deferred edits after writer crashes
tinsever Sep 30, 2026
eb619ef
fix(board): reconcile changing public pagination
tinsever Sep 30, 2026
ed40a77
fix(integrations): preserve newer edits during crash recovery
tinsever Sep 30, 2026
af3409a
fix(integrations): repair using the current local value
tinsever Sep 30, 2026
5c9b8e4
fix(board): reconcile public related pages
tinsever Sep 30, 2026
8cb307e
fix(integrations): stop corrective writes after disconnect
tinsever Sep 30, 2026
719a78b
fix(integrations): confirm colliding reversed deliveries
tinsever Sep 30, 2026
87c8d3d
fix(board): refresh label choices and guard disabled drops
tinsever Sep 30, 2026
d468019
fix(integrations): distinguish first-write crash recovery
tinsever Sep 30, 2026
1a1a0a8
fix(board): refresh moved parents and stabilize page revisions
tinsever Sep 30, 2026
e95ab6b
fix(integrations): retire deferred reads for missing issues
tinsever Sep 30, 2026
1666d4d
fix(integrations): order provider edits and repair uncertain writes
tinsever Sep 30, 2026
ecda605
fix(integrations): guard observed recovery against concurrent edits
tinsever Sep 30, 2026
c2c3bcf
fix(integrations): revalidate provider confirmation snapshots
tinsever Sep 30, 2026
1c51887
docs: update sponsors
github-actions[bot] Sep 30, 2026
a404003
test(integrations): provide a fresh confirmation after metadata changes
tinsever Sep 30, 2026
7ec77a0
fix(integrations): stop sync retries after provider reconfiguration
tinsever Sep 30, 2026
16ba4fa
fix(integrations): bound and coalesce corrective provider writes
tinsever Sep 30, 2026
b1857c6
fix(integrations): retire uncertain intents after queued corrections
tinsever Sep 30, 2026
75c47ad
fix(integrations): retire all captured uncertain writes after repair
tinsever Sep 30, 2026
5a2f16f
fix(integrations): revalidate inbound confirmation repository scope
tinsever Sep 30, 2026
0a25118
test(integrations): seed a valid provider repository binding
tinsever Sep 30, 2026
f6c1f39
fix(integrations): persist each successful field repair before contin…
tinsever Sep 30, 2026
b32b357
fix(integrations): settle concurrent uncertainty at correction receipts
tinsever Sep 30, 2026
3728f7c
fix(integrations): index deferred jobs and claim replay across replicas
tinsever Sep 30, 2026
14d1869
fix(integrations): preserve queued field intent ordering
tinsever Sep 30, 2026
b085cb1
fix(integrations): retire intents from obsolete provider bindings
tinsever Sep 30, 2026
4e674b8
Merge PR #1878 into dependent PR #1879
tinsever Sep 30, 2026
0f47144
fix(integrations): preserve webhook versions and final workflow states
tinsever Sep 30, 2026
ca79249
Merge pull request #1867 from usekaneo/fix/review-06-integration-scope
tinsever Sep 30, 2026
1d546b7
Merge pull request #1874 from usekaneo/fix/review-16-integration-echoes
tinsever Sep 30, 2026
a143c88
Merge main into board stack
tinsever Sep 30, 2026
6068860
fix(board): reconcile status repairs and public page edits
tinsever Sep 30, 2026
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
54 changes: 49 additions & 5 deletions apps/api/src/task/controllers/get-task.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { eq } from "drizzle-orm";
import { boundedTaskRead } from "../bounded-read";
import { alias } from "drizzle-orm/pg-core";
import { boardDescription, descriptionDeferred } from "../description-pages";
import { getSubtaskCounts } from "../get-subtask-counts";
import { and, eq, sql } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import db from "../../database";
import { taskTable, userTable } from "../../database/schema";
import {
taskTable,
userTable,
projectTable,
taskRelationTable,
} from "../../database/schema";

async function getTask(taskId: string) {
async function getTask(taskId: string, board = false) {
const task = await db
.select({
id: taskTable.id,
title: taskTable.title,
number: taskTable.number,
description: taskTable.description,
description: board ? boardDescription : taskTable.description,
...(board
? {
descriptionDeferred,
workspaceId: sql<string>`(select ${projectTable.workspaceId} from ${projectTable} where ${projectTable.id} = ${taskTable.projectId})`,
}
: {}),
status: taskTable.status,
priority: taskTable.priority,
startDate: taskTable.startDate,
Expand All @@ -32,7 +47,36 @@ async function getTask(taskId: string) {
});
}

return task[0];
const { workspaceId, ...result } = task[0];
if (!board || !workspaceId) return result;
const parent = alias(taskTable, "parent");
const parents = await db
.selectDistinct({ id: parent.id })
.from(taskRelationTable)
.innerJoin(parent, eq(taskRelationTable.sourceTaskId, parent.id))
.where(
and(
eq(taskRelationTable.targetTaskId, taskId),
eq(taskRelationTable.relationType, "subtask"),
eq(parent.projectId, result.projectId),
Comment thread
tinsever marked this conversation as resolved.
),
);
const counts = await boundedTaskRead((tx) =>
getSubtaskCounts(
tx,
[taskId, ...parents.map((task) => task.id)],
workspaceId,
false,
),
);
return {
...result,
subtaskCounts: counts.get(taskId) ?? { completed: 0, total: 0 },
parentSubtaskCounts: parents.map(({ id }) => ({
taskId: id,
...(counts.get(id) ?? { completed: 0, total: 0 }),
})),
};
}

export default getTask;
10 changes: 7 additions & 3 deletions apps/api/src/task/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,13 @@ const getTaskRoute = createRoute({
path: "/{id}",
tags: ["Tasks"],
summary: "Get task",
description: "Get a single task by ID, with its assignee's name resolved.",
description:
"Get a single task by ID, with its assignee name. The board view omits descriptions above 64 KiB and includes task and same-project parent subtask progress.",
middleware: [workspaceAccess.fromTask()] as const,
request: { params: taskParam },
request: {
params: taskParam,
query: z.object({ view: z.enum(["detail", "board"]).optional() }),
},
responses: {
200: jsonResponse("Task details", taskWithAssigneeSchema),
400: errorResponse(
Expand Down Expand Up @@ -806,7 +810,7 @@ const task = apiRouter<BaseVariables & { workspaceId: string }>()
.openapi(getTaskRoute, async (c) => {
const { id } = c.req.valid("param");

const task = await getTask(id);
const task = await getTask(id, c.req.valid("query").view === "board");

return c.json(task, 200);
})
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/task/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export const taskSchema = z

export const taskWithAssigneeSchema = taskSchema
.extend({
subtaskCounts: z
.object({ completed: z.number(), total: z.number() })
.optional(),
parentSubtaskCounts: z
.array(
z.object({
taskId: z.string(),
completed: z.number(),
total: z.number(),
}),
)
.optional(),
assigneeName: z.string().nullable(),
assigneeId: z.string().nullable(),
})
Expand Down
51 changes: 50 additions & 1 deletion apps/docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,43 @@
{
"type": "object",
"properties": {
"subtaskCounts": {
"type": "object",
"properties": {
"completed": {
"type": "number"
},
"total": {
"type": "number"
}
},
"required": [
"completed",
"total"
]
},
"parentSubtaskCounts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
},
"completed": {
"type": "number"
},
"total": {
"type": "number"
}
},
"required": [
"taskId",
"completed",
"total"
]
}
},
"assigneeName": {
"type": [
"string",
Expand Down Expand Up @@ -9012,7 +9049,7 @@
"Tasks"
],
"summary": "Get task",
"description": "Get a single task by ID, with its assignee's name resolved.",
"description": "Get a single task by ID, with its assignee name. The board view omits descriptions above 64 KiB and includes task and same-project parent subtask progress.",
"parameters": [
{
"schema": {
Expand All @@ -9021,6 +9058,18 @@
"required": true,
"name": "id",
"in": "path"
},
{
"schema": {
"type": "string",
"enum": [
"detail",
"board"
]
},
"required": false,
"name": "view",
"in": "query"
}
],
"responses": {
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/kanban-board/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markBoardCacheChanged } from "@/lib/board-cache-version";
import {
closestCorners,
DndContext,
Expand Down Expand Up @@ -166,14 +167,21 @@ function KanbanBoard({
const activeId = active.id.toString();
const overId = over.id.toString();

if (isReordering) return;
if (
isReordering ||
queryClient.getQueryState(["tasks", project.id])?.fetchStatus ===
"fetching"
)
Comment thread
tinsever marked this conversation as resolved.
return;
const canonical =
queryClient.getQueryData<ProjectWithTasks>(["tasks", project.id]) ??
(storedProject?.id === project.id ? storedProject : undefined);
if (!canonical) return;

const moved = moveBoardTask(canonical, activeId, overId, sortedByNumber);
if (!moved || !moved.tasks.length) return;
for (const task of moved.tasks)
markBoardCacheChanged(queryClient, project.id, task.id);
setProject(moved.project);
queryClient.setQueryData(["tasks", project.id], moved.project);
reorder({ projectId: project.id, tasks: moved.tasks });
Expand Down
44 changes: 27 additions & 17 deletions apps/web/src/fetchers/project/get-public-project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { client } from "@kaneo/libs";
import type { InferRequestType } from "hono/client";
import type { InferRequestType, InferResponseType } from "hono/client";
import { loadBoardPages } from "@/fetchers/task/load-board-pages";
import { HttpError } from "@/lib/http-error";
export type GetPublicProjectRequest = InferRequestType<
Expand All @@ -8,23 +8,33 @@ export type GetPublicProjectRequest = InferRequestType<
async function getPublicProject(
{ id }: GetPublicProjectRequest,
signal?: AbortSignal,
onProgress?: (
board: Omit<
InferResponseType<(typeof client)["public-project"][":id"]["$get"], 200>,
"pagination"
>,
) => void,
) {
return loadBoardPages(async (page, relatedPage) => {
const response = await client["public-project"][":id"].$get(
{
param: { id },
query: {
page: String(page),
limit: "100",
...(relatedPage ? { relatedPage: String(relatedPage) } : {}),
return loadBoardPages(
async (page, relatedPage) => {
const response = await client["public-project"][":id"].$get(
{
param: { id },
query: {
page: String(page),
limit: "100",
...(relatedPage ? { relatedPage: String(relatedPage) } : {}),
},
},
},
{ init: { signal } },
);
if (!response.ok)
throw new HttpError(response.status, await response.text());
const { pagination, ...data } = await response.json();
return { data, pagination };
}, signal);
{ init: { signal } },
);
if (!response.ok)
throw new HttpError(response.status, await response.text());
const { pagination, ...data } = await response.json();
return { data, pagination };
},
signal,
onProgress,
);
}
export default getPublicProject;
7 changes: 5 additions & 2 deletions apps/web/src/fetchers/task/get-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ export type GetTaskRequest = InferRequestType<
(typeof client)["task"][":id"]["$get"]
>["param"];

async function getTask(taskId: string) {
const response = await client.task[":id"].$get({ param: { id: taskId } });
async function getTask(taskId: string, view: "detail" | "board" = "detail") {
const response = await client.task[":id"].$get({
param: { id: taskId },
query: { view },
});

if (!response.ok) {
throw new HttpError(response.status, await response.text());
Expand Down
46 changes: 30 additions & 16 deletions apps/web/src/fetchers/task/get-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { client } from "@kaneo/libs";
import type { InferResponseType } from "hono/client";
import { HttpError } from "@/lib/http-error";
import { loadBoardPages } from "./load-board-pages";

async function getTasks(projectId: string, signal?: AbortSignal) {
return loadBoardPages(async (page, relatedPage) => {
const response = await client.task.tasks[":projectId"].$get(
{
param: { projectId },
query: {
page: String(page),
limit: "100",
...(relatedPage ? { relatedPage: String(relatedPage) } : {}),
async function getTasks(
projectId: string,
signal?: AbortSignal,
onProgress?: (
board: InferResponseType<
(typeof client)["task"]["tasks"][":projectId"]["$get"],
200
>["data"],
) => void,
) {
return loadBoardPages(
async (page, relatedPage) => {
const response = await client.task.tasks[":projectId"].$get(
{
param: { projectId },
query: {
page: String(page),
limit: "100",
...(relatedPage ? { relatedPage: String(relatedPage) } : {}),
},
},
},
{ init: { signal } },
);
if (!response.ok)
throw new HttpError(response.status, "Failed to fetch tasks");
return response.json();
}, signal);
{ init: { signal } },
);
if (!response.ok)
throw new HttpError(response.status, "Failed to fetch tasks");
return response.json();
},
signal,
onProgress,
);
}
export default getTasks;
26 changes: 26 additions & 0 deletions apps/web/src/fetchers/task/load-board-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ function page(number: number, totalPages = 3) {
};
}
describe("complete board loading through bounded pages", () => {
it("publishes the first page before a slow continuation and keeps snapshots immutable", async () => {
let finish!: (value: ReturnType<typeof page>) => void;
const snapshots: ProjectWithTasks[] = [];
const load = vi
.fn()
.mockResolvedValueOnce(page(1, 2))
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const loading = loadBoardPages(load, undefined, (board) =>
snapshots.push(board),
);
await Promise.resolve();
await Promise.resolve();
expect(snapshots[0].columns[0].tasks.map((task) => task.id)).toEqual(["1"]);
finish(page(2, 2));
await loading;
expect(snapshots.at(-1)?.columns[0].tasks.map((task) => task.id)).toEqual([
"1",
"2",
]);
expect(snapshots[0].columns[0].tasks).toHaveLength(1);
});
it("loads sequentially and merges every column, planned and archived task with its complete fields", async () => {
let active = 0;
let peak = 0;
Expand Down
Loading