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.
- package/LICENSE +202 -0
- package/README.md +100 -2
- package/backend/convex/_generated/api.d.ts +181 -0
- package/backend/convex/_generated/api.js +23 -0
- package/backend/convex/_generated/dataModel.d.ts +60 -0
- package/backend/convex/_generated/server.d.ts +162 -0
- package/backend/convex/_generated/server.js +101 -0
- package/backend/convex/apiKeys.ts +171 -0
- package/backend/convex/app.ts +197 -0
- package/backend/convex/auth.config.ts +28 -0
- package/backend/convex/calendar.ts +61 -0
- package/backend/convex/calendarFeeds.ts +105 -0
- package/backend/convex/callLimits.ts +67 -0
- package/backend/convex/connections.ts +28 -0
- package/backend/convex/crons.ts +18 -0
- package/backend/convex/devices.ts +224 -0
- package/backend/convex/http.ts +56 -0
- package/backend/convex/lib/activity.ts +155 -0
- package/backend/convex/lib/agentControls.ts +146 -0
- package/backend/convex/lib/agentWork.ts +250 -0
- package/backend/convex/lib/appToken.ts +68 -0
- package/backend/convex/lib/approvals.ts +311 -0
- package/backend/convex/lib/args.ts +175 -0
- package/backend/convex/lib/backlogMd.ts +309 -0
- package/backend/convex/lib/backlogOps.ts +361 -0
- package/backend/convex/lib/brief.ts +132 -0
- package/backend/convex/lib/cimd.ts +57 -0
- package/backend/convex/lib/core.ts +380 -0
- package/backend/convex/lib/dates.ts +114 -0
- package/backend/convex/lib/env.ts +19 -0
- package/backend/convex/lib/exportPlan.ts +98 -0
- package/backend/convex/lib/findTasks.ts +119 -0
- package/backend/convex/lib/frontmatter.ts +165 -0
- package/backend/convex/lib/guide.ts +286 -0
- package/backend/convex/lib/health.ts +115 -0
- package/backend/convex/lib/http.ts +27 -0
- package/backend/convex/lib/ical.ts +243 -0
- package/backend/convex/lib/importFormats.ts +429 -0
- package/backend/convex/lib/importOps.ts +145 -0
- package/backend/convex/lib/jsonSchema.ts +50 -0
- package/backend/convex/lib/limits.ts +42 -0
- package/backend/convex/lib/mcpContent.ts +220 -0
- package/backend/convex/lib/mcpProtocol.ts +128 -0
- package/backend/convex/lib/mcpTools.ts +505 -0
- package/backend/convex/lib/oauthConfig.ts +59 -0
- package/backend/convex/lib/oauthPage.ts +125 -0
- package/backend/convex/lib/oauthRules.ts +159 -0
- package/backend/convex/lib/openapi.ts +230 -0
- package/backend/convex/lib/outputSchemas.ts +2901 -0
- package/backend/convex/lib/problem.ts +69 -0
- package/backend/convex/lib/proposals.ts +172 -0
- package/backend/convex/lib/repeat.ts +185 -0
- package/backend/convex/lib/restRoutes.ts +294 -0
- package/backend/convex/lib/rrule.ts +171 -0
- package/backend/convex/lib/secrets.ts +135 -0
- package/backend/convex/lib/settingsOps.ts +47 -0
- package/backend/convex/lib/taskOps.ts +304 -0
- package/backend/convex/lib/toolResults.ts +50 -0
- package/backend/convex/lib/validators.ts +143 -0
- package/backend/convex/lib/views.ts +145 -0
- package/backend/convex/lib/vtimezone.ts +151 -0
- package/backend/convex/lib/webhookEvents.ts +160 -0
- package/backend/convex/lib/webhookSign.ts +30 -0
- package/backend/convex/lib/webhookTarget.ts +75 -0
- package/backend/convex/mcp.ts +405 -0
- package/backend/convex/migrations.ts +91 -0
- package/backend/convex/oauth.ts +388 -0
- package/backend/convex/oauthHttp.ts +381 -0
- package/backend/convex/oauthOwner.ts +173 -0
- package/backend/convex/ops.ts +205 -0
- package/backend/convex/pairing.ts +178 -0
- package/backend/convex/rest.ts +254 -0
- package/backend/convex/restAccount.ts +56 -0
- package/backend/convex/restImport.ts +73 -0
- package/backend/convex/schema.ts +435 -0
- package/backend/convex/tsconfig.json +18 -0
- package/backend/convex/webhooks.ts +339 -0
- package/backend/package-lock.json +3068 -0
- package/backend/package.json +33 -0
- package/backend/scripts/app-token.mjs +79 -0
- package/backend/scripts/fetch-local-backend.mjs +72 -0
- package/backend/scripts/gen-output-schemas.mjs +60 -0
- package/backend/scripts/jwt.mjs +67 -0
- package/backend/scripts/local-backend-lib.mjs +123 -0
- package/backend/scripts/local-backend.mjs +81 -0
- package/backend/scripts/setup-convex.mjs +158 -0
- package/backend/scripts/setup-lib.mjs +338 -0
- package/backend/scripts/setup-output.mjs +108 -0
- package/backend/scripts/setup.mjs +344 -0
- package/backend/scripts/signing-key.mjs +61 -0
- package/backend/tsconfig.json +16 -0
- package/dist/backlog/folder.js +114 -0
- package/dist/bin/dayhand.js +10 -0
- package/dist/bin/hlm.js +12 -0
- package/dist/completion/cache.js +54 -0
- package/dist/completion/engine.js +187 -0
- package/dist/completion/scripts.js +82 -0
- package/dist/config/aliases.js +25 -0
- package/dist/config/env.js +68 -0
- package/dist/config/file.js +146 -0
- package/dist/config/paths.js +58 -0
- package/dist/config/resolve.js +53 -0
- package/dist/config/url.js +26 -0
- package/dist/core/editor.js +37 -0
- package/dist/core/exit.js +59 -0
- package/dist/core/io.js +14 -0
- package/dist/core/output.js +39 -0
- package/dist/core/prompt.js +55 -0
- package/dist/core/version.js +15 -0
- package/dist/core/words.js +36 -0
- package/dist/credentials/file.js +65 -0
- package/dist/credentials/index.js +29 -0
- package/dist/credentials/keychain.js +44 -0
- package/dist/credentials/store.js +39 -0
- package/dist/docs.js +116 -0
- package/dist/fields.js +33 -0
- package/dist/globals.js +16 -0
- package/dist/guide.js +36 -0
- package/dist/help.js +56 -0
- package/dist/i18n/dates.js +84 -0
- package/dist/i18n/de.js +240 -0
- package/dist/i18n/en.js +243 -0
- package/dist/i18n/es.js +240 -0
- package/dist/i18n/fr.js +240 -0
- package/dist/i18n/index.js +67 -0
- package/dist/i18n/ja.js +240 -0
- package/dist/i18n/pl.js +245 -0
- package/dist/i18n/pt-BR.js +240 -0
- package/dist/i18n/zh-Hans.js +240 -0
- package/dist/main.js +180 -0
- package/dist/man.js +56 -0
- package/dist/registry/agents.js +67 -0
- package/dist/registry/auth.js +168 -0
- package/dist/registry/backlog.js +106 -0
- package/dist/registry/calendar.js +103 -0
- package/dist/registry/dates.js +33 -0
- package/dist/registry/filters.js +98 -0
- package/dist/registry/index.js +24 -0
- package/dist/registry/integrations.js +245 -0
- package/dist/registry/local.js +89 -0
- package/dist/registry/model.js +104 -0
- package/dist/registry/ops.js +47 -0
- package/dist/registry/owner.js +349 -0
- package/dist/registry/packaged-backend.js +59 -0
- package/dist/registry/parse.js +129 -0
- package/dist/registry/server.js +252 -0
- package/dist/registry/system.js +220 -0
- package/dist/registry/tasks.js +512 -0
- package/dist/registry/types.js +1 -0
- package/dist/registry/ui.js +51 -0
- package/dist/registry/work.js +207 -0
- package/dist/render/format.js +146 -0
- package/dist/render/model.js +29 -0
- package/dist/render/style.js +24 -0
- package/dist/render/table.js +46 -0
- package/dist/render/views.js +256 -0
- package/dist/run.js +133 -0
- package/dist/setup.js +26 -0
- package/dist/skill.js +83 -0
- package/dist/topics.js +121 -0
- package/dist/transport/client.js +196 -0
- package/dist/ui/agents.js +12 -0
- package/dist/ui/api.js +99 -0
- package/dist/ui/app.js +62 -0
- package/dist/ui/budget.js +42 -0
- package/dist/ui/controller.js +368 -0
- package/dist/ui/effects.js +166 -0
- package/dist/ui/frame.js +206 -0
- package/dist/ui/handle.js +271 -0
- package/dist/ui/health.js +22 -0
- package/dist/ui/keys.js +120 -0
- package/dist/ui/line.js +56 -0
- package/dist/ui/lists.js +58 -0
- package/dist/ui/model.js +25 -0
- package/dist/ui/quickadd.js +66 -0
- package/dist/ui/rows.js +68 -0
- package/dist/ui/run.js +54 -0
- package/dist/ui/screens/agents.js +65 -0
- package/dist/ui/screens/board.js +41 -0
- package/dist/ui/screens/detail.js +142 -0
- package/dist/ui/screens/overlay.js +58 -0
- package/dist/ui/screens/screen.js +3 -0
- package/dist/ui/screens/today.js +60 -0
- package/dist/ui/screens/week.js +76 -0
- package/dist/ui/state.js +128 -0
- package/dist/ui/text.js +70 -0
- package/dist/when/calendar.js +95 -0
- package/dist/when/parse.js +314 -0
- package/man/dayhand.1 +1163 -0
- package/man/dh.1 +1 -0
- package/package.json +56 -4
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Approval requests (docs/agent-work.md#5-approval-requests): an agent asks the
|
|
2
|
+
// owner before it does something, with 2 to 4 options and what each one leads
|
|
3
|
+
// to; the owner decides on the phone before the request expires. Dayhand keeps
|
|
4
|
+
// the request and the decision; acting on the decision is up to the agent,
|
|
5
|
+
// except for the requests a tool policy "ask" makes (source "policy", lib/agentControls.ts):
|
|
6
|
+
// an "allow" there lets exactly one matching call through.
|
|
7
|
+
import type { ObjectType } from "convex/values";
|
|
8
|
+
import type { Doc } from "../_generated/dataModel";
|
|
9
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
|
10
|
+
import type { args } from "./args";
|
|
11
|
+
import { checkClientId, fail, loadTask, ownerOnly, recordEvent, type Identity } from "./core";
|
|
12
|
+
import { sha256Hex } from "./secrets";
|
|
13
|
+
import type { ApprovalOption, ApprovalOptionInput } from "./validators";
|
|
14
|
+
import { queueApprovalDecided } from "./webhookEvents";
|
|
15
|
+
|
|
16
|
+
type Args<K extends keyof typeof args> = ObjectType<(typeof args)[K]>;
|
|
17
|
+
|
|
18
|
+
export const MAX_APPROVAL_MINUTES = 7 * 24 * 60;
|
|
19
|
+
|
|
20
|
+
const LIMITS = {
|
|
21
|
+
action: 200,
|
|
22
|
+
detail: 4_000,
|
|
23
|
+
minOptions: 2,
|
|
24
|
+
maxOptions: 4,
|
|
25
|
+
optionId: 32,
|
|
26
|
+
label: 100,
|
|
27
|
+
consequence: 500,
|
|
28
|
+
note: 1_000,
|
|
29
|
+
minutes: MAX_APPROVAL_MINUTES,
|
|
30
|
+
list: 200,
|
|
31
|
+
arguments: 2_000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_APPROVAL_MINUTES = 24 * 60;
|
|
35
|
+
/** How long a policy request waits for the owner. */
|
|
36
|
+
export const POLICY_APPROVAL_MINUTES = 60;
|
|
37
|
+
const DEFAULT_LIST = 50;
|
|
38
|
+
|
|
39
|
+
export type ApprovalStatus = "pending" | "decided" | "expired";
|
|
40
|
+
|
|
41
|
+
export const statusOf = (approval: Doc<"approvals">, now: number): ApprovalStatus =>
|
|
42
|
+
approval.decidedAt !== undefined ? "decided" : now >= approval.expiresAt ? "expired" : "pending";
|
|
43
|
+
|
|
44
|
+
/** An approval request as clients see it. */
|
|
45
|
+
export async function approvalView(ctx: QueryCtx, approval: Doc<"approvals">, now = Date.now()) {
|
|
46
|
+
const task = approval.taskId ? await ctx.db.get(approval.taskId) : null;
|
|
47
|
+
const chosen = approval.options.find((option) => option.id === approval.optionId);
|
|
48
|
+
return {
|
|
49
|
+
id: approval._id,
|
|
50
|
+
taskId: approval.taskId ?? null,
|
|
51
|
+
ref: task?.ref ?? null,
|
|
52
|
+
taskTitle: task?.title ?? null,
|
|
53
|
+
action: approval.title,
|
|
54
|
+
detail: approval.detail ?? null,
|
|
55
|
+
options: approval.options.map((option) => ({ id: option.id, label: option.label, consequence: option.consequence ?? null })),
|
|
56
|
+
reversible: approval.reversible,
|
|
57
|
+
status: statusOf(approval, now),
|
|
58
|
+
expiresAt: approval.expiresAt,
|
|
59
|
+
createdAt: approval.createdAt,
|
|
60
|
+
requestedBy: { type: approval.requestedBy.actor, name: approval.requestedBy.name },
|
|
61
|
+
source: approval.source,
|
|
62
|
+
tool: approval.tool ?? null,
|
|
63
|
+
arguments: approval.arguments ?? null,
|
|
64
|
+
used: approval.usedAt !== undefined,
|
|
65
|
+
decision:
|
|
66
|
+
approval.decidedAt === undefined
|
|
67
|
+
? null
|
|
68
|
+
: {
|
|
69
|
+
optionId: approval.optionId!,
|
|
70
|
+
label: chosen?.label ?? approval.optionId!,
|
|
71
|
+
note: approval.note ?? null,
|
|
72
|
+
decidedAt: approval.decidedAt,
|
|
73
|
+
by: approval.decidedBy ?? "app",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export type ApprovalView = Awaited<ReturnType<typeof approvalView>>;
|
|
78
|
+
|
|
79
|
+
// ---- Input checks ----
|
|
80
|
+
|
|
81
|
+
function checkLine(field: string, value: string, max: number): string {
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
if (!trimmed) fail("invalid_argument", `${field} must not be empty`);
|
|
84
|
+
if (trimmed.length > max) fail("invalid_argument", `${field} is longer than ${max} characters`);
|
|
85
|
+
return trimmed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The options as stored: 2 to 4, trimmed labels, distinct ids; a new option gets the next free `o<n>`. */
|
|
89
|
+
export function checkOptions(items: ApprovalOptionInput[]): ApprovalOption[] {
|
|
90
|
+
if (items.length < LIMITS.minOptions || items.length > LIMITS.maxOptions) {
|
|
91
|
+
fail("invalid_argument", `options must list ${LIMITS.minOptions} to ${LIMITS.maxOptions} choices`);
|
|
92
|
+
}
|
|
93
|
+
const given = items.map((item) => item.id?.trim()).filter((id): id is string => id !== undefined);
|
|
94
|
+
if (given.some((id) => !/^[A-Za-z0-9_-]+$/.test(id) || id.length > LIMITS.optionId)) {
|
|
95
|
+
fail("invalid_argument", `option ids are 1 to ${LIMITS.optionId} letters, digits, - or _`);
|
|
96
|
+
}
|
|
97
|
+
if (new Set(given).size !== given.length) fail("invalid_argument", "option ids must be distinct");
|
|
98
|
+
const used = new Set(given);
|
|
99
|
+
let next = 1;
|
|
100
|
+
const freeId = () => {
|
|
101
|
+
while (used.has(`o${next}`)) next++;
|
|
102
|
+
used.add(`o${next}`);
|
|
103
|
+
return `o${next}`;
|
|
104
|
+
};
|
|
105
|
+
return items.map((item) => {
|
|
106
|
+
const label = checkLine("option label", item.label, LIMITS.label);
|
|
107
|
+
const consequence = item.consequence?.trim();
|
|
108
|
+
if (consequence && consequence.length > LIMITS.consequence) fail("invalid_argument", `option consequences are at most ${LIMITS.consequence} characters`);
|
|
109
|
+
const id = item.id?.trim() || freeId();
|
|
110
|
+
return consequence ? { id, label, consequence } : { id, label };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkMinutes(minutes: number | undefined): number {
|
|
115
|
+
const value = minutes ?? DEFAULT_APPROVAL_MINUTES;
|
|
116
|
+
if (!Number.isInteger(value) || value < 1 || value > LIMITS.minutes) {
|
|
117
|
+
fail("invalid_argument", `expiresInMinutes must be a whole number from 1 to ${LIMITS.minutes}`);
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Who asks, as stored on the request. */
|
|
123
|
+
function requesterOf(identity: Identity): Doc<"approvals">["requestedBy"] {
|
|
124
|
+
return {
|
|
125
|
+
actor: identity.actor,
|
|
126
|
+
keyId: identity.keyId,
|
|
127
|
+
connectionId: identity.connectionId,
|
|
128
|
+
name: identity.actorName ?? identity.actor,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function loadApproval(ctx: QueryCtx, userId: string, approvalId: string): Promise<Doc<"approvals">> {
|
|
133
|
+
const id = ctx.db.normalizeId("approvals", approvalId);
|
|
134
|
+
if (!id) fail("invalid_argument", `approvalId is not an approval request id: "${approvalId}"`);
|
|
135
|
+
const approval = await ctx.db.get(id);
|
|
136
|
+
if (!approval || approval.userId !== userId) fail("not_found", `Approval request ${approvalId} not found`);
|
|
137
|
+
return approval;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** History of a request: on its task when it has one (and so in the activity log), else on the account. */
|
|
141
|
+
async function record(ctx: MutationCtx, identity: Identity, approval: Doc<"approvals">, action: string, after: Record<string, unknown>) {
|
|
142
|
+
await recordEvent(ctx, identity, { taskId: approval.taskId, action, after: { approvalId: approval._id, ...after } });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Operations ----
|
|
146
|
+
|
|
147
|
+
/** request_approval: a new request, waiting for the owner. Idempotent by clientId. */
|
|
148
|
+
export async function requestApproval(ctx: MutationCtx, identity: Identity, input: Args<"requestApproval">) {
|
|
149
|
+
const clientId = checkClientId(input.clientId);
|
|
150
|
+
if (clientId !== undefined) {
|
|
151
|
+
const existing = await ctx.db
|
|
152
|
+
.query("approvals")
|
|
153
|
+
.withIndex("by_user_clientId", (q) => q.eq("userId", identity.userId).eq("clientId", clientId))
|
|
154
|
+
.first();
|
|
155
|
+
if (existing) return approvalView(ctx, existing);
|
|
156
|
+
}
|
|
157
|
+
const task = input.taskId === undefined ? null : await loadTask(ctx, identity.userId, input.taskId);
|
|
158
|
+
const detail = input.detail?.trim();
|
|
159
|
+
if (detail && detail.length > LIMITS.detail) fail("invalid_argument", `detail is longer than ${LIMITS.detail} characters`);
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const id = await ctx.db.insert("approvals", {
|
|
162
|
+
userId: identity.userId,
|
|
163
|
+
taskId: task?._id,
|
|
164
|
+
title: checkLine("action", input.action, LIMITS.action),
|
|
165
|
+
detail: detail || undefined,
|
|
166
|
+
options: checkOptions(input.options),
|
|
167
|
+
reversible: input.reversible,
|
|
168
|
+
expiresAt: now + checkMinutes(input.expiresInMinutes) * 60_000,
|
|
169
|
+
requestedBy: requesterOf(identity),
|
|
170
|
+
source: "agent",
|
|
171
|
+
createdAt: now,
|
|
172
|
+
clientId,
|
|
173
|
+
});
|
|
174
|
+
const approval = (await ctx.db.get(id))!;
|
|
175
|
+
await record(ctx, identity, approval, "approvalRequest", { action: approval.title });
|
|
176
|
+
return approvalView(ctx, approval);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function getApproval(ctx: QueryCtx, identity: Identity, input: Args<"getApproval">) {
|
|
180
|
+
return approvalView(ctx, await loadApproval(ctx, identity.userId, input.approvalId));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The user's requests, newest first, narrowed by status and task. */
|
|
184
|
+
export async function listApprovals(ctx: QueryCtx, identity: Identity, input: Args<"listApprovals">) {
|
|
185
|
+
const limit = input.limit ?? DEFAULT_LIST;
|
|
186
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > LIMITS.list) fail("invalid_argument", `limit must be a whole number from 1 to ${LIMITS.list}`);
|
|
187
|
+
const taskId = input.taskId === undefined ? undefined : (await loadTask(ctx, identity.userId, input.taskId))._id;
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const approvals = [];
|
|
190
|
+
for await (const approval of ctx.db
|
|
191
|
+
.query("approvals")
|
|
192
|
+
.withIndex("by_user", (q) => q.eq("userId", identity.userId))
|
|
193
|
+
.order("desc")) {
|
|
194
|
+
if (approvals.length >= limit) break;
|
|
195
|
+
if (taskId !== undefined && approval.taskId !== taskId) continue;
|
|
196
|
+
if (input.status !== undefined && statusOf(approval, now) !== input.status) continue;
|
|
197
|
+
approvals.push(await approvalView(ctx, approval, now));
|
|
198
|
+
}
|
|
199
|
+
return { approvals };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** decide_approval: the owner picks an option of a waiting request. Deciding it again the same way changes nothing. */
|
|
203
|
+
export async function decideApproval(ctx: MutationCtx, identity: Identity, input: Args<"decideApproval">) {
|
|
204
|
+
ownerOnly(identity, "decide approval requests");
|
|
205
|
+
const approval = await loadApproval(ctx, identity.userId, input.approvalId);
|
|
206
|
+
const option = approval.options.find((o) => o.id === input.optionId);
|
|
207
|
+
if (!option) fail("invalid_argument", `optionId must be one of ${approval.options.map((o) => o.id).join(", ")}`);
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const status = statusOf(approval, now);
|
|
210
|
+
if (status === "decided") {
|
|
211
|
+
if (approval.optionId === option.id) return approvalView(ctx, approval, now);
|
|
212
|
+
fail("precondition_failed", `This request was already decided: ${approval.optionId}`);
|
|
213
|
+
}
|
|
214
|
+
if (status === "expired") fail("precondition_failed", "This request expired before it was decided; the agent has to ask again");
|
|
215
|
+
const note = input.note?.trim();
|
|
216
|
+
if (note && note.length > LIMITS.note) fail("invalid_argument", `note is longer than ${LIMITS.note} characters`);
|
|
217
|
+
await ctx.db.patch(approval._id, { decidedAt: now, optionId: option.id, note: note || undefined, decidedBy: identity.actor });
|
|
218
|
+
const decided = (await ctx.db.get(approval._id))!;
|
|
219
|
+
await record(ctx, identity, decided, "approvalDecide", { optionId: option.id, label: option.label });
|
|
220
|
+
const view = await approvalView(ctx, decided, now);
|
|
221
|
+
await queueApprovalDecided(ctx, identity.userId, decided._id, view, { type: identity.actor, name: identity.actorName ?? null }, now);
|
|
222
|
+
return view;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Requests made by a tool policy "ask" (lib/agentControls.ts) ----
|
|
226
|
+
|
|
227
|
+
const NOT_HASHED = new Set(["userId", "actor", "keyId", "connectionId", "actorName", "clientId", "dryRun", "approvalId", "ifMatch", "tool", "at"]);
|
|
228
|
+
|
|
229
|
+
/** The call's arguments as they are compared: without identity, retry and preview fields, keys sorted. */
|
|
230
|
+
export function callArguments(input: Record<string, unknown>): Record<string, unknown> {
|
|
231
|
+
const sorted = (value: unknown): unknown => {
|
|
232
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
233
|
+
if (value === null || typeof value !== "object") return value;
|
|
234
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted((value as Record<string, unknown>)[key])]));
|
|
235
|
+
};
|
|
236
|
+
const kept = Object.fromEntries(Object.entries(input).filter(([key, value]) => !NOT_HASHED.has(key) && value !== undefined));
|
|
237
|
+
return sorted(kept) as Record<string, unknown>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** What one approval allows: this key (or connection), this tool, these arguments. */
|
|
241
|
+
export async function policyHash(identity: Identity, tool: string, input: Record<string, unknown>): Promise<string> {
|
|
242
|
+
const holder = identity.keyId ?? identity.connectionId ?? identity.actor;
|
|
243
|
+
return sha256Hex(JSON.stringify([holder, tool, callArguments(input)]));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const ALLOW = "allow";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The request a refused call points to: a waiting one for the same call is
|
|
250
|
+
* reused, so an agent that repeats the call does not flood the phone.
|
|
251
|
+
*/
|
|
252
|
+
export async function requestForPolicy(ctx: MutationCtx, identity: Identity, tool: string, input: Record<string, unknown>, reversible: boolean) {
|
|
253
|
+
const argsHash = await policyHash(identity, tool, input);
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const same = await ctx.db
|
|
256
|
+
.query("approvals")
|
|
257
|
+
.withIndex("by_user_hash", (q) => q.eq("userId", identity.userId).eq("argsHash", argsHash))
|
|
258
|
+
.collect();
|
|
259
|
+
const waiting = same.find((a) => statusOf(a, now) === "pending" || (a.optionId === ALLOW && a.usedAt === undefined));
|
|
260
|
+
if (waiting) return approvalView(ctx, waiting, now);
|
|
261
|
+
const shown = JSON.stringify(callArguments(input));
|
|
262
|
+
const taskRef = typeof input.taskId === "string" ? input.taskId : null;
|
|
263
|
+
let task: Doc<"tasks"> | null = null;
|
|
264
|
+
if (taskRef !== null) {
|
|
265
|
+
try {
|
|
266
|
+
task = await loadTask(ctx, identity.userId, taskRef);
|
|
267
|
+
} catch {
|
|
268
|
+
task = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const name = identity.actorName ?? identity.actor;
|
|
272
|
+
const id = await ctx.db.insert("approvals", {
|
|
273
|
+
userId: identity.userId,
|
|
274
|
+
taskId: task?._id,
|
|
275
|
+
title: `${name} wants to call ${tool}`.slice(0, LIMITS.action),
|
|
276
|
+
detail: `Your policy asks before this key calls ${tool}. Arguments: ${shown}`.slice(0, LIMITS.detail),
|
|
277
|
+
options: [
|
|
278
|
+
{ id: ALLOW, label: "Allow once", consequence: `The agent repeats the call and ${tool} runs once, with exactly these arguments.` },
|
|
279
|
+
{ id: "deny", label: "Deny", consequence: "The call stays refused; the agent is told you said no." },
|
|
280
|
+
],
|
|
281
|
+
reversible,
|
|
282
|
+
expiresAt: now + POLICY_APPROVAL_MINUTES * 60_000,
|
|
283
|
+
requestedBy: requesterOf(identity),
|
|
284
|
+
source: "policy",
|
|
285
|
+
tool,
|
|
286
|
+
argsHash,
|
|
287
|
+
arguments: shown.slice(0, LIMITS.arguments),
|
|
288
|
+
createdAt: now,
|
|
289
|
+
});
|
|
290
|
+
const approval = (await ctx.db.get(id))!;
|
|
291
|
+
await record(ctx, identity, approval, "approvalRequest", { action: approval.title, tool });
|
|
292
|
+
return approvalView(ctx, approval, now);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Lets a call through on its policy approval, or refuses it. The approval is
|
|
297
|
+
* used in the same transaction as the change, so one "allow" is one change.
|
|
298
|
+
*/
|
|
299
|
+
export async function usePolicyApproval(ctx: MutationCtx, identity: Identity, tool: string, input: Record<string, unknown>, approvalId: string) {
|
|
300
|
+
const approval = await loadApproval(ctx, identity.userId, approvalId);
|
|
301
|
+
const argsHash = await policyHash(identity, tool, input);
|
|
302
|
+
if (approval.source !== "policy" || approval.argsHash !== argsHash) {
|
|
303
|
+
fail("approval_required", `Approval request ${approvalId} is for another call; repeat exactly the call it was made for`);
|
|
304
|
+
}
|
|
305
|
+
const status = statusOf(approval, Date.now());
|
|
306
|
+
if (status === "pending") fail("approval_required", "The owner has not decided yet; check with get_approval and try again later", { approvalId: approval._id });
|
|
307
|
+
if (status === "expired") fail("approval_required", "The approval request expired; call again for a new one");
|
|
308
|
+
if (approval.optionId !== ALLOW) fail("forbidden", `The owner denied this call${approval.note ? `: ${approval.note}` : ""}`);
|
|
309
|
+
if (approval.usedAt !== undefined) fail("approval_required", "This approval was already used; call again for a new one");
|
|
310
|
+
await ctx.db.patch(approval._id, { usedAt: Date.now() });
|
|
311
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Argument validators of every domain operation, shared by the internal
|
|
2
|
+
// domain functions, the app layer and the MCP tool schemas.
|
|
3
|
+
import { v } from "convex/values";
|
|
4
|
+
import {
|
|
5
|
+
acceptanceInputValidator,
|
|
6
|
+
approvalOptionInputValidator,
|
|
7
|
+
checklistInputValidator,
|
|
8
|
+
clearable,
|
|
9
|
+
keyKindValidator,
|
|
10
|
+
keyScopeValidator,
|
|
11
|
+
linkValidator,
|
|
12
|
+
policyRuleValidator,
|
|
13
|
+
proofValidator,
|
|
14
|
+
reminderValidator,
|
|
15
|
+
repeatInputValidator,
|
|
16
|
+
statusValidator,
|
|
17
|
+
taskSourceValidator,
|
|
18
|
+
} from "./validators";
|
|
19
|
+
|
|
20
|
+
const clientId = v.optional(v.string());
|
|
21
|
+
// A task id, or the task's number written as "#42".
|
|
22
|
+
const taskId = v.string();
|
|
23
|
+
const at = v.optional(v.number());
|
|
24
|
+
|
|
25
|
+
// Agent work fields of a task (docs/agent-work.md), in add_task and update_task.
|
|
26
|
+
const agentWork = {
|
|
27
|
+
acceptance: v.optional(v.array(acceptanceInputValidator)),
|
|
28
|
+
plan: v.optional(v.string()),
|
|
29
|
+
dependsOn: v.optional(v.array(taskId)),
|
|
30
|
+
blockedReason: v.optional(v.string()),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const args = {
|
|
34
|
+
add: {
|
|
35
|
+
title: v.string(),
|
|
36
|
+
note: v.optional(v.string()),
|
|
37
|
+
dueDate: v.optional(v.string()),
|
|
38
|
+
dueTime: v.optional(v.string()),
|
|
39
|
+
status: v.optional(v.union(v.literal("todo"), v.literal("doing"))),
|
|
40
|
+
resumeNote: v.optional(v.string()),
|
|
41
|
+
repeat: v.optional(repeatInputValidator),
|
|
42
|
+
labels: v.optional(v.array(v.string())),
|
|
43
|
+
links: v.optional(v.array(linkValidator)),
|
|
44
|
+
checklist: v.optional(v.array(checklistInputValidator)),
|
|
45
|
+
reminders: v.optional(v.array(reminderValidator)),
|
|
46
|
+
...agentWork,
|
|
47
|
+
// Since 0.11.0 (the proposal inbox): wait for the owner, where it came from, who asked, who hears back.
|
|
48
|
+
proposal: v.optional(v.boolean()),
|
|
49
|
+
source: v.optional(taskSourceValidator),
|
|
50
|
+
requestedBy: v.optional(v.string()),
|
|
51
|
+
returnTo: v.optional(v.string()),
|
|
52
|
+
clientId,
|
|
53
|
+
},
|
|
54
|
+
update: {
|
|
55
|
+
taskId,
|
|
56
|
+
title: v.optional(v.string()),
|
|
57
|
+
note: clearable(v.string()),
|
|
58
|
+
dueDate: clearable(v.string()),
|
|
59
|
+
dueTime: clearable(v.string()),
|
|
60
|
+
resumeNote: clearable(v.string()),
|
|
61
|
+
repeat: clearable(repeatInputValidator),
|
|
62
|
+
labels: v.optional(v.array(v.string())),
|
|
63
|
+
links: v.optional(v.array(linkValidator)),
|
|
64
|
+
checklist: clearable(v.array(checklistInputValidator)),
|
|
65
|
+
reminders: clearable(v.array(reminderValidator)),
|
|
66
|
+
acceptance: clearable(v.array(acceptanceInputValidator)),
|
|
67
|
+
plan: clearable(v.string()),
|
|
68
|
+
dependsOn: v.optional(v.array(taskId)),
|
|
69
|
+
blockedReason: clearable(v.string()),
|
|
70
|
+
clientId,
|
|
71
|
+
},
|
|
72
|
+
setStatus: { taskId, status: statusValidator, clientId },
|
|
73
|
+
setNow: { taskId: v.union(taskId, v.null()), resumeNote: v.optional(v.string()), clientId },
|
|
74
|
+
complete: { taskId, proof: v.optional(proofValidator), clientId },
|
|
75
|
+
reschedule: { taskId, dueDate: v.union(v.string(), v.null()), dueTime: clearable(v.string()), clientId },
|
|
76
|
+
archive: { taskId, clientId },
|
|
77
|
+
softDelete: { taskId, clientId },
|
|
78
|
+
restore: { taskId, clientId },
|
|
79
|
+
listToday: { at },
|
|
80
|
+
listAll: {
|
|
81
|
+
labels: v.optional(v.array(v.string())),
|
|
82
|
+
includeDone: v.optional(v.boolean()),
|
|
83
|
+
includeArchived: v.optional(v.boolean()),
|
|
84
|
+
at,
|
|
85
|
+
},
|
|
86
|
+
// list_all through MCP: listAll plus search, filters and pages.
|
|
87
|
+
findTasks: {
|
|
88
|
+
labels: v.optional(v.array(v.string())),
|
|
89
|
+
includeDone: v.optional(v.boolean()),
|
|
90
|
+
includeArchived: v.optional(v.boolean()),
|
|
91
|
+
query: v.optional(v.string()),
|
|
92
|
+
status: v.optional(v.array(statusValidator)),
|
|
93
|
+
dueBefore: v.optional(v.string()),
|
|
94
|
+
dueAfter: v.optional(v.string()),
|
|
95
|
+
overdue: v.optional(v.boolean()),
|
|
96
|
+
ready: v.optional(v.boolean()),
|
|
97
|
+
proposed: v.optional(v.boolean()),
|
|
98
|
+
limit: v.optional(v.number()),
|
|
99
|
+
cursor: v.optional(v.string()),
|
|
100
|
+
at,
|
|
101
|
+
},
|
|
102
|
+
// Time-boxed claims (claim_task, renew_claim, release_task).
|
|
103
|
+
claim: { taskId, minutes: v.optional(v.number()), clientId },
|
|
104
|
+
renewClaim: { taskId, minutes: v.optional(v.number()), clientId },
|
|
105
|
+
release: { taskId, note: v.optional(v.string()), clientId },
|
|
106
|
+
// The activity log (list_events): task changes after a position.
|
|
107
|
+
listEvents: {
|
|
108
|
+
since: v.optional(v.string()),
|
|
109
|
+
// The actor filter: app, cli, agent or "agent: <name>"; `actor` is the caller's own identity.
|
|
110
|
+
by: v.optional(v.string()),
|
|
111
|
+
taskId: v.optional(taskId),
|
|
112
|
+
limit: v.optional(v.number()),
|
|
113
|
+
},
|
|
114
|
+
// Approval requests (request_approval, get_approval, list_approvals, decide_approval).
|
|
115
|
+
requestApproval: {
|
|
116
|
+
taskId: v.optional(taskId),
|
|
117
|
+
action: v.string(),
|
|
118
|
+
detail: v.optional(v.string()),
|
|
119
|
+
options: v.array(approvalOptionInputValidator),
|
|
120
|
+
reversible: v.boolean(),
|
|
121
|
+
expiresInMinutes: v.optional(v.number()),
|
|
122
|
+
clientId,
|
|
123
|
+
},
|
|
124
|
+
getApproval: { approvalId: v.string() },
|
|
125
|
+
listApprovals: {
|
|
126
|
+
status: v.optional(v.union(v.literal("pending"), v.literal("decided"), v.literal("expired"))),
|
|
127
|
+
taskId: v.optional(taskId),
|
|
128
|
+
limit: v.optional(v.number()),
|
|
129
|
+
},
|
|
130
|
+
decideApproval: { approvalId: v.string(), optionId: v.string(), note: v.optional(v.string()) },
|
|
131
|
+
// The owner's controls over agents (get_agent_controls, pause_agents, set_tool_policy).
|
|
132
|
+
getAgentControls: {},
|
|
133
|
+
pauseAgents: { paused: v.boolean(), reason: v.optional(v.string()) },
|
|
134
|
+
// Not keyId and tool: those name the caller and the tool being called (set by the server).
|
|
135
|
+
setToolPolicy: { apiKeyId: v.string(), toolName: v.string(), rule: policyRuleValidator },
|
|
136
|
+
// The proposal inbox (accept_proposal, merge_proposal, reject_proposal).
|
|
137
|
+
acceptProposal: { taskId, clientId },
|
|
138
|
+
mergeProposal: { taskId, intoTaskId: taskId, clientId },
|
|
139
|
+
rejectProposal: { taskId, reason: v.optional(v.string()), clientId },
|
|
140
|
+
// The plan health report (plan_health).
|
|
141
|
+
planHealth: { at },
|
|
142
|
+
// Since 0.12.0: the agent brief (agent_brief, dayhand prime).
|
|
143
|
+
agentBrief: { at },
|
|
144
|
+
// Since 0.12.0: Backlog.md task files in (import_backlog) and out (export_backlog), docs/backlog-md.md.
|
|
145
|
+
importBacklog: { files: v.array(v.object({ path: v.string(), content: v.string() })) },
|
|
146
|
+
exportBacklog: { prefix: v.optional(v.string()), includeArchived: v.optional(v.boolean()) },
|
|
147
|
+
listTrash: {},
|
|
148
|
+
board: { doneLimit: v.optional(v.number()), at },
|
|
149
|
+
getTask: { taskId, at },
|
|
150
|
+
exportPlan: { format: v.union(v.literal("markdown"), v.literal("json"), v.literal("ics")), at },
|
|
151
|
+
getSettings: {},
|
|
152
|
+
updateSettings: {
|
|
153
|
+
timezone: v.optional(v.string()),
|
|
154
|
+
summaryTime: v.optional(v.string()),
|
|
155
|
+
defaultReminderMinutes: v.optional(v.array(v.number())),
|
|
156
|
+
clientId,
|
|
157
|
+
},
|
|
158
|
+
revokeApiKey: { keyId: v.id("apiKeys") },
|
|
159
|
+
listApiKeys: {},
|
|
160
|
+
createApiKey: {
|
|
161
|
+
name: v.string(),
|
|
162
|
+
kind: v.optional(keyKindValidator),
|
|
163
|
+
scope: v.optional(keyScopeValidator),
|
|
164
|
+
expiresAt: v.optional(v.number()),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Only the MCP endpoint offers a dry run: the app always applies its changes.
|
|
169
|
+
export const dryRunArgs = { dryRun: v.optional(v.boolean()) };
|
|
170
|
+
|
|
171
|
+
// Since 0.11.0: a change an owner's policy "ask" held back runs with the approved request's id.
|
|
172
|
+
export const approvalArgs = { approvalId: v.optional(v.string()) };
|
|
173
|
+
|
|
174
|
+
/** Code of the error that rolls a dry run back; its data carries the result the change would have had. */
|
|
175
|
+
export const DRY_RUN_CODE = "dry_run";
|