From 0aa197e5153991ffb094ca8ca4fbf6ddbe919e9e Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 19 Sep 2026 19:02:07 +0200 Subject: [PATCH] feat(contexts): carry subContexts and daemonCleanupErrors from the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vta/contexts/preview-delete/1.0` gained `subContexts` in trust-tasks 0.21.4, so the console no longer derives the cascade from the context list — the agent decides what the deletion reaches, so the agent is the one that should name it. `vta/contexts/delete/1.0` gained `daemonCleanupErrors`: DIDs whose host copy the agent could not confirm removing. A success carrying a non-empty list is not a finished deletion, and the spec says a consumer MUST surface it, so it is returned rather than dropped. Signed-off-by: Glenn Gore --- packages/core/src/admin/contexts.ts | 36 +++++++++++++++---- packages/core/tests/admin.contexts.mjs | 31 +++++++++++++++- .../extension/src/manager/panes/contexts.tsx | 35 +++--------------- .../contexts-pane-delete.render.test.mts | 16 ++++++--- 4 files changed, 77 insertions(+), 41 deletions(-) diff --git a/packages/core/src/admin/contexts.ts b/packages/core/src/admin/contexts.ts index c314bd7..d0d6a67 100644 --- a/packages/core/src/admin/contexts.ts +++ b/packages/core/src/admin/contexts.ts @@ -30,11 +30,19 @@ export interface ContextDeleteParams { /** The delete response, from the binding. The hand-written copy omitted * `ext`, which SPEC §4.5.1 lets any agent send. */ -export type ContextDeleteResult = VTAContextsDeleteResponsePayload; +export type ContextDeleteResult = VTAContextsDeleteResponsePayload & { + /** DIDs whose host copy the agent could not confirm removing. Empty is the + * ordinary case; non-empty means the deletion is not finished. */ + daemonCleanupErrors: string[]; +}; /** What deleting a context would destroy, as the agent reports it. */ export interface ContextDeletePreview { id: string; + /** Sub-contexts that go with it, deepest first. Every other array here is + * the union over these and the named context, because that is what the + * deletion acts on. Empty for a leaf. */ + subContexts: string[]; keys: string[]; webvhDids: string[]; /** Subjects whose ACL entry disappears entirely — this context (or the @@ -54,9 +62,9 @@ export interface ContextDeletePreview { * * **Every array covers the whole subtree**, because the deletion does — the * agent counts the sub-contexts' keys, DIDs and grants alongside this - * context's own. What it does not yet report is *which* sub-contexts those - * are; `vta/contexts/preview-delete/1.0` has no member for that, so a - * consumer wanting to name them derives them from the context list. + * context's own, and `subContexts` names them. Consumers used to derive that + * list from the context list; the agent reports it as of trust-tasks 0.21.4, + * and the agent is the one that decides what the cascade reaches. * * This returned three of the six arrays until now. `aclEntriesRemoved` in * particular is the one an operator most needs — it names the subjects about @@ -76,12 +84,14 @@ export async function contextPreviewDelete( id: string; keys?: string[]; webvhDids?: string[]; + subContexts?: string[]; aclEntriesRemoved?: string[]; aclEntriesUpdated?: string[]; didTemplates?: string[]; /** Pre-fold spellings, still sent by an agent that has not taken the * camelCase change. Accepted on read; never emitted. */ webvh_dids?: string[]; + sub_contexts?: string[]; acl_entries_removed?: string[]; acl_entries_updated?: string[]; did_templates?: string[]; @@ -91,6 +101,7 @@ export async function contextPreviewDelete( }); return { id: payload.id, + subContexts: payload.subContexts ?? payload.sub_contexts ?? [], keys: payload.keys ?? [], webvhDids: payload.webvhDids ?? payload.webvh_dids ?? [], aclEntriesRemoved: payload.aclEntriesRemoved ?? payload.acl_entries_removed ?? [], @@ -109,9 +120,22 @@ export async function contextDelete( { id: params.id, force: params.force ?? false }, { issuer: params.holder.did, recipient: params.service.did }, ); - const payload = await sender.send<{ id: string; deleted?: boolean }>(envelope, { + const payload = await sender.send<{ + id: string; + deleted?: boolean; + daemonCleanupErrors?: string[]; + daemon_cleanup_errors?: string[]; + }>(envelope, { expectedResponseType: `${TASK_CONTEXTS_DELETE}#response`, operationLabel: "vta/contexts/delete/1.0", }); - return { id: payload.id, deleted: payload.deleted ?? false }; + return { + id: payload.id, + deleted: payload.deleted ?? false, + // A success that is not the whole story: these DIDs' records are gone + // and their published logs may still be served. The spec says a consumer + // MUST surface it rather than report the deletion as complete, so it is + // returned rather than dropped here. + daemonCleanupErrors: payload.daemonCleanupErrors ?? payload.daemon_cleanup_errors ?? [], + }; } diff --git a/packages/core/tests/admin.contexts.mjs b/packages/core/tests/admin.contexts.mjs index 18082ab..528c268 100644 --- a/packages/core/tests/admin.contexts.mjs +++ b/packages/core/tests/admin.contexts.mjs @@ -36,7 +36,7 @@ test("delete sends force explicitly, defaulting to false", async () => { opts.expectedResponseType, "https://trusttasks.org/spec/vta/contexts/delete/1.0#response", ); - assert.deepEqual(result, { id: "demo", deleted: true }); + assert.deepEqual(result, { id: "demo", deleted: true, daemonCleanupErrors: [] }); }); test("force is passed through when the caller means it", async () => { @@ -56,6 +56,7 @@ test("preview translates every snake_case list across the casing boundary", asyn id: "demo", keys: ["key-1"], webvh_dids: ["did:webvh:QmX:h"], + sub_contexts: ["demo/sub"], acl_entries_removed: ["did:key:zGone"], acl_entries_updated: ["did:key:zNarrowed"], did_templates: ["persona"], @@ -63,6 +64,7 @@ test("preview translates every snake_case list across the casing boundary", asyn const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); assert.deepEqual(result, { id: "demo", + subContexts: ["demo/sub"], keys: ["key-1"], webvhDids: ["did:webvh:QmX:h"], aclEntriesRemoved: ["did:key:zGone"], @@ -95,6 +97,7 @@ test("preview defaults every list when the context holds nothing", async () => { const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); assert.deepEqual(result, { id: "demo", + subContexts: [], keys: [], webvhDids: [], aclEntriesRemoved: [], @@ -102,3 +105,29 @@ test("preview defaults every list when the context holds nothing", async () => { didTemplates: [], }); }); + +test("a delete that left host copies behind says so, rather than reporting done", async () => { + // The agent removed the records and the hosting server did not confirm + // removing the published logs, so those DIDs may still resolve. A success + // the caller must not read as a completed deletion. + const channel = recorder({ + id: "demo", + deleted: true, + daemonCleanupErrors: ["did:webvh:QmX:h: daemon `h` rejected delete: 503"], + }); + const result = await contextDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); + assert.equal(result.deleted, true); + assert.deepEqual(result.daemonCleanupErrors, [ + "did:webvh:QmX:h: daemon `h` rejected delete: 503", + ]); +}); + +test("the snake_case spelling of the cleanup report is read too", async () => { + const channel = recorder({ + id: "demo", + deleted: true, + daemon_cleanup_errors: ["did:webvh:QmY:h: orphaned"], + }); + const result = await contextDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); + assert.deepEqual(result.daemonCleanupErrors, ["did:webvh:QmY:h: orphaned"]); +}); diff --git a/packages/extension/src/manager/panes/contexts.tsx b/packages/extension/src/manager/panes/contexts.tsx index 86bef2c..e443c77 100644 --- a/packages/extension/src/manager/panes/contexts.tsx +++ b/packages/extension/src/manager/panes/contexts.tsx @@ -290,20 +290,11 @@ function Destroyed({ function DeleteContext({ parties, record, - subContexts, authority, onDeleted, }: { parties: Parties; record: ContextRecord; - /** The subtree going with it, deepest first. - * - * Derived by the caller from the context list rather than read off the - * preview, because `vta/contexts/preview-delete/1.0` has no member naming - * them. It still has to be shown: the preview's own arrays already *count* - * what the sub-contexts hold, so without this the operator sees keys and - * DIDs that belong to contexts the panel never mentions. */ - subContexts: string[]; authority: Authority | null; onDeleted: () => void; }) { @@ -327,7 +318,7 @@ function DeleteContext({ // used to read as empty here, so the panel sent `force: false` and the // agent refused with nothing on screen explaining why. needsForce={(p) => - subContexts.length > 0 || + p.subContexts.length > 0 || p.keys.length > 0 || p.webvhDids.length > 0 || p.aclEntriesRemoved.length > 0 || @@ -337,7 +328,7 @@ function DeleteContext({ forceLabel="Delete anyway, destroying everything listed above" renderPreview={(p) => { const nothing = - subContexts.length === 0 && + p.subContexts.length === 0 && p.keys.length === 0 && p.webvhDids.length === 0 && p.aclEntriesRemoved.length === 0 && @@ -354,10 +345,10 @@ function DeleteContext({ {/* First, because it changes what every list below means: those are the subtree's contents, not this context's. */} - {subContexts.map((id) => ( + {p.subContexts.map((id) => (
  • {id}
  • @@ -580,21 +571,6 @@ function ContextDid({ ); } -/** - * The contexts strictly below `id`, deepest first. - * - * Path-derived, because a context id *is* its path — `acme/eng/ci` is under - * `acme`, and the agent builds its own cascade the same way. Segment-wise so - * that `acme-corp` is not read as a child of `acme`. - */ -function descendantsOf(id: string, records: ContextRecord[]): string[] { - const prefix = `${id}/`; - return records - .map((r) => r.id) - .filter((cid) => cid !== id && cid.startsWith(prefix)) - .sort((a, b) => b.split("/").length - a.split("/").length || a.localeCompare(b)); -} - export function ContextsPane({ parties, authority, @@ -653,7 +629,6 @@ export function ContextsPane({ diff --git a/packages/extension/tests/contexts-pane-delete.render.test.mts b/packages/extension/tests/contexts-pane-delete.render.test.mts index 81a5587..f60fddc 100644 --- a/packages/extension/tests/contexts-pane-delete.render.test.mts +++ b/packages/extension/tests/contexts-pane-delete.render.test.mts @@ -35,12 +35,15 @@ const ctx = (id: string) => ({ }); /** A subtree: `acme` over `acme/eng` over `acme/eng/ci`, plus an unrelated - * `acme-corp` that must not be read as a child of `acme`. */ + * `acme-corp`. The agent decides what the cascade reaches and says so in + * `subContexts`; the console no longer derives it, so `acme-corp`'s absence + * here is the agent's answer rather than the console's path matching. */ const RECORDS = [ctx("acme"), ctx("acme/eng"), ctx("acme/eng/ci"), ctx("acme-corp")]; +const SUBTREE = ["acme/eng/ci", "acme/eng"]; const mount = async (preview: Record, records = RECORDS) => { const a = agent({ - [PREVIEW]: { id: "acme", keys: [], webvhDids: [], ...preview }, + [PREVIEW]: { id: "acme", keys: [], webvhDids: [], subContexts: SUBTREE, ...preview }, [DELETE]: { id: "acme", deleted: true }, [LIST_DIDS]: { dids: [] }, }); @@ -69,7 +72,11 @@ test("the sub-contexts that go with it are named, not merely implied", async () const text = (screen as never as { text: () => string }).text(); assert.match(text, /acme\/eng\/ci/, "the deepest sub-context is not on screen"); assert.match(text, /acme\/eng(?!\/)/, "the intermediate sub-context is not on screen"); - assert.doesNotMatch(text, /acme-corp/, "a sibling whose id merely shares a prefix is not a child"); + assert.doesNotMatch( + text, + /acme-corp/, + "only what the agent named is shown — the console does not add to it", + ); }); test("a context holding nothing itself still asks, because its children go too", async () => { @@ -88,7 +95,7 @@ test("a context holding nothing itself still asks, because its children go too", }); test("a leaf holding nothing says so, and does not ask for force", async () => { - const { screen } = await mount({}, [ctx("acme"), ctx("acme-corp")]); + const { screen } = await mount({ subContexts: [] }, [ctx("acme"), ctx("acme-corp")]); await openPreview(screen as never); const text = (screen as never as { text: () => string }).text(); assert.match(text, /holds nothing, and has no sub-contexts/); @@ -100,6 +107,7 @@ test("grants and templates count as contents, not only keys and DIDs", async () // the force control entirely. const { screen } = await mount( { + subContexts: [], aclEntriesRemoved: ["did:key:z6MkBankBot"], didTemplates: ["bank-persona"], },