dayhand 0.0.0-stage → 0.14.0

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.
Files changed (191) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -2
  3. package/backend/convex/_generated/api.d.ts +181 -0
  4. package/backend/convex/_generated/api.js +23 -0
  5. package/backend/convex/_generated/dataModel.d.ts +60 -0
  6. package/backend/convex/_generated/server.d.ts +162 -0
  7. package/backend/convex/_generated/server.js +101 -0
  8. package/backend/convex/apiKeys.ts +171 -0
  9. package/backend/convex/app.ts +197 -0
  10. package/backend/convex/auth.config.ts +28 -0
  11. package/backend/convex/calendar.ts +61 -0
  12. package/backend/convex/calendarFeeds.ts +105 -0
  13. package/backend/convex/callLimits.ts +67 -0
  14. package/backend/convex/connections.ts +28 -0
  15. package/backend/convex/crons.ts +18 -0
  16. package/backend/convex/devices.ts +224 -0
  17. package/backend/convex/http.ts +56 -0
  18. package/backend/convex/lib/activity.ts +155 -0
  19. package/backend/convex/lib/agentControls.ts +146 -0
  20. package/backend/convex/lib/agentWork.ts +250 -0
  21. package/backend/convex/lib/appToken.ts +68 -0
  22. package/backend/convex/lib/approvals.ts +311 -0
  23. package/backend/convex/lib/args.ts +175 -0
  24. package/backend/convex/lib/backlogMd.ts +309 -0
  25. package/backend/convex/lib/backlogOps.ts +361 -0
  26. package/backend/convex/lib/brief.ts +132 -0
  27. package/backend/convex/lib/cimd.ts +57 -0
  28. package/backend/convex/lib/core.ts +380 -0
  29. package/backend/convex/lib/dates.ts +114 -0
  30. package/backend/convex/lib/env.ts +19 -0
  31. package/backend/convex/lib/exportPlan.ts +98 -0
  32. package/backend/convex/lib/findTasks.ts +119 -0
  33. package/backend/convex/lib/frontmatter.ts +165 -0
  34. package/backend/convex/lib/guide.ts +286 -0
  35. package/backend/convex/lib/health.ts +115 -0
  36. package/backend/convex/lib/http.ts +27 -0
  37. package/backend/convex/lib/ical.ts +243 -0
  38. package/backend/convex/lib/importFormats.ts +429 -0
  39. package/backend/convex/lib/importOps.ts +145 -0
  40. package/backend/convex/lib/jsonSchema.ts +50 -0
  41. package/backend/convex/lib/limits.ts +42 -0
  42. package/backend/convex/lib/mcpContent.ts +220 -0
  43. package/backend/convex/lib/mcpProtocol.ts +128 -0
  44. package/backend/convex/lib/mcpTools.ts +505 -0
  45. package/backend/convex/lib/oauthConfig.ts +59 -0
  46. package/backend/convex/lib/oauthPage.ts +125 -0
  47. package/backend/convex/lib/oauthRules.ts +159 -0
  48. package/backend/convex/lib/openapi.ts +230 -0
  49. package/backend/convex/lib/outputSchemas.ts +2901 -0
  50. package/backend/convex/lib/problem.ts +69 -0
  51. package/backend/convex/lib/proposals.ts +172 -0
  52. package/backend/convex/lib/repeat.ts +185 -0
  53. package/backend/convex/lib/restRoutes.ts +294 -0
  54. package/backend/convex/lib/rrule.ts +171 -0
  55. package/backend/convex/lib/secrets.ts +135 -0
  56. package/backend/convex/lib/settingsOps.ts +47 -0
  57. package/backend/convex/lib/taskOps.ts +304 -0
  58. package/backend/convex/lib/toolResults.ts +50 -0
  59. package/backend/convex/lib/validators.ts +143 -0
  60. package/backend/convex/lib/views.ts +145 -0
  61. package/backend/convex/lib/vtimezone.ts +151 -0
  62. package/backend/convex/lib/webhookEvents.ts +160 -0
  63. package/backend/convex/lib/webhookSign.ts +30 -0
  64. package/backend/convex/lib/webhookTarget.ts +75 -0
  65. package/backend/convex/mcp.ts +405 -0
  66. package/backend/convex/migrations.ts +91 -0
  67. package/backend/convex/oauth.ts +388 -0
  68. package/backend/convex/oauthHttp.ts +381 -0
  69. package/backend/convex/oauthOwner.ts +173 -0
  70. package/backend/convex/ops.ts +205 -0
  71. package/backend/convex/pairing.ts +178 -0
  72. package/backend/convex/rest.ts +254 -0
  73. package/backend/convex/restAccount.ts +56 -0
  74. package/backend/convex/restImport.ts +73 -0
  75. package/backend/convex/schema.ts +435 -0
  76. package/backend/convex/tsconfig.json +18 -0
  77. package/backend/convex/webhooks.ts +339 -0
  78. package/backend/package-lock.json +3068 -0
  79. package/backend/package.json +33 -0
  80. package/backend/scripts/app-token.mjs +79 -0
  81. package/backend/scripts/fetch-local-backend.mjs +72 -0
  82. package/backend/scripts/gen-output-schemas.mjs +60 -0
  83. package/backend/scripts/jwt.mjs +67 -0
  84. package/backend/scripts/local-backend-lib.mjs +123 -0
  85. package/backend/scripts/local-backend.mjs +81 -0
  86. package/backend/scripts/setup-convex.mjs +158 -0
  87. package/backend/scripts/setup-lib.mjs +338 -0
  88. package/backend/scripts/setup-output.mjs +108 -0
  89. package/backend/scripts/setup.mjs +344 -0
  90. package/backend/scripts/signing-key.mjs +61 -0
  91. package/backend/tsconfig.json +16 -0
  92. package/dist/backlog/folder.js +114 -0
  93. package/dist/bin/dayhand.js +10 -0
  94. package/dist/bin/hlm.js +12 -0
  95. package/dist/completion/cache.js +54 -0
  96. package/dist/completion/engine.js +187 -0
  97. package/dist/completion/scripts.js +82 -0
  98. package/dist/config/aliases.js +25 -0
  99. package/dist/config/env.js +68 -0
  100. package/dist/config/file.js +146 -0
  101. package/dist/config/paths.js +58 -0
  102. package/dist/config/resolve.js +53 -0
  103. package/dist/config/url.js +26 -0
  104. package/dist/core/editor.js +37 -0
  105. package/dist/core/exit.js +59 -0
  106. package/dist/core/io.js +14 -0
  107. package/dist/core/output.js +39 -0
  108. package/dist/core/prompt.js +55 -0
  109. package/dist/core/version.js +15 -0
  110. package/dist/core/words.js +36 -0
  111. package/dist/credentials/file.js +65 -0
  112. package/dist/credentials/index.js +29 -0
  113. package/dist/credentials/keychain.js +44 -0
  114. package/dist/credentials/store.js +39 -0
  115. package/dist/docs.js +116 -0
  116. package/dist/fields.js +33 -0
  117. package/dist/globals.js +16 -0
  118. package/dist/guide.js +36 -0
  119. package/dist/help.js +56 -0
  120. package/dist/i18n/dates.js +84 -0
  121. package/dist/i18n/de.js +240 -0
  122. package/dist/i18n/en.js +243 -0
  123. package/dist/i18n/es.js +240 -0
  124. package/dist/i18n/fr.js +240 -0
  125. package/dist/i18n/index.js +67 -0
  126. package/dist/i18n/ja.js +240 -0
  127. package/dist/i18n/pl.js +245 -0
  128. package/dist/i18n/pt-BR.js +240 -0
  129. package/dist/i18n/zh-Hans.js +240 -0
  130. package/dist/main.js +180 -0
  131. package/dist/man.js +56 -0
  132. package/dist/registry/agents.js +67 -0
  133. package/dist/registry/auth.js +168 -0
  134. package/dist/registry/backlog.js +106 -0
  135. package/dist/registry/calendar.js +103 -0
  136. package/dist/registry/dates.js +33 -0
  137. package/dist/registry/filters.js +98 -0
  138. package/dist/registry/index.js +24 -0
  139. package/dist/registry/integrations.js +245 -0
  140. package/dist/registry/local.js +89 -0
  141. package/dist/registry/model.js +104 -0
  142. package/dist/registry/ops.js +47 -0
  143. package/dist/registry/owner.js +349 -0
  144. package/dist/registry/packaged-backend.js +59 -0
  145. package/dist/registry/parse.js +129 -0
  146. package/dist/registry/server.js +252 -0
  147. package/dist/registry/system.js +220 -0
  148. package/dist/registry/tasks.js +512 -0
  149. package/dist/registry/types.js +1 -0
  150. package/dist/registry/ui.js +51 -0
  151. package/dist/registry/work.js +207 -0
  152. package/dist/render/format.js +146 -0
  153. package/dist/render/model.js +29 -0
  154. package/dist/render/style.js +24 -0
  155. package/dist/render/table.js +46 -0
  156. package/dist/render/views.js +256 -0
  157. package/dist/run.js +133 -0
  158. package/dist/setup.js +26 -0
  159. package/dist/skill.js +83 -0
  160. package/dist/topics.js +121 -0
  161. package/dist/transport/client.js +196 -0
  162. package/dist/ui/agents.js +12 -0
  163. package/dist/ui/api.js +99 -0
  164. package/dist/ui/app.js +62 -0
  165. package/dist/ui/budget.js +42 -0
  166. package/dist/ui/controller.js +368 -0
  167. package/dist/ui/effects.js +166 -0
  168. package/dist/ui/frame.js +206 -0
  169. package/dist/ui/handle.js +271 -0
  170. package/dist/ui/health.js +22 -0
  171. package/dist/ui/keys.js +120 -0
  172. package/dist/ui/line.js +56 -0
  173. package/dist/ui/lists.js +58 -0
  174. package/dist/ui/model.js +25 -0
  175. package/dist/ui/quickadd.js +66 -0
  176. package/dist/ui/rows.js +68 -0
  177. package/dist/ui/run.js +54 -0
  178. package/dist/ui/screens/agents.js +65 -0
  179. package/dist/ui/screens/board.js +41 -0
  180. package/dist/ui/screens/detail.js +142 -0
  181. package/dist/ui/screens/overlay.js +58 -0
  182. package/dist/ui/screens/screen.js +3 -0
  183. package/dist/ui/screens/today.js +60 -0
  184. package/dist/ui/screens/week.js +76 -0
  185. package/dist/ui/state.js +128 -0
  186. package/dist/ui/text.js +70 -0
  187. package/dist/when/calendar.js +95 -0
  188. package/dist/when/parse.js +314 -0
  189. package/man/dayhand.1 +1163 -0
  190. package/man/dh.1 +1 -0
  191. package/package.json +56 -4
@@ -0,0 +1,132 @@
1
+ // The agent brief (agent_brief, dayhand prime): what an agent needs at the start
2
+ // of a session, from its own point of view. It is list_today with more fields:
3
+ // the claims in force and the caller's own, the owner's pause and the caller's
4
+ // tool policies, approval requests waiting and the caller's recent decisions,
5
+ // the first tasks ready to work on, and next steps written out as sentences.
6
+ import type { ObjectType } from "convex/values";
7
+ import type { Doc } from "../_generated/dataModel";
8
+ import type { QueryCtx } from "../_generated/server";
9
+ import { getAgentControls } from "./agentControls";
10
+ import { isReady, sameHolder } from "./agentWork";
11
+ import { MAX_APPROVAL_MINUTES, statusOf } from "./approvals";
12
+ import type { args } from "./args";
13
+ import { loadSettings, type Identity } from "./core";
14
+ import { localDateTime } from "./dates";
15
+ import { listToday } from "./views";
16
+
17
+ type Args = ObjectType<(typeof args)["agentBrief"]>;
18
+
19
+ /** Items listed per section; `count` counts them all. */
20
+ export const BRIEF_LIST = 10;
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+ const APPROVAL_WINDOW_MS = MAX_APPROVAL_MINUTES * 60_000 + DAY_MS;
23
+
24
+ const brief = (t: Doc<"tasks">) => ({ id: t._id, ref: t.ref ?? null, title: t.title, status: t.status, dueDate: t.dueDate ?? null, dueTime: t.dueTime ?? null });
25
+ const label = (t: Doc<"tasks">) => `#${t.ref ?? t._id} ${t.title}`;
26
+ const section = <T>(items: T[]) => ({ count: items.length, items: items.slice(0, BRIEF_LIST) });
27
+
28
+ const byDue = (a: Doc<"tasks">, b: Doc<"tasks">) =>
29
+ (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999") || (a.dueTime ?? "99").localeCompare(b.dueTime ?? "99") || (a.ref ?? 0) - (b.ref ?? 0);
30
+
31
+ function claimView(t: Doc<"tasks">, identity: Identity) {
32
+ return { ...brief(t), claimedBy: t.claimedBy!.name, claimExpiresAt: t.claimExpiresAt ?? null, mine: sameHolder(t.claimedBy, identity) };
33
+ }
34
+
35
+ function approvalItem(a: Doc<"approvals">, tasks: Map<string, Doc<"tasks">>, identity: Identity) {
36
+ const task = a.taskId ? tasks.get(a.taskId) : undefined;
37
+ const chosen = a.options.find((o) => o.id === a.optionId);
38
+ return {
39
+ id: a._id,
40
+ action: a.title,
41
+ taskId: a.taskId ?? null,
42
+ ref: task?.ref ?? null,
43
+ expiresAt: a.expiresAt,
44
+ requestedBy: a.requestedBy.name,
45
+ mine: sameHolder(a.requestedBy, identity),
46
+ decision: a.decidedAt === undefined ? null : { optionId: a.optionId!, label: chosen?.label ?? a.optionId!, note: a.note ?? null, decidedAt: a.decidedAt },
47
+ };
48
+ }
49
+
50
+ type Facts = {
51
+ paused: boolean;
52
+ reason: string | null;
53
+ mine: Doc<"tasks">[];
54
+ expiredMine: Doc<"tasks">[];
55
+ decided: Doc<"approvals">[];
56
+ waiting: Doc<"approvals">[];
57
+ overdue: number;
58
+ ready: Doc<"tasks">[];
59
+ timezone: string;
60
+ };
61
+
62
+ /** The next steps, most pressing first, as sentences that name the tools and resources to use. */
63
+ function nextSteps(f: Facts): string[] {
64
+ const at = (ms: number) => localDateTime(ms, f.timezone);
65
+ if (f.paused) {
66
+ return [`The owner paused all agents${f.reason ? ` (${f.reason})` : ""}: read, but change nothing until they resume you; do not retry in a loop.`];
67
+ }
68
+ const steps = [
69
+ ...f.decided.map((a) => `The owner decided "${a.title}": ${a.options.find((o) => o.id === a.optionId)?.label ?? a.optionId}${a.note ? ` (${a.note})` : ""}. Act on that choice and nothing else.`),
70
+ ...f.mine.map((t) => `You hold ${label(t)} until ${at(t.claimExpiresAt!)}: renew_claim while you work, then complete_task with proof, or release_task with a checkpoint note (dayhand://workflow/execution).`),
71
+ ...f.expiredMine.map((t) => `Your claim of ${label(t)} ran out at ${at(t.claimExpiresAt!)}: renew_claim to go on (nobody took it over) or release_task.`),
72
+ ...f.waiting.map((a) => `Waiting for the owner on "${a.title}" until ${at(a.expiresAt)}: check get_approval before that step, and do not take it without a decision.`),
73
+ ];
74
+ if (f.overdue > 0) steps.push(`${f.overdue} task(s) are overdue: go through them with the owner (the prompt triage_overdue) before moving any.`);
75
+ if (f.mine.length === 0 && f.ready[0]) steps.push(`Ready to work on: ${label(f.ready[0])}. claim_task it first; write acceptance criteria if it has none (dayhand://workflow/intake).`);
76
+ if (f.mine.length === 0 && f.ready.length === 0) steps.push("Nothing is ready to work on: ask the owner, or propose work with add_task and proposal: true (dayhand://workflow/intake).");
77
+ return steps;
78
+ }
79
+
80
+ export async function agentBrief(ctx: QueryCtx, identity: Identity, input: Args) {
81
+ const at = input.at ?? Date.now();
82
+ const today = await listToday(ctx, identity, { at });
83
+ const { timezone } = await loadSettings(ctx, identity.userId);
84
+ const controls = await getAgentControls(ctx, identity);
85
+ const all = await ctx.db
86
+ .query("tasks")
87
+ .withIndex("by_user", (q) => q.eq("userId", identity.userId))
88
+ .collect();
89
+ const byId = new Map(all.map((t) => [t._id as string, t]));
90
+ const open = all.filter((t) => t.deletedAt === undefined && t.archivedAt === undefined && t.proposedAt === undefined && t.status !== "done");
91
+ const live = (t: Doc<"tasks">) => t.claimedBy !== undefined && (t.claimExpiresAt ?? 0) > at;
92
+ const active = open.filter(live).sort((a, b) => a.claimExpiresAt! - b.claimExpiresAt!);
93
+ const expired = open.filter((t) => t.claimedBy !== undefined && !live(t)).sort((a, b) => a.claimExpiresAt! - b.claimExpiresAt!);
94
+ const ready = open.filter((t) => isReady(t, byId) && !(live(t) && !sameHolder(t.claimedBy, identity))).sort(byDue);
95
+
96
+ const approvals = await ctx.db
97
+ .query("approvals")
98
+ .withIndex("by_user", (q) => q.eq("userId", identity.userId).gte("createdAt", at - APPROVAL_WINDOW_MS))
99
+ .collect();
100
+ const pending = approvals.filter((a) => statusOf(a, at) === "pending").sort((a, b) => a.expiresAt - b.expiresAt);
101
+ const decided = approvals.filter((a) => a.decidedAt !== undefined && a.decidedAt > at - DAY_MS && sameHolder(a.requestedBy, identity)).sort((a, b) => b.decidedAt! - a.decidedAt!);
102
+ const mine = active.filter((t) => sameHolder(t.claimedBy, identity));
103
+
104
+ return {
105
+ ...today,
106
+ at,
107
+ caller: { type: identity.actor, name: identity.actorName ?? null },
108
+ agentsPaused: controls.paused,
109
+ pausedReason: controls.reason,
110
+ policies: controls.policies.filter((p) => p.keyId === identity.keyId).map(({ tool, rule }) => ({ tool, rule })),
111
+ claims: {
112
+ active: section(active.map((t) => claimView(t, identity))),
113
+ expired: section(expired.map((t) => claimView(t, identity))),
114
+ },
115
+ approvals: {
116
+ pending: section(pending.map((a) => approvalItem(a, byId, identity))),
117
+ decided: section(decided.map((a) => approvalItem(a, byId, identity))),
118
+ },
119
+ ready: section(ready.map(brief)),
120
+ nextSteps: nextSteps({
121
+ paused: controls.paused,
122
+ reason: controls.reason,
123
+ mine,
124
+ expiredMine: expired.filter((t) => sameHolder(t.claimedBy, identity)),
125
+ decided,
126
+ waiting: pending.filter((a) => sameHolder(a.requestedBy, identity)),
127
+ overdue: today.overdue.length,
128
+ ready,
129
+ timezone,
130
+ }),
131
+ };
132
+ }
@@ -0,0 +1,57 @@
1
+ // Fetching a Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document).
2
+ // Only URLs on the allowed origins are fetched (oauthConfig.cimdOrigins), so the
3
+ // server never requests an address a stranger chose; redirects are refused and
4
+ // the answer is cut off by size and time.
5
+ import { cimdOrigins } from "./oauthConfig";
6
+ import { checkMetadataDocument, cimdUrlProblem, type ClientCheck } from "./oauthRules";
7
+
8
+ const MAX_DOCUMENT_BYTES = 16 * 1024;
9
+ const FETCH_TIMEOUT_MS = 5_000;
10
+
11
+ async function readLimited(response: Response): Promise<string | null> {
12
+ const reader = response.body?.getReader();
13
+ if (!reader) return null;
14
+ const chunks: Uint8Array[] = [];
15
+ let size = 0;
16
+ for (;;) {
17
+ const { done, value } = await reader.read();
18
+ if (done) break;
19
+ size += value.byteLength;
20
+ if (size > MAX_DOCUMENT_BYTES) {
21
+ await reader.cancel();
22
+ return null;
23
+ }
24
+ chunks.push(value);
25
+ }
26
+ const all = new Uint8Array(size);
27
+ let offset = 0;
28
+ for (const chunk of chunks) {
29
+ all.set(chunk, offset);
30
+ offset += chunk.byteLength;
31
+ }
32
+ return new TextDecoder().decode(all);
33
+ }
34
+
35
+ /** The client behind a URL client_id, or why it cannot be used. Never throws. */
36
+ export async function fetchClientMetadata(clientId: string): Promise<ClientCheck> {
37
+ const problem = cimdUrlProblem(clientId, cimdOrigins());
38
+ if (problem) return { ok: false, error: problem };
39
+ let response: Response;
40
+ try {
41
+ response = await fetch(clientId, {
42
+ headers: { Accept: "application/json" },
43
+ redirect: "error",
44
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
45
+ });
46
+ } catch {
47
+ return { ok: false, error: "Could not fetch the client metadata" };
48
+ }
49
+ if (response.status !== 200) return { ok: false, error: `Client metadata answered HTTP ${response.status}` };
50
+ const text = await readLimited(response).catch(() => null);
51
+ if (text === null) return { ok: false, error: "Client metadata is too large or unreadable" };
52
+ try {
53
+ return checkMetadataDocument(clientId, JSON.parse(text));
54
+ } catch {
55
+ return { ok: false, error: "Client metadata is not JSON" };
56
+ }
57
+ }
@@ -0,0 +1,380 @@
1
+ // Shared building blocks of the domain layer: identity, errors, history,
2
+ // idempotency, settings and the public shape of a task.
3
+ import { ConvexError } from "convex/values";
4
+ import type { Doc, Id } from "../_generated/dataModel";
5
+ import type { MutationCtx, QueryCtx } from "../_generated/server";
6
+ import { isValidDate, isValidTime, localDate, resolveDate } from "./dates";
7
+ import { validateRepeat } from "./repeat";
8
+ import { RRuleError, parseRRule, toRRule } from "./rrule";
9
+ import { queueTaskEvents } from "./webhookEvents";
10
+ import type { Actor, ChecklistInput, ChecklistItem, Link, Reminder, RepeatInput, RepeatRule } from "./validators";
11
+
12
+ /**
13
+ * Who is acting. Thin layers (app, MCP) establish it; the domain trusts it.
14
+ * Changes made with an API key also carry the key's id and name; changes made
15
+ * through an OAuth connection carry the connection's id and the client's name.
16
+ */
17
+ export type Identity = {
18
+ userId: string;
19
+ actor: Actor;
20
+ keyId?: Id<"apiKeys">;
21
+ connectionId?: Id<"oauthGrants">;
22
+ actorName?: string;
23
+ };
24
+
25
+ /** The identity part of an internal function's arguments. */
26
+ export function identityOf({ userId, actor, keyId, connectionId, actorName }: Identity): Identity {
27
+ return { userId, actor, keyId, connectionId, actorName };
28
+ }
29
+
30
+ /** How history shows who made a change: "app", "cli", "agent" or "agent: <key or client name>". */
31
+ export function actorLabel(event: { actor: Actor; actorName?: string }): string {
32
+ return event.actor === "agent" && event.actorName ? `agent: ${event.actorName}` : event.actor;
33
+ }
34
+
35
+ // conflict (since 0.10.0): another agent holds the task's claim (claim_task).
36
+ // Since 0.11.0 (docs/agent-work.md): agents_paused, the owner paused every agent;
37
+ // forbidden, only the owner may do this or the owner's policy denies the tool;
38
+ // approval_required, the owner's policy asks first (approvalId says which request).
39
+ export type ErrorCode =
40
+ | "invalid_argument"
41
+ | "not_found"
42
+ | "unauthenticated"
43
+ | "precondition_failed"
44
+ | "conflict"
45
+ | "agents_paused"
46
+ | "forbidden"
47
+ | "approval_required";
48
+
49
+ export function fail(code: ErrorCode, message: string, extra: { approvalId?: string } = {}): never {
50
+ throw new ConvexError({ code, message, ...extra });
51
+ }
52
+
53
+ /** Refuses an operation only the owner may run (the app, or a cli key at the owner's terminal). */
54
+ export function ownerOnly(identity: Identity, what: string) {
55
+ if (identity.actor === "agent") fail("forbidden", `Only the owner can ${what} (in the app or with a cli key)`);
56
+ }
57
+
58
+ export const DEFAULT_SETTINGS = {
59
+ timezone: "Europe/London",
60
+ summaryTime: "05:00",
61
+ defaultReminderMinutes: [0],
62
+ };
63
+
64
+ export type Settings = typeof DEFAULT_SETTINGS;
65
+
66
+ export async function loadSettings(ctx: QueryCtx, userId: string): Promise<Settings> {
67
+ const row = await ctx.db
68
+ .query("settings")
69
+ .withIndex("by_user", (q) => q.eq("userId", userId))
70
+ .unique();
71
+ if (!row) return { ...DEFAULT_SETTINGS };
72
+ return {
73
+ timezone: row.timezone,
74
+ summaryTime: row.summaryTime,
75
+ defaultReminderMinutes: row.defaultReminderMinutes,
76
+ };
77
+ }
78
+
79
+ export async function todayFor(ctx: QueryCtx, userId: string, at: number): Promise<string> {
80
+ const { timezone } = await loadSettings(ctx, userId);
81
+ return localDate(at, timezone);
82
+ }
83
+
84
+ /**
85
+ * A task as returned to clients: stable `id` and `ref`, no internal fields.
86
+ * A repeating task also carries its rule as an RFC 5545 RRULE (`rrule`).
87
+ */
88
+ export function toTask(doc: Doc<"tasks">) {
89
+ const { _id, _creationTime, userId, ...fields } = doc;
90
+ return fields.repeat ? { id: _id, ...fields, rrule: toRRule(fields.repeat, fields.dueDate) } : { id: _id, ...fields };
91
+ }
92
+ export type TaskView = ReturnType<typeof toTask>;
93
+
94
+ /**
95
+ * The version of a stored task as an HTTP entity tag (RFC 9110): it changes
96
+ * with every change to the task. Derived fields such as `overdue` are not part of it.
97
+ */
98
+ export const versionTag = (task: { updatedAt: number }) => `"${task.updatedAt}"`;
99
+
100
+ const TASK_REF = /^#(\d{1,15})$/;
101
+
102
+ /** Loads one of the user's tasks by its id or by its number written as "#42". */
103
+ export async function loadTask(ctx: QueryCtx, userId: string, taskId: string) {
104
+ const ref = TASK_REF.exec(taskId);
105
+ if (ref) {
106
+ const task = await ctx.db
107
+ .query("tasks")
108
+ .withIndex("by_user_ref", (q) => q.eq("userId", userId).eq("ref", Number(ref[1])))
109
+ .first();
110
+ if (!task) fail("not_found", `Task #${Number(ref[1])} not found`);
111
+ return task;
112
+ }
113
+ const id = ctx.db.normalizeId("tasks", taskId);
114
+ if (!id) fail("invalid_argument", `Invalid arguments: taskId must be a task id or "#<number>", got "${taskId}"`);
115
+ const task = await ctx.db.get(id);
116
+ if (!task || task.userId !== userId) fail("not_found", `Task ${taskId} not found`);
117
+ return task;
118
+ }
119
+
120
+ /** Gives the user's next task number. Numbers start at 1 and are never given twice. */
121
+ export async function nextTaskRef(ctx: MutationCtx, userId: string): Promise<number> {
122
+ const counter = await ctx.db
123
+ .query("taskCounters")
124
+ .withIndex("by_user", (q) => q.eq("userId", userId))
125
+ .unique();
126
+ const ref = (counter?.lastRef ?? 0) + 1;
127
+ if (counter) await ctx.db.patch(counter._id, { lastRef: ref });
128
+ else await ctx.db.insert("taskCounters", { userId, lastRef: ref });
129
+ return ref;
130
+ }
131
+
132
+ /**
133
+ * The next position in the user's activity log (taskEvents.seq). It starts
134
+ * from the change's time in microseconds and grows by one past the last
135
+ * position, so it follows the order changes commit even when two changes
136
+ * share a millisecond: every task change reads and writes the counter, and
137
+ * Convex runs such mutations one after the other.
138
+ */
139
+ async function nextEventSeq(ctx: MutationCtx, userId: string, at: number): Promise<number> {
140
+ const counter = await ctx.db
141
+ .query("eventCounters")
142
+ .withIndex("by_user", (q) => q.eq("userId", userId))
143
+ .unique();
144
+ const seq = Math.max((counter?.lastSeq ?? 0) + 1, at * 1000);
145
+ if (counter) await ctx.db.patch(counter._id, { lastSeq: seq });
146
+ else await ctx.db.insert("eventCounters", { userId, lastSeq: seq });
147
+ return seq;
148
+ }
149
+
150
+ export async function recordEvent(
151
+ ctx: MutationCtx,
152
+ identity: Identity,
153
+ event: {
154
+ taskId?: Id<"tasks">;
155
+ action: string;
156
+ before?: Record<string, unknown>;
157
+ after?: Record<string, unknown>;
158
+ clientId?: string;
159
+ },
160
+ ) {
161
+ const at = Date.now();
162
+ const eventId = await ctx.db.insert("taskEvents", {
163
+ userId: identity.userId,
164
+ actor: identity.actor,
165
+ keyId: identity.keyId,
166
+ connectionId: identity.connectionId,
167
+ actorName: identity.actorName,
168
+ at,
169
+ // Only changes of a task enter the activity log (list_events).
170
+ seq: event.taskId ? await nextEventSeq(ctx, identity.userId, at) : undefined,
171
+ ...event,
172
+ });
173
+ // A change of a task is also an event for the user's webhooks (lib/webhookEvents.ts).
174
+ if (event.taskId) {
175
+ const { taskId, action, before, after } = event;
176
+ const { userId, actor, actorName } = identity;
177
+ await queueTaskEvents(ctx, { userId, eventId, taskId, action, at, actor, actorName, before, after }, toTask);
178
+ }
179
+ }
180
+
181
+ /** Changed fields of `patch` relative to `doc`, as before/after maps. */
182
+ export function diff<T extends Record<string, unknown>>(doc: T, patch: Partial<T>) {
183
+ const before: Record<string, unknown> = {};
184
+ const after: Record<string, unknown> = {};
185
+ for (const [key, value] of Object.entries(patch)) {
186
+ if (key === "updatedAt") continue;
187
+ if (JSON.stringify(doc[key]) !== JSON.stringify(value)) {
188
+ before[key] = doc[key] ?? null;
189
+ after[key] = value ?? null;
190
+ }
191
+ }
192
+ return { before, after, changed: Object.keys(after).length > 0 };
193
+ }
194
+
195
+ /** Applies a patch to a task and records it in history when anything changed. */
196
+ export async function patchTask(
197
+ ctx: MutationCtx,
198
+ identity: Identity,
199
+ task: Doc<"tasks">,
200
+ action: string,
201
+ patch: Partial<Doc<"tasks">>,
202
+ clientId?: string,
203
+ ) {
204
+ const { before, after, changed } = diff(task, patch);
205
+ if (!changed) return task;
206
+ await ctx.db.patch(task._id, { ...patch, updatedAt: Date.now() });
207
+ await recordEvent(ctx, identity, { taskId: task._id, action, before, after, clientId });
208
+ return (await ctx.db.get(task._id))!;
209
+ }
210
+
211
+ /** The event of an operation already applied with this clientId, if any. */
212
+ export async function appliedOperation(ctx: QueryCtx, userId: string, clientId?: string) {
213
+ if (clientId === undefined) return null;
214
+ return ctx.db
215
+ .query("taskEvents")
216
+ .withIndex("by_user_clientId", (q) => q.eq("userId", userId).eq("clientId", clientId))
217
+ .first();
218
+ }
219
+
220
+ /** Current state of the task touched by an already applied operation, or null. */
221
+ export async function replay(ctx: MutationCtx, userId: string, clientId?: string) {
222
+ const event = await appliedOperation(ctx, userId, checkClientId(clientId));
223
+ if (!event) return null;
224
+ const task = event.taskId ? await ctx.db.get(event.taskId) : null;
225
+ if (!task) fail("not_found", "Task of this operation no longer exists");
226
+ return task;
227
+ }
228
+
229
+ // ---- Input checks shared by all operations ----
230
+
231
+ const LIMITS = {
232
+ title: 500,
233
+ note: 20_000,
234
+ resumeNote: 2_000,
235
+ label: 50,
236
+ labels: 20,
237
+ links: 20,
238
+ url: 2_048,
239
+ clientId: 128,
240
+ checklist: 100,
241
+ checklistId: 64,
242
+ reminders: 5,
243
+ reminderMinutes: 7 * 24 * 60,
244
+ reminderDays: 30,
245
+ };
246
+
247
+ export function checkTitle(title: string): string {
248
+ const trimmed = title.trim();
249
+ if (!trimmed) fail("invalid_argument", "title must not be empty");
250
+ if (trimmed.length > LIMITS.title) fail("invalid_argument", `title is longer than ${LIMITS.title} characters`);
251
+ return trimmed;
252
+ }
253
+
254
+ export function checkText(field: "note" | "resumeNote", value: string): string | undefined {
255
+ if (value.length > LIMITS[field]) fail("invalid_argument", `${field} is longer than ${LIMITS[field]} characters`);
256
+ return value.trim() === "" ? undefined : value;
257
+ }
258
+
259
+ export function checkDueDate(value: string, today: string): string {
260
+ const resolved = resolveDate(value, today);
261
+ if (!resolved) fail("invalid_argument", `dueDate must be YYYY-MM-DD, "today" or "tomorrow", got "${value}"`);
262
+ return resolved;
263
+ }
264
+
265
+ export function checkDueTime(value: string): string {
266
+ if (!isValidTime(value)) fail("invalid_argument", `dueTime must be HH:MM (24h), got "${value}"`);
267
+ return value;
268
+ }
269
+
270
+ export function checkLabels(labels: string[]): string[] {
271
+ const cleaned = [...new Set(labels.map((l) => l.trim()).filter((l) => l !== ""))];
272
+ if (cleaned.length > LIMITS.labels) fail("invalid_argument", `at most ${LIMITS.labels} labels`);
273
+ if (cleaned.some((l) => l.length > LIMITS.label)) fail("invalid_argument", `labels are at most ${LIMITS.label} characters`);
274
+ return cleaned;
275
+ }
276
+
277
+ export function checkLinks(links: Link[]): Link[] {
278
+ if (links.length > LIMITS.links) fail("invalid_argument", `at most ${LIMITS.links} links`);
279
+ return links.map((link) => {
280
+ let parsed: URL;
281
+ try {
282
+ parsed = new URL(link.url);
283
+ } catch {
284
+ fail("invalid_argument", `invalid link URL "${link.url}"`);
285
+ }
286
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || link.url.length > LIMITS.url) {
287
+ fail("invalid_argument", `links must be http(s) URLs up to ${LIMITS.url} characters`);
288
+ }
289
+ const title = link.title?.trim();
290
+ return title ? { url: link.url, title } : { url: link.url };
291
+ });
292
+ }
293
+
294
+ /** A rule object, or an RFC 5545 RRULE string read in the user's time zone. */
295
+ export function checkRepeat(input: RepeatInput, timezone: string): RepeatRule {
296
+ if (typeof input === "string") {
297
+ try {
298
+ return parseRRule(input, timezone);
299
+ } catch (error) {
300
+ if (error instanceof RRuleError) fail("invalid_argument", `repeat: ${error.message}`);
301
+ throw error;
302
+ }
303
+ }
304
+ const error = validateRepeat(input);
305
+ if (error) fail("invalid_argument", error);
306
+ return input;
307
+ }
308
+
309
+ /**
310
+ * The checklist as stored: trimmed titles in the given order, `done` false
311
+ * unless set, and an id for every item. New items get the next free `c<n>`.
312
+ * An empty list is no checklist.
313
+ */
314
+ export function checkChecklist(items: ChecklistInput[]): ChecklistItem[] | undefined {
315
+ if (items.length > LIMITS.checklist) fail("invalid_argument", `at most ${LIMITS.checklist} checklist items`);
316
+ const given = items.map((item) => item.id).filter((id): id is string => id !== undefined);
317
+ if (given.some((id) => id.trim() === "" || id.length > LIMITS.checklistId)) {
318
+ fail("invalid_argument", `checklist item ids must be 1 to ${LIMITS.checklistId} characters`);
319
+ }
320
+ if (new Set(given).size !== given.length) fail("invalid_argument", "checklist item ids must be distinct");
321
+ const used = new Set(given);
322
+ let next = 1;
323
+ const freeId = () => {
324
+ while (used.has(`c${next}`)) next++;
325
+ used.add(`c${next}`);
326
+ return `c${next}`;
327
+ };
328
+ const checked = items.map((item) => {
329
+ const title = item.title.trim();
330
+ if (!title) fail("invalid_argument", "checklist items need a title");
331
+ if (title.length > LIMITS.title) fail("invalid_argument", `checklist items are at most ${LIMITS.title} characters`);
332
+ return { id: item.id ?? "", title, done: item.done ?? false };
333
+ });
334
+ const result = checked.map((item) => (item.id ? item : { ...item, id: freeId() }));
335
+ return result.length > 0 ? result : undefined;
336
+ }
337
+
338
+ /** Reminders as stored: checked, without duplicates, `daysBefore` only when not 0. Empty = none. */
339
+ export function checkReminders(reminders: Reminder[]): Reminder[] {
340
+ if (reminders.length > LIMITS.reminders) fail("invalid_argument", `at most ${LIMITS.reminders} reminders per task`);
341
+ const seen = new Set<string>();
342
+ const result: Reminder[] = [];
343
+ for (const reminder of reminders) {
344
+ let clean: Reminder;
345
+ if ("minutesBefore" in reminder) {
346
+ const m = reminder.minutesBefore;
347
+ if (!Number.isInteger(m) || m < 0 || m > LIMITS.reminderMinutes) {
348
+ fail("invalid_argument", `reminders: minutesBefore must be whole minutes from 0 to ${LIMITS.reminderMinutes}`);
349
+ }
350
+ clean = { minutesBefore: m };
351
+ } else {
352
+ if (!isValidTime(reminder.time)) fail("invalid_argument", `reminders: time must be HH:MM (24h), got "${reminder.time}"`);
353
+ const days = reminder.daysBefore ?? 0;
354
+ if (!Number.isInteger(days) || days < 0 || days > LIMITS.reminderDays) {
355
+ fail("invalid_argument", `reminders: daysBefore must be a whole number from 0 to ${LIMITS.reminderDays}`);
356
+ }
357
+ clean = days === 0 ? { time: reminder.time } : { time: reminder.time, daysBefore: days };
358
+ }
359
+ const key = JSON.stringify(clean);
360
+ if (!seen.has(key)) result.push(clean);
361
+ seen.add(key);
362
+ }
363
+ return result;
364
+ }
365
+
366
+ export function checkClientId(clientId?: string): string | undefined {
367
+ if (clientId !== undefined && (clientId === "" || clientId.length > LIMITS.clientId)) {
368
+ fail("invalid_argument", `clientId must be 1 to ${LIMITS.clientId} characters`);
369
+ }
370
+ return clientId;
371
+ }
372
+
373
+ export function checkSchedule(dueDate: string | undefined, dueTime: string | undefined) {
374
+ if (dueTime !== undefined && dueDate === undefined) {
375
+ fail("invalid_argument", "dueTime needs a dueDate");
376
+ }
377
+ if (dueDate !== undefined && !isValidDate(dueDate)) {
378
+ fail("invalid_argument", `invalid dueDate "${dueDate}"`);
379
+ }
380
+ }
@@ -0,0 +1,114 @@
1
+ // Calendar dates are plain YYYY-MM-DD strings in the user's time zone.
2
+ // Everything that decides "today" or "overdue" lives in this file.
3
+
4
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
5
+ const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
6
+
7
+ export function isValidDate(value: string): boolean {
8
+ const match = DATE_RE.exec(value);
9
+ if (!match) return false;
10
+ const [, y, m, d] = match.map(Number);
11
+ const date = new Date(Date.UTC(y, m - 1, d));
12
+ return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
13
+ }
14
+
15
+ export function isValidTime(value: string): boolean {
16
+ return TIME_RE.test(value);
17
+ }
18
+
19
+ export function isValidTimezone(timezone: string): boolean {
20
+ try {
21
+ new Intl.DateTimeFormat("en-GB", { timeZone: timezone });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** The calendar date at instant `ms` in `timezone`. */
29
+ export function localDate(ms: number, timezone: string): string {
30
+ const parts = new Intl.DateTimeFormat("en-GB", {
31
+ timeZone: timezone,
32
+ year: "numeric",
33
+ month: "2-digit",
34
+ day: "2-digit",
35
+ }).formatToParts(new Date(ms));
36
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
37
+ return `${get("year")}-${get("month")}-${get("day")}`;
38
+ }
39
+
40
+ /** "YYYY-MM-DD HH:MM" at instant `ms` in `timezone`. */
41
+ export function localDateTime(ms: number, timezone: string): string {
42
+ const parts = new Intl.DateTimeFormat("en-GB", {
43
+ timeZone: timezone,
44
+ year: "numeric",
45
+ month: "2-digit",
46
+ day: "2-digit",
47
+ hour: "2-digit",
48
+ minute: "2-digit",
49
+ hourCycle: "h23",
50
+ }).formatToParts(new Date(ms));
51
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
52
+ return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
53
+ }
54
+
55
+ function toUtcDate(date: string): Date {
56
+ const [y, m, d] = date.split("-").map(Number);
57
+ return new Date(Date.UTC(y, m - 1, d));
58
+ }
59
+
60
+ function fromUtcDate(date: Date): string {
61
+ return date.toISOString().slice(0, 10);
62
+ }
63
+
64
+ export function addDays(date: string, days: number): string {
65
+ const result = toUtcDate(date);
66
+ result.setUTCDate(result.getUTCDate() + days);
67
+ return fromUtcDate(result);
68
+ }
69
+
70
+ /** Adds months keeping `anchorDay` where the month allows it (Jan 31 -> Feb 28 -> Mar 31). */
71
+ export function addMonths(date: string, months: number, anchorDay: number): string {
72
+ const start = toUtcDate(date);
73
+ const firstOfTarget = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + months, 1));
74
+ const lastDay = new Date(
75
+ Date.UTC(firstOfTarget.getUTCFullYear(), firstOfTarget.getUTCMonth() + 1, 0),
76
+ ).getUTCDate();
77
+ firstOfTarget.setUTCDate(Math.min(anchorDay, lastDay));
78
+ return fromUtcDate(firstOfTarget);
79
+ }
80
+
81
+ /** ISO weekday: 1 = Monday ... 7 = Sunday. */
82
+ export function weekday(date: string): number {
83
+ const day = toUtcDate(date).getUTCDay();
84
+ return day === 0 ? 7 : day;
85
+ }
86
+
87
+ export function dayOfMonth(date: string): number {
88
+ return toUtcDate(date).getUTCDate();
89
+ }
90
+
91
+ /**
92
+ * Overdue = not done, still active, and due before today in the user's zone.
93
+ * The single definition used by every view.
94
+ */
95
+ export function isOverdue(
96
+ task: { status: string; dueDate?: string; archivedAt?: number; deletedAt?: number },
97
+ today: string,
98
+ ): boolean {
99
+ return (
100
+ task.status !== "done" &&
101
+ task.archivedAt === undefined &&
102
+ task.deletedAt === undefined &&
103
+ task.dueDate !== undefined &&
104
+ task.dueDate < today
105
+ );
106
+ }
107
+
108
+ /** Accepts YYYY-MM-DD or the words "today" / "tomorrow". Returns null when invalid. */
109
+ export function resolveDate(input: string, today: string): string | null {
110
+ const value = input.trim().toLowerCase();
111
+ if (value === "today") return today;
112
+ if (value === "tomorrow") return addDays(today, 1);
113
+ return isValidDate(value) ? value : null;
114
+ }