handzon-core 0.7.0 → 0.8.1
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/collections.ts +97 -3
- package/src/components/Sidebar.astro +5 -2
- package/src/components/ai/ChatButton.tsx +51 -3
- package/src/components/ai/ChatPanel.tsx +86 -23
- package/src/components/ai/CopyStep.tsx +44 -0
- package/src/components/ai/OpenInAgent.tsx +55 -0
- package/src/components/ai/SelectionAsk.tsx +98 -0
- package/src/components/ai/StepHelp.tsx +31 -0
- package/src/components/home/Hero.astro +4 -3
- package/src/components/mdx/Checkpoint.tsx +66 -2
- package/src/components/mdx/CopyPrompt.astro +10 -0
- package/src/components/mdx/CopyPrompt.tsx +56 -0
- package/src/components/mdx/HelpMe.astro +10 -0
- package/src/components/mdx/HelpMe.tsx +29 -0
- package/src/components/mdx/Playground.tsx +61 -9
- package/src/components/mdx/Quiz.tsx +18 -0
- package/src/index.ts +5 -0
- package/src/layouts/BaseLayout.astro +6 -0
- package/src/layouts/TutorialLayout.astro +37 -9
- package/src/lib/ai/assist.ts +81 -0
- package/src/lib/ai/prompts.ts +126 -0
- package/src/lib/ai/stepData.ts +74 -0
- package/src/lib/mdx-components.ts +4 -0
- package/src/lib/progress/remote.ts +86 -25
- package/src/lib/progress/types.ts +23 -0
- package/src/lib/progress/useProgress.ts +8 -4
- package/src/pages/Home.astro +7 -1
- package/src/pages/TutorialLanding.astro +6 -4
- package/src/pages/TutorialStep.astro +13 -1
- package/src/server/auth.ts +84 -1
- package/src/server/db/schema.ts +53 -0
- package/src/server/handlers/helpInbox.ts +45 -0
- package/src/server/handlers/mcp.ts +72 -0
- package/src/server/handlers/progress.ts +7 -51
- package/src/server/handlers/progressEvents.ts +68 -0
- package/src/server/mcp/protocol.ts +99 -0
- package/src/server/mcp/server.ts +94 -0
- package/src/server/mcp/tools.ts +175 -0
- package/src/server/mcp/writeTools.ts +407 -0
- package/src/server/progress.ts +86 -0
- package/src/server/progressBus.ts +51 -0
- package/src/server/tokens.ts +80 -0
- package/src/server/verify/evaluator.ts +134 -0
- package/src/types/ai.ts +6 -0
- package/styles/base.css +16 -12
- package/styles/components/assist.css +101 -0
- package/styles/components/checkpoint.css +29 -0
- package/styles/components.css +1 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { getStep } from "../../lib/content.ts";
|
|
2
|
+
import { getDb } from "../db/client.ts";
|
|
3
|
+
import { helpRequests } from "../db/schema.ts";
|
|
4
|
+
import { writeProgressEntries } from "../progress.ts";
|
|
5
|
+
import { type CheckObservation, evaluate } from "../verify/evaluator.ts";
|
|
6
|
+
import { errorResult, type McpTool, text } from "./protocol.ts";
|
|
7
|
+
|
|
8
|
+
const SCOPE = "progress:write";
|
|
9
|
+
|
|
10
|
+
function requireLearner(learnerId: string | undefined) {
|
|
11
|
+
if (!learnerId) {
|
|
12
|
+
throw new Error("No resolved learner — bearer token required.");
|
|
13
|
+
}
|
|
14
|
+
return learnerId;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MCP write tools. Each one builds the same `{ kind, scope, key, value }`
|
|
19
|
+
* row shape the cookie-authed /api/progress endpoint accepts and pipes
|
|
20
|
+
* it through the shared writeProgressEntries() writer. Sources tag the
|
|
21
|
+
* row with `source: "mcp"` so author-side telemetry can distinguish
|
|
22
|
+
* agent-driven completions from in-browser clicks.
|
|
23
|
+
*/
|
|
24
|
+
export const progressWriteTools: McpTool[] = [
|
|
25
|
+
{
|
|
26
|
+
name: "complete_checkpoint",
|
|
27
|
+
description:
|
|
28
|
+
"Mark a checkpoint complete for the authenticated learner. Optionally include evidence (command, output, files) for telemetry.",
|
|
29
|
+
requiredScope: SCOPE,
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
tutorial: { type: "string", minLength: 1 },
|
|
34
|
+
step: { type: "string", minLength: 1 },
|
|
35
|
+
checkpointId: { type: "string", minLength: 1 },
|
|
36
|
+
evidence: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
command: { type: "string" },
|
|
40
|
+
output: { type: "string" },
|
|
41
|
+
files: { type: "object" },
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: true,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ["tutorial", "step", "checkpointId"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
handler: async (args, ctx) => {
|
|
50
|
+
const a = args as {
|
|
51
|
+
tutorial: string;
|
|
52
|
+
step: string;
|
|
53
|
+
checkpointId: string;
|
|
54
|
+
evidence?: Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
57
|
+
// Checkpoint rows use scope "global" to match the in-browser
|
|
58
|
+
// client (state.checkpoints is keyed by checkpoint id, not
|
|
59
|
+
// by step). Tutorial + step are part of the value payload
|
|
60
|
+
// for telemetry and for the SSE handler that wants to know
|
|
61
|
+
// which step the ack belongs to.
|
|
62
|
+
await writeProgressEntries(learnerId, [
|
|
63
|
+
{
|
|
64
|
+
kind: "checkpoint",
|
|
65
|
+
scope: "global",
|
|
66
|
+
key: a.checkpointId,
|
|
67
|
+
value: {
|
|
68
|
+
source: "mcp",
|
|
69
|
+
tutorial: a.tutorial,
|
|
70
|
+
step: a.step,
|
|
71
|
+
evidence: a.evidence ?? null,
|
|
72
|
+
ts: Date.now(),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
return text(`Checkpoint ${a.checkpointId} marked complete.`);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "uncheck_checkpoint",
|
|
81
|
+
description: "Undo a previously completed checkpoint (tombstone the row).",
|
|
82
|
+
requiredScope: SCOPE,
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
tutorial: { type: "string", minLength: 1 },
|
|
87
|
+
step: { type: "string", minLength: 1 },
|
|
88
|
+
checkpointId: { type: "string", minLength: 1 },
|
|
89
|
+
},
|
|
90
|
+
required: ["tutorial", "step", "checkpointId"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
handler: async (args, ctx) => {
|
|
94
|
+
const a = args as { tutorial: string; step: string; checkpointId: string };
|
|
95
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
96
|
+
// Uncheck also drops the matching kind:"verification" telemetry
|
|
97
|
+
// row so a re-attempt isn't pre-poisoned by the previous
|
|
98
|
+
// failure feedback.
|
|
99
|
+
await writeProgressEntries(learnerId, [
|
|
100
|
+
{ kind: "checkpoint", scope: "global", key: a.checkpointId, value: null },
|
|
101
|
+
{
|
|
102
|
+
kind: "verification",
|
|
103
|
+
scope: `${a.tutorial}/${a.step}`,
|
|
104
|
+
key: a.checkpointId,
|
|
105
|
+
value: null,
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
return text(`Checkpoint ${a.checkpointId} unchecked.`);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "complete_step",
|
|
113
|
+
description: "Mark a step complete (mirrors clicking through after a checkpoint).",
|
|
114
|
+
requiredScope: SCOPE,
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
tutorial: { type: "string", minLength: 1 },
|
|
119
|
+
step: { type: "string", minLength: 1 },
|
|
120
|
+
},
|
|
121
|
+
required: ["tutorial", "step"],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
handler: async (args, ctx) => {
|
|
125
|
+
const a = args as { tutorial: string; step: string };
|
|
126
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
127
|
+
await writeProgressEntries(learnerId, [
|
|
128
|
+
{
|
|
129
|
+
kind: "step",
|
|
130
|
+
scope: a.tutorial,
|
|
131
|
+
key: a.step,
|
|
132
|
+
value: "complete",
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
return text(`Step ${a.step} marked complete.`);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "mark_step_incomplete",
|
|
140
|
+
description: "Undo a step completion.",
|
|
141
|
+
requiredScope: SCOPE,
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
tutorial: { type: "string", minLength: 1 },
|
|
146
|
+
step: { type: "string", minLength: 1 },
|
|
147
|
+
},
|
|
148
|
+
required: ["tutorial", "step"],
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
},
|
|
151
|
+
handler: async (args, ctx) => {
|
|
152
|
+
const a = args as { tutorial: string; step: string };
|
|
153
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
154
|
+
await writeProgressEntries(learnerId, [
|
|
155
|
+
{
|
|
156
|
+
kind: "step",
|
|
157
|
+
scope: a.tutorial,
|
|
158
|
+
key: a.step,
|
|
159
|
+
value: null,
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
return text(`Step ${a.step} marked incomplete.`);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "record_quiz",
|
|
167
|
+
description: "Record a quiz attempt (chosen indices + correct flag).",
|
|
168
|
+
requiredScope: SCOPE,
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
tutorial: { type: "string", minLength: 1 },
|
|
173
|
+
quizId: { type: "string", minLength: 1 },
|
|
174
|
+
chosen: { type: "array", items: { type: "integer" } },
|
|
175
|
+
correct: { type: "boolean" },
|
|
176
|
+
},
|
|
177
|
+
required: ["tutorial", "quizId", "chosen", "correct"],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
},
|
|
180
|
+
handler: async (args, ctx) => {
|
|
181
|
+
const a = args as {
|
|
182
|
+
tutorial: string;
|
|
183
|
+
quizId: string;
|
|
184
|
+
chosen: number[];
|
|
185
|
+
correct: boolean;
|
|
186
|
+
};
|
|
187
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
188
|
+
await writeProgressEntries(learnerId, [
|
|
189
|
+
{
|
|
190
|
+
kind: "quiz",
|
|
191
|
+
scope: a.tutorial,
|
|
192
|
+
key: a.quizId,
|
|
193
|
+
value: { chosen: a.chosen, correct: a.correct, ts: Date.now(), source: "mcp" },
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
return text(`Quiz ${a.quizId} recorded.`);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "set_last_visited",
|
|
201
|
+
description: "Update the learner's last-visited step for a tutorial.",
|
|
202
|
+
requiredScope: SCOPE,
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
tutorial: { type: "string", minLength: 1 },
|
|
207
|
+
step: { type: "string", minLength: 1 },
|
|
208
|
+
},
|
|
209
|
+
required: ["tutorial", "step"],
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
},
|
|
212
|
+
handler: async (args, ctx) => {
|
|
213
|
+
const a = args as { tutorial: string; step: string };
|
|
214
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
215
|
+
await writeProgressEntries(learnerId, [
|
|
216
|
+
{
|
|
217
|
+
kind: "lastVisited",
|
|
218
|
+
scope: a.tutorial,
|
|
219
|
+
key: "step",
|
|
220
|
+
value: { step: a.step, ts: Date.now() },
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
return text(`Last-visited set to ${a.tutorial}/${a.step}.`);
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "request_help",
|
|
228
|
+
description:
|
|
229
|
+
"Post a help request from the agent into the in-browser tutor's inbox. The next time the learner opens ChatPanel, your query is prepended as a user turn.",
|
|
230
|
+
requiredScope: SCOPE,
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
tutorial: { type: "string", minLength: 1 },
|
|
235
|
+
step: { type: "string", minLength: 1 },
|
|
236
|
+
query: { type: "string", minLength: 1 },
|
|
237
|
+
},
|
|
238
|
+
required: ["tutorial", "step", "query"],
|
|
239
|
+
additionalProperties: false,
|
|
240
|
+
},
|
|
241
|
+
handler: async (args, ctx) => {
|
|
242
|
+
const a = args as { tutorial: string; step: string; query: string };
|
|
243
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
244
|
+
const db = getDb();
|
|
245
|
+
const [row] = await db
|
|
246
|
+
.insert(helpRequests)
|
|
247
|
+
.values({
|
|
248
|
+
learnerId,
|
|
249
|
+
tutorialSlug: a.tutorial,
|
|
250
|
+
stepSlug: a.step,
|
|
251
|
+
query: a.query,
|
|
252
|
+
})
|
|
253
|
+
.returning({ id: helpRequests.id });
|
|
254
|
+
return text(
|
|
255
|
+
`Help request queued (${row!.id}). It will appear in the learner's tutor on next open.`,
|
|
256
|
+
);
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: "set_preference",
|
|
261
|
+
description: "Set a learner preference (free-form key/value).",
|
|
262
|
+
requiredScope: SCOPE,
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
key: { type: "string", minLength: 1 },
|
|
267
|
+
value: {},
|
|
268
|
+
},
|
|
269
|
+
required: ["key", "value"],
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
},
|
|
272
|
+
handler: async (args, ctx) => {
|
|
273
|
+
const a = args as { key: string; value: unknown };
|
|
274
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
275
|
+
await writeProgressEntries(learnerId, [
|
|
276
|
+
{
|
|
277
|
+
kind: "pref",
|
|
278
|
+
scope: "global",
|
|
279
|
+
key: a.key,
|
|
280
|
+
value: a.value,
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
return text(`Preference ${a.key} set.`);
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Catch-all helper for surfacing handler errors as MCP-shaped error
|
|
290
|
+
* results — keeps the tool factories tidy when a wrapped call fails.
|
|
291
|
+
*/
|
|
292
|
+
export function toolError(message: string) {
|
|
293
|
+
return errorResult(message);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Family D verification: agent reports observations, server scores.
|
|
298
|
+
* On pass, the same writeProgressEntries() path that
|
|
299
|
+
* complete_checkpoint uses fires + a kind:"verification" telemetry
|
|
300
|
+
* row lands. On fail, only the telemetry row lands and the verdict
|
|
301
|
+
* is returned to the agent. SSE fans the telemetry row to the open
|
|
302
|
+
* browser tab so <Checkpoint> can render an inline failure hint.
|
|
303
|
+
*/
|
|
304
|
+
export const verificationTools: McpTool[] = [
|
|
305
|
+
{
|
|
306
|
+
name: "submit_verification",
|
|
307
|
+
description:
|
|
308
|
+
"Submit observed values for the current step's verify checks. The server scores against the declared spec and (on pass) marks the matching checkpoint complete. On fail, returns the failing check + hint and does not write a checkpoint row.",
|
|
309
|
+
requiredScope: SCOPE,
|
|
310
|
+
inputSchema: {
|
|
311
|
+
type: "object",
|
|
312
|
+
properties: {
|
|
313
|
+
tutorial: { type: "string", minLength: 1 },
|
|
314
|
+
step: { type: "string", minLength: 1 },
|
|
315
|
+
observations: {
|
|
316
|
+
type: "array",
|
|
317
|
+
description:
|
|
318
|
+
"One observation per declared check, in order. Fields per kind: file_exists {exists}, file_contains {exists, body}, shell {exitCode, stdout}, http {status, responseBody}.",
|
|
319
|
+
items: { type: "object" },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
required: ["tutorial", "step", "observations"],
|
|
323
|
+
additionalProperties: false,
|
|
324
|
+
},
|
|
325
|
+
handler: async (args, ctx) => {
|
|
326
|
+
const a = args as {
|
|
327
|
+
tutorial: string;
|
|
328
|
+
step: string;
|
|
329
|
+
observations: CheckObservation[];
|
|
330
|
+
};
|
|
331
|
+
const learnerId = requireLearner(ctx.learnerId);
|
|
332
|
+
const stepEntry = await getStep(a.tutorial, a.step);
|
|
333
|
+
if (!stepEntry) {
|
|
334
|
+
return errorResult(`No step "${a.step}" in "${a.tutorial}".`);
|
|
335
|
+
}
|
|
336
|
+
const spec = (stepEntry.data as { verify?: unknown }).verify as
|
|
337
|
+
| import("../../collections.ts").VerifySpec
|
|
338
|
+
| undefined;
|
|
339
|
+
if (!spec) {
|
|
340
|
+
return errorResult(
|
|
341
|
+
`Step ${a.tutorial}/${a.step} has no verify block. Use complete_checkpoint for prose-fallback verification.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const verdict = evaluate(spec, a.observations);
|
|
346
|
+
const scope = `${a.tutorial}/${a.step}`;
|
|
347
|
+
const ts = Date.now();
|
|
348
|
+
|
|
349
|
+
if (verdict.passed) {
|
|
350
|
+
await writeProgressEntries(learnerId, [
|
|
351
|
+
{
|
|
352
|
+
kind: "checkpoint",
|
|
353
|
+
scope: "global",
|
|
354
|
+
key: spec.id,
|
|
355
|
+
value: {
|
|
356
|
+
source: "verify",
|
|
357
|
+
tutorial: a.tutorial,
|
|
358
|
+
step: a.step,
|
|
359
|
+
results: a.observations,
|
|
360
|
+
ts,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
kind: "verification",
|
|
365
|
+
scope,
|
|
366
|
+
key: spec.id,
|
|
367
|
+
value: { pass: true, ts },
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
return text(
|
|
371
|
+
JSON.stringify(
|
|
372
|
+
{ passed: true, checkpointId: spec.id, message: "All checks passed." },
|
|
373
|
+
null,
|
|
374
|
+
2,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await writeProgressEntries(learnerId, [
|
|
380
|
+
{
|
|
381
|
+
kind: "verification",
|
|
382
|
+
scope,
|
|
383
|
+
key: spec.id,
|
|
384
|
+
value: {
|
|
385
|
+
pass: false,
|
|
386
|
+
failingCheckIndex: verdict.failingCheckIndex,
|
|
387
|
+
reason: verdict.reason,
|
|
388
|
+
hint: verdict.hint,
|
|
389
|
+
ts,
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
]);
|
|
393
|
+
return text(
|
|
394
|
+
JSON.stringify(
|
|
395
|
+
{
|
|
396
|
+
passed: false,
|
|
397
|
+
failingCheckIndex: verdict.failingCheckIndex,
|
|
398
|
+
reason: verdict.reason,
|
|
399
|
+
hint: verdict.hint,
|
|
400
|
+
},
|
|
401
|
+
null,
|
|
402
|
+
2,
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { getDb } from "./db/client.ts";
|
|
3
|
+
import { progressEntries } from "./db/schema.ts";
|
|
4
|
+
import { publishLearner } from "./progressBus.ts";
|
|
5
|
+
|
|
6
|
+
export type ProgressKind =
|
|
7
|
+
| "step"
|
|
8
|
+
| "checkpoint"
|
|
9
|
+
| "quiz"
|
|
10
|
+
| "pref"
|
|
11
|
+
| "lastVisited"
|
|
12
|
+
| "tutorial"
|
|
13
|
+
| "verification";
|
|
14
|
+
|
|
15
|
+
export interface ProgressEntryWrite {
|
|
16
|
+
kind: ProgressKind;
|
|
17
|
+
scope: string;
|
|
18
|
+
key: string;
|
|
19
|
+
/** Use `null` to delete the row (the tombstone signal). */
|
|
20
|
+
value: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared writer used by both the cookie-authed POST /api/progress
|
|
25
|
+
* handler and the bearer-authed MCP write tools. Splits entries into
|
|
26
|
+
* deletes (value === null) and upserts, then runs them with the same
|
|
27
|
+
* UPSERT semantics the API handler uses.
|
|
28
|
+
*
|
|
29
|
+
* Returns the count of rows written/deleted so the caller can echo a
|
|
30
|
+
* sensible response back.
|
|
31
|
+
*/
|
|
32
|
+
export async function writeProgressEntries(
|
|
33
|
+
learnerId: string,
|
|
34
|
+
entries: ProgressEntryWrite[],
|
|
35
|
+
): Promise<number> {
|
|
36
|
+
if (entries.length === 0) return 0;
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const now = new Date();
|
|
39
|
+
const deletes = entries.filter((e) => e.value === null);
|
|
40
|
+
const upserts = entries.filter((e) => e.value !== null);
|
|
41
|
+
|
|
42
|
+
for (const d of deletes) {
|
|
43
|
+
await db
|
|
44
|
+
.delete(progressEntries)
|
|
45
|
+
.where(
|
|
46
|
+
and(
|
|
47
|
+
eq(progressEntries.learnerId, learnerId),
|
|
48
|
+
eq(progressEntries.kind, d.kind),
|
|
49
|
+
eq(progressEntries.scope, d.scope),
|
|
50
|
+
eq(progressEntries.key, d.key),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (upserts.length > 0) {
|
|
56
|
+
const rows = upserts.map((b) => ({
|
|
57
|
+
learnerId,
|
|
58
|
+
kind: b.kind,
|
|
59
|
+
scope: b.scope,
|
|
60
|
+
key: b.key,
|
|
61
|
+
value: b.value,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
}));
|
|
64
|
+
await db
|
|
65
|
+
.insert(progressEntries)
|
|
66
|
+
.values(rows)
|
|
67
|
+
.onConflictDoUpdate({
|
|
68
|
+
target: [
|
|
69
|
+
progressEntries.learnerId,
|
|
70
|
+
progressEntries.kind,
|
|
71
|
+
progressEntries.scope,
|
|
72
|
+
progressEntries.key,
|
|
73
|
+
],
|
|
74
|
+
set: {
|
|
75
|
+
value: sql`excluded.value`,
|
|
76
|
+
updatedAt: sql`excluded.updated_at`,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fan out to any open SSE subscriber for this learner so the
|
|
82
|
+
// browser tab catches MCP-driven writes without a polling loop.
|
|
83
|
+
publishLearner(learnerId, entries);
|
|
84
|
+
|
|
85
|
+
return entries.length;
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ProgressEntryWrite } from "./progress.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-local pub/sub for progress writes. Both the cookie-authed
|
|
5
|
+
* POST /api/progress handler and the MCP write tools call publish()
|
|
6
|
+
* after persisting; the SSE endpoint subscribes per learner so the
|
|
7
|
+
* open browser tab sees MCP-driven writes within one event-loop
|
|
8
|
+
* tick.
|
|
9
|
+
*
|
|
10
|
+
* Multi-instance caveat: on a horizontally-scaled deploy, a write
|
|
11
|
+
* landing on instance A is invisible to a subscriber on instance B.
|
|
12
|
+
* v1 is single-instance; v2 swaps this for Postgres LISTEN/NOTIFY on
|
|
13
|
+
* a "learner_<uuid>" channel (Postgres is already in the stack).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ProgressBusMessage extends ProgressEntryWrite {
|
|
17
|
+
ts: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Listener = (msg: ProgressBusMessage) => void;
|
|
21
|
+
|
|
22
|
+
const channels = new Map<string, Set<Listener>>();
|
|
23
|
+
|
|
24
|
+
export function subscribeLearner(learnerId: string, listener: Listener): () => void {
|
|
25
|
+
let set = channels.get(learnerId);
|
|
26
|
+
if (!set) {
|
|
27
|
+
set = new Set();
|
|
28
|
+
channels.set(learnerId, set);
|
|
29
|
+
}
|
|
30
|
+
set.add(listener);
|
|
31
|
+
return () => {
|
|
32
|
+
set?.delete(listener);
|
|
33
|
+
if (set && set.size === 0) channels.delete(learnerId);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function publishLearner(learnerId: string, entries: ProgressEntryWrite[]): void {
|
|
38
|
+
const subs = channels.get(learnerId);
|
|
39
|
+
if (!subs || subs.size === 0) return;
|
|
40
|
+
const ts = Date.now();
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const msg: ProgressBusMessage = { ...entry, ts };
|
|
43
|
+
for (const fn of subs) {
|
|
44
|
+
try {
|
|
45
|
+
fn(msg);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn("[handzon] progress bus listener threw:", e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { generatePat, hashPat } from "./auth.ts";
|
|
3
|
+
import { getDb } from "./db/client.ts";
|
|
4
|
+
import { learnerApiTokens } from "./db/schema.ts";
|
|
5
|
+
|
|
6
|
+
/** Known scopes. The MCP endpoint gates writes on progress:write;
|
|
7
|
+
* catalog reads need any valid token. We keep the strings as
|
|
8
|
+
* comma-joined values in DB to mirror what an OAuth 2.1 token would
|
|
9
|
+
* carry in the v2 proxy.
|
|
10
|
+
*/
|
|
11
|
+
export const KNOWN_SCOPES = ["progress:read", "progress:write"] as const;
|
|
12
|
+
export type Scope = (typeof KNOWN_SCOPES)[number];
|
|
13
|
+
|
|
14
|
+
export interface TokenRow {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
scopes: string[];
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
lastUsedAt: Date | null;
|
|
20
|
+
expiresAt: Date | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function listTokens(userId: string): Promise<TokenRow[]> {
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const rows = await db
|
|
26
|
+
.select({
|
|
27
|
+
id: learnerApiTokens.id,
|
|
28
|
+
name: learnerApiTokens.name,
|
|
29
|
+
scopes: learnerApiTokens.scopes,
|
|
30
|
+
createdAt: learnerApiTokens.createdAt,
|
|
31
|
+
lastUsedAt: learnerApiTokens.lastUsedAt,
|
|
32
|
+
expiresAt: learnerApiTokens.expiresAt,
|
|
33
|
+
})
|
|
34
|
+
.from(learnerApiTokens)
|
|
35
|
+
.where(eq(learnerApiTokens.userId, userId))
|
|
36
|
+
.orderBy(desc(learnerApiTokens.createdAt));
|
|
37
|
+
return rows.map((r) => ({
|
|
38
|
+
...r,
|
|
39
|
+
scopes: r.scopes
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((s) => s.trim())
|
|
42
|
+
.filter(Boolean),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mint a new PAT for the user. Returns the raw token *once* — it is
|
|
48
|
+
* never stored, and there is no path to recover it. Caller is
|
|
49
|
+
* responsible for showing it to the user a single time.
|
|
50
|
+
*/
|
|
51
|
+
export async function createToken(opts: {
|
|
52
|
+
userId: string;
|
|
53
|
+
name: string;
|
|
54
|
+
scopes: Scope[];
|
|
55
|
+
expiresAt?: Date | null;
|
|
56
|
+
}): Promise<{ id: string; raw: string }> {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
const raw = generatePat();
|
|
59
|
+
const tokenHash = await hashPat(raw);
|
|
60
|
+
const [row] = await db
|
|
61
|
+
.insert(learnerApiTokens)
|
|
62
|
+
.values({
|
|
63
|
+
userId: opts.userId,
|
|
64
|
+
name: opts.name,
|
|
65
|
+
tokenHash,
|
|
66
|
+
scopes: opts.scopes.join(","),
|
|
67
|
+
expiresAt: opts.expiresAt ?? null,
|
|
68
|
+
})
|
|
69
|
+
.returning({ id: learnerApiTokens.id });
|
|
70
|
+
return { id: row!.id, raw };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function revokeToken(userId: string, tokenId: string): Promise<boolean> {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const result = await db
|
|
76
|
+
.delete(learnerApiTokens)
|
|
77
|
+
.where(and(eq(learnerApiTokens.userId, userId), eq(learnerApiTokens.id, tokenId)))
|
|
78
|
+
.returning({ id: learnerApiTokens.id });
|
|
79
|
+
return result.length > 0;
|
|
80
|
+
}
|