Skip to content

Commit 98083c0

Browse files
authored
feat(examples): refresh Perplexity output through the retained grant (#103)
1 parent 9f9df1c commit 98083c0

5 files changed

Lines changed: 525 additions & 17 deletions

File tree

‎examples/perplexity-demo/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,19 @@ Use the maintained [connection verification evidence rules](https://github.com/v
129129
Keep private titles and raw payloads outside Git/Linear. A successful server access log with no saved demo result is a failed consumer-delivery attempt, not end-to-end success. Preserve its receipt and cost; a fresh charged attempt requires an explicit operator decision rather than automatic replacement. Compare saved content to what the browser actually displays, and retain failure attempts rather than relabelling them. A grant's chain finalization and consumer acknowledgement are separate observations.
130130

131131
[Motel composition guidance](https://github.com/vana-com/unity-surfaces/blob/a87843f90b9b570ce493e769d348b1a869a9ebae/.agents/skills/us-verify-with-motel/SKILL.md) is optional diagnosis. Its existing exporter is Desktop-specific and does not capture this example. Use existing request/payment/save evidence first; add app-owned, metadata-only probes only for a concrete unresolved failure. Do not launch Desktop, upgrade Personal Server or build an observability programme for this milestone.
132+
133+
## Iterate through the same grant — BUI-932
134+
135+
After the first result is saved, displayed and acknowledged, **Read latest** performs a fresh SDK read using the owner/app/network/grant/server binding retained privately by the backend. It accepts no grant, server, scope or request overrides from the browser and does not reopen the completed Direct request. The backend rechecks the live grant and owner-bound server. If access has expired or been revoked, the saved list stays visible and **Review access again** is available. Funding and transient server errors remain read/funding problems.
136+
137+
Every deliberately fresh read can incur the current capped test-VANA fee. Earlier successful receipts remain in private history; a failed or uncertain fresh attempt reuses its own receipt instead of authorizing another fee. A delivered body that has not been decoded/saved, or a saved response lost before the browser received it, is recovered before another paid read. Acknowledgement follows rendering of the recovered/new revision. The BUI-931 ledger is adopted only when its saved, acknowledged revision matches its retained received body; uncertain legacy payments are not relabelled as successful.
138+
139+
To prove a connector change:
140+
141+
1. Record one real conversation's stable ID and current title in private evidence. Keep the same app, grant, owner, network and conversations scope.
142+
2. In an isolated, unpublished checkout of the **same connector version**, edit `connectors/perplexity/parsers.ts` so `conversationRecord` prefixes only that record's returned title with `CLI edit — `. Keep its ID and scope unchanged and respect the title's 500-character bound. Do not edit exported JSON, the app's saved list or synthetic fixtures.
143+
3. Rerun `vana connect perplexity --from /path/to/edited/data-connectors --streams conversations --network moksha --yes --ipc`. The selected 0.1.1 conversations-only path emits its listed conversations on every run; do not clear cursors, bypass refresh limits or import messages merely to force an update. Keep the CLI receipt and prove that the actual emitted record contains the changed title.
144+
4. Verify ingestion and serving of the changed payload on the same owner-bound server. A new version number alone is insufficient. Record any manual setup/copy assistance explicitly; a development-wallet copy is not automatic CLI delivery.
145+
5. After the original Direct request is complete, click **Read latest**. Prove a new saved revision with the changed title in the real demo, using the same grant, then observe acknowledgement after rendering. Record the current fee, preserved previous receipts and total authorized amount. Refresh the page to verify the saved result survives.
146+
147+
The local connector edit is an acceptance experiment, not a connector publication or upstream feature. Keep its private record ID and real data outside this repository. Mainnet, native per-account delivery, publishing, review and the public Registry remain separate.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { shelf } from "../../../config.js";
2+
import { DeliveryError } from "../../../server/delivery.js";
3+
import { failure, json, sameOrigin, session } from "../../../server/http.js";
4+
5+
/** A fresh read uses only the binding retained by the server after initial delivery. */
6+
export async function POST(request: Request) {
7+
try {
8+
sameOrigin(request);
9+
let body: unknown;
10+
try {
11+
body = await request.json();
12+
} catch {
13+
throw new DeliveryError("invalid_input", "Invalid JSON body.", 400);
14+
}
15+
if (
16+
!body ||
17+
typeof body !== "object" ||
18+
Array.isArray(body) ||
19+
Object.keys(body).length
20+
) {
21+
throw new DeliveryError(
22+
"invalid_input",
23+
"Read latest accepts no grant, server, scope or request overrides.",
24+
400,
25+
);
26+
}
27+
return json(await shelf.read(session(request)));
28+
} catch (error) {
29+
return failure(error);
30+
}
31+
}

‎examples/perplexity-demo/src/components/ConnectFlow.tsx‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export default function ConnectFlow() {
4242
const connectionGeneration = useRef(0);
4343
const acknowledged = !!saved && acknowledgedRevision === saved.revision;
4444
const [ackError, setAckError] = useState<string>();
45+
const [refreshing, setRefreshing] = useState(false);
46+
const [refreshError, setRefreshError] = useState<string>();
47+
const [reviewRequired, setReviewRequired] = useState(false);
4548
const { state, start, reset, retryRead } = useDirectVanaConnect<
4649
NonNullable<Connection["saved"]>
4750
>({
@@ -124,6 +127,50 @@ export default function ConnectFlow() {
124127
}
125128
}
126129

130+
async function readLatest() {
131+
const generation = connectionGeneration.current;
132+
setRefreshing(true);
133+
setRefreshError(undefined);
134+
setReviewRequired(false);
135+
try {
136+
const response = await fetch("/api/latest", {
137+
method: "POST",
138+
headers: { "Content-Type": "application/json" },
139+
body: "{}",
140+
});
141+
if (!response.ok && connectionGeneration.current === generation) {
142+
const refusal = await response
143+
.clone()
144+
.json()
145+
.catch(() => undefined);
146+
setReviewRequired(
147+
[401, 403].includes(response.status) ||
148+
["approval_required", "grant_invalid", "grant_refused"].includes(
149+
refusal?.code,
150+
),
151+
);
152+
}
153+
const result =
154+
await checkedResponse<
155+
ApprovedDataResult<NonNullable<Connection["saved"]>>
156+
>(response);
157+
if (connectionGeneration.current !== generation) return;
158+
setSaved(result.data);
159+
setAcknowledgedRevision(undefined);
160+
setAckError(undefined);
161+
setReviewRequired(false);
162+
} catch (error) {
163+
if (connectionGeneration.current !== generation) return;
164+
setRefreshError(
165+
error instanceof Error
166+
? error.message
167+
: "The latest read did not finish.",
168+
);
169+
} finally {
170+
if (connectionGeneration.current === generation) setRefreshing(false);
171+
}
172+
}
173+
127174
// Effects run after the saved list has committed to the DOM.
128175
useEffect(() => {
129176
displayedRevision.current = saved?.revision;
@@ -160,9 +207,13 @@ export default function ConnectFlow() {
160207
onClick={() => {
161208
connectionGeneration.current++;
162209
setAckError(undefined);
210+
setRefreshError(undefined);
211+
setReviewRequired(false);
212+
setRefreshing(false);
163213
void start();
164214
}}
165215
className="btn-primary full-width"
216+
disabled={refreshing}
166217
>
167218
Connect with Vana
168219
</button>
@@ -261,6 +312,32 @@ export default function ConnectFlow() {
261312
)}
262313
</>
263314
)}
315+
{saved && (
316+
<>
317+
<button
318+
type="button"
319+
className="btn-primary full-width"
320+
disabled={
321+
refreshing ||
322+
!acknowledged ||
323+
!["idle", "done", "error"].includes(state.type)
324+
}
325+
onClick={() => void readLatest()}
326+
>
327+
{refreshing ? "Reading latest…" : "Read latest"}
328+
</button>
329+
{refreshError && (
330+
<p className="text-error" role="alert">
331+
Your saved list is still available. {refreshError}
332+
</p>
333+
)}
334+
{reviewRequired && state.type !== "idle" && (
335+
<button type="button" className="btn-ghost" onClick={reset}>
336+
Review access again
337+
</button>
338+
)}
339+
</>
340+
)}
264341
{ackError && (
265342
<>
266343
<p className="text-error" role="alert">

‎examples/perplexity-demo/src/server/delivery.ts‎

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,16 @@ export function createShelf(config: DeliveryConfig) {
123123
const store = new ShelfStore(config.storageDirectory);
124124
const payments = new ShelfStore(`${config.storageDirectory}/payments`);
125125
interface PaymentLedger {
126+
format?: number;
126127
spent: string;
127128
payment?: { header: string; operation: string; amount: string };
128-
received?: { revision: string; receivedAt: string; body: unknown };
129+
received?: {
130+
revision: string;
131+
receivedAt: string;
132+
body: unknown;
133+
paymentHeaderHash?: string;
134+
};
135+
history?: { header: string; operation: string; amount: string }[];
129136
}
130137
function paymentPolicy(amount: bigint, spent: bigint) {
131138
if (!config.allowPayment || !config.maxFee || !config.maxTotal)
@@ -163,21 +170,27 @@ export function createShelf(config: DeliveryConfig) {
163170
return state;
164171
}
165172

166-
async function liveBinding(state: Connection) {
173+
async function liveBinding(state: Connection, requireCompleted = false) {
167174
const status = await access.getAccessRequestStatus(state.requestId);
168175
if (!["approved", "ready_for_read", "completed"].includes(status.status)) {
169176
throw new DeliveryError(
170177
"approval_required",
171178
`The request is ${status.status}. Review access in Vana.`,
172179
);
173180
}
181+
if (requireCompleted && status.status !== "completed")
182+
throw new DeliveryError(
183+
"delivery_incomplete",
184+
"Confirm the original delivery before reading latest.",
185+
);
174186
const binding = state.binding;
175187
const grantId = binding?.grantId ?? status.grantId;
176188
const serverUrl = binding?.serverUrl ?? status.personalServerUrl;
177189
if (
178190
!grantId ||
179191
!serverUrl ||
180-
!status.scopes?.includes(SCOPE) ||
192+
((!binding || status.status !== "completed") &&
193+
!status.scopes?.includes(SCOPE)) ||
181194
(status.grantId && status.grantId !== grantId) ||
182195
(status.personalServerUrl && status.personalServerUrl !== serverUrl)
183196
) {
@@ -299,7 +312,22 @@ export function createShelf(config: DeliveryConfig) {
299312
// A lost response can be recovered without another paid read.
300313
if (requestId && state.saved)
301314
return { scope: SCOPE, data: state.saved };
302-
const grant = await liveBinding(state);
315+
// Recover a saved response lost in transport before attempting another paid read.
316+
if (
317+
!requestId &&
318+
state.saved &&
319+
state.acknowledged !== state.saved.revision
320+
)
321+
return { scope: SCOPE, data: state.saved };
322+
if (
323+
!requestId &&
324+
(!state.saved || state.acknowledged !== state.saved.revision)
325+
)
326+
throw new DeliveryError(
327+
"delivery_incomplete",
328+
"Save, display and confirm the initial delivery before reading latest.",
329+
);
330+
const grant = await liveBinding(state, !requestId);
303331
await store.save(token, state);
304332
const binding = state.binding!;
305333
const ledgerKey = createHash("sha256")
@@ -315,8 +343,34 @@ export function createShelf(config: DeliveryConfig) {
315343
.digest("hex");
316344
return payments.exclusive(ledgerKey, async () => {
317345
const ledger = (await payments.read<PaymentLedger>(ledgerKey)) ?? {
346+
format: 2,
318347
spent: "0",
319348
};
349+
if (ledger.format !== undefined && ledger.format !== 2)
350+
throw new DeliveryError(
351+
"storage_version",
352+
"This payment store needs a compatible demo version.",
353+
);
354+
if (ledger.format === undefined) {
355+
// BUI-931 had no fresh-read endpoint: an acknowledged saved revision
356+
// matching its received body proves which existing header delivered it.
357+
if (
358+
ledger.payment &&
359+
ledger.received &&
360+
state.saved?.revision === ledger.received.revision &&
361+
state.acknowledged === state.saved.revision &&
362+
JSON.stringify(state.saved.threads) ===
363+
JSON.stringify(threadsFrom(ledger.received.body))
364+
) {
365+
ledger.received.paymentHeaderHash = createHash("sha256")
366+
.update(ledger.payment.header)
367+
.digest("hex");
368+
}
369+
if (!ledger.received || ledger.received.paymentHeaderHash) {
370+
ledger.format = 2;
371+
await payments.save(ledgerKey, ledger);
372+
}
373+
}
320374
async function saveReceived() {
321375
const received = ledger.received!;
322376
state.saved = {
@@ -328,7 +382,15 @@ export function createShelf(config: DeliveryConfig) {
328382
return { scope: SCOPE, data: state.saved };
329383
}
330384
// Recover a delivered body after decoder/save failure without another paid read.
331-
if (requestId && ledger.received) return saveReceived();
385+
if (
386+
ledger.received &&
387+
(requestId || ledger.received.revision !== state.saved?.revision)
388+
)
389+
return saveReceived();
390+
const previousDelivered =
391+
!!ledger.payment &&
392+
ledger.received?.paymentHeaderHash ===
393+
createHash("sha256").update(ledger.payment.header).digest("hex");
332394
let challengeOperation: string | undefined;
333395
const fetchRead: PersonalServerFetch = async (input, init) => {
334396
const headers = { ...init.headers };
@@ -344,6 +406,7 @@ export function createShelf(config: DeliveryConfig) {
344406
Buffer.from(outgoing, "base64").toString("utf8"),
345407
);
346408
const amount: string = payment.payload.message.amount;
409+
if (ledger.payment) (ledger.history ??= []).push(ledger.payment);
347410
ledger.payment = {
348411
header: outgoing,
349412
operation: challengeOperation,
@@ -352,7 +415,7 @@ export function createShelf(config: DeliveryConfig) {
352415
ledger.spent = (BigInt(ledger.spent) + BigInt(amount)).toString();
353416
// Persist before transmission: an uncertain response must not authorize another fee.
354417
await payments.save(ledgerKey, ledger);
355-
} else if (ledger.payment) {
418+
} else if (ledger.payment && (requestId || !previousDelivered)) {
356419
paymentPolicy(
357420
BigInt(ledger.payment.amount),
358421
BigInt(ledger.spent),
@@ -403,9 +466,7 @@ export function createShelf(config: DeliveryConfig) {
403466
const operation = `${required.opId}:${required.accessRecord?.dataPointId ?? ""}:${required.accessRecord?.version ?? ""}`;
404467
if (
405468
ledger.payment &&
406-
(outgoing ||
407-
ledger.payment.operation === operation ||
408-
!required.accessRecord)
469+
(outgoing || requestId || !previousDelivered)
409470
)
410471
throw new DeliveryError(
411472
"payment_unconfirmed",
@@ -457,6 +518,9 @@ export function createShelf(config: DeliveryConfig) {
457518
revision: randomUUID(),
458519
receivedAt: new Date().toISOString(),
459520
body: result.data,
521+
paymentHeaderHash: ledger.payment
522+
? createHash("sha256").update(ledger.payment.header).digest("hex")
523+
: undefined,
460524
};
461525
await payments.save(ledgerKey, ledger);
462526
return saveReceived();

0 commit comments

Comments
 (0)