handzon-core 0.7.0 → 0.8.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/package.json +1 -1
- package/src/collections.ts +97 -3
- 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/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/TutorialLayout.astro +19 -0
- 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/TutorialStep.astro +12 -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/components/assist.css +101 -0
- package/styles/components/checkpoint.css +29 -0
- package/styles/components.css +1 -0
|
@@ -70,6 +70,14 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
|
70
70
|
for (const id of Object.keys(prev.checkpoints)) {
|
|
71
71
|
if (!next.checkpoints[id]) {
|
|
72
72
|
out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
|
|
73
|
+
// Family D: drop the matching kind:"verification" telemetry
|
|
74
|
+
// row so a re-attempt isn't pre-poisoned by the previous
|
|
75
|
+
// failure feedback. Scope comes from the feedback entry
|
|
76
|
+
// populated by SSE.
|
|
77
|
+
const feedback = prev.verificationFeedback[id];
|
|
78
|
+
if (feedback) {
|
|
79
|
+
out.push({ kind: "verification", scope: feedback.scope, key: id, value: null });
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
for (const [k, value] of Object.entries(next.prefs)) {
|
|
@@ -95,6 +103,59 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
|
95
103
|
return out;
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Apply one server-side progress entry to a mutable state object.
|
|
108
|
+
* Shared by the initial snapshot fetch and the SSE per-event path.
|
|
109
|
+
* Mutates in place — callers replace the store atom after batching.
|
|
110
|
+
*/
|
|
111
|
+
function applyEntryInto(state: ProgressState, e: ProgressEntry): void {
|
|
112
|
+
if (e.kind === "step") {
|
|
113
|
+
state.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
|
|
114
|
+
| "incomplete"
|
|
115
|
+
| "complete";
|
|
116
|
+
} else if (e.kind === "quiz") {
|
|
117
|
+
state.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
118
|
+
} else if (e.kind === "checkpoint") {
|
|
119
|
+
if (e.value == null) {
|
|
120
|
+
delete state.checkpoints[e.key];
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
state.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
124
|
+
} else if (e.kind === "pref") {
|
|
125
|
+
(state.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
126
|
+
} else if (e.kind === "lastVisited") {
|
|
127
|
+
const v = e.value as unknown;
|
|
128
|
+
state.lastVisited[e.scope] =
|
|
129
|
+
typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
|
|
130
|
+
} else if (e.kind === "tutorial") {
|
|
131
|
+
const v = (e.value as { ts?: number }) ?? {};
|
|
132
|
+
const marker = state.tutorials[e.scope] ?? {};
|
|
133
|
+
if (e.key === "started") marker.started = v.ts;
|
|
134
|
+
else if (e.key === "completed") marker.completed = v.ts;
|
|
135
|
+
state.tutorials[e.scope] = marker;
|
|
136
|
+
} else if (e.kind === "verification") {
|
|
137
|
+
if (e.value == null) {
|
|
138
|
+
delete state.verificationFeedback[e.key];
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const v = e.value as {
|
|
142
|
+
pass: boolean;
|
|
143
|
+
failingCheckIndex?: number;
|
|
144
|
+
reason?: string;
|
|
145
|
+
hint?: string;
|
|
146
|
+
ts?: number;
|
|
147
|
+
};
|
|
148
|
+
state.verificationFeedback[e.key] = {
|
|
149
|
+
scope: e.scope as `${string}/${string}`,
|
|
150
|
+
pass: !!v.pass,
|
|
151
|
+
failingCheckIndex: v.failingCheckIndex,
|
|
152
|
+
reason: v.reason,
|
|
153
|
+
hint: v.hint,
|
|
154
|
+
ts: v.ts ?? Date.now(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
98
159
|
/**
|
|
99
160
|
* Same shape as the local store, but mirrors writes to /api/progress with
|
|
100
161
|
* debounced batching and an offline queue.
|
|
@@ -149,31 +210,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
149
210
|
entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
|
|
150
211
|
};
|
|
151
212
|
const merged: ProgressState = { ...emptyState(), ...state };
|
|
152
|
-
for (const e of entries)
|
|
153
|
-
if (e.kind === "step") {
|
|
154
|
-
merged.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
|
|
155
|
-
| "incomplete"
|
|
156
|
-
| "complete";
|
|
157
|
-
} else if (e.kind === "quiz") {
|
|
158
|
-
merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
159
|
-
} else if (e.kind === "checkpoint") {
|
|
160
|
-
if (e.value == null) continue;
|
|
161
|
-
merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
162
|
-
} else if (e.kind === "pref") {
|
|
163
|
-
(merged.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
164
|
-
} else if (e.kind === "lastVisited") {
|
|
165
|
-
// Tolerate both new ({step, ts}) and legacy (string) shapes.
|
|
166
|
-
const v = e.value as unknown;
|
|
167
|
-
merged.lastVisited[e.scope] =
|
|
168
|
-
typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
|
|
169
|
-
} else if (e.kind === "tutorial") {
|
|
170
|
-
const v = (e.value as { ts?: number }) ?? {};
|
|
171
|
-
const marker = merged.tutorials[e.scope] ?? {};
|
|
172
|
-
if (e.key === "started") marker.started = v.ts;
|
|
173
|
-
else if (e.key === "completed") marker.completed = v.ts;
|
|
174
|
-
merged.tutorials[e.scope] = marker;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
213
|
+
for (const e of entries) applyEntryInto(merged, e);
|
|
177
214
|
state = merged;
|
|
178
215
|
writeStorage(state);
|
|
179
216
|
for (const fn of subscribers) fn(state);
|
|
@@ -181,6 +218,30 @@ export function createRemoteStore(): ProgressStore {
|
|
|
181
218
|
// ignore — local data still drives the UI
|
|
182
219
|
}
|
|
183
220
|
})();
|
|
221
|
+
|
|
222
|
+
// Live sync: subscribe to per-learner SSE so MCP-driven writes
|
|
223
|
+
// (or another tab via the cookie POST) show up immediately. The
|
|
224
|
+
// standard EventSource auto-reconnects on transient drops.
|
|
225
|
+
if (typeof EventSource !== "undefined") {
|
|
226
|
+
try {
|
|
227
|
+
const es = new EventSource("/api/progress/events", { withCredentials: true });
|
|
228
|
+
es.addEventListener("message", (ev) => {
|
|
229
|
+
try {
|
|
230
|
+
const entry = JSON.parse(ev.data) as ProgressEntry;
|
|
231
|
+
const next: ProgressState = { ...state };
|
|
232
|
+
applyEntryInto(next, entry);
|
|
233
|
+
state = next;
|
|
234
|
+
writeStorage(state);
|
|
235
|
+
for (const fn of subscribers) fn(state);
|
|
236
|
+
channel?.postMessage({ type: "set", state });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn("[handzon] sse parse failed:", e);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
} catch {
|
|
242
|
+
// ignore — polling-via-mount still keeps things eventually consistent
|
|
243
|
+
}
|
|
244
|
+
}
|
|
184
245
|
}
|
|
185
246
|
|
|
186
247
|
return {
|
|
@@ -16,10 +16,32 @@ export interface TutorialMarker {
|
|
|
16
16
|
completed?: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Family D verification feedback delivered via SSE from
|
|
21
|
+
* `submit_verification` failures. Keyed by the checkpoint id (which
|
|
22
|
+
* equals `verify.id`). Carries the step scope so the diff can emit
|
|
23
|
+
* a tombstone with the right scope when the learner unchecks.
|
|
24
|
+
*/
|
|
25
|
+
export interface VerificationFeedbackEntry {
|
|
26
|
+
scope: StepKey;
|
|
27
|
+
pass: boolean;
|
|
28
|
+
failingCheckIndex?: number;
|
|
29
|
+
reason?: string;
|
|
30
|
+
hint?: string;
|
|
31
|
+
ts: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export type ProgressState = {
|
|
20
35
|
steps: Record<StepKey, "incomplete" | "complete">;
|
|
21
36
|
quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
|
|
22
37
|
checkpoints: Record<string, { ts: number }>;
|
|
38
|
+
/**
|
|
39
|
+
* Latest verification verdict per checkpoint id. `pass: true`
|
|
40
|
+
* entries hang around as evidence; the Family D UI only renders
|
|
41
|
+
* the inline hint block on `pass: false`. Cleared when the
|
|
42
|
+
* learner unchecks the matching checkpoint.
|
|
43
|
+
*/
|
|
44
|
+
verificationFeedback: Record<string, VerificationFeedbackEntry>;
|
|
23
45
|
prefs: {
|
|
24
46
|
packageManager?: "npm" | "pnpm" | "yarn" | "bun";
|
|
25
47
|
os?: "macos" | "linux" | "windows";
|
|
@@ -50,6 +72,7 @@ export const emptyState = (): ProgressState => ({
|
|
|
50
72
|
steps: {},
|
|
51
73
|
quizzes: {},
|
|
52
74
|
checkpoints: {},
|
|
75
|
+
verificationFeedback: {},
|
|
53
76
|
prefs: {},
|
|
54
77
|
lastVisited: {},
|
|
55
78
|
tutorials: {},
|
|
@@ -59,10 +59,14 @@ export function useProgress(): ProgressApi {
|
|
|
59
59
|
})),
|
|
60
60
|
removeCheckpoint: (checkpointId: string) =>
|
|
61
61
|
store.set((s) => {
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const hadCheckpoint = !!s.checkpoints[checkpointId];
|
|
63
|
+
const hadFeedback = !!s.verificationFeedback[checkpointId];
|
|
64
|
+
if (!hadCheckpoint && !hadFeedback) return s;
|
|
65
|
+
const nextCheckpoints = { ...s.checkpoints };
|
|
66
|
+
delete nextCheckpoints[checkpointId];
|
|
67
|
+
const nextFeedback = { ...s.verificationFeedback };
|
|
68
|
+
delete nextFeedback[checkpointId];
|
|
69
|
+
return { ...s, checkpoints: nextCheckpoints, verificationFeedback: nextFeedback };
|
|
66
70
|
}),
|
|
67
71
|
setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
|
|
68
72
|
store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { render } from "astro:content";
|
|
3
3
|
import TutorialLayout from "../layouts/TutorialLayout.astro";
|
|
4
4
|
import ChatButton from "../components/ai/ChatButton.tsx";
|
|
5
|
+
import OpenInAgent from "../components/ai/OpenInAgent.tsx";
|
|
6
|
+
import SelectionAsk from "../components/ai/SelectionAsk.tsx";
|
|
7
|
+
import StepHelp from "../components/ai/StepHelp.tsx";
|
|
5
8
|
import { parseStepId } from "../lib/content.ts";
|
|
6
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
7
10
|
import { mdxComponents } from "../lib/mdx-components.ts";
|
|
@@ -61,7 +64,15 @@ const initialContext = buildContext({
|
|
|
61
64
|
repoUrl={repoUrl}
|
|
62
65
|
>
|
|
63
66
|
<Content components={components} />
|
|
67
|
+
{aiConfig.enabled && aiConfig.autoStepHelp && (
|
|
68
|
+
<StepHelp client:visible stepTitle={currentStep.data.title} />
|
|
69
|
+
)}
|
|
70
|
+
<OpenInAgent client:visible />
|
|
71
|
+
|
|
64
72
|
{aiConfig.enabled && (
|
|
65
|
-
|
|
73
|
+
<>
|
|
74
|
+
<ChatButton client:idle config={aiConfig} context={initialContext} />
|
|
75
|
+
<SelectionAsk client:idle />
|
|
76
|
+
</>
|
|
66
77
|
)}
|
|
67
78
|
</TutorialLayout>
|
package/src/server/auth.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AstroCookieSetOptions, AstroCookies } from "astro";
|
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
3
|
import { getAuthedUser } from "./auth/session.ts";
|
|
4
4
|
import { getDb } from "./db/client.ts";
|
|
5
|
-
import { learners, progressEntries } from "./db/schema.ts";
|
|
5
|
+
import { learnerApiTokens, learners, progressEntries } from "./db/schema.ts";
|
|
6
6
|
|
|
7
7
|
const COOKIE = "tt-device";
|
|
8
8
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
@@ -121,3 +121,86 @@ async function maybeClaimDeviceProgress(
|
|
|
121
121
|
await tx.delete(learners).where(eq(learners.id, orphan.id));
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
const PAT_PREFIX = "hzn_pat_";
|
|
126
|
+
const PAT_RANDOM_BYTES = 32;
|
|
127
|
+
|
|
128
|
+
/** Hash a raw PAT string to its database form. SHA-256 hex. */
|
|
129
|
+
export async function hashPat(raw: string): Promise<string> {
|
|
130
|
+
const data = new TextEncoder().encode(raw);
|
|
131
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
132
|
+
return Array.from(new Uint8Array(digest))
|
|
133
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
134
|
+
.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generate a fresh PAT to show the learner once. The settings page is
|
|
139
|
+
* the only caller — the API surface never sees the raw token after
|
|
140
|
+
* mint, and only the hash hits the database.
|
|
141
|
+
*/
|
|
142
|
+
export function generatePat(): string {
|
|
143
|
+
const bytes = new Uint8Array(PAT_RANDOM_BYTES);
|
|
144
|
+
crypto.getRandomValues(bytes);
|
|
145
|
+
// Base64url without padding — agent config files want short, copy/pasteable tokens.
|
|
146
|
+
const b64 = btoa(String.fromCharCode(...bytes))
|
|
147
|
+
.replace(/\+/g, "-")
|
|
148
|
+
.replace(/\//g, "_")
|
|
149
|
+
.replace(/=+$/, "");
|
|
150
|
+
return `${PAT_PREFIX}${b64}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve a bearer token presented on an incoming request to the
|
|
155
|
+
* learner that owns it. Returns null when no token, the token is
|
|
156
|
+
* unknown, or it has expired. `last_used_at` is touched
|
|
157
|
+
* asynchronously so a slow DB write doesn't add latency to MCP calls.
|
|
158
|
+
*
|
|
159
|
+
* Used by the MCP endpoint; the cookie-based progress endpoint stays
|
|
160
|
+
* on getOrCreateLearner so the same-origin guard remains effective.
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveBearerLearner(
|
|
163
|
+
request: Request,
|
|
164
|
+
): Promise<{ learnerId: string; scopes: string[] } | null> {
|
|
165
|
+
const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
|
|
166
|
+
if (!header) return null;
|
|
167
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
168
|
+
if (!match) return null;
|
|
169
|
+
const raw = match[1]!;
|
|
170
|
+
if (!raw.startsWith(PAT_PREFIX)) return null;
|
|
171
|
+
|
|
172
|
+
const db = getDb();
|
|
173
|
+
const hash = await hashPat(raw);
|
|
174
|
+
const rows = await db
|
|
175
|
+
.select()
|
|
176
|
+
.from(learnerApiTokens)
|
|
177
|
+
.where(eq(learnerApiTokens.tokenHash, hash))
|
|
178
|
+
.limit(1);
|
|
179
|
+
const token = rows[0];
|
|
180
|
+
if (!token) return null;
|
|
181
|
+
if (token.expiresAt && token.expiresAt.getTime() < Date.now()) return null;
|
|
182
|
+
|
|
183
|
+
const learnerRow = await db
|
|
184
|
+
.select({ id: learners.id })
|
|
185
|
+
.from(learners)
|
|
186
|
+
.where(eq(learners.userId, token.userId))
|
|
187
|
+
.limit(1);
|
|
188
|
+
const learner = learnerRow[0];
|
|
189
|
+
if (!learner) return null;
|
|
190
|
+
|
|
191
|
+
// Fire-and-forget last-used touch. Failures are logged-but-ignored;
|
|
192
|
+
// an audit log is more useful than blocking the call.
|
|
193
|
+
void db
|
|
194
|
+
.update(learnerApiTokens)
|
|
195
|
+
.set({ lastUsedAt: new Date() })
|
|
196
|
+
.where(eq(learnerApiTokens.id, token.id))
|
|
197
|
+
.catch((e) => {
|
|
198
|
+
console.warn("[handzon] failed to update PAT last_used_at:", e);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const scopes = token.scopes
|
|
202
|
+
.split(",")
|
|
203
|
+
.map((s) => s.trim())
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
return { learnerId: learner.id, scopes };
|
|
206
|
+
}
|
package/src/server/db/schema.ts
CHANGED
|
@@ -40,6 +40,59 @@ export const learners = pgTable(
|
|
|
40
40
|
}),
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Personal access tokens for the per-user MCP surface.
|
|
45
|
+
*
|
|
46
|
+
* Created by the settings/tokens page in the scaffold template. Stored
|
|
47
|
+
* as SHA-256 hashes of `hzn_pat_<32 base64url bytes>` strings — the
|
|
48
|
+
* raw token is shown to the learner once at mint time and never
|
|
49
|
+
* persisted. The MCP v2 OAuth proxy reuses this table, so the row
|
|
50
|
+
* shape is forward-compatible with DCR-minted tokens.
|
|
51
|
+
*
|
|
52
|
+
* scopes: comma-separated; v1 known values are "progress:read" and
|
|
53
|
+
* "progress:write". Catalog reads don't require any scope beyond a
|
|
54
|
+
* valid token.
|
|
55
|
+
*/
|
|
56
|
+
export const learnerApiTokens = pgTable("learner_api_tokens", {
|
|
57
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
58
|
+
userId: uuid("user_id")
|
|
59
|
+
.notNull()
|
|
60
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
61
|
+
name: text("name").notNull(),
|
|
62
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
63
|
+
scopes: text("scopes").notNull(),
|
|
64
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
65
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
66
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Help-bridge inbox: pending help requests posted by the agent on
|
|
71
|
+
* the learner's machine (`request_help` MCP tool). ChatPanel reads
|
|
72
|
+
* pending rows on open, prepends them as a user turn, and marks
|
|
73
|
+
* them consumed so the next open doesn't re-replay them.
|
|
74
|
+
*/
|
|
75
|
+
export const helpRequests = pgTable(
|
|
76
|
+
"help_requests",
|
|
77
|
+
{
|
|
78
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
79
|
+
learnerId: uuid("learner_id")
|
|
80
|
+
.notNull()
|
|
81
|
+
.references(() => learners.id, { onDelete: "cascade" }),
|
|
82
|
+
tutorialSlug: text("tutorial_slug").notNull(),
|
|
83
|
+
stepSlug: text("step_slug").notNull(),
|
|
84
|
+
query: text("query").notNull(),
|
|
85
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
86
|
+
consumedAt: timestamp("consumed_at", { withTimezone: true }),
|
|
87
|
+
},
|
|
88
|
+
(table) => ({
|
|
89
|
+
byLearnerPending: index("help_requests_by_learner_pending").on(
|
|
90
|
+
table.learnerId,
|
|
91
|
+
table.consumedAt,
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
43
96
|
export const progressEntries = pgTable(
|
|
44
97
|
"progress_entries",
|
|
45
98
|
{
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { getOrCreateLearner } from "../auth.ts";
|
|
4
|
+
import { getDb } from "../db/client.ts";
|
|
5
|
+
import { helpRequests } from "../db/schema.ts";
|
|
6
|
+
import { isSameOrigin, json } from "../http.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the learner's pending help-request inbox (rows where
|
|
10
|
+
* consumed_at IS NULL) and stamps them as consumed in one
|
|
11
|
+
* round-trip. ChatPanel calls this when it opens; the response
|
|
12
|
+
* becomes the seed user turns that get auto-streamed.
|
|
13
|
+
*
|
|
14
|
+
* Same-origin guarded — only the in-browser ChatPanel reads the
|
|
15
|
+
* inbox. The MCP-side `request_help` tool produces rows; this
|
|
16
|
+
* endpoint consumes them.
|
|
17
|
+
*/
|
|
18
|
+
export const GET: APIRoute = async ({ cookies, request }) => {
|
|
19
|
+
if (!process.env.DATABASE_URL) return json({ requests: [] });
|
|
20
|
+
if (!isSameOrigin(request)) {
|
|
21
|
+
return json({ error: "Cross-origin read rejected." }, { status: 403 });
|
|
22
|
+
}
|
|
23
|
+
const learner = await getOrCreateLearner(cookies, request);
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const rows = await db
|
|
26
|
+
.select()
|
|
27
|
+
.from(helpRequests)
|
|
28
|
+
.where(and(eq(helpRequests.learnerId, learner.id), isNull(helpRequests.consumedAt)));
|
|
29
|
+
if (rows.length > 0) {
|
|
30
|
+
const now = new Date();
|
|
31
|
+
await db
|
|
32
|
+
.update(helpRequests)
|
|
33
|
+
.set({ consumedAt: now })
|
|
34
|
+
.where(and(eq(helpRequests.learnerId, learner.id), isNull(helpRequests.consumedAt)));
|
|
35
|
+
}
|
|
36
|
+
return json({
|
|
37
|
+
requests: rows.map((r) => ({
|
|
38
|
+
id: r.id,
|
|
39
|
+
tutorialSlug: r.tutorialSlug,
|
|
40
|
+
stepSlug: r.stepSlug,
|
|
41
|
+
query: r.query,
|
|
42
|
+
createdAt: r.createdAt.toISOString(),
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { resolveBearerLearner } from "../auth.ts";
|
|
3
|
+
import type { JsonRpcRequest } from "../mcp/protocol.ts";
|
|
4
|
+
import { type DispatchOptions, dispatchMcp } from "../mcp/server.ts";
|
|
5
|
+
import { defaultTools } from "../mcp/tools.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build an Astro POST handler that mounts the MCP JSON-RPC dispatcher
|
|
11
|
+
* with the given tool set. Scaffold templates re-export this from
|
|
12
|
+
* `src/pages/api/mcp/index.ts` so updates land for every site
|
|
13
|
+
* without a code change in the consumer.
|
|
14
|
+
*
|
|
15
|
+
* GET /api/mcp returns a tiny capability descriptor that some
|
|
16
|
+
* clients ping before issuing JSON-RPC.
|
|
17
|
+
*/
|
|
18
|
+
export function createMcpHandler(
|
|
19
|
+
opts: DispatchOptions = { tools: defaultTools, resolveAuth: resolveBearerLearner },
|
|
20
|
+
): {
|
|
21
|
+
GET: APIRoute;
|
|
22
|
+
POST: APIRoute;
|
|
23
|
+
} {
|
|
24
|
+
const GET: APIRoute = async () =>
|
|
25
|
+
new Response(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
ok: true,
|
|
28
|
+
transport: "http+json",
|
|
29
|
+
message: "POST a JSON-RPC 2.0 request to invoke MCP tools.",
|
|
30
|
+
}),
|
|
31
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const POST: APIRoute = async ({ request }) => {
|
|
35
|
+
const lengthHeader = request.headers.get("content-length");
|
|
36
|
+
if (lengthHeader && Number(lengthHeader) > MAX_BODY_BYTES) {
|
|
37
|
+
return jsonRpcHttp({
|
|
38
|
+
jsonrpc: "2.0",
|
|
39
|
+
id: null,
|
|
40
|
+
error: { code: -32600, message: "Payload too large." },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const raw = await request.text();
|
|
44
|
+
if (raw.length > MAX_BODY_BYTES) {
|
|
45
|
+
return jsonRpcHttp({
|
|
46
|
+
jsonrpc: "2.0",
|
|
47
|
+
id: null,
|
|
48
|
+
error: { code: -32600, message: "Payload too large." },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
let body: JsonRpcRequest;
|
|
52
|
+
try {
|
|
53
|
+
body = JSON.parse(raw) as JsonRpcRequest;
|
|
54
|
+
} catch {
|
|
55
|
+
return jsonRpcHttp({
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id: null,
|
|
58
|
+
error: { code: -32700, message: "Parse error." },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const response = await dispatchMcp(request, body, opts);
|
|
62
|
+
return jsonRpcHttp(response);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return { GET, POST };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function jsonRpcHttp(body: unknown): Response {
|
|
69
|
+
return new Response(JSON.stringify(body), {
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { getOrCreateLearner } from "../auth.ts";
|
|
5
5
|
import { getDb } from "../db/client.ts";
|
|
6
6
|
import { progressEntries } from "../db/schema.ts";
|
|
7
7
|
import { isSameOrigin, json } from "../http.ts";
|
|
8
|
+
import { writeProgressEntries } from "../progress.ts";
|
|
8
9
|
|
|
9
10
|
const MAX_BODY_BYTES = 32 * 1024;
|
|
10
11
|
const MAX_ENTRIES = 200;
|
|
@@ -59,55 +60,10 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
59
60
|
if (parsed.length === 0) return json({ written: 0 });
|
|
60
61
|
|
|
61
62
|
const learner = await getOrCreateLearner(cookies, request);
|
|
62
|
-
const db = getDb();
|
|
63
|
-
const now = new Date();
|
|
64
|
-
|
|
65
63
|
// `value: null` is the tombstone signal for "this entry was undone"
|
|
66
|
-
// (e.g. unchecking a checkpoint).
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
for (const d of deletes) {
|
|
72
|
-
await db
|
|
73
|
-
.delete(progressEntries)
|
|
74
|
-
.where(
|
|
75
|
-
and(
|
|
76
|
-
eq(progressEntries.learnerId, learner.id),
|
|
77
|
-
eq(progressEntries.kind, d.kind),
|
|
78
|
-
eq(progressEntries.scope, d.scope),
|
|
79
|
-
eq(progressEntries.key, d.key),
|
|
80
|
-
),
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (upserts.length > 0) {
|
|
85
|
-
const rows = upserts.map((b) => ({
|
|
86
|
-
learnerId: learner.id,
|
|
87
|
-
kind: b.kind,
|
|
88
|
-
scope: b.scope,
|
|
89
|
-
key: b.key,
|
|
90
|
-
value: b.value,
|
|
91
|
-
updatedAt: now,
|
|
92
|
-
}));
|
|
93
|
-
await db
|
|
94
|
-
.insert(progressEntries)
|
|
95
|
-
.values(rows)
|
|
96
|
-
.onConflictDoUpdate({
|
|
97
|
-
target: [
|
|
98
|
-
progressEntries.learnerId,
|
|
99
|
-
progressEntries.kind,
|
|
100
|
-
progressEntries.scope,
|
|
101
|
-
progressEntries.key,
|
|
102
|
-
],
|
|
103
|
-
set: {
|
|
104
|
-
// `excluded` is the row Postgres would have inserted — without
|
|
105
|
-
// this the SET was a no-op (`value = progress_entries.value`).
|
|
106
|
-
value: sql`excluded.value`,
|
|
107
|
-
updatedAt: sql`excluded.updated_at`,
|
|
108
|
-
},
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return json({ written: parsed.length });
|
|
64
|
+
// (e.g. unchecking a checkpoint). writeProgressEntries handles the
|
|
65
|
+
// split into deletes + upserts; the same writer is used by the MCP
|
|
66
|
+
// write tools so behaviour stays consistent across surfaces.
|
|
67
|
+
const written = await writeProgressEntries(learner.id, parsed);
|
|
68
|
+
return json({ written });
|
|
113
69
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getOrCreateLearner } from "../auth.ts";
|
|
3
|
+
import { isSameOrigin } from "../http.ts";
|
|
4
|
+
import { subscribeLearner } from "../progressBus.ts";
|
|
5
|
+
|
|
6
|
+
const HEARTBEAT_MS = 25_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Server-Sent Events stream of progress writes for the current
|
|
10
|
+
* learner. One connection per open browser tab; the in-app store
|
|
11
|
+
* (`createRemoteStore`) opens an EventSource on first mount and
|
|
12
|
+
* merges incoming entries through the same reducer as the
|
|
13
|
+
* POST-response path.
|
|
14
|
+
*
|
|
15
|
+
* Each event has the shape:
|
|
16
|
+
* data: { "kind": "...", "scope": "...", "key": "...", "value": ..., "ts": ... }
|
|
17
|
+
*
|
|
18
|
+
* Heartbeat comments keep proxies (CDN, Render's edge) from
|
|
19
|
+
* killing the connection on idle.
|
|
20
|
+
*/
|
|
21
|
+
export const GET: APIRoute = async ({ cookies, request }) => {
|
|
22
|
+
if (!process.env.DATABASE_URL) {
|
|
23
|
+
return new Response("SSE disabled — no DATABASE_URL.", { status: 503 });
|
|
24
|
+
}
|
|
25
|
+
if (!isSameOrigin(request)) {
|
|
26
|
+
return new Response("Cross-origin SSE rejected.", { status: 403 });
|
|
27
|
+
}
|
|
28
|
+
const learner = await getOrCreateLearner(cookies, request);
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
let unsubscribe: (() => void) | null = null;
|
|
31
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
32
|
+
|
|
33
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
34
|
+
start(controller) {
|
|
35
|
+
// Initial connect comment — clients show "open" only after a
|
|
36
|
+
// first bit of data, and some proxies need a flush.
|
|
37
|
+
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
38
|
+
unsubscribe = subscribeLearner(learner.id, (msg) => {
|
|
39
|
+
try {
|
|
40
|
+
const payload = JSON.stringify(msg);
|
|
41
|
+
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.warn("[handzon] sse encode failed:", e);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
heartbeat = setInterval(() => {
|
|
47
|
+
try {
|
|
48
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
49
|
+
} catch {
|
|
50
|
+
/* controller closed — cancel will clean up */
|
|
51
|
+
}
|
|
52
|
+
}, HEARTBEAT_MS);
|
|
53
|
+
},
|
|
54
|
+
cancel() {
|
|
55
|
+
unsubscribe?.();
|
|
56
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return new Response(stream, {
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "text/event-stream",
|
|
63
|
+
"Cache-Control": "no-cache, no-transform",
|
|
64
|
+
Connection: "keep-alive",
|
|
65
|
+
"X-Accel-Buffering": "no",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
};
|