@danypops/papyrus 0.60.2 → 0.60.4
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/package.json +1 -1
- package/src/artifact/artifact.ts +9 -0
- package/src/cli/note-command.ts +29 -0
- package/src/cli/task-command.ts +51 -0
- package/src/cli.ts +2 -0
- package/src/constants.ts +3 -0
- package/src/daemon/daemon.ts +2 -0
- package/src/daemon/task-mutation-push.ts +18 -0
- package/src/handlers/notes.ts +15 -0
- package/src/handlers/tasks.ts +13 -0
- package/src/modules/notes.ts +10 -0
- package/src/modules/tasks.ts +9 -0
- package/src/note/note-service.ts +72 -0
- package/src/ops.ts +7 -1
- package/src/service.ts +2 -0
- package/src/task/task-service.ts +96 -0
package/package.json
CHANGED
package/src/artifact/artifact.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface UpdateArtifactInput {
|
|
|
64
64
|
alias?: string;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface ArtifactQueryCursor {
|
|
68
|
+
createdAt: string;
|
|
69
|
+
id: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export interface ArtifactQuery {
|
|
68
73
|
kind?: string;
|
|
69
74
|
status?: string;
|
|
@@ -74,6 +79,10 @@ export interface ArtifactQuery {
|
|
|
74
79
|
labels?: string[];
|
|
75
80
|
extraEquals?: Record<string, string | number | boolean>;
|
|
76
81
|
limit?: number;
|
|
82
|
+
/** Stable inventory ordering, independent of content updates. Defaults to updated_at descending. */
|
|
83
|
+
order?: "updated_desc" | "created_desc";
|
|
84
|
+
/** Exclusive keyset cursor for created_desc ordering. */
|
|
85
|
+
after?: ArtifactQueryCursor;
|
|
77
86
|
/** Trashed artifacts (see artifact-trash.ts) are excluded from every query by default; set true to include them, e.g. for a trash-listing view. */
|
|
78
87
|
includeTrashed?: boolean;
|
|
79
88
|
/**
|
package/src/cli/note-command.ts
CHANGED
|
@@ -59,6 +59,34 @@ const listCommand = buildCommand({
|
|
|
59
59
|
docs: { brief: "List open (draft/active) notes, or a specific status" },
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
const listPageCommand = buildCommand({
|
|
63
|
+
func: async function (
|
|
64
|
+
this: NoteContext,
|
|
65
|
+
flags: { status?: string; text?: string; limit?: number; cursor?: string; allProjects?: boolean },
|
|
66
|
+
) {
|
|
67
|
+
const page = (await this.client.call("notes.list_page", {
|
|
68
|
+
...(flags.allProjects ? {} : { project_root: this.projectRoot }),
|
|
69
|
+
...(flags.status ? { status: flags.status } : {}),
|
|
70
|
+
...(flags.text ? { text: flags.text } : {}),
|
|
71
|
+
...(flags.limit === undefined ? {} : { limit: flags.limit }),
|
|
72
|
+
...(flags.cursor ? { cursor: flags.cursor } : {}),
|
|
73
|
+
})) as { items: CliArtifact[]; nextCursor?: string };
|
|
74
|
+
const lines = page.items.map((note) => `[${note.status}] ${artifactLabel(note)}`);
|
|
75
|
+
if (page.nextCursor) lines.push(`Next cursor: ${page.nextCursor}`);
|
|
76
|
+
renderResult.call(this, page, lines.length > 0 ? lines.join("\n") : "No open notes.");
|
|
77
|
+
},
|
|
78
|
+
parameters: {
|
|
79
|
+
flags: {
|
|
80
|
+
status: { brief: "Filter by status (draft|active|archived)", kind: "parsed", parse: String, placeholder: "status", optional: true },
|
|
81
|
+
text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
82
|
+
limit: { brief: "Page size", kind: "parsed", parse: numberParser, optional: true },
|
|
83
|
+
cursor: { brief: "Opaque nextCursor from the preceding page", kind: "parsed", parse: String, placeholder: "cursor", optional: true },
|
|
84
|
+
allProjects: { brief: "Inventory Notes across every project", kind: "boolean", optional: true },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
docs: { brief: "Cursor-paginate a stable Note inventory" },
|
|
88
|
+
});
|
|
89
|
+
|
|
62
90
|
const showCommand = buildCommand({
|
|
63
91
|
func: async function (this: NoteContext, _flags: Record<string, never>, id: string) {
|
|
64
92
|
const result = (await this.client.call("notes.show", { id, project_root: this.projectRoot })) as CliArtifact;
|
|
@@ -168,6 +196,7 @@ const app = buildApplication(
|
|
|
168
196
|
routes: {
|
|
169
197
|
capture: captureCommand,
|
|
170
198
|
list: listCommand,
|
|
199
|
+
page: listPageCommand,
|
|
171
200
|
show: showCommand,
|
|
172
201
|
history: historyCommand,
|
|
173
202
|
consume: consumeCommand,
|
package/src/cli/task-command.ts
CHANGED
|
@@ -456,6 +456,56 @@ const listCommand = buildCommand({
|
|
|
456
456
|
docs: { brief: "List Tasks" },
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
const listPageCommand = buildCommand({
|
|
460
|
+
func: async function (
|
|
461
|
+
this: TaskContext,
|
|
462
|
+
flags: {
|
|
463
|
+
status?: string;
|
|
464
|
+
text?: string;
|
|
465
|
+
limit?: number;
|
|
466
|
+
labelsJson?: string[];
|
|
467
|
+
scope?: "project" | "graph" | "all";
|
|
468
|
+
rootTaskId?: string;
|
|
469
|
+
sessionId?: string;
|
|
470
|
+
cursor?: string;
|
|
471
|
+
},
|
|
472
|
+
) {
|
|
473
|
+
const page = await this.client.call<Record<string, unknown>, { items: CliArtifact[]; nextCursor?: string }>("tasks.list_page", {
|
|
474
|
+
status: flags.status,
|
|
475
|
+
text: flags.text,
|
|
476
|
+
limit: flags.limit,
|
|
477
|
+
labels: flags.labelsJson,
|
|
478
|
+
project_root: this.projectRoot,
|
|
479
|
+
scope: flags.scope,
|
|
480
|
+
root_task_id: flags.rootTaskId,
|
|
481
|
+
session_id: flags.sessionId,
|
|
482
|
+
cursor: flags.cursor,
|
|
483
|
+
});
|
|
484
|
+
const rows = page.items.map((row) => artifactLabel(row));
|
|
485
|
+
if (page.nextCursor) rows.push(`Next cursor: ${page.nextCursor}`);
|
|
486
|
+
render.call(this, page, rows.length === 0 ? "No tasks found." : rows.join("\n"));
|
|
487
|
+
},
|
|
488
|
+
parameters: {
|
|
489
|
+
flags: {
|
|
490
|
+
status: { brief: "Filter by status", kind: "parsed", parse: String, placeholder: "status", optional: true },
|
|
491
|
+
text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
492
|
+
limit: { brief: "Page size", kind: "parsed", parse: numberParser, optional: true },
|
|
493
|
+
labelsJson: {
|
|
494
|
+
brief: "JSON string array of labels to filter by",
|
|
495
|
+
kind: "parsed",
|
|
496
|
+
parse: parseStringArray,
|
|
497
|
+
placeholder: "json",
|
|
498
|
+
optional: true,
|
|
499
|
+
},
|
|
500
|
+
scope: { brief: "project|graph|all", kind: "enum", values: ["project", "graph", "all"], optional: true },
|
|
501
|
+
rootTaskId: { brief: "Root task id, required with graph scope", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
502
|
+
sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
503
|
+
cursor: { brief: "Opaque nextCursor from the preceding page", kind: "parsed", parse: String, placeholder: "cursor", optional: true },
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
docs: { brief: "Cursor-paginate a stable Task inventory" },
|
|
507
|
+
});
|
|
508
|
+
|
|
459
509
|
const showCommand = buildCommand({
|
|
460
510
|
func: async function (this: TaskContext, _flags: Record<string, never>, id: string) {
|
|
461
511
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("tasks.show", { id });
|
|
@@ -963,6 +1013,7 @@ const app = buildApplication(
|
|
|
963
1013
|
"mutation-status": mutationStatusCommand,
|
|
964
1014
|
create: createCommand,
|
|
965
1015
|
list: listCommand,
|
|
1016
|
+
page: listPageCommand,
|
|
966
1017
|
show: showCommand,
|
|
967
1018
|
"run-gates": runGatesCommand,
|
|
968
1019
|
"set-checklist": setChecklistCommand,
|
package/src/cli.ts
CHANGED
|
@@ -152,6 +152,7 @@ const USAGE = `Usage:
|
|
|
152
152
|
papyrus playbooks undepend <id> <dependency-id> [--json]
|
|
153
153
|
papyrus notes capture <request> [--title <title>] [--json]
|
|
154
154
|
papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
|
|
155
|
+
papyrus notes page [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--cursor <cursor>] [--all-projects] [--json]
|
|
155
156
|
papyrus notes show <id> [--json]
|
|
156
157
|
papyrus notes consume <id> [--reason <reason>] [--json]
|
|
157
158
|
papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
|
|
@@ -204,6 +205,7 @@ const USAGE = `Usage:
|
|
|
204
205
|
papyrus tasks uncontain <parent-id> <child-id> [--reason <reason>] [--session-id <id>] [--json]
|
|
205
206
|
papyrus tasks create --title <title> [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--gates-json <json>] [--checklist-json <json>] [--template-id <id>] [--parent-id <id>] [--depends-on-json <json>] [--session-id <id>] [--json]
|
|
206
207
|
papyrus tasks list [--status <status>] [--text <query>] [--limit <count>] [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
|
|
208
|
+
papyrus tasks page [--status <status>] [--text <query>] [--limit <count>] [--cursor <cursor>] [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
|
|
207
209
|
papyrus tasks show <id> [--json]
|
|
208
210
|
papyrus tasks run-gates <id> [--json]
|
|
209
211
|
papyrus tasks set-checklist <id> --checklist-json <json> [--json]
|
package/src/constants.ts
CHANGED
|
@@ -308,6 +308,9 @@ export const SESSION_IDENTITY_MAX_ROWS = 2_000;
|
|
|
308
308
|
export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
309
309
|
/** Persisted project and focused-graph Task view bounds. */
|
|
310
310
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
311
|
+
/** Cursor-paged Task inventory bounds; pages stay comfortably below Vehicle response limits. */
|
|
312
|
+
export const TASK_LIST_PAGE_DEFAULT_LIMIT = 100;
|
|
313
|
+
export const TASK_LIST_PAGE_MAX_LIMIT = 200;
|
|
311
314
|
/** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
|
|
312
315
|
export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
|
|
313
316
|
/** How many distinct registered projects a single Doc/Rule/Playbook may belong to at once, in "projects" scope mode. Kept alongside ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT (identical value) for the pure-project call sites/tests that predate mixed project+group membership. */
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
|
11
11
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, metricsPath, WAL_CHECKPOINT_INTERVAL_MS } from "../constants.ts";
|
|
12
12
|
import { logEvent, logger } from "../log/log.ts";
|
|
13
13
|
import { createApp, createPapyrusService } from "../service.ts";
|
|
14
|
+
import { createTaskMutationPushMiddleware } from "./task-mutation-push.ts";
|
|
14
15
|
import {
|
|
15
16
|
clearDaemonPort,
|
|
16
17
|
clearSharedVehicleHandle,
|
|
@@ -81,6 +82,7 @@ export async function serveMain(): Promise<void> {
|
|
|
81
82
|
service.vehicle.useExecutionMiddleware(createVehicleMetricsMiddleware(vehicleMetrics, "papyrus"));
|
|
82
83
|
registerVehicleMetricsOperations(service.vehicle, vehicleMetrics, "papyrus");
|
|
83
84
|
const pushChannel = new PushChannel({ token });
|
|
85
|
+
service.vehicle.useExecutionMiddleware(createTaskMutationPushMiddleware((operation) => pushChannel.publish("tasks", { operation })));
|
|
84
86
|
const app = createApp({
|
|
85
87
|
service,
|
|
86
88
|
token,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { VehicleExecutionMiddleware } from "@danypops/vehicle-server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publishes one invalidation only after a successful Vehicle task mutation.
|
|
5
|
+
* Kept as execution middleware so HTTP, LocalVehicleClient, jobs, and future
|
|
6
|
+
* transports all observe the same mutation rather than each transport trying
|
|
7
|
+
* to infer success from its own response framing.
|
|
8
|
+
*/
|
|
9
|
+
export function createTaskMutationPushMiddleware(publish: (operation: string) => void): VehicleExecutionMiddleware {
|
|
10
|
+
return {
|
|
11
|
+
id: "papyrus.task-mutation-push",
|
|
12
|
+
async intercept(request, next) {
|
|
13
|
+
const output = await next(request.input);
|
|
14
|
+
if (request.operation.name.startsWith("tasks.") && request.operation.effect !== "read") publish(request.operation.name);
|
|
15
|
+
return output;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/handlers/notes.ts
CHANGED
|
@@ -60,6 +60,21 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
|
|
|
60
60
|
(input) => input,
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
+
define(
|
|
64
|
+
"list_page",
|
|
65
|
+
"Cursor-paginates a stable Note inventory. Omit project_root only for an intentional cross-project audit; use nextCursor until absent.",
|
|
66
|
+
"read",
|
|
67
|
+
{
|
|
68
|
+
project_root: stringProp,
|
|
69
|
+
status: { type: "string", enum: ["draft", "active", "archived"] },
|
|
70
|
+
text: stringProp,
|
|
71
|
+
limit: numberProp,
|
|
72
|
+
cursor: stringProp,
|
|
73
|
+
},
|
|
74
|
+
[],
|
|
75
|
+
(input) => input,
|
|
76
|
+
);
|
|
77
|
+
|
|
63
78
|
define(
|
|
64
79
|
"show",
|
|
65
80
|
"Shows one note by id or title.",
|
package/src/handlers/tasks.ts
CHANGED
|
@@ -246,6 +246,7 @@ const readSchemaProps = {
|
|
|
246
246
|
|
|
247
247
|
/** list-only: opts into full Artifact bodies instead of the lean summarizeArtifact() default (modules/tasks.ts). */
|
|
248
248
|
const listSchemaProps = { ...readSchemaProps, full: booleanProp };
|
|
249
|
+
const listPageSchemaProps = { ...listSchemaProps, cursor: stringProp };
|
|
249
250
|
|
|
250
251
|
/** Same gate/checklist narrative lines the removed tool built client-side. */
|
|
251
252
|
function completionContentText(labels: Map<string, string>, result: TaskCompletion): string {
|
|
@@ -447,6 +448,18 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
447
448
|
},
|
|
448
449
|
);
|
|
449
450
|
|
|
451
|
+
define(
|
|
452
|
+
"list_page",
|
|
453
|
+
"Cursor-paginates a stable creation-ordered Task inventory. Use nextCursor until it is absent. Existing tasks.list remains the lean array convenience API.",
|
|
454
|
+
"read",
|
|
455
|
+
listPageSchemaProps,
|
|
456
|
+
["project_root"],
|
|
457
|
+
(input) => {
|
|
458
|
+
const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
|
|
459
|
+
return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
|
|
450
463
|
define(
|
|
451
464
|
"graph",
|
|
452
465
|
"Returns the full task graph (nodes with parent/child/dependency ids) for the requested scope. project_root is required.",
|
package/src/modules/notes.ts
CHANGED
|
@@ -24,6 +24,7 @@ const MODULE_ID = "notes";
|
|
|
24
24
|
export const NOTES_OPERATION_NAMES = [
|
|
25
25
|
"notes.capture",
|
|
26
26
|
"notes.list",
|
|
27
|
+
"notes.list_page",
|
|
27
28
|
"notes.show",
|
|
28
29
|
"notes.history",
|
|
29
30
|
"notes.consume",
|
|
@@ -57,6 +58,15 @@ export function notesOperations(notes: Notes): OperationDefinition[] {
|
|
|
57
58
|
limit: optionalNumber(input, "limit"),
|
|
58
59
|
}),
|
|
59
60
|
),
|
|
61
|
+
define("notes.list_page", (input: OperationInput) =>
|
|
62
|
+
notes.listPage({
|
|
63
|
+
projectRoot: optionalString(input, "project_root"),
|
|
64
|
+
status: optionalString(input, "status") as "draft" | "active" | "archived" | undefined,
|
|
65
|
+
text: optionalString(input, "text"),
|
|
66
|
+
limit: optionalNumber(input, "limit"),
|
|
67
|
+
cursor: optionalString(input, "cursor"),
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
60
70
|
define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
|
|
61
71
|
define("notes.history", (input: OperationInput) =>
|
|
62
72
|
notes.history(string(input, "id"), string(input, "project_root"), {
|
package/src/modules/tasks.ts
CHANGED
|
@@ -54,6 +54,7 @@ const taskFilter = (input: OperationInput) => ({
|
|
|
54
54
|
rootTaskId: optionalString(input, "root_task_id"),
|
|
55
55
|
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
56
56
|
labels: optionalStringArray(input, "labels"),
|
|
57
|
+
cursor: optionalString(input, "cursor"),
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -69,6 +70,7 @@ export const TASKS_OPERATION_NAMES = [
|
|
|
69
70
|
"tasks.create",
|
|
70
71
|
"tasks.update",
|
|
71
72
|
"tasks.list",
|
|
73
|
+
"tasks.list_page",
|
|
72
74
|
"tasks.graph",
|
|
73
75
|
"tasks.plan",
|
|
74
76
|
"tasks.show",
|
|
@@ -164,6 +166,13 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
|
|
|
164
166
|
const rows = tasks.list(taskFilter(input));
|
|
165
167
|
return optionalBoolean(input, "full") === true ? rows : rows.map(summarizeArtifact);
|
|
166
168
|
}),
|
|
169
|
+
define("tasks.list_page", (input: OperationInput) => {
|
|
170
|
+
const page = tasks.listPage(taskFilter(input));
|
|
171
|
+
return {
|
|
172
|
+
...page,
|
|
173
|
+
items: optionalBoolean(input, "full") === true ? page.items : page.items.map(summarizeArtifact),
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
167
176
|
define("tasks.graph", (input: OperationInput) => tasks.graph(taskFilter(input))),
|
|
168
177
|
define("tasks.plan", (input: OperationInput) => projectTaskExecution(tasks.graph(taskFilter(input)))),
|
|
169
178
|
define("tasks.show", (input: OperationInput) => tasks.show(string(input, "id"))),
|
package/src/note/note-service.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
2
3
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
3
4
|
import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
|
|
@@ -37,6 +38,27 @@ export interface ListNotesInput {
|
|
|
37
38
|
limit?: number;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface ListNotesPageInput {
|
|
42
|
+
/** Omit only for an explicit cross-project inventory. */
|
|
43
|
+
projectRoot?: string;
|
|
44
|
+
status?: "draft" | "active" | "archived";
|
|
45
|
+
text?: string;
|
|
46
|
+
limit?: number;
|
|
47
|
+
cursor?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NotesPage {
|
|
51
|
+
items: Artifact[];
|
|
52
|
+
nextCursor?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface NotesPageCursor {
|
|
56
|
+
v: 1;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
id: string;
|
|
59
|
+
filterHash: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
export interface ArchiveNoteInput extends NoteProvenance {
|
|
41
63
|
projectRoot: string;
|
|
42
64
|
disposition: NoteDisposition;
|
|
@@ -54,6 +76,29 @@ function optionalBounded(value: string | undefined, field: string, maximum: numb
|
|
|
54
76
|
return requiredBounded(value, field, maximum);
|
|
55
77
|
}
|
|
56
78
|
|
|
79
|
+
function notesPageFilterHash(input: ListNotesPageInput, projectRoot: string | undefined): string {
|
|
80
|
+
return createHash("sha256")
|
|
81
|
+
.update(JSON.stringify({ projectRoot, status: input.status, text: input.text }))
|
|
82
|
+
.digest("base64url");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function decodeNotesPageCursor(cursor: string | undefined, filterHash: string): NotesPageCursor | undefined {
|
|
86
|
+
if (cursor === undefined) return undefined;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<NotesPageCursor>;
|
|
89
|
+
if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
|
|
90
|
+
return parsed as NotesPageCursor;
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("notes page cursor is invalid or does not match the requested filters");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function encodeNotesPageCursor(note: Artifact, filterHash: string): string {
|
|
97
|
+
return Buffer.from(JSON.stringify({ v: 1, createdAt: note.created_at, id: note.id, filterHash } satisfies NotesPageCursor)).toString(
|
|
98
|
+
"base64url",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
function noteTitle(body: string, requested?: string): string {
|
|
58
103
|
if (requested !== undefined) return requiredBounded(requested, "note title", NOTE_TITLE_MAX_CHARACTERS);
|
|
59
104
|
const firstLine = body.split(/\r?\n/, 1)[0]!.replace(/\s+/g, " ").trim();
|
|
@@ -110,6 +155,33 @@ export class Notes {
|
|
|
110
155
|
});
|
|
111
156
|
}
|
|
112
157
|
|
|
158
|
+
/** Cursor-paged inventory; omitting projectRoot intentionally enumerates notes across projects. */
|
|
159
|
+
listPage(input: ListNotesPageInput): NotesPage {
|
|
160
|
+
const projectRoot =
|
|
161
|
+
input.projectRoot === undefined ? undefined : requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
|
|
162
|
+
const limit = input.limit ?? NOTE_LIST_DEFAULT_LIMIT;
|
|
163
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_LIST_MAX_LIMIT) {
|
|
164
|
+
throw new Error(`note limit must be an integer from 1 to ${NOTE_LIST_MAX_LIMIT}`);
|
|
165
|
+
}
|
|
166
|
+
const filterHash = notesPageFilterHash(input, projectRoot);
|
|
167
|
+
const cursor = decodeNotesPageCursor(input.cursor, filterHash);
|
|
168
|
+
const candidates = this.artifacts.query({
|
|
169
|
+
kind: "doc",
|
|
170
|
+
subtype: NOTE_SUBTYPE,
|
|
171
|
+
...(input.status ? { status: input.status } : { statuses: ["draft", "active"] }),
|
|
172
|
+
...(input.text ? { text: input.text } : {}),
|
|
173
|
+
...(projectRoot ? { extraEquals: { projectRoot } } : {}),
|
|
174
|
+
order: "created_desc",
|
|
175
|
+
...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
|
|
176
|
+
limit: limit + 1,
|
|
177
|
+
});
|
|
178
|
+
const items = candidates.slice(0, limit);
|
|
179
|
+
return {
|
|
180
|
+
items,
|
|
181
|
+
...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeNotesPageCursor(items.at(-1)!, filterHash) } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
113
185
|
show(id: string, projectRoot: string): Artifact {
|
|
114
186
|
const note = this.requireNote(id);
|
|
115
187
|
this.requireProject(note, projectRoot);
|
package/src/ops.ts
CHANGED
|
@@ -367,8 +367,14 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
|
|
|
367
367
|
conditions.push("json_extract(extra, ?) = ?");
|
|
368
368
|
params.push(`$.${key}`, value);
|
|
369
369
|
}
|
|
370
|
+
if (filter.after) {
|
|
371
|
+
if (filter.order !== "created_desc") throw new Error("artifact query cursor requires created_desc ordering");
|
|
372
|
+
if (!filter.after.createdAt || !filter.after.id) throw new Error("artifact query cursor is invalid");
|
|
373
|
+
conditions.push("(created_at < ? OR (created_at = ? AND id > ?))");
|
|
374
|
+
params.push(filter.after.createdAt, filter.after.createdAt, filter.after.id);
|
|
375
|
+
}
|
|
370
376
|
if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
371
|
-
sql += " ORDER BY updated_at DESC";
|
|
377
|
+
sql += filter.order === "created_desc" ? " ORDER BY created_at DESC, id ASC" : " ORDER BY updated_at DESC, id ASC";
|
|
372
378
|
if (filter.limit !== undefined) {
|
|
373
379
|
if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("artifact query limit must be a positive integer");
|
|
374
380
|
sql += " LIMIT ?";
|
package/src/service.ts
CHANGED
|
@@ -409,6 +409,7 @@ function handlers(
|
|
|
409
409
|
"tasks.create": forwardToModule("tasks.create"),
|
|
410
410
|
"tasks.update": forwardToModule("tasks.update"),
|
|
411
411
|
"tasks.list": forwardToModule("tasks.list"),
|
|
412
|
+
"tasks.list_page": forwardToModule("tasks.list_page"),
|
|
412
413
|
"tasks.graph": forwardToModule("tasks.graph"),
|
|
413
414
|
"tasks.plan": forwardToModule("tasks.plan"),
|
|
414
415
|
"tasks.show": forwardToModule("tasks.show"),
|
|
@@ -487,6 +488,7 @@ function handlers(
|
|
|
487
488
|
"docs.update": forwardToModule("docs.update"),
|
|
488
489
|
"notes.capture": forwardToModule("notes.capture"),
|
|
489
490
|
"notes.list": forwardToModule("notes.list"),
|
|
491
|
+
"notes.list_page": forwardToModule("notes.list_page"),
|
|
490
492
|
"notes.show": forwardToModule("notes.show"),
|
|
491
493
|
"notes.history": forwardToModule("notes.history"),
|
|
492
494
|
"notes.consume": forwardToModule("notes.consume"),
|
package/src/task/task-service.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
TASK_EXECUTION_MAX_NODES,
|
|
12
12
|
TASK_LABEL_MAX_COUNT,
|
|
13
13
|
TASK_LABEL_MAX_LENGTH,
|
|
14
|
+
TASK_LIST_PAGE_DEFAULT_LIMIT,
|
|
15
|
+
TASK_LIST_PAGE_MAX_LIMIT,
|
|
14
16
|
TASK_SCOPE_MAX_TASKS,
|
|
15
17
|
TASK_TITLE_MAX_LENGTH,
|
|
16
18
|
} from "../constants.ts";
|
|
@@ -98,6 +100,15 @@ export interface TaskFilter {
|
|
|
98
100
|
labels?: string[];
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
export interface TaskPageFilter extends TaskFilter {
|
|
104
|
+
cursor?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TaskPage {
|
|
108
|
+
items: Artifact[];
|
|
109
|
+
nextCursor?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
export type TaskStatus = TaskLifecycleStatus;
|
|
102
113
|
|
|
103
114
|
export interface TaskMutationMetadata {
|
|
@@ -207,6 +218,45 @@ function canonicalJson(value: unknown): string {
|
|
|
207
218
|
return JSON.stringify(value) ?? "null";
|
|
208
219
|
}
|
|
209
220
|
|
|
221
|
+
interface TaskPageCursor {
|
|
222
|
+
v: 1;
|
|
223
|
+
createdAt: string;
|
|
224
|
+
id: string;
|
|
225
|
+
filterHash: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function taskPageFilterHash(filter: TaskFilter, selection: TaskViewSelection): string {
|
|
229
|
+
return createHash("sha256")
|
|
230
|
+
.update(
|
|
231
|
+
canonicalJson({
|
|
232
|
+
status: filter.status,
|
|
233
|
+
text: filter.text,
|
|
234
|
+
labels: [...(filter.labels ?? [])].sort(),
|
|
235
|
+
mode: selection.mode,
|
|
236
|
+
projectRoot: selection.projectRoot,
|
|
237
|
+
rootTaskId: selection.rootTaskId,
|
|
238
|
+
}),
|
|
239
|
+
)
|
|
240
|
+
.digest("base64url");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function encodeTaskPageCursor(task: Artifact, filterHash: string): string {
|
|
244
|
+
return Buffer.from(JSON.stringify({ v: 1, createdAt: task.created_at, id: task.id, filterHash } satisfies TaskPageCursor)).toString(
|
|
245
|
+
"base64url",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function decodeTaskPageCursor(cursor: string | undefined, filterHash: string): TaskPageCursor | undefined {
|
|
250
|
+
if (cursor === undefined) return undefined;
|
|
251
|
+
try {
|
|
252
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<TaskPageCursor>;
|
|
253
|
+
if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
|
|
254
|
+
return parsed as TaskPageCursor;
|
|
255
|
+
} catch {
|
|
256
|
+
throw new Error("task page cursor is invalid or does not match the requested filters");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
210
260
|
export class Tasks {
|
|
211
261
|
constructor(
|
|
212
262
|
private readonly artifacts: ArtifactStore,
|
|
@@ -428,6 +478,52 @@ export class Tasks {
|
|
|
428
478
|
.slice(0, limit);
|
|
429
479
|
}
|
|
430
480
|
|
|
481
|
+
/** Stable, cursor-paged Task inventory. Creation order is immutable, so content updates cannot move an item between pages. */
|
|
482
|
+
listPage(filter: TaskPageFilter = {}): TaskPage {
|
|
483
|
+
const selection = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
484
|
+
const limit = filter.limit ?? TASK_LIST_PAGE_DEFAULT_LIMIT;
|
|
485
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_LIST_PAGE_MAX_LIMIT) {
|
|
486
|
+
throw new Error(`task page limit must be between 1 and ${TASK_LIST_PAGE_MAX_LIMIT}`);
|
|
487
|
+
}
|
|
488
|
+
const filterHash = taskPageFilterHash(filter, selection);
|
|
489
|
+
const cursor = decodeTaskPageCursor(filter.cursor, filterHash);
|
|
490
|
+
let candidates: Artifact[];
|
|
491
|
+
if (selection.mode === "all") {
|
|
492
|
+
candidates = this.artifacts.query({
|
|
493
|
+
kind: "task",
|
|
494
|
+
excludeSubtype: DISCUSSION_SUBTYPE,
|
|
495
|
+
status: filter.status,
|
|
496
|
+
text: filter.text,
|
|
497
|
+
labels: filter.labels,
|
|
498
|
+
order: "created_desc",
|
|
499
|
+
...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
|
|
500
|
+
limit: limit + 1,
|
|
501
|
+
});
|
|
502
|
+
} else {
|
|
503
|
+
const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
|
|
504
|
+
if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
|
|
505
|
+
const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
|
|
506
|
+
const text = filter.text?.toLowerCase();
|
|
507
|
+
const labels = filter.labels ?? [];
|
|
508
|
+
candidates = this.artifacts
|
|
509
|
+
.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, ids: [...selectedIds] })
|
|
510
|
+
.filter((task) => filter.status === undefined || task.status === filter.status)
|
|
511
|
+
.filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
|
|
512
|
+
.filter((task) => labels.every((label) => task.labels.includes(label)))
|
|
513
|
+
.sort((left, right) => right.created_at.localeCompare(left.created_at) || left.id.localeCompare(right.id))
|
|
514
|
+
.filter(
|
|
515
|
+
(task) =>
|
|
516
|
+
cursor === undefined || task.created_at < cursor.createdAt || (task.created_at === cursor.createdAt && task.id > cursor.id),
|
|
517
|
+
)
|
|
518
|
+
.slice(0, limit + 1);
|
|
519
|
+
}
|
|
520
|
+
const items = candidates.slice(0, limit);
|
|
521
|
+
return {
|
|
522
|
+
items,
|
|
523
|
+
...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeTaskPageCursor(items.at(-1)!, filterHash) } : {}),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
431
527
|
scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
432
528
|
return this.projectScope.scopeSelection(projectRoot, mode, rootTaskId);
|
|
433
529
|
}
|