@hachej/boring-feedback 0.0.0-stage → 0.2.4

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.
Files changed (92) hide show
  1. package/INVARIANTS.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +20 -2
  4. package/dist/agent/capability.d.ts +52 -0
  5. package/dist/agent/capability.js +230 -0
  6. package/dist/agent/index.d.ts +4 -0
  7. package/dist/agent/index.js +5 -0
  8. package/dist/agent/preview.d.ts +10 -0
  9. package/dist/agent/preview.js +27 -0
  10. package/dist/format/index.d.ts +7 -0
  11. package/dist/format/index.js +6 -0
  12. package/dist/format/json.d.ts +30 -0
  13. package/dist/format/json.js +193 -0
  14. package/dist/format/preview.d.ts +28 -0
  15. package/dist/format/preview.js +45 -0
  16. package/dist/format/readable.d.ts +10 -0
  17. package/dist/format/readable.js +19 -0
  18. package/dist/format/report.d.ts +187 -0
  19. package/dist/format/report.js +669 -0
  20. package/dist/format/requests.d.ts +53 -0
  21. package/dist/format/requests.js +69 -0
  22. package/dist/page/app-element.d.ts +79 -0
  23. package/dist/page/app-element.js +451 -0
  24. package/dist/page/canaries.d.ts +46 -0
  25. package/dist/page/canaries.js +142 -0
  26. package/dist/page/index.d.ts +6 -0
  27. package/dist/page/index.js +8 -0
  28. package/dist/page/picker/index.d.ts +4 -0
  29. package/dist/page/picker/index.js +4 -0
  30. package/dist/page/picker/label.d.ts +16 -0
  31. package/dist/page/picker/label.js +58 -0
  32. package/dist/page/picker/overlay.d.ts +42 -0
  33. package/dist/page/picker/overlay.js +126 -0
  34. package/dist/page/picker/picker.d.ts +72 -0
  35. package/dist/page/picker/picker.js +386 -0
  36. package/dist/page/privacy/digest.d.ts +6 -0
  37. package/dist/page/privacy/digest.js +24 -0
  38. package/dist/page/privacy/index.d.ts +6 -0
  39. package/dist/page/privacy/index.js +5 -0
  40. package/dist/page/privacy/name.d.ts +8 -0
  41. package/dist/page/privacy/name.js +106 -0
  42. package/dist/page/privacy/policy.d.ts +65 -0
  43. package/dist/page/privacy/policy.js +125 -0
  44. package/dist/page/privacy/route.d.ts +6 -0
  45. package/dist/page/privacy/route.js +20 -0
  46. package/dist/page/privacy/serialize.d.ts +42 -0
  47. package/dist/page/privacy/serialize.js +90 -0
  48. package/dist/page/voice/align.d.ts +36 -0
  49. package/dist/page/voice/align.js +122 -0
  50. package/dist/page/voice/capture.d.ts +43 -0
  51. package/dist/page/voice/capture.js +186 -0
  52. package/dist/page/voice/index.d.ts +3 -0
  53. package/dist/page/voice/index.js +3 -0
  54. package/dist/preview/agent.d.ts +34 -0
  55. package/dist/preview/agent.js +91 -0
  56. package/dist/preview/index.d.ts +5 -0
  57. package/dist/preview/index.js +6 -0
  58. package/dist/preview/page.d.ts +50 -0
  59. package/dist/preview/page.js +293 -0
  60. package/dist/preview/session.d.ts +63 -0
  61. package/dist/preview/session.js +110 -0
  62. package/dist/source/index.d.ts +24 -0
  63. package/dist/source/index.js +59 -0
  64. package/dist/source/jsx-dev-runtime.d.ts +5 -0
  65. package/dist/source/jsx-dev-runtime.js +29 -0
  66. package/dist/store/index.d.ts +5 -0
  67. package/dist/store/index.js +4 -0
  68. package/dist/store/mentions.d.ts +17 -0
  69. package/dist/store/mentions.js +27 -0
  70. package/dist/store/store.d.ts +121 -0
  71. package/dist/store/store.js +353 -0
  72. package/dist/tickets/index.d.ts +5 -0
  73. package/dist/tickets/index.js +5 -0
  74. package/dist/tickets/sinks.d.ts +27 -0
  75. package/dist/tickets/sinks.js +84 -0
  76. package/dist/tickets/ticket.d.ts +93 -0
  77. package/dist/tickets/ticket.js +148 -0
  78. package/dist/ui/annotate.d.ts +103 -0
  79. package/dist/ui/annotate.js +154 -0
  80. package/dist/ui/index.d.ts +7 -0
  81. package/dist/ui/index.js +8 -0
  82. package/dist/ui/report.d.ts +51 -0
  83. package/dist/ui/report.js +58 -0
  84. package/dist/ui/save.d.ts +45 -0
  85. package/dist/ui/save.js +65 -0
  86. package/dist/ui/session.d.ts +186 -0
  87. package/dist/ui/session.js +762 -0
  88. package/dist/ui/show.d.ts +43 -0
  89. package/dist/ui/show.js +72 -0
  90. package/dist/ui/state.d.ts +11 -0
  91. package/dist/ui/state.js +21 -0
  92. package/package.json +98 -4
@@ -0,0 +1,121 @@
1
+ import type { ProviderGuarantees, PublicationLookup, ResourceAccess, ResourcePublisher, ResourceReader, ResourceView } from '@hachej/boring-files';
2
+ import type { FeedbackDraft, FeedbackListItem, FeedbackReport, FeedbackRoot, Observed } from '../format/index.js';
3
+ export type FeedbackProtection = 'protected' | 'unprotected';
4
+ export type FeedbackPermission = 'annotate' | 'read';
5
+ export type FeedbackAction = 'create' | 'resolve';
6
+ /** What the host authorizes. `key` is the subject key (`subjectKeyOf`): list, read and resolve decide on it alone, so a
7
+ * decision must be a function of `key`. `observed` is supplied on create only. */
8
+ export interface FeedbackSubject {
9
+ readonly key: string;
10
+ readonly observed?: Observed;
11
+ }
12
+ /** `id` is the caller's stable operation id (the browser's per-draft id, or the agent's admitted `[namespace, taskId]`).
13
+ * `key` is the binding admitted with it, computed by `store.operationKey` when the operation was first admitted. */
14
+ export interface FeedbackOperation {
15
+ readonly id: string;
16
+ readonly key: string;
17
+ }
18
+ export interface FeedbackStoreOptions<Context = void> {
19
+ readonly providerId: string;
20
+ readonly view: ResourceView;
21
+ readonly reader: ResourceReader;
22
+ readonly publisher: ResourcePublisher;
23
+ readonly lookup: PublicationLookup;
24
+ /** The provider's declared capabilities (a `ResourceCapabilities` satisfies this). */
25
+ readonly capabilities: {
26
+ readonly guarantees: ProviderGuarantees;
27
+ };
28
+ /** The names of the files in one folder of the view (`''` is the view's root), or a refusal. The host lists its workspace's file
29
+ * system here (Pi's `listDir`); a missing folder is an empty list. */
30
+ readonly listFolder: (folder: string, access: ResourceAccess) => Promise<readonly string[] | FeedbackRefusal>;
31
+ /** The folder of the reports: empty or a relative path ending with `/`. */
32
+ readonly root: string;
33
+ /** Stable identity of the storage partition and this binding. */
34
+ readonly operationNamespace: string;
35
+ /** The host's access resolution, kept for the routes and tools built on this store. The store itself never calls it:
36
+ * every method takes the access the caller resolved for this request or tool call. */
37
+ readonly resolveAccess: (context: Context) => ResourceAccess | Promise<ResourceAccess>;
38
+ readonly authorizeSubject: (access: ResourceAccess, subject: FeedbackSubject, permission: FeedbackPermission) => boolean | Promise<boolean>;
39
+ readonly displayName: (principalId: string) => string | Promise<string>;
40
+ /** `'protected'`: native working tools, shell and Git cannot write the root. `'unprotected'` must be chosen explicitly,
41
+ * and every surface states it. */
42
+ readonly protection: FeedbackProtection;
43
+ /** Time source for `created`, and resolutions. Defaults to the system clock. */
44
+ readonly now?: () => Date;
45
+ }
46
+ export interface FeedbackDenied {
47
+ readonly kind: 'denied';
48
+ readonly reason: string;
49
+ }
50
+ export interface FeedbackUnavailable {
51
+ readonly kind: 'unavailable';
52
+ readonly reason: string;
53
+ }
54
+ export interface FeedbackMissing {
55
+ readonly kind: 'missing';
56
+ }
57
+ /** `current` is the report's current revision when the conflict is on the report, `null` when it has none or the
58
+ * conflict is not on a report revision. */
59
+ export interface FeedbackConflict {
60
+ readonly kind: 'conflict';
61
+ readonly current: string | null;
62
+ readonly reason: string;
63
+ }
64
+ /** The effect is not known: reconcile `operationId` later. Never a success. */
65
+ export interface FeedbackUnknown {
66
+ readonly kind: 'unknown';
67
+ readonly operationId: string;
68
+ readonly reason: string;
69
+ }
70
+ export type FeedbackRefusal = FeedbackDenied | FeedbackUnavailable;
71
+ export interface FeedbackStored {
72
+ readonly report: FeedbackReport;
73
+ readonly revision: string;
74
+ }
75
+ export interface FeedbackApplied extends FeedbackStored {
76
+ readonly kind: 'applied';
77
+ readonly operationId: string;
78
+ }
79
+ export type FeedbackCreateResult = FeedbackApplied | FeedbackConflict | FeedbackRefusal | FeedbackUnknown;
80
+ export type FeedbackResolveResult = FeedbackApplied | FeedbackConflict | FeedbackMissing | FeedbackRefusal | FeedbackUnknown;
81
+ export type FeedbackReadResult = ({
82
+ readonly kind: 'available';
83
+ } & FeedbackStored) | FeedbackMissing | FeedbackRefusal;
84
+ export type { FeedbackListItem };
85
+ export type FeedbackListResult = {
86
+ readonly kind: 'available';
87
+ readonly items: readonly FeedbackListItem[];
88
+ readonly cursor: string | null;
89
+ readonly protection: FeedbackProtection;
90
+ } | FeedbackRefusal;
91
+ export interface FeedbackListQuery {
92
+ readonly status?: 'open' | 'addressed';
93
+ readonly subject?: string;
94
+ readonly cursor?: string;
95
+ }
96
+ export interface FeedbackResolveInput {
97
+ readonly expectedRevision: string;
98
+ readonly note: string;
99
+ }
100
+ export interface FeedbackStore<Context = void> {
101
+ readonly root: FeedbackRoot;
102
+ readonly resolveAccess: (context: Context) => ResourceAccess | Promise<ResourceAccess>;
103
+ /** The binding to admit with an operation id: namespace, root, provider, view, access fields, action and arguments. */
104
+ readonly operationKey: (action: FeedbackAction, args: unknown, access: ResourceAccess) => string;
105
+ readonly create: (draft: FeedbackDraft, access: ResourceAccess, operation: FeedbackOperation) => Promise<FeedbackCreateResult>;
106
+ readonly read: (id: string, access: ResourceAccess) => Promise<FeedbackReadResult>;
107
+ readonly list: (query: FeedbackListQuery, access: ResourceAccess) => Promise<FeedbackListResult>;
108
+ readonly resolve: (id: string, input: FeedbackResolveInput, access: ResourceAccess, operation: FeedbackOperation) => Promise<FeedbackResolveResult>;
109
+ readonly guarantees: () => {
110
+ readonly protection: FeedbackProtection;
111
+ };
112
+ }
113
+ export declare const FEEDBACK_PAGE_SIZE = 50;
114
+ /** The most report files `list` reads in one call; a folder holding more is refused, never silently truncated. */
115
+ export declare const FEEDBACK_LIST_LIMIT = 5000;
116
+ /** `fb_` and 16 base-58 characters from the platform's cryptographic source (rejection sampling, no modulo bias). */
117
+ export declare function feedbackId(): string;
118
+ /** Build a store over injected resource contracts. Refuses to start without conditional publication, operation lookup, an
119
+ * atomic mutation and receipt, or an explicit protection declaration. */
120
+ export declare function createFeedbackStore<Context = void>(options: FeedbackStoreOptions<Context>): FeedbackStore<Context>;
121
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,353 @@
1
+ import { randomUUID } from '@hachej/boring-files/platform';
2
+ import { FEEDBACK_ID, FeedbackFormatError, checkFeedback, createRequest, draftProblems, draftReport, draftBody, listItemOf, parseFeedback, reportLocator, reportPath, resolveRequest, serializeFeedback, subjectKeyOf, } from '../format/index.js';
3
+ export const FEEDBACK_PAGE_SIZE = 50;
4
+ /** The most report files `list` reads in one call; a folder holding more is refused, never silently truncated. */
5
+ export const FEEDBACK_LIST_LIMIT = 5000;
6
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
7
+ /** `fb_` and 16 base-58 characters from the platform's cryptographic source (rejection sampling, no modulo bias). */
8
+ export function feedbackId() {
9
+ let out = '';
10
+ while (out.length < 16) {
11
+ const hex = randomUUID().replace(/-/g, '');
12
+ // Skip the UUID's version and variant nibbles, which are not random.
13
+ const random = hex.slice(0, 12) + hex.slice(13, 16) + hex.slice(17);
14
+ for (let at = 0; at + 2 <= random.length && out.length < 16; at += 2) {
15
+ const byte = Number.parseInt(random.slice(at, at + 2), 16);
16
+ if (byte < 232)
17
+ out += BASE58[byte % 58];
18
+ }
19
+ }
20
+ return `fb_${out}`;
21
+ }
22
+ /** JSON with object keys sorted, so equal values give equal keys whatever their property order. */
23
+ function canonical(value) {
24
+ if (Array.isArray(value))
25
+ return `[${value.map(item => canonical(item === undefined ? null : item)).join(',')}]`;
26
+ if (value !== null && typeof value === 'object') {
27
+ const entries = Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
28
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
29
+ }
30
+ return JSON.stringify(value) ?? 'null';
31
+ }
32
+ const problemsText = (error) => error.problems.map(problem => `${problem.at}: ${problem.message}`).join('; ');
33
+ const sameBytes = (a, b) => a.length === b.length && a.every((byte, index) => byte === b[index]);
34
+ const time = (value) => { const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; };
35
+ /** Newest first; ties by id, so the order is total and a cursor is stable. */
36
+ const order = (a, b) => (time(b.created) - time(a.created)) || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
37
+ function encodeCursor(entry) {
38
+ return btoa(JSON.stringify([entry.created, entry.id])).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
39
+ }
40
+ function decodeCursor(cursor) {
41
+ try {
42
+ const value = JSON.parse(atob(cursor.replace(/-/g, '+').replace(/_/g, '/')));
43
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && typeof value[1] === 'string' && FEEDBACK_ID.test(value[1]) && !Number.isNaN(Date.parse(value[0])))
44
+ return { created: value[0], id: value[1] };
45
+ }
46
+ catch { /* refused below */ }
47
+ return undefined;
48
+ }
49
+ /** Build a store over injected resource contracts. Refuses to start without conditional publication, operation lookup, an
50
+ * atomic mutation and receipt, or an explicit protection declaration. */
51
+ export function createFeedbackStore(options) {
52
+ const { providerId, view, reader, publisher, lookup, listFolder, operationNamespace, authorizeSubject, displayName, protection } = options;
53
+ const guarantees = options.capabilities?.guarantees;
54
+ if (guarantees?.conditionalPublication !== true || guarantees.operationLookup !== true || guarantees.atomicMutationAndReceipt !== true) {
55
+ throw new TypeError('The feedback store needs a provider that declares conditionalPublication, operationLookup and atomicMutationAndReceipt');
56
+ }
57
+ if (protection !== 'protected' && protection !== 'unprotected')
58
+ throw new TypeError("The feedback store needs an explicit protection: 'protected' or 'unprotected'");
59
+ if (!providerId)
60
+ throw new TypeError('A resource provider id is required');
61
+ if (!operationNamespace)
62
+ throw new TypeError('A stable operation namespace is required');
63
+ if (typeof options.root !== 'string' || (options.root && (!options.root.endsWith('/') || options.root.startsWith('/') || options.root.split('/').some(segment => segment === '..' || segment === '.')))) {
64
+ throw new TypeError('The feedback root must be empty or a relative folder ending with /');
65
+ }
66
+ if (!view || (view.kind !== 'published' && view.kind !== 'working'))
67
+ throw new TypeError('A resource view is required');
68
+ for (const [name, value] of [['reader.read', reader?.read], ['publisher.publish', publisher?.publish], ['lookup.lookup', lookup?.lookup], ['listFolder', listFolder], ['authorizeSubject', authorizeSubject], ['displayName', displayName], ['resolveAccess', options.resolveAccess]]) {
69
+ if (typeof value !== 'function')
70
+ throw new TypeError(`${name} is required`);
71
+ }
72
+ const root = Object.freeze({ providerId, view, path: options.root });
73
+ const now = () => (options.now ?? (() => new Date()))().toISOString();
74
+ const operationKey = (action, args, access) => canonical([
75
+ 'boring.feedback.operation.v1', operationNamespace, providerId, view, root.path, access.scopeId, access.principalId, access.initiatorId, access.authorizationRef ?? null, action, args,
76
+ ]);
77
+ async function allowed(access, subject, permission) {
78
+ try {
79
+ return await authorizeSubject({ ...access }, subject, permission) === true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ async function readReport(id, access, revision) {
86
+ let read;
87
+ try {
88
+ read = await reader.read({ target: reportLocator(root, id), revision: revision === undefined ? { kind: 'latest' } : { kind: 'exact', value: revision } }, access);
89
+ }
90
+ catch {
91
+ return { ok: false, refusal: { kind: 'unavailable', reason: 'The feedback report could not be read' } };
92
+ }
93
+ if (read.kind !== 'available')
94
+ return { ok: false, refusal: read };
95
+ const parsed = parseFeedback(read.snapshot.bytes);
96
+ if (!parsed.ok || parsed.report.id !== id)
97
+ return { ok: false, refusal: { kind: 'unavailable', reason: `The feedback report ${id} is not valid` } };
98
+ if (revision !== undefined && read.snapshot.ref.revision !== revision)
99
+ return { ok: false, refusal: { kind: 'unavailable', reason: 'Expected the exact report revision' } };
100
+ return { ok: true, value: { ref: read.snapshot.ref, report: parsed.report } };
101
+ }
102
+ async function submit(request, access) {
103
+ let result;
104
+ try {
105
+ result = await publisher.publish(request, access);
106
+ }
107
+ catch {
108
+ return { kind: 'uncertain', reason: 'The publication did not answer' };
109
+ }
110
+ if (result.kind === 'committed')
111
+ return result;
112
+ if (result.kind === 'conflict')
113
+ return { kind: 'conflict' };
114
+ if (result.kind === 'denied' || result.kind === 'unavailable')
115
+ return { kind: result.kind, reason: result.reason };
116
+ // A change above the byte limit is a definite refusal: nothing was written, so it is not a partial outcome.
117
+ if (result.kind === 'file_too_large')
118
+ return { kind: 'unavailable', reason: result.reason };
119
+ return { kind: 'uncertain', reason: result.kind === 'unknown' ? result.reason : 'The publication reported a partial outcome' };
120
+ }
121
+ const reportChangeOf = (receipt) => {
122
+ const [first, second] = receipt.changes;
123
+ if (receipt.changes.length !== 1 || second)
124
+ return undefined;
125
+ return first;
126
+ };
127
+ const mismatch = () => ({ kind: 'conflict', current: null, reason: 'The operation id is already bound to a different feedback change' });
128
+ const unreconciled = (operationId, reason) => ({ kind: 'unknown', operationId, reason });
129
+ /** After an uncertain outcome: one lookup, never a fresh retry. */
130
+ async function reconcile(operationId, access, match) {
131
+ let found;
132
+ try {
133
+ found = await lookup.lookup(operationId, access);
134
+ }
135
+ catch {
136
+ return unreconciled(operationId, 'The operation lookup did not answer; its effect is unknown');
137
+ }
138
+ if (found.kind === 'committed')
139
+ return match(found.receipt);
140
+ if (found.kind === 'not-found')
141
+ return unreconciled(operationId, 'No receipt is retained for the operation; its effect is unknown');
142
+ if (found.kind === 'conflict')
143
+ return { kind: 'conflict', current: null, reason: found.reason };
144
+ if (found.kind === 'denied' || found.kind === 'unavailable')
145
+ return { kind: found.kind, reason: found.reason };
146
+ return unreconciled(operationId, found.kind === 'unknown' ? found.reason : 'The operation has a partial outcome');
147
+ }
148
+ /** Admission shared by create, resolve and remove: binding check, then a replay lookup before anything is prepared. */
149
+ async function admit(action, args, access, operation, match, fresh) {
150
+ if (typeof operation?.id !== 'string' || !operation.id || typeof operation.key !== 'string')
151
+ return { kind: 'denied', reason: 'A stable operation identity is required' };
152
+ const operationId = JSON.stringify([operationNamespace, operation.id]);
153
+ if (operation.key !== operationKey(action, args, access))
154
+ return unreconciled(operationId, 'The admitted feedback binding changed; reconcile the original operation');
155
+ let prior;
156
+ try {
157
+ prior = await lookup.lookup(operationId, access);
158
+ }
159
+ catch {
160
+ return unreconciled(operationId, 'The operation lookup did not answer');
161
+ }
162
+ if (prior.kind === 'committed')
163
+ return match(prior.receipt);
164
+ if (prior.kind !== 'not-found')
165
+ return unreconciled(operationId, prior.kind === 'unknown' ? prior.reason : 'The operation cannot be reconciled');
166
+ return fresh(operationId);
167
+ }
168
+ const create = async (draft, access, operation) => {
169
+ const problems = draftProblems(draft);
170
+ if (problems.length)
171
+ return { kind: 'denied', reason: problems.map(problem => `${problem.at}: ${problem.message}`).join('; ') };
172
+ let checked;
173
+ try {
174
+ checked = draftReport(draft, { id: 'fb_1111111111111111', created: '2000-01-01T00:00:00Z' });
175
+ }
176
+ catch (error) {
177
+ if (error instanceof FeedbackFormatError)
178
+ return { kind: 'denied', reason: problemsText(error) };
179
+ throw error;
180
+ }
181
+ // Notes and steps are part of the binding only when present, so a Release 1 draft keeps its operation key.
182
+ const args = draftBody(checked);
183
+ const subject = { key: subjectKeyOf(checked.observed), observed: checked.observed };
184
+ const match = async (receipt) => {
185
+ const change = reportChangeOf(receipt);
186
+ const path = change?.after?.resource.path ?? '';
187
+ const id = path.slice(root.path.length, -'.md'.length);
188
+ if (change?.kind !== 'create' || !path.startsWith(root.path) || !FEEDBACK_ID.test(id) || reportPath(root, id) !== path)
189
+ return mismatch();
190
+ const stored = await readReport(id, access, change.after.revision);
191
+ if (!stored.ok)
192
+ return unreconciled(receipt.operationId, 'The committed report could not be read to reconcile the operation');
193
+ const { report } = stored.value;
194
+ const { notes: _notes, steps: _steps, ...withoutBody } = report;
195
+ const asCalled = checkFeedback({ ...withoutBody, ...args });
196
+ if (!asCalled.ok || !sameBytes(serializeFeedback(asCalled.report), serializeFeedback(report)) || report.author?.principalId !== access.principalId)
197
+ return mismatch();
198
+ if (!await allowed(access, { key: subjectKeyOf(report.observed), observed: report.observed }, 'annotate'))
199
+ return unreconciled(receipt.operationId, 'Current access does not permit reconciliation of this operation');
200
+ return { kind: 'applied', operationId: receipt.operationId, report, revision: change.after.revision };
201
+ };
202
+ return admit('create', args, access, operation, match, async (operationId) => {
203
+ if (!await allowed(access, subject, 'annotate'))
204
+ return { kind: 'denied', reason: 'Annotating this subject is not authorized' };
205
+ let display;
206
+ try {
207
+ display = String(await displayName(access.principalId));
208
+ }
209
+ catch {
210
+ display = '';
211
+ }
212
+ if (!display.trim() || /[\n\r]/.test(display))
213
+ display = access.principalId;
214
+ const author = { principalId: access.principalId, display };
215
+ const id = feedbackId(), created = now();
216
+ let built;
217
+ try {
218
+ built = createRequest({ operationId, root, draft: args, id, created, author });
219
+ }
220
+ catch (error) {
221
+ if (error instanceof FeedbackFormatError)
222
+ return { kind: 'denied', reason: problemsText(error) };
223
+ throw error;
224
+ }
225
+ const outcome = await submit(built.request, access);
226
+ if (outcome.kind === 'committed')
227
+ return match(outcome.receipt);
228
+ if (outcome.kind === 'uncertain')
229
+ return reconcile(operationId, access, match);
230
+ if (outcome.kind !== 'conflict')
231
+ return outcome;
232
+ const existing = await readReport(id, access);
233
+ return { kind: 'conflict', current: existing.ok ? existing.value.ref.revision : null, reason: 'A report with this id already exists' };
234
+ });
235
+ };
236
+ /** The current report, then authorization on its subject. */
237
+ async function authorized(id, access, permission) {
238
+ if (typeof id !== 'string' || !FEEDBACK_ID.test(id))
239
+ return { ok: false, refusal: { kind: 'missing' } };
240
+ const current = await readReport(id, access);
241
+ if (!current.ok)
242
+ return current;
243
+ if (!await allowed(access, { key: subjectKeyOf(current.value.report.observed) }, permission))
244
+ return { ok: false, refusal: { kind: 'denied', reason: permission === 'read' ? 'Reading this feedback is not authorized' : 'Annotating this subject is not authorized' } };
245
+ return current;
246
+ }
247
+ const read = async (id, access) => {
248
+ const found = await authorized(id, access, 'read');
249
+ if (!found.ok)
250
+ return found.refusal;
251
+ return { kind: 'available', report: found.value.report, revision: found.value.ref.revision };
252
+ };
253
+ const list = async (query, access) => {
254
+ const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor);
255
+ if (query.cursor !== undefined && !after)
256
+ return { kind: 'unavailable', reason: 'The cursor is not valid' };
257
+ let names;
258
+ try {
259
+ names = await listFolder(root.path, { ...access });
260
+ }
261
+ catch {
262
+ return { kind: 'unavailable', reason: 'The feedback folder could not be listed' };
263
+ }
264
+ if (!Array.isArray(names))
265
+ return names;
266
+ const ids = names.filter(name => name.endsWith('.md') && FEEDBACK_ID.test(name.slice(0, -'.md'.length))).map(name => name.slice(0, -'.md'.length));
267
+ if (ids.length > FEEDBACK_LIST_LIMIT)
268
+ return { kind: 'unavailable', reason: `The feedback folder holds more than ${FEEDBACK_LIST_LIMIT} reports` };
269
+ const decisions = new Map();
270
+ const readable = async (key) => {
271
+ let decision = decisions.get(key);
272
+ if (decision === undefined) {
273
+ decision = await allowed(access, { key }, 'read');
274
+ decisions.set(key, decision);
275
+ }
276
+ return decision;
277
+ };
278
+ const entries = [];
279
+ for (const id of ids) {
280
+ const stored = await readReport(id, access);
281
+ // A file that vanished or is not a valid report is not a report: it is not listed.
282
+ if (stored.ok)
283
+ entries.push(listItemOf(root, stored.value.report));
284
+ }
285
+ const items = [];
286
+ let more = false;
287
+ for (const entry of entries.sort(order)) {
288
+ if (after && order(after, entry) >= 0)
289
+ continue;
290
+ if (query.status !== undefined && entry.status !== query.status)
291
+ continue;
292
+ if (query.subject !== undefined && entry.subject !== query.subject)
293
+ continue;
294
+ if (!await readable(entry.subject))
295
+ continue;
296
+ if (items.length === FEEDBACK_PAGE_SIZE) {
297
+ more = true;
298
+ break;
299
+ }
300
+ items.push(entry);
301
+ }
302
+ const last = items.at(-1);
303
+ return { kind: 'available', items, cursor: more && last ? encodeCursor(last) : null, protection };
304
+ };
305
+ const resolve = async (id, input, access, operation) => {
306
+ const args = { id, expectedRevision: input?.expectedRevision, note: input?.note };
307
+ const match = async (receipt) => {
308
+ const change = reportChangeOf(receipt);
309
+ if (change?.kind !== 'replace' || change.before.resource.path !== reportPath(root, id) || change.before.revision !== args.expectedRevision)
310
+ return mismatch();
311
+ const stored = await readReport(id, access, change.after.revision);
312
+ if (!stored.ok)
313
+ return unreconciled(receipt.operationId, 'The committed report could not be read to reconcile the operation');
314
+ const { report } = stored.value;
315
+ const last = report.resolutions.at(-1);
316
+ if (last?.note !== args.note || last.by !== access.principalId)
317
+ return mismatch();
318
+ if (!await allowed(access, { key: subjectKeyOf(report.observed) }, 'annotate'))
319
+ return unreconciled(receipt.operationId, 'Current access does not permit reconciliation of this operation');
320
+ return { kind: 'applied', operationId: receipt.operationId, report, revision: change.after.revision };
321
+ };
322
+ return admit('resolve', args, access, operation, match, async (operationId) => {
323
+ if (typeof args.expectedRevision !== 'string' || !args.expectedRevision)
324
+ return { kind: 'denied', reason: 'An expected revision is required' };
325
+ if (typeof args.note !== 'string')
326
+ return { kind: 'denied', reason: 'A resolution note is required' };
327
+ const current = await authorized(id, access, 'annotate');
328
+ if (!current.ok)
329
+ return current.refusal;
330
+ if (current.value.ref.revision !== args.expectedRevision)
331
+ return { kind: 'conflict', current: current.value.ref.revision, reason: 'The report changed' };
332
+ let built;
333
+ try {
334
+ built = resolveRequest({ operationId, root, current: current.value, resolution: { at: now(), by: access.principalId, note: args.note } });
335
+ }
336
+ catch (error) {
337
+ if (error instanceof FeedbackFormatError)
338
+ return { kind: 'denied', reason: problemsText(error) };
339
+ throw error;
340
+ }
341
+ const outcome = await submit(built.request, access);
342
+ if (outcome.kind === 'committed')
343
+ return match(outcome.receipt);
344
+ if (outcome.kind === 'uncertain')
345
+ return reconcile(operationId, access, match);
346
+ if (outcome.kind !== 'conflict')
347
+ return outcome;
348
+ const latest = await readReport(id, access);
349
+ return latest.ok ? { kind: 'conflict', current: latest.value.ref.revision, reason: 'The report changed' } : latest.refusal;
350
+ });
351
+ };
352
+ return Object.freeze({ root, resolveAccess: options.resolveAccess, operationKey, create, read, list, resolve, guarantees: () => ({ protection }) });
353
+ }
@@ -0,0 +1,5 @@
1
+ export { parseTicket, withTicketOutcome, createTicketSinks } from './ticket.js';
2
+ export type { ProjectInfo, ProjectRepo, Ticket, TicketSink, TicketSinkContext, TicketSinkResult, TicketOutcome, ParsedTicket, TicketSinkOptions, TicketSinks, TicketMirrorResult } from './ticket.js';
3
+ export { githubSink, fileSink, ticketRepoOf, GITHUB_API } from './sinks.js';
4
+ export type { GithubSinkOptions, FileSinkOptions } from './sinks.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ // @hachej/boring-feedback/tickets (FEEDBACK.md, "Tickets"), server only: a ticket is a Markdown file the assistant writes under `tickets/` with
2
+ // Pi's own file tools; `createTicketSinks(...).mirror(path)`, called by the host when a write creates one, publishes it once to the first
3
+ // sink that accepts the project and writes the outcome back into its front matter. Imports `@hachej/boring-files` contracts (type-only) and nothing else.
4
+ export { parseTicket, withTicketOutcome, createTicketSinks } from './ticket.js';
5
+ export { githubSink, fileSink, ticketRepoOf, GITHUB_API } from './sinks.js';
@@ -0,0 +1,27 @@
1
+ import type { ProjectInfo, Ticket, TicketSink } from './ticket.js';
2
+ export declare const GITHUB_API = "https://api.github.com";
3
+ /** The repository a project's tickets go to: the one with role `app`, else the first; only `owner/name` entries count. */
4
+ export declare function ticketRepoOf(project: ProjectInfo): string | undefined;
5
+ export interface GithubSinkOptions {
6
+ /** A token that may create issues in the project's repository. Server environment only. */
7
+ readonly token: string;
8
+ readonly fetch?: typeof globalThis.fetch;
9
+ /** Defaults to https://api.github.com (GitHub Enterprise: `https://<host>/api/v3`). */
10
+ readonly apiUrl?: string;
11
+ /** Added to the ticket's own labels. Defaults to `feedback`. */
12
+ readonly labels?: readonly string[];
13
+ readonly timeoutMs?: number;
14
+ }
15
+ /**
16
+ * Opens one GitHub issue per ticket: `POST /repos/<owner>/<name>/issues` with the ticket's title, body and labels (plus `labels`). One
17
+ * request, never retried: a refusal (401, 403, 404, 410, 422, any other status, no answer) is returned with a plain reason, and nothing
18
+ * is filed twice by this sink.
19
+ */
20
+ export declare function githubSink(options: GithubSinkOptions): TicketSink;
21
+ export interface FileSinkOptions {
22
+ /** The viewer or share link of a ticket file. */
23
+ readonly linkFor: (ticket: Ticket) => string;
24
+ }
25
+ /** The ticket file is the ticket: accepts every project and answers its link. */
26
+ export declare function fileSink(options: FileSinkOptions): TicketSink;
27
+ //# sourceMappingURL=sinks.d.ts.map
@@ -0,0 +1,84 @@
1
+ export const GITHUB_API = 'https://api.github.com';
2
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3
+ /** The repository a project's tickets go to: the one with role `app`, else the first; only `owner/name` entries count. */
4
+ export function ticketRepoOf(project) {
5
+ const repos = project.repos.filter(item => REPO.test(item.repo));
6
+ return (repos.find(item => item.role === 'app') ?? repos[0])?.repo;
7
+ }
8
+ const REFUSALS = {
9
+ 401: () => 'GitHub refused the token (401): it is missing, expired or revoked.',
10
+ 403: repo => `GitHub denied access (403): the token may not create issues in ${repo}.`,
11
+ 404: repo => `GitHub cannot see ${repo} (404): check the repository name and the token's access.`,
12
+ 410: repo => `Issues are turned off in ${repo} (410).`,
13
+ };
14
+ async function messageOf(response) {
15
+ try {
16
+ const body = await response.json();
17
+ const message = body !== null && typeof body === 'object' ? body['message'] : undefined;
18
+ return typeof message === 'string' && message.trim() ? message.trim().slice(0, 200) : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /**
25
+ * Opens one GitHub issue per ticket: `POST /repos/<owner>/<name>/issues` with the ticket's title, body and labels (plus `labels`). One
26
+ * request, never retried: a refusal (401, 403, 404, 410, 422, any other status, no answer) is returned with a plain reason, and nothing
27
+ * is filed twice by this sink.
28
+ */
29
+ export function githubSink(options) {
30
+ const token = options.token;
31
+ if (typeof token !== 'string' || !token.trim())
32
+ throw new TypeError('A GitHub token is required');
33
+ const fetchImpl = options.fetch ?? globalThis.fetch;
34
+ const api = (options.apiUrl ?? GITHUB_API).replace(/\/+$/, '');
35
+ const extra = options.labels ?? ['source:feedback'];
36
+ const timeoutMs = options.timeoutMs ?? 30_000;
37
+ return Object.freeze({
38
+ name: 'github',
39
+ accepts: (project) => ticketRepoOf(project) !== undefined,
40
+ publish: async (ticket, context) => {
41
+ const repo = ticketRepoOf(context.project);
42
+ if (!repo)
43
+ return { refused: `The project "${context.project.name}" names no GitHub repository.` };
44
+ const labels = [...new Set([...ticket.labels, ...extra])];
45
+ const body = `${ticket.body}\n\n<!-- boring-ticket: ${ticket.id} -->\n`;
46
+ const signal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
47
+ let response;
48
+ try {
49
+ response = await fetchImpl(`${api}/repos/${repo}/issues`, {
50
+ method: 'POST', signal,
51
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'content-type': 'application/json', 'user-agent': 'boring-feedback-tickets' },
52
+ body: JSON.stringify({ title: ticket.title, body, labels }),
53
+ });
54
+ }
55
+ catch {
56
+ return { refused: `GitHub did not answer; the issue may or may not exist in ${repo}: check before filing again.` };
57
+ }
58
+ if (response.status === 201) {
59
+ let url;
60
+ try {
61
+ url = (await response.json())['html_url'];
62
+ }
63
+ catch {
64
+ url = undefined;
65
+ }
66
+ return typeof url === 'string' && /^https:\/\//.test(url) ? { url } : { refused: `GitHub created an issue in ${repo} but sent no link to it.` };
67
+ }
68
+ const known = REFUSALS[response.status];
69
+ if (known)
70
+ return { refused: known(repo) };
71
+ if (response.status === 422)
72
+ return { refused: `GitHub refused the issue (422)${await messageOf(response).then(message => message ? `: ${message}` : '')}.` };
73
+ return { refused: `GitHub answered ${response.status}; nothing is known to be filed in ${repo}.` };
74
+ },
75
+ });
76
+ }
77
+ /** The ticket file is the ticket: accepts every project and answers its link. */
78
+ export function fileSink(options) {
79
+ return Object.freeze({
80
+ name: 'file',
81
+ accepts: () => true,
82
+ publish: async (ticket) => ({ url: options.linkFor(ticket) }),
83
+ });
84
+ }