vouchington-tooling 0.23.0 → 0.24.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/README.md +76 -4
- package/dist/agent-blackboard/client.d.mts +34 -0
- package/dist/agent-blackboard/client.mjs +41 -0
- package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
- package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
- package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
- package/dist/agent-blackboard/feedback-codec.mjs +121 -0
- package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
- package/dist/agent-blackboard/feedback-compose.mjs +109 -0
- package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
- package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
- package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
- package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
- package/dist/agent-blackboard/feedback-fields.mjs +38 -0
- package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
- package/dist/agent-blackboard/feedback-identity.mjs +29 -0
- package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
- package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
- package/dist/agent-blackboard/feedback-online.d.mts +2 -0
- package/dist/agent-blackboard/feedback-online.mjs +137 -0
- package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
- package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
- package/dist/agent-blackboard/feedback-types.d.mts +66 -0
- package/dist/agent-blackboard/feedback-types.mjs +1 -0
- package/dist/agent-blackboard/index.d.mts +16 -39
- package/dist/agent-blackboard/index.mjs +35 -81
- package/dist/cli/commands/agent-blackboard.mjs +35 -4
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -1
- package/dist/retrospective-facts/coverage.d.mts +6 -0
- package/dist/retrospective-facts/coverage.mjs +42 -0
- package/dist/retrospective-facts/index.d.mts +3 -0
- package/dist/retrospective-facts/index.mjs +18 -1
- package/dist/retrospective-transcript/codex.mjs +8 -3
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +29 -9
- package/dist/session-friction/classify.mjs +25 -14
- package/dist/session-friction/coverage-ledger.d.mts +10 -0
- package/dist/session-friction/coverage-ledger.mjs +75 -0
- package/dist/session-friction/index.d.mts +1 -1
- package/dist/session-friction/log-file.d.mts +2 -2
- package/dist/session-friction/log-file.mjs +22 -10
- package/dist/session-friction/log.mjs +57 -10
- package/dist/session-friction/report.mjs +36 -7
- package/dist/session-friction/sandbox.mjs +11 -4
- package/dist/session-friction/types.d.mts +25 -4
- package/dist/workflow-policy/index.d.mts +1 -1
- package/dist/workflow-policy/index.mjs +1 -1
- package/dist/workflow-policy/permission-declarations.d.mts +14 -0
- package/dist/workflow-policy/permission-declarations.mjs +55 -0
- package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
- package/dist/workflow-policy/permission-inheritance.mjs +88 -0
- package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
- package/dist/workflow-policy/permission-mismatches.mjs +14 -3
- package/docs/workflow-and-compiler.md +18 -1
- package/package.json +1 -1
- package/skills/blackboard/SKILL.md +34 -14
- package/skills/retrospective/SKILL.md +27 -16
- package/skills/retrospective-distill/SKILL.md +15 -6
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isWellFormedUnicode } from '../session-friction/text.mjs';
|
|
2
|
+
export function validateFeedbackText(value, label, maximum) {
|
|
3
|
+
if (typeof value !== 'string' ||
|
|
4
|
+
!value.trim() ||
|
|
5
|
+
Buffer.byteLength(value) > maximum ||
|
|
6
|
+
!isWellFormedUnicode(value) ||
|
|
7
|
+
// oxlint-disable-next-line no-control-regex -- reject controls at the durable boundary
|
|
8
|
+
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value))
|
|
9
|
+
throw new Error(`${label} must be bounded non-empty Unicode text`);
|
|
10
|
+
}
|
|
11
|
+
export function normalizeRepositories(value, required = true) {
|
|
12
|
+
if (value === undefined && !required)
|
|
13
|
+
return [];
|
|
14
|
+
if (!Array.isArray(value) || value.length > 32 || (required && value.length === 0))
|
|
15
|
+
throw new Error('repositories must be a non-empty array of owner/name strings');
|
|
16
|
+
return [
|
|
17
|
+
...new Set(value.map((candidate) => {
|
|
18
|
+
if (typeof candidate !== 'string' || candidate.length > 160)
|
|
19
|
+
throw new Error('repositories must be a non-empty array of owner/name strings');
|
|
20
|
+
const repository = candidate.trim().toLowerCase();
|
|
21
|
+
if (!/^[a-z0-9-]+\/[a-z0-9._-]+$/.test(repository))
|
|
22
|
+
throw new Error('invalid repository attribution');
|
|
23
|
+
return repository;
|
|
24
|
+
})),
|
|
25
|
+
].sort();
|
|
26
|
+
}
|
|
27
|
+
export function validateFeedbackReferences(value, label) {
|
|
28
|
+
if (!Array.isArray(value) || value.length > 64)
|
|
29
|
+
throw new Error(`${label} must be a bounded reference array`);
|
|
30
|
+
for (const reference of value) {
|
|
31
|
+
if (typeof reference === 'number') {
|
|
32
|
+
if (!Number.isSafeInteger(reference) || reference <= 0)
|
|
33
|
+
throw new Error(`${label} numbers must be positive integers`);
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
validateFeedbackText(reference, label, 256);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isWellFormedUnicode } from '../session-friction/text.mjs';
|
|
2
|
+
import { assertSessionId } from './session-id.mjs';
|
|
3
|
+
import { isObject } from './snapshot-partition-guards.mjs';
|
|
4
|
+
export function validateFeedbackIdentity(identity) {
|
|
5
|
+
if (!isObject(identity) || typeof identity.sessionId !== 'string')
|
|
6
|
+
throw new Error('explicit feedback identity is required');
|
|
7
|
+
assertSessionId(identity.sessionId);
|
|
8
|
+
if (identity.sessionId.length > 256)
|
|
9
|
+
throw new Error('session identity is too long');
|
|
10
|
+
if (identity.parentSessionId !== null) {
|
|
11
|
+
if (typeof identity.parentSessionId !== 'string')
|
|
12
|
+
throw new Error('explicit parent identity is required');
|
|
13
|
+
assertSessionId(identity.parentSessionId, 'parent session id');
|
|
14
|
+
if (identity.parentSessionId.length > 256)
|
|
15
|
+
throw new Error('parent session identity is too long');
|
|
16
|
+
}
|
|
17
|
+
if (identity.parentSessionId === identity.sessionId)
|
|
18
|
+
throw new Error('a session cannot parent itself');
|
|
19
|
+
for (const key of ['agent', 'version'])
|
|
20
|
+
if (typeof identity[key] !== 'string' ||
|
|
21
|
+
!identity[key].trim() ||
|
|
22
|
+
identity[key].length > 128 ||
|
|
23
|
+
!isWellFormedUnicode(identity[key]) ||
|
|
24
|
+
// oxlint-disable-next-line no-control-regex -- reject control characters at the durable boundary
|
|
25
|
+
/[\x00-\x1f\x7f]/.test(identity[key]))
|
|
26
|
+
throw new Error(`explicit ${key} is required`);
|
|
27
|
+
if (Object.keys(identity).some((key) => !['sessionId', 'parentSessionId', 'agent', 'version'].includes(key)))
|
|
28
|
+
throw new Error('unsupported identity field');
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FeedbackDiagnostic } from './feedback-types.mts';
|
|
2
|
+
export declare class FeedbackDeliveryError extends Error {
|
|
3
|
+
readonly status = "blocked";
|
|
4
|
+
readonly diagnostic: FeedbackDiagnostic;
|
|
5
|
+
constructor(diagnostic: FeedbackDiagnostic);
|
|
6
|
+
}
|
|
7
|
+
export declare function feedbackDiagnostic(error: unknown): FeedbackDiagnostic;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isObject } from './snapshot-partition-guards.mjs';
|
|
2
|
+
export class FeedbackDeliveryError extends Error {
|
|
3
|
+
status = 'blocked';
|
|
4
|
+
diagnostic;
|
|
5
|
+
constructor(diagnostic) {
|
|
6
|
+
super(`Blackboard feedback blocked: ${diagnostic}`);
|
|
7
|
+
this.name = 'FeedbackDeliveryError';
|
|
8
|
+
this.diagnostic = diagnostic;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function feedbackDiagnostic(error) {
|
|
12
|
+
if (error instanceof FeedbackDeliveryError)
|
|
13
|
+
return error.diagnostic;
|
|
14
|
+
if (isObject(error) &&
|
|
15
|
+
(error.status === 401 ||
|
|
16
|
+
error.status === 403 ||
|
|
17
|
+
error.statusCode === 401 ||
|
|
18
|
+
error.statusCode === 403))
|
|
19
|
+
return 'authentication-rejected';
|
|
20
|
+
if (isObject(error) && (error.status === 409 || error.statusCode === 409))
|
|
21
|
+
return 'identity-conflict';
|
|
22
|
+
return 'blackboard-unavailable';
|
|
23
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { feedbackDeadline } from './feedback-deadline.mjs';
|
|
2
|
+
import { FeedbackDeliveryError, feedbackDiagnostic } from './feedback-online-error.mjs';
|
|
3
|
+
import { canonicalFeedback } from './feedback-canonical.mjs';
|
|
4
|
+
import { loadClient, resolveBlackboardConnection } from './client.mjs';
|
|
5
|
+
import { normalizeRepositories, validateFeedbackEnvelope } from './feedback-codec.mjs';
|
|
6
|
+
import { validateFeedbackIdentity } from './feedback-identity.mjs';
|
|
7
|
+
import { isObject } from './snapshot-partition-guards.mjs';
|
|
8
|
+
async function ensureSession(sessions, identity) {
|
|
9
|
+
try {
|
|
10
|
+
return await sessions.ensure({
|
|
11
|
+
id: identity.sessionId,
|
|
12
|
+
parentSessionId: identity.parentSessionId,
|
|
13
|
+
agent: identity.agent,
|
|
14
|
+
version: identity.version,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
let existing;
|
|
19
|
+
try {
|
|
20
|
+
existing = await sessions.get(identity.sessionId);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (isObject(existing) &&
|
|
26
|
+
existing.id === identity.sessionId &&
|
|
27
|
+
['parentSessionId', 'agent', 'version'].some((field) => (existing[field] ?? null) !== identity[field]))
|
|
28
|
+
throw new FeedbackDeliveryError('identity-conflict');
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function findEvent(entries, envelope, expectedCreatedAt) {
|
|
33
|
+
let count = 0;
|
|
34
|
+
let inspectedBytes = 0;
|
|
35
|
+
let found;
|
|
36
|
+
let matching = 0;
|
|
37
|
+
for await (const entry of entries) {
|
|
38
|
+
inspectedBytes += Buffer.byteLength(JSON.stringify(entry));
|
|
39
|
+
if (++count > 10_000 || inspectedBytes > 2_000_000)
|
|
40
|
+
throw new FeedbackDeliveryError('readback-unconfirmed');
|
|
41
|
+
if (!isObject(entry) ||
|
|
42
|
+
!isObject(entry.data) ||
|
|
43
|
+
entry.data.sourceEventId !== envelope.sourceEventId)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
validateFeedbackEnvelope(entry.data);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new FeedbackDeliveryError('event-conflict');
|
|
50
|
+
}
|
|
51
|
+
if (canonicalFeedback(entry.data) !== canonicalFeedback(envelope) ||
|
|
52
|
+
typeof entry.createdAt !== 'string')
|
|
53
|
+
throw new FeedbackDeliveryError('event-conflict');
|
|
54
|
+
if (++matching > 1 && expectedCreatedAt !== undefined)
|
|
55
|
+
throw new FeedbackDeliveryError('event-conflict');
|
|
56
|
+
if (expectedCreatedAt === undefined || entry.createdAt === expectedCreatedAt)
|
|
57
|
+
found = { createdAt: entry.createdAt };
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
export async function deliverFeedbackOnline(input, fresh = false) {
|
|
62
|
+
validateFeedbackIdentity(input.identity);
|
|
63
|
+
validateFeedbackEnvelope(input.envelope);
|
|
64
|
+
return feedbackDeadline(async () => {
|
|
65
|
+
try {
|
|
66
|
+
let connection;
|
|
67
|
+
try {
|
|
68
|
+
connection = resolveBlackboardConnection(input.env);
|
|
69
|
+
const url = new URL(connection.baseUrl);
|
|
70
|
+
if (url.username ||
|
|
71
|
+
url.password ||
|
|
72
|
+
(url.protocol !== 'https:' &&
|
|
73
|
+
!(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
|
|
74
|
+
throw new Error('invalid connection URL');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new FeedbackDeliveryError('configuration-invalid');
|
|
78
|
+
}
|
|
79
|
+
let client;
|
|
80
|
+
try {
|
|
81
|
+
client = await loadClient(input.dependencies);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new FeedbackDeliveryError('client-unavailable');
|
|
85
|
+
}
|
|
86
|
+
const { Sessions, Entries } = client;
|
|
87
|
+
const sessions = new Sessions(connection);
|
|
88
|
+
const entries = new Entries(connection);
|
|
89
|
+
const ensured = await ensureSession(sessions, input.identity);
|
|
90
|
+
if (ensured.session.archivedAt != null)
|
|
91
|
+
throw new FeedbackDeliveryError('archived-session');
|
|
92
|
+
const existing = await findEvent(entries.get({ sessionId: input.identity.sessionId, format: 'jsonl' }), input.envelope);
|
|
93
|
+
if (existing && fresh)
|
|
94
|
+
throw new FeedbackDeliveryError('event-conflict');
|
|
95
|
+
const merged = [
|
|
96
|
+
...new Set([
|
|
97
|
+
...normalizeRepositories(ensured.session.data.repositories, false),
|
|
98
|
+
...input.envelope.repositories,
|
|
99
|
+
]),
|
|
100
|
+
].sort();
|
|
101
|
+
if (JSON.stringify(ensured.session.data.repositories) !== JSON.stringify(merged))
|
|
102
|
+
await sessions.patch({
|
|
103
|
+
sessionId: input.identity.sessionId,
|
|
104
|
+
data: { repositories: merged },
|
|
105
|
+
});
|
|
106
|
+
if (existing)
|
|
107
|
+
return {
|
|
108
|
+
sessionId: input.identity.sessionId,
|
|
109
|
+
sourceEventId: input.envelope.sourceEventId,
|
|
110
|
+
createdAt: existing.createdAt,
|
|
111
|
+
verified: true,
|
|
112
|
+
};
|
|
113
|
+
const appended = await entries.append({
|
|
114
|
+
sessionId: input.identity.sessionId,
|
|
115
|
+
data: input.envelope,
|
|
116
|
+
});
|
|
117
|
+
if (fresh &&
|
|
118
|
+
(!isObject(appended) ||
|
|
119
|
+
typeof appended.createdAt !== 'string' ||
|
|
120
|
+
!isObject(appended.data) ||
|
|
121
|
+
canonicalFeedback(appended.data) !== canonicalFeedback(input.envelope)))
|
|
122
|
+
throw new FeedbackDeliveryError('readback-unconfirmed');
|
|
123
|
+
const confirmed = await findEvent(entries.get({ sessionId: input.identity.sessionId, format: 'jsonl' }), input.envelope, fresh ? appended.createdAt : undefined);
|
|
124
|
+
if (!confirmed)
|
|
125
|
+
throw new FeedbackDeliveryError('readback-unconfirmed');
|
|
126
|
+
return {
|
|
127
|
+
sessionId: input.identity.sessionId,
|
|
128
|
+
sourceEventId: input.envelope.sourceEventId,
|
|
129
|
+
createdAt: confirmed.createdAt,
|
|
130
|
+
verified: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
throw new FeedbackDeliveryError(feedbackDiagnostic(error));
|
|
135
|
+
}
|
|
136
|
+
}, input.timeoutMs);
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FeedbackOutboxRecord, FeedbackOutboxStatus } from './feedback-types.mts';
|
|
2
|
+
export declare function feedbackOutboxStatus(directory: string): FeedbackOutboxStatus;
|
|
3
|
+
export declare function listFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
|
|
4
|
+
export declare function persistFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): number;
|
|
5
|
+
export declare function removeFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): {
|
|
6
|
+
pendingCount: number;
|
|
7
|
+
cleanupDiagnostic?: 'outbox-cleanup-failed';
|
|
8
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { canonicalFeedback } from './feedback-canonical.mjs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, constants, fsyncSync, fstatSync, openSync, readdirSync, renameSync, unlinkSync, } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { ensurePrivateDirectory } from '../session-friction/directory.mjs';
|
|
6
|
+
import { withFileLock } from '../session-friction/lock.mjs';
|
|
7
|
+
import { openLogFile, readLogContent, writeAll } from '../session-friction/log-file.mjs';
|
|
8
|
+
import { validateFeedbackEnvelope } from './feedback-codec.mjs';
|
|
9
|
+
import { validateFeedbackIdentity } from './feedback-identity.mjs';
|
|
10
|
+
import { isObject } from './snapshot-partition-guards.mjs';
|
|
11
|
+
const FEEDBACK_OUTBOX_MAX_RECORDS = 128;
|
|
12
|
+
const FEEDBACK_OUTBOX_MAX_BYTES = 2_000_000;
|
|
13
|
+
const RECORD_MAX_BYTES = 18_000;
|
|
14
|
+
function outboxDirectory(directory, create) {
|
|
15
|
+
if (typeof directory !== 'string' || directory.length > 4096 || !isAbsolute(directory))
|
|
16
|
+
throw new Error('feedback outbox requires an absolute private directory');
|
|
17
|
+
const path = resolve(directory);
|
|
18
|
+
ensurePrivateDirectory(path, create);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
function filename(record) {
|
|
22
|
+
return `${createHash('sha256')
|
|
23
|
+
.update(JSON.stringify([record.identity.sessionId, record.envelope.sourceEventId]))
|
|
24
|
+
.digest('hex')}.json`;
|
|
25
|
+
}
|
|
26
|
+
function readRecord(path) {
|
|
27
|
+
const descriptor = openLogFile(path, constants.O_RDONLY);
|
|
28
|
+
try {
|
|
29
|
+
const status = fstatSync(descriptor);
|
|
30
|
+
if ((status.mode & 0o777) !== 0o600 || status.uid !== process.geteuid?.())
|
|
31
|
+
throw new Error('feedback outbox record must be private and owned by caller');
|
|
32
|
+
const bytes = status.size;
|
|
33
|
+
if (bytes > RECORD_MAX_BYTES)
|
|
34
|
+
throw new Error('feedback outbox record is too large');
|
|
35
|
+
const value = JSON.parse(readLogContent(descriptor, RECORD_MAX_BYTES));
|
|
36
|
+
if (!isObject(value) ||
|
|
37
|
+
Object.keys(value).some((key) => !['identity', 'envelope'].includes(key)))
|
|
38
|
+
throw new Error('invalid feedback outbox record');
|
|
39
|
+
validateFeedbackIdentity(value.identity);
|
|
40
|
+
validateFeedbackEnvelope(value.envelope);
|
|
41
|
+
return { record: { identity: value.identity, envelope: value.envelope }, bytes };
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
closeSync(descriptor);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function records(directory) {
|
|
48
|
+
const paths = readdirSync(directory).filter((name) => name !== '.records.lock' && name !== '.records.lock.reap');
|
|
49
|
+
if (paths.length > FEEDBACK_OUTBOX_MAX_RECORDS)
|
|
50
|
+
throw new Error('feedback outbox capacity exceeded; unsent records retained');
|
|
51
|
+
const result = paths.sort().map((name) => {
|
|
52
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name))
|
|
53
|
+
throw new Error('feedback outbox contains an unresolved persistence file');
|
|
54
|
+
const path = join(directory, name);
|
|
55
|
+
const loaded = readRecord(path);
|
|
56
|
+
if (filename(loaded.record) !== name)
|
|
57
|
+
throw new Error('feedback outbox record identity does not match its path');
|
|
58
|
+
return { ...loaded, path };
|
|
59
|
+
});
|
|
60
|
+
if (result.reduce((sum, item) => sum + item.bytes, 0) > FEEDBACK_OUTBOX_MAX_BYTES)
|
|
61
|
+
throw new Error('feedback outbox byte capacity exceeded; unsent records retained');
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
function syncDirectory(directory) {
|
|
65
|
+
const descriptor = openSync(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
66
|
+
try {
|
|
67
|
+
fsyncSync(descriptor);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
closeSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function feedbackOutboxStatus(directory) {
|
|
74
|
+
const path = outboxDirectory(directory, false);
|
|
75
|
+
if (!ensurePrivateDirectory(path, false))
|
|
76
|
+
return { status: 'empty', pendingCount: 0 };
|
|
77
|
+
return withFileLock(join(path, '.records'), () => {
|
|
78
|
+
const pendingCount = records(path).length;
|
|
79
|
+
return { status: pendingCount ? 'pending' : 'empty', pendingCount };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function listFeedbackOutbox(directory) {
|
|
83
|
+
const path = outboxDirectory(directory, true);
|
|
84
|
+
return withFileLock(join(path, '.records'), () => records(path).map((item) => item.record));
|
|
85
|
+
}
|
|
86
|
+
export function persistFeedbackOutbox(directory, record) {
|
|
87
|
+
validateFeedbackIdentity(record.identity);
|
|
88
|
+
validateFeedbackEnvelope(record.envelope);
|
|
89
|
+
const path = outboxDirectory(directory, true);
|
|
90
|
+
return withFileLock(join(path, '.records'), () => {
|
|
91
|
+
const current = records(path);
|
|
92
|
+
const name = filename(record);
|
|
93
|
+
const existing = current.find((item) => item.path === join(path, name));
|
|
94
|
+
const serialized = JSON.stringify(record);
|
|
95
|
+
if (existing) {
|
|
96
|
+
if (canonicalFeedback(existing.record) !== canonicalFeedback(record))
|
|
97
|
+
throw new Error('feedback source event conflicts with a retained unsent record');
|
|
98
|
+
return current.length;
|
|
99
|
+
}
|
|
100
|
+
const bytes = Buffer.byteLength(serialized);
|
|
101
|
+
if (bytes > RECORD_MAX_BYTES ||
|
|
102
|
+
current.length >= FEEDBACK_OUTBOX_MAX_RECORDS ||
|
|
103
|
+
current.reduce((sum, item) => sum + item.bytes, 0) + bytes > FEEDBACK_OUTBOX_MAX_BYTES)
|
|
104
|
+
throw new Error('feedback outbox is full; unsent records retained');
|
|
105
|
+
const temporary = join(path, `.persist-${randomUUID()}`);
|
|
106
|
+
const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
107
|
+
try {
|
|
108
|
+
writeAll(descriptor, serialized);
|
|
109
|
+
fsyncSync(descriptor);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
closeSync(descriptor);
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(temporary);
|
|
115
|
+
}
|
|
116
|
+
catch { }
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
closeSync(descriptor);
|
|
120
|
+
try {
|
|
121
|
+
renameSync(temporary, join(path, name));
|
|
122
|
+
syncDirectory(path);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
try {
|
|
126
|
+
unlinkSync(temporary);
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
return current.length + 1;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function removeFeedbackOutbox(directory, record) {
|
|
135
|
+
const path = outboxDirectory(directory, true);
|
|
136
|
+
return withFileLock(join(path, '.records'), () => {
|
|
137
|
+
const current = records(path);
|
|
138
|
+
const existing = current.find((item) => item.path === join(path, filename(record)));
|
|
139
|
+
if (!existing)
|
|
140
|
+
return { pendingCount: current.length };
|
|
141
|
+
if (canonicalFeedback(existing.record) !== canonicalFeedback(record))
|
|
142
|
+
throw new Error('feedback outbox changed during delivery');
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(existing.path);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { pendingCount: current.length, cleanupDiagnostic: 'outbox-cleanup-failed' };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
syncDirectory(path);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { pendingCount: current.length - 1, cleanupDiagnostic: 'outbox-cleanup-failed' };
|
|
154
|
+
}
|
|
155
|
+
return { pendingCount: current.length - 1 };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BlackboardClientDependencies } from './client.mts';
|
|
2
|
+
export type FeedbackMode = 'interactive' | 'autonomous';
|
|
3
|
+
export type WorkOutcome = 'in-progress' | 'success' | 'failure' | 'cancelled' | 'timed-out' | 'no-change' | 'policy-refusal' | 'unknown';
|
|
4
|
+
export type FeedbackCoverage = {
|
|
5
|
+
status: 'complete' | 'partial' | 'unavailable' | 'not-assessed' | 'not-started';
|
|
6
|
+
sources: string[];
|
|
7
|
+
droppedCount: number;
|
|
8
|
+
};
|
|
9
|
+
export type FeedbackReference = string | number;
|
|
10
|
+
export type FeedbackEnvelope = {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
type: 'journal' | 'retrospective';
|
|
13
|
+
sourceEventId: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
repositories: string[];
|
|
16
|
+
markdown: string;
|
|
17
|
+
workOutcome: WorkOutcome;
|
|
18
|
+
feedbackCoverage: FeedbackCoverage;
|
|
19
|
+
category?: string;
|
|
20
|
+
date?: string;
|
|
21
|
+
issues?: FeedbackReference[];
|
|
22
|
+
prs?: FeedbackReference[];
|
|
23
|
+
};
|
|
24
|
+
export type FeedbackIdentity = {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
parentSessionId: string | null;
|
|
27
|
+
agent: string;
|
|
28
|
+
version: string;
|
|
29
|
+
};
|
|
30
|
+
export type FeedbackReceipt = {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
sourceEventId: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
verified: true;
|
|
35
|
+
};
|
|
36
|
+
export type FeedbackDiagnostic = 'blackboard-unavailable' | 'client-unavailable' | 'configuration-invalid' | 'authentication-rejected' | 'identity-conflict' | 'readback-unconfirmed' | 'event-conflict' | 'archived-session' | 'delivery-timeout';
|
|
37
|
+
export type FeedbackDeliveryResult = {
|
|
38
|
+
status: 'delivered';
|
|
39
|
+
sourceEventId: string;
|
|
40
|
+
pendingCount: number;
|
|
41
|
+
receipt: FeedbackReceipt;
|
|
42
|
+
cleanupDiagnostic?: 'outbox-cleanup-failed';
|
|
43
|
+
} | {
|
|
44
|
+
status: 'pending';
|
|
45
|
+
sourceEventId: string;
|
|
46
|
+
pendingCount: number;
|
|
47
|
+
diagnostic: FeedbackDiagnostic;
|
|
48
|
+
};
|
|
49
|
+
export type FeedbackDeliveryOptions = {
|
|
50
|
+
identity: FeedbackIdentity;
|
|
51
|
+
envelope: FeedbackEnvelope;
|
|
52
|
+
mode: FeedbackMode;
|
|
53
|
+
outboxDirectory?: string;
|
|
54
|
+
env?: NodeJS.ProcessEnv;
|
|
55
|
+
dependencies?: BlackboardClientDependencies;
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
};
|
|
58
|
+
export type FeedbackOnlineOptions = Omit<FeedbackDeliveryOptions, 'mode' | 'outboxDirectory'>;
|
|
59
|
+
export type FeedbackOutboxRecord = {
|
|
60
|
+
identity: FeedbackIdentity;
|
|
61
|
+
envelope: FeedbackEnvelope;
|
|
62
|
+
};
|
|
63
|
+
export type FeedbackOutboxStatus = {
|
|
64
|
+
status: 'empty' | 'pending';
|
|
65
|
+
pendingCount: number;
|
|
66
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,48 +1,25 @@
|
|
|
1
|
+
import type { FeedbackCoverage, FeedbackDeliveryOptions, FeedbackDeliveryResult, FeedbackIdentity, WorkOutcome } from './feedback-types.mts';
|
|
2
|
+
import { type BlackboardClientDependencies } from './client.mts';
|
|
1
3
|
export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mts';
|
|
2
4
|
export { assertSessionId } from './session-id.mts';
|
|
3
5
|
export type * from './snapshot-types.mts';
|
|
4
|
-
export type BlackboardConnection = {
|
|
5
|
-
baseUrl: string;
|
|
6
|
-
token: string;
|
|
7
|
-
readRetry: Record<string, never>;
|
|
8
|
-
};
|
|
9
|
-
export type BlackboardClientModule = {
|
|
10
|
-
Sessions: new (connection: BlackboardConnection) => {
|
|
11
|
-
ensure(input: unknown): Promise<{
|
|
12
|
-
status: 'created' | 'exists';
|
|
13
|
-
session: {
|
|
14
|
-
data: Record<string, unknown>;
|
|
15
|
-
archivedAt?: string | null;
|
|
16
|
-
};
|
|
17
|
-
}>;
|
|
18
|
-
patch(input: unknown): Promise<unknown>;
|
|
19
|
-
list(input: unknown): Promise<unknown>;
|
|
20
|
-
get(id: string): Promise<unknown>;
|
|
21
|
-
};
|
|
22
|
-
Entries: new (connection: BlackboardConnection) => {
|
|
23
|
-
append(input: unknown): Promise<{
|
|
24
|
-
createdAt: string;
|
|
25
|
-
}>;
|
|
26
|
-
get(input: unknown): AsyncIterable<unknown>;
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
type BlackboardClientLoader = () => Promise<BlackboardClientModule>;
|
|
30
|
-
export type BlackboardClientDependencies = {
|
|
31
|
-
loadClient?: BlackboardClientLoader;
|
|
32
|
-
resolveFrom?: string | URL;
|
|
33
|
-
};
|
|
34
|
-
export declare function resolveBlackboardConnection(env?: NodeJS.ProcessEnv): BlackboardConnection;
|
|
35
6
|
export declare function probeBlackboard(env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<void>;
|
|
36
|
-
export declare function appendJournal(input: {
|
|
37
|
-
sessionId: string;
|
|
38
|
-
agent: string;
|
|
39
|
-
version: string;
|
|
7
|
+
export declare function appendJournal(input: Omit<FeedbackDeliveryOptions, 'identity' | 'envelope'> & FeedbackIdentity & {
|
|
40
8
|
repositories: string[];
|
|
41
9
|
markdownFile: string;
|
|
42
|
-
|
|
10
|
+
sourceEventId: string;
|
|
11
|
+
workOutcome: WorkOutcome;
|
|
12
|
+
feedbackCoverage: FeedbackCoverage;
|
|
43
13
|
timestamp?: string;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}): Promise<string>;
|
|
14
|
+
category?: string;
|
|
15
|
+
}): Promise<FeedbackDeliveryResult>;
|
|
47
16
|
export declare function readJournal(sessionId: string, env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<unknown[]>;
|
|
48
17
|
export declare function formatJournalEntries(sessionId: string, entries: unknown[]): string;
|
|
18
|
+
export { resolveBlackboardConnection } from './client.mts';
|
|
19
|
+
export type { BlackboardConnection, BlackboardClientModule, BlackboardClientDependencies, } from './client.mts';
|
|
20
|
+
export type * from './feedback-types.mts';
|
|
21
|
+
export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mts';
|
|
22
|
+
export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from './feedback-delivery.mts';
|
|
23
|
+
export { feedbackOutboxStatus } from './feedback-outbox.mts';
|
|
24
|
+
export { composeRetrospective } from './feedback-compose.mts';
|
|
25
|
+
export type { RetrospectiveCompositionInput, FeedbackAssessment } from './feedback-compose.mts';
|