@fungi.computer/inkcap 0.0.1 → 0.2.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/backend/methods.ts +52 -11
- package/backend/test/gills.test.ts +1 -1
- package/backend/test/methods.test.ts +84 -18
- package/backend/test/present.test.ts +25 -0
- package/dist/assets/{FileTab-bwd_G6y3.js → FileTab-D5FlpG7C.js} +1 -1
- package/dist/assets/InkcapFileTree-B-gxxHjo.js +1 -0
- package/dist/assets/dialog-jssrzu-h.js +83 -0
- package/dist/assets/index-Bj0ZDQ9x.js +1267 -0
- package/dist/assets/index-C9u7iVEg.css +2 -0
- package/dist/assets/{useForm-CuiUTLkb.js → useForm-JWOEk10W.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +29 -11
- package/dist/assets/InkcapFileTree-Cx3HsaWr.js +0 -1
- package/dist/assets/dialog-CWwO4mBD.js +0 -71
- package/dist/assets/index-BNEzthyN.js +0 -1279
- package/dist/assets/index-ByFi_OY2.css +0 -2
package/backend/methods.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resultRows } from "@fungi.computer/agar/method-presentation";
|
|
1
2
|
import { recentFilesGill } from "./gills.js";
|
|
2
3
|
import {
|
|
3
4
|
documentIdentitySchema,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
type DocumentActivityStore,
|
|
12
13
|
} from "./document-activity.js";
|
|
13
14
|
import * as z from "zod";
|
|
15
|
+
import { filesNavigateIntent } from "@fungi.computer/agar/roles/files";
|
|
14
16
|
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
15
17
|
import {
|
|
16
18
|
defineAppBackend,
|
|
@@ -24,16 +26,16 @@ import {
|
|
|
24
26
|
listOutputSchema,
|
|
25
27
|
readBytesInputSchema,
|
|
26
28
|
writeOutputSchema,
|
|
27
|
-
} from "@fungi.computer/computer
|
|
29
|
+
} from "@fungi.computer/computer-contract";
|
|
28
30
|
export type InkcapBackendContext = {
|
|
29
31
|
activity: DocumentActivityStore;
|
|
30
32
|
documentsChanged(computerId: string, sequence: number): Promise<void>;
|
|
31
33
|
env: {
|
|
32
34
|
APP_STORAGE: Pick<AppStorageWire, "read" | "write">;
|
|
33
|
-
COMPUTER
|
|
34
|
-
list
|
|
35
|
-
read
|
|
36
|
-
write
|
|
35
|
+
COMPUTER: {
|
|
36
|
+
list(input: z.output<typeof listInput>): Promise<WhistleJsonValue>;
|
|
37
|
+
read(input: z.output<typeof readInput>): Promise<WhistleJsonValue>;
|
|
38
|
+
write(input: z.output<typeof writeInput>): Promise<WhistleJsonValue>;
|
|
37
39
|
};
|
|
38
40
|
};
|
|
39
41
|
};
|
|
@@ -101,6 +103,19 @@ const refusal = z.union([
|
|
|
101
103
|
const listReply = z.union([listOutputSchema, refusal]);
|
|
102
104
|
const readReply = z.union([readOutput, refusal]);
|
|
103
105
|
const writeReply = z.union([writeOutputSchema, refusal]);
|
|
106
|
+
// The host always supplies callable bindings; admission is checked on invocation.
|
|
107
|
+
const bindingNotGranted = z.object({ code: z.literal("binding_not_granted") });
|
|
108
|
+
async function computerCall(
|
|
109
|
+
call: () => Promise<WhistleJsonValue>,
|
|
110
|
+
): Promise<WhistleJsonValue> {
|
|
111
|
+
try {
|
|
112
|
+
return await call();
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
if (bindingNotGranted.safeParse(cause).success)
|
|
115
|
+
return { error: "not_granted" };
|
|
116
|
+
throw cause;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
104
119
|
async function recordDocument(
|
|
105
120
|
input: z.output<typeof documentIdentitySchema>,
|
|
106
121
|
context: InkcapBackendContext,
|
|
@@ -162,9 +177,11 @@ export const appBackend = defineAppBackend({
|
|
|
162
177
|
input: listInput,
|
|
163
178
|
output: listReply,
|
|
164
179
|
handler: async (input, context: InkcapBackendContext, caller) =>
|
|
165
|
-
caller.computerId === null
|
|
180
|
+
caller.computerId === null
|
|
166
181
|
? refusal.parse({ error: "not_granted" })
|
|
167
|
-
: listReply.parse(
|
|
182
|
+
: listReply.parse(
|
|
183
|
+
await computerCall(() => context.env.COMPUTER.list(input)),
|
|
184
|
+
),
|
|
168
185
|
}),
|
|
169
186
|
readFile: defineAppMethod({
|
|
170
187
|
description:
|
|
@@ -174,9 +191,11 @@ export const appBackend = defineAppBackend({
|
|
|
174
191
|
input: readInput,
|
|
175
192
|
output: readReply,
|
|
176
193
|
handler: async (input, context: InkcapBackendContext, caller) =>
|
|
177
|
-
caller.computerId === null
|
|
194
|
+
caller.computerId === null
|
|
178
195
|
? refusal.parse({ error: "not_granted" })
|
|
179
|
-
: readReply.parse(
|
|
196
|
+
: readReply.parse(
|
|
197
|
+
await computerCall(() => context.env.COMPUTER.read(input)),
|
|
198
|
+
),
|
|
180
199
|
}),
|
|
181
200
|
writeFile: defineAppMethod({
|
|
182
201
|
description:
|
|
@@ -185,10 +204,32 @@ export const appBackend = defineAppBackend({
|
|
|
185
204
|
requires: ["computer.writeFile"],
|
|
186
205
|
input: writeInput,
|
|
187
206
|
output: writeReply,
|
|
207
|
+
present: {
|
|
208
|
+
rows: resultRows,
|
|
209
|
+
verb: "wrote",
|
|
210
|
+
row: ({ output }) =>
|
|
211
|
+
"error" in output
|
|
212
|
+
? {
|
|
213
|
+
id: "refused",
|
|
214
|
+
title: "Inkcap could not write the file",
|
|
215
|
+
detail: output.error,
|
|
216
|
+
tone: "error",
|
|
217
|
+
}
|
|
218
|
+
: {
|
|
219
|
+
id: output.path,
|
|
220
|
+
title: `Inkcap wrote ${output.path.split("/").at(-1) ?? output.path}`,
|
|
221
|
+
detail: `${output.path} · ${String(output.bytes)} bytes`,
|
|
222
|
+
open: {
|
|
223
|
+
params: filesNavigateIntent({ path: output.path }).params,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
188
227
|
handler: async (input, context: InkcapBackendContext, caller) =>
|
|
189
|
-
caller.computerId === null
|
|
228
|
+
caller.computerId === null
|
|
190
229
|
? refusal.parse({ error: "not_granted" })
|
|
191
|
-
: writeReply.parse(
|
|
230
|
+
: writeReply.parse(
|
|
231
|
+
await computerCall(() => context.env.COMPUTER.write(input)),
|
|
232
|
+
),
|
|
192
233
|
}),
|
|
193
234
|
},
|
|
194
235
|
});
|
|
@@ -27,7 +27,7 @@ it("projects the saved Recent Files source with stable identities and its own cu
|
|
|
27
27
|
documentsChanged: () => Promise.resolve(),
|
|
28
28
|
env: {
|
|
29
29
|
APP_STORAGE: { read: unavailable, write: unavailable },
|
|
30
|
-
COMPUTER: { read: unavailable },
|
|
30
|
+
COMPUTER: { list: unavailable, read: unavailable, write: unavailable },
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
const caller = {
|
|
@@ -37,6 +37,7 @@ const unavailable = (): Promise<never> =>
|
|
|
37
37
|
Promise.reject(new Error("Computer calls must not access repositories"));
|
|
38
38
|
const unrelated = {
|
|
39
39
|
APP_STORAGE: { read: unavailable, write: unavailable },
|
|
40
|
+
COMPUTER: { list: unavailable, read: unavailable, write: unavailable },
|
|
40
41
|
};
|
|
41
42
|
const emptyMarker = () =>
|
|
42
43
|
Promise.resolve({
|
|
@@ -49,7 +50,7 @@ const emptyMarker = () =>
|
|
|
49
50
|
canWriteTeam: false,
|
|
50
51
|
},
|
|
51
52
|
});
|
|
52
|
-
type ComputerBinding =
|
|
53
|
+
type ComputerBinding = InkcapBackendContext["env"]["COMPUTER"];
|
|
53
54
|
|
|
54
55
|
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
55
56
|
actor: { kind: "agent", agentId: "agent-a" },
|
|
@@ -69,9 +70,8 @@ it("lists files only through the admitted pinned Computer binding", async () =>
|
|
|
69
70
|
env: {
|
|
70
71
|
...unrelated,
|
|
71
72
|
COMPUTER: {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
) => {
|
|
73
|
+
...unrelated.COMPUTER,
|
|
74
|
+
list: (input: Parameters<ComputerBinding["list"]>[0]) => {
|
|
75
75
|
calls.push(input);
|
|
76
76
|
return Promise.resolve(reply);
|
|
77
77
|
},
|
|
@@ -95,6 +95,7 @@ it("does not dispatch when the caller has no pinned Computer", async () => {
|
|
|
95
95
|
env: {
|
|
96
96
|
...unrelated,
|
|
97
97
|
COMPUTER: {
|
|
98
|
+
...unrelated.COMPUTER,
|
|
98
99
|
read: () => {
|
|
99
100
|
calls++;
|
|
100
101
|
return Promise.resolve({});
|
|
@@ -116,7 +117,8 @@ it("forwards bounded byte reads and compare-and-swap writes", async () => {
|
|
|
116
117
|
env: {
|
|
117
118
|
...unrelated,
|
|
118
119
|
COMPUTER: {
|
|
119
|
-
|
|
120
|
+
...unrelated.COMPUTER,
|
|
121
|
+
read: (input: Parameters<ComputerBinding["read"]>[0]) => {
|
|
120
122
|
calls.push(input);
|
|
121
123
|
return Promise.resolve({
|
|
122
124
|
path: "/notes",
|
|
@@ -126,9 +128,7 @@ it("forwards bounded byte reads and compare-and-swap writes", async () => {
|
|
|
126
128
|
truncated: false,
|
|
127
129
|
});
|
|
128
130
|
},
|
|
129
|
-
write: (
|
|
130
|
-
input: Parameters<NonNullable<ComputerBinding["write"]>>[0],
|
|
131
|
-
) => {
|
|
131
|
+
write: (input: Parameters<ComputerBinding["write"]>[0]) => {
|
|
132
132
|
calls.push(input);
|
|
133
133
|
return Promise.resolve({ path: "/notes", bytes: 2 });
|
|
134
134
|
},
|
|
@@ -230,6 +230,7 @@ it("retains typed conflict and attachment budget refusals", async () => {
|
|
|
230
230
|
env: {
|
|
231
231
|
...unrelated,
|
|
232
232
|
COMPUTER: {
|
|
233
|
+
...unrelated.COMPUTER,
|
|
233
234
|
write: () => Promise.resolve(z.json().parse(response)),
|
|
234
235
|
},
|
|
235
236
|
},
|
|
@@ -239,16 +240,77 @@ it("retains typed conflict and attachment budget refusals", async () => {
|
|
|
239
240
|
).toEqual(response);
|
|
240
241
|
}
|
|
241
242
|
});
|
|
242
|
-
it(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
|
|
243
|
+
it.each([
|
|
244
|
+
["listFiles", {}],
|
|
245
|
+
["readFile", { path: "/notes" }],
|
|
246
|
+
["writeFile", { path: "/notes", content: "hi" }],
|
|
247
|
+
] as const)(
|
|
248
|
+
"maps the host's missing-grant refusal for %s",
|
|
249
|
+
async (method, input) => {
|
|
250
|
+
const denied = (): Promise<never> =>
|
|
251
|
+
Promise.reject(
|
|
252
|
+
Object.assign(new Error("App binding refused: binding_not_granted"), {
|
|
253
|
+
name: "AppInstanceError",
|
|
254
|
+
code: "binding_not_granted",
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
await expect(
|
|
258
|
+
appBackend.call(
|
|
259
|
+
method,
|
|
260
|
+
input,
|
|
261
|
+
{
|
|
262
|
+
activity,
|
|
263
|
+
documentsChanged,
|
|
264
|
+
env: {
|
|
265
|
+
...unrelated,
|
|
266
|
+
COMPUTER: { list: denied, read: denied, write: denied },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
caller,
|
|
270
|
+
),
|
|
271
|
+
).resolves.toEqual({ error: "not_granted" });
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
it.each([
|
|
275
|
+
["listFiles", {}],
|
|
276
|
+
["readFile", { path: "/notes" }],
|
|
277
|
+
["writeFile", { path: "/notes", content: "hi" }],
|
|
278
|
+
] as const)(
|
|
279
|
+
"preserves other binding failures for %s",
|
|
280
|
+
async (method, input) => {
|
|
281
|
+
for (const failure of [
|
|
282
|
+
Object.assign(new Error("App binding refused: binding_unavailable"), {
|
|
283
|
+
name: "AppInstanceError",
|
|
284
|
+
code: "binding_unavailable",
|
|
285
|
+
}),
|
|
286
|
+
Object.assign(
|
|
287
|
+
new Error("App binding refused: invalid_invocation_facts"),
|
|
288
|
+
{
|
|
289
|
+
name: "AppInstanceError",
|
|
290
|
+
code: "invalid_invocation_facts",
|
|
291
|
+
},
|
|
292
|
+
),
|
|
293
|
+
new Error("binding_not_granted"),
|
|
294
|
+
]) {
|
|
295
|
+
const failed = (): Promise<never> => Promise.reject(failure);
|
|
296
|
+
await expect(
|
|
297
|
+
appBackend.call(
|
|
298
|
+
method,
|
|
299
|
+
input,
|
|
300
|
+
{
|
|
301
|
+
activity,
|
|
302
|
+
documentsChanged,
|
|
303
|
+
env: {
|
|
304
|
+
...unrelated,
|
|
305
|
+
COMPUTER: { list: failed, read: failed, write: failed },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
caller,
|
|
309
|
+
),
|
|
310
|
+
).rejects.toBe(failure);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
);
|
|
252
314
|
it("does not expose Computer methods through unauthenticated repository HTTP", async () => {
|
|
253
315
|
expect(
|
|
254
316
|
(await Inkcap.prototype.fetch(new Request("https://inkcap.test/"))).status,
|
|
@@ -274,6 +336,7 @@ it("persists admitted document activity and pages saved rows without provider ca
|
|
|
274
336
|
activity,
|
|
275
337
|
documentsChanged,
|
|
276
338
|
env: {
|
|
339
|
+
...unrelated,
|
|
277
340
|
APP_STORAGE: {
|
|
278
341
|
read: emptyMarker,
|
|
279
342
|
write: (input) => {
|
|
@@ -311,6 +374,7 @@ it("keeps cursor pages and activity private to the actor and pinned Computer", a
|
|
|
311
374
|
activity: createDocumentActivityStore(sql),
|
|
312
375
|
documentsChanged,
|
|
313
376
|
env: {
|
|
377
|
+
...unrelated,
|
|
314
378
|
APP_STORAGE: {
|
|
315
379
|
read: emptyMarker,
|
|
316
380
|
write: () =>
|
|
@@ -386,6 +450,7 @@ it("bounds retained documents and returns source continuation without trimming a
|
|
|
386
450
|
activity,
|
|
387
451
|
documentsChanged,
|
|
388
452
|
env: {
|
|
453
|
+
...unrelated,
|
|
389
454
|
APP_STORAGE: {
|
|
390
455
|
read: emptyMarker,
|
|
391
456
|
write: () =>
|
|
@@ -440,6 +505,7 @@ it("preserves the original marker failure without any provider or retry", async
|
|
|
440
505
|
activity,
|
|
441
506
|
documentsChanged,
|
|
442
507
|
env: {
|
|
508
|
+
...unrelated,
|
|
443
509
|
APP_STORAGE: {
|
|
444
510
|
read: emptyMarker,
|
|
445
511
|
write: () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { appBackend } from "../methods.js";
|
|
3
|
+
|
|
4
|
+
it("reads a written file as a row that opens it in Inkcap", () => {
|
|
5
|
+
expect(
|
|
6
|
+
appBackend.methods.writeFile?.present?.(
|
|
7
|
+
{ path: "/storyboard/storyboard.md", content: "# Panels" },
|
|
8
|
+
{ path: "/storyboard/storyboard.md", bytes: 8 },
|
|
9
|
+
),
|
|
10
|
+
).toEqual({
|
|
11
|
+
id: "/storyboard/storyboard.md",
|
|
12
|
+
title: "Inkcap wrote storyboard.md",
|
|
13
|
+
detail: "/storyboard/storyboard.md · 8 bytes",
|
|
14
|
+
open: { params: [{ key: "path", value: "/storyboard/storyboard.md" }] },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reads a refused write as an error row", () => {
|
|
19
|
+
expect(
|
|
20
|
+
appBackend.methods.writeFile?.present?.(
|
|
21
|
+
{ path: "/a.md", content: "x" },
|
|
22
|
+
{ error: "not_granted" },
|
|
23
|
+
),
|
|
24
|
+
).toMatchObject({ title: "Inkcap could not write the file", tone: "error" });
|
|
25
|
+
});
|