@agent-relay/factory 0.1.56 → 0.1.58
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 +148 -2
- package/dist/cli/fleet.d.ts +13 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +431 -103
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1107 -348
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +67 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/cost/index.d.ts +3 -0
- package/dist/cost/index.d.ts.map +1 -0
- package/dist/cost/index.js +3 -0
- package/dist/cost/index.js.map +1 -0
- package/dist/cost/ledger.d.ts +69 -0
- package/dist/cost/ledger.d.ts.map +1 -0
- package/dist/cost/ledger.js +162 -0
- package/dist/cost/ledger.js.map +1 -0
- package/dist/cost/pricing.d.ts +18 -0
- package/dist/cost/pricing.d.ts.map +1 -0
- package/dist/cost/pricing.js +54 -0
- package/dist/cost/pricing.js.map +1 -0
- package/dist/feature-guardian/conversation.d.ts +251 -0
- package/dist/feature-guardian/conversation.d.ts.map +1 -0
- package/dist/feature-guardian/conversation.js +1893 -0
- package/dist/feature-guardian/conversation.js.map +1 -0
- package/dist/feature-guardian/index.d.ts +2 -0
- package/dist/feature-guardian/index.d.ts.map +1 -0
- package/dist/feature-guardian/index.js +2 -0
- package/dist/feature-guardian/index.js.map +1 -0
- package/dist/fleet/internal-fleet-client.d.ts +3 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +81 -0
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +3 -1
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +48 -0
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/index.d.ts +2 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +1 -0
- package/dist/github/index.js.map +1 -1
- package/dist/github/routed-pr-babysitter.d.ts +48 -0
- package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
- package/dist/github/routed-pr-babysitter.js +197 -0
- package/dist/github/routed-pr-babysitter.js.map +1 -0
- package/dist/hosted/index.d.ts +1 -1
- package/dist/hosted/index.d.ts.map +1 -1
- package/dist/hosted/index.js +1 -1
- package/dist/hosted/index.js.map +1 -1
- package/dist/hosted/orchestrator.d.ts +1 -0
- package/dist/hosted/orchestrator.d.ts.map +1 -1
- package/dist/hosted/orchestrator.js +190 -1
- package/dist/hosted/orchestrator.js.map +1 -1
- package/dist/hosted/types.d.ts +4 -0
- package/dist/hosted/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/intake/index.d.ts +4 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +4 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/notion-relay-claim.d.ts +32 -0
- package/dist/intake/notion-relay-claim.d.ts.map +1 -0
- package/dist/intake/notion-relay-claim.js +173 -0
- package/dist/intake/notion-relay-claim.js.map +1 -0
- package/dist/intake/notion-relay-contract.d.ts +33 -0
- package/dist/intake/notion-relay-contract.d.ts.map +1 -0
- package/dist/intake/notion-relay-contract.js +163 -0
- package/dist/intake/notion-relay-contract.js.map +1 -0
- package/dist/intake/notion.d.ts +375 -0
- package/dist/intake/notion.d.ts.map +1 -0
- package/dist/intake/notion.js +773 -0
- package/dist/intake/notion.js.map +1 -0
- package/dist/mount/github-api-issue-read.d.ts +28 -0
- package/dist/mount/github-api-issue-read.d.ts.map +1 -0
- package/dist/mount/github-api-issue-read.js +163 -0
- package/dist/mount/github-api-issue-read.js.map +1 -0
- package/dist/mount/local-mount-preflight.d.ts +2 -0
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +59 -9
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/mount-auth-error.d.ts +41 -0
- package/dist/mount/mount-auth-error.d.ts.map +1 -0
- package/dist/mount/mount-auth-error.js +94 -0
- package/dist/mount/mount-auth-error.js.map +1 -0
- package/dist/mount/relayfile-binary.d.ts +5 -0
- package/dist/mount/relayfile-binary.d.ts.map +1 -1
- package/dist/mount/relayfile-binary.js +16 -2
- package/dist/mount/relayfile-binary.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +157 -13
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/workspace-mirror.d.ts +13 -0
- package/dist/mount/workspace-mirror.d.ts.map +1 -0
- package/dist/mount/workspace-mirror.js +94 -0
- package/dist/mount/workspace-mirror.js.map +1 -0
- package/dist/observability/events.d.ts +715 -47
- package/dist/observability/events.d.ts.map +1 -1
- package/dist/observability/events.js +41 -0
- package/dist/observability/events.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +51 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +574 -50
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +11 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +2 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +58 -1
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +42 -5
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +10 -1
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +91 -0
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +10 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +43 -0
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/telemetry.d.ts +9 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +8 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing/fakes.d.ts +8 -1
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +18 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +175 -42
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +19 -0
- package/dist/triage/schema.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +39 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +15 -2
|
@@ -0,0 +1,1893 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { defineAgent } from '@agentworkforce/runtime';
|
|
3
|
+
import { RelayFileApiError, RelayFileClient, RevisionConflictError, } from '@relayfile/sdk';
|
|
4
|
+
import { githubClient, slackClient, } from '@relayfile/relay-helpers';
|
|
5
|
+
export const GUARDIAN_CONVERSATION_STATE_PATH = '/memory/workspace/feature-guardian/conversations.json';
|
|
6
|
+
export const GUARDIAN_CONFIRMATIONS_PATH = '/memory/workspace/feature-guardian/confirmations';
|
|
7
|
+
export const GUARDIAN_REMEDIATIONS_PATH = '/memory/workspace/feature-guardian/remediations';
|
|
8
|
+
export const GUARDIAN_STATE_TIMEOUT_MS = 5_000;
|
|
9
|
+
export const GUARDIAN_WRITEBACK_TIMEOUT_MS = 15_000;
|
|
10
|
+
export const GUARDIAN_WRITEBACK_POLL_MS = 250;
|
|
11
|
+
const MAX_STATE_BYTES = 256 * 1024;
|
|
12
|
+
const MAX_CONVERSATIONS = 512;
|
|
13
|
+
const MAX_SEEN_EVENTS = 128;
|
|
14
|
+
const MAX_EVIDENCE_ITEMS = 32;
|
|
15
|
+
const MAX_TURNS = 12;
|
|
16
|
+
const MAX_REMEDIATION_ATTEMPTS = 8;
|
|
17
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
18
|
+
const PREVIEW_IMMUTABLE_CLAIMS = new WeakMap();
|
|
19
|
+
export class GuardianStateConflictError extends Error {
|
|
20
|
+
constructor(message = 'feature guardian conversation state revision conflict') {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'GuardianStateConflictError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reusable feature-guardian factory. Repository adopters provide their catalog,
|
|
27
|
+
* procedures, gates, prompt, authority, and issue policy; this factory owns the
|
|
28
|
+
* scoped Slack listeners and dispatches every response through the shared state
|
|
29
|
+
* machine.
|
|
30
|
+
*/
|
|
31
|
+
export function defineFeatureGuardianAgent(options) {
|
|
32
|
+
const channelInput = options.channelInput ?? 'SLACK_CHANNEL';
|
|
33
|
+
const paths = [`/slack/channels/\${${channelInput}}/messages/**`];
|
|
34
|
+
return defineAgent({
|
|
35
|
+
triggers: {
|
|
36
|
+
slack: [
|
|
37
|
+
{ on: 'message.created', paths, maxConcurrency: 1 },
|
|
38
|
+
{ on: 'reaction.added', paths, maxConcurrency: 1 },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
schedules: options.schedules ?? [
|
|
42
|
+
{ name: 'hourly-check', cron: '0 * * * *', tz: 'America/New_York' },
|
|
43
|
+
],
|
|
44
|
+
handler: async (ctx, event) => {
|
|
45
|
+
if (event.type === 'cron.tick') {
|
|
46
|
+
await options.scheduled(ctx, event);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await runGuardianConversationTurn(ctx, event, options.adapters);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** A stable content revision suitable for binding a question to exact source text. */
|
|
54
|
+
export function guardianContentRevision(content) {
|
|
55
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
/** Stable identity for one question in one manifest generation. */
|
|
58
|
+
export function guardianConversationId(question) {
|
|
59
|
+
return [
|
|
60
|
+
question.feature.id,
|
|
61
|
+
question.manifestRevision,
|
|
62
|
+
`g${question.generation}`,
|
|
63
|
+
question.channelId,
|
|
64
|
+
question.threadTs,
|
|
65
|
+
].join(':');
|
|
66
|
+
}
|
|
67
|
+
/** Stable key shared by retries of one threaded response. */
|
|
68
|
+
export function guardianReplyIdempotencyKey(record, eventId, purpose) {
|
|
69
|
+
return `feature-guardian:${record.id}:${eventId}:${purpose}`;
|
|
70
|
+
}
|
|
71
|
+
/** Immutable confirmation path. The filename is a hash so provider IDs never become paths. */
|
|
72
|
+
export function guardianConfirmationPath(record) {
|
|
73
|
+
const identity = [
|
|
74
|
+
record.featureId,
|
|
75
|
+
record.manifestRevision,
|
|
76
|
+
`g${record.generation}`,
|
|
77
|
+
record.slack.channelId,
|
|
78
|
+
record.slack.threadTs,
|
|
79
|
+
].join(':');
|
|
80
|
+
return `${GUARDIAN_CONFIRMATIONS_PATH}/${createHash('sha256').update(identity).digest('hex')}.json`;
|
|
81
|
+
}
|
|
82
|
+
/** Parse checkmark/wrench/question reactions and conversational text without an LLM guess. */
|
|
83
|
+
export function classifyGuardianResponse(input) {
|
|
84
|
+
const reaction = (input.reaction ?? '').trim().toLowerCase().replaceAll('-', '_');
|
|
85
|
+
if (['white_check_mark', 'heavy_check_mark', 'ballot_box_with_check', 'check'].includes(reaction)) {
|
|
86
|
+
return 'affirmative';
|
|
87
|
+
}
|
|
88
|
+
if (['wrench', 'hammer_and_wrench', 'hammer', 'x'].includes(reaction))
|
|
89
|
+
return 'failure';
|
|
90
|
+
if (['question', 'grey_question', 'interrobang'].includes(reaction))
|
|
91
|
+
return 'untested';
|
|
92
|
+
const text = (input.text ?? '').trim().toLowerCase();
|
|
93
|
+
if (!text)
|
|
94
|
+
return 'ambiguous';
|
|
95
|
+
if (/\b(defer(?:red)?|manual|skip(?:ped)?|cannot run|can't run|blocked by credentials)\b/u.test(text)) {
|
|
96
|
+
return 'deferred';
|
|
97
|
+
}
|
|
98
|
+
if (/\b(untested|not tested|haven't tested|have not tested|unknown|unsure|don't know|do not know)\b/u.test(text)) {
|
|
99
|
+
return 'untested';
|
|
100
|
+
}
|
|
101
|
+
if (/\b(?:does(?:n't| not)|is(?:n't| not)|not)\s+(?:work(?:ing)?|pass(?:ing)?)\b/u.test(text)) {
|
|
102
|
+
return 'failure';
|
|
103
|
+
}
|
|
104
|
+
if (/^(?:not broken|not failing|no problems?|no issues?|works? for me)[.!\s]*$/u.test(text)) {
|
|
105
|
+
return 'affirmative';
|
|
106
|
+
}
|
|
107
|
+
if (/\b(broken|breaks?|failing|fails?|failed|regression|drift(?:ed)?|wrong|bug|problem|off)\b/u.test(text)) {
|
|
108
|
+
return 'failure';
|
|
109
|
+
}
|
|
110
|
+
if (/^(?:yes|yep|yeah|confirmed|works?|working|passes?|passed|all good|looks good|✅)[.!\s]*$/u.test(text)) {
|
|
111
|
+
return 'affirmative';
|
|
112
|
+
}
|
|
113
|
+
if (/\b(tested|verified|confirmed)\b/u.test(text) && /\b(works?|working|passes?|passed|expected|good)\b/u.test(text)) {
|
|
114
|
+
return 'affirmative';
|
|
115
|
+
}
|
|
116
|
+
return 'ambiguous';
|
|
117
|
+
}
|
|
118
|
+
/** Expand and fail-closed parse one Slack reply/reaction event. */
|
|
119
|
+
export async function parseGuardianSlackEvent(event) {
|
|
120
|
+
if (!event.type.startsWith('slack.'))
|
|
121
|
+
return null;
|
|
122
|
+
const envelopeId = nonEmptyString(event.id);
|
|
123
|
+
if (!envelopeId || typeof event.expand !== 'function')
|
|
124
|
+
return null;
|
|
125
|
+
const expanded = await event.expand('full');
|
|
126
|
+
const root = asRecord(expanded?.data);
|
|
127
|
+
const payload = findSlackPayload(root);
|
|
128
|
+
if (!payload)
|
|
129
|
+
return null;
|
|
130
|
+
const item = asRecord(payload.item);
|
|
131
|
+
const isReaction = event.type.includes('reaction') || nonEmptyString(payload.reaction) !== undefined;
|
|
132
|
+
const channelId = nonEmptyString(payload.channel) ??
|
|
133
|
+
nonEmptyString(payload.channel_id) ??
|
|
134
|
+
nonEmptyString(item?.channel) ??
|
|
135
|
+
channelFromPath(event.resource?.path);
|
|
136
|
+
const messageTs = nonEmptyString(payload.ts) ??
|
|
137
|
+
nonEmptyString(payload.message_ts) ??
|
|
138
|
+
nonEmptyString(item?.ts) ??
|
|
139
|
+
messageTsFromPath(event.resource?.path);
|
|
140
|
+
const threadTs = isReaction
|
|
141
|
+
? messageTs
|
|
142
|
+
: nonEmptyString(payload.thread_ts) ?? nonEmptyString(payload.threadTs) ?? '';
|
|
143
|
+
const actorId = nonEmptyString(payload.user) ??
|
|
144
|
+
nonEmptyString(payload.user_id) ??
|
|
145
|
+
nonEmptyString(event.summary?.actor?.id);
|
|
146
|
+
if (!channelId || !messageTs || !threadTs || !actorId)
|
|
147
|
+
return null;
|
|
148
|
+
if (!isReaction && messageTs === threadTs)
|
|
149
|
+
return null;
|
|
150
|
+
const providerEventTs = nonEmptyString(payload.event_ts) ?? (isReaction ? undefined : messageTs);
|
|
151
|
+
const occurredAt = canonicalTimestamp(event.occurredAt) ??
|
|
152
|
+
timestampFromSlackTs(providerEventTs ?? messageTs);
|
|
153
|
+
if (!occurredAt)
|
|
154
|
+
return null;
|
|
155
|
+
const resourceChannelId = channelFromPath(event.resource?.path);
|
|
156
|
+
const resourceMessageTs = messageTsFromPath(event.resource?.path);
|
|
157
|
+
if (resourceChannelId && resourceChannelId !== channelId)
|
|
158
|
+
return null;
|
|
159
|
+
if (resourceMessageTs &&
|
|
160
|
+
resourceMessageTs !== messageTs &&
|
|
161
|
+
(!isReaction && resourceMessageTs !== threadTs))
|
|
162
|
+
return null;
|
|
163
|
+
const providerIdentity = isReaction
|
|
164
|
+
? providerEventTs ?? envelopeId
|
|
165
|
+
: messageTs;
|
|
166
|
+
const eventId = `slack:${createHash('sha256')
|
|
167
|
+
.update([
|
|
168
|
+
channelId,
|
|
169
|
+
threadTs,
|
|
170
|
+
messageTs,
|
|
171
|
+
actorId,
|
|
172
|
+
nonEmptyString(payload.reaction) ?? '',
|
|
173
|
+
providerIdentity,
|
|
174
|
+
].join(':'))
|
|
175
|
+
.digest('hex')}`;
|
|
176
|
+
const eventOrder = [occurredAt, slackTsOrder(providerEventTs ?? messageTs), messageTs, eventId].join(':');
|
|
177
|
+
return {
|
|
178
|
+
eventId,
|
|
179
|
+
eventOrder,
|
|
180
|
+
occurredAt,
|
|
181
|
+
channelId,
|
|
182
|
+
threadTs,
|
|
183
|
+
messageTs,
|
|
184
|
+
actorId,
|
|
185
|
+
text: nonEmptyString(payload.text) ?? '',
|
|
186
|
+
...(nonEmptyString(payload.reaction) ? { reaction: nonEmptyString(payload.reaction) } : {}),
|
|
187
|
+
isReaction,
|
|
188
|
+
isBot: payload.user_is_bot === true ||
|
|
189
|
+
Boolean(nonEmptyString(payload.bot_id)) ||
|
|
190
|
+
nonEmptyString(payload.subtype) === 'bot_message',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Register a provider-confirmed question before the scheduled cycle advances. */
|
|
194
|
+
export async function registerGuardianQuestion(ctx, question, dependencies = {}) {
|
|
195
|
+
const store = dependencies.conversationStore ?? createConversationStore(ctx);
|
|
196
|
+
const now = (dependencies.now ?? (() => new Date()))().toISOString();
|
|
197
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
198
|
+
const snapshot = await store.load();
|
|
199
|
+
const state = snapshot?.state ?? emptyConversationState();
|
|
200
|
+
const id = guardianConversationId(question);
|
|
201
|
+
const existing = state.records.find((record) => record.id === id);
|
|
202
|
+
if (existing) {
|
|
203
|
+
if (!sameQuestion(existing, question)) {
|
|
204
|
+
throw new Error('guardian question identity collided with different question data');
|
|
205
|
+
}
|
|
206
|
+
return existing;
|
|
207
|
+
}
|
|
208
|
+
const record = {
|
|
209
|
+
...question,
|
|
210
|
+
id,
|
|
211
|
+
status: 'asked',
|
|
212
|
+
turnCount: 0,
|
|
213
|
+
clarificationCount: 0,
|
|
214
|
+
seenEventIds: [],
|
|
215
|
+
evidence: [],
|
|
216
|
+
updatedAt: now,
|
|
217
|
+
};
|
|
218
|
+
const retained = retainConversationCapacity(state.records, record);
|
|
219
|
+
try {
|
|
220
|
+
await store.save({ ...state, records: [...retained, record] }, snapshot);
|
|
221
|
+
return record;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!(error instanceof GuardianStateConflictError) || attempt === 2)
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new Error('unreachable guardian question registration retry');
|
|
229
|
+
}
|
|
230
|
+
/** Own one correlated Slack response through a durable or explicitly deferred outcome. */
|
|
231
|
+
export async function runGuardianConversationTurn(ctx, event, adapters, dependencies = {}) {
|
|
232
|
+
const parsed = await parseGuardianSlackEvent(event);
|
|
233
|
+
if (!parsed || parsed.isBot)
|
|
234
|
+
return;
|
|
235
|
+
const configuredChannel = resolvedInput(ctx, 'SLACK_CHANNEL');
|
|
236
|
+
if (!configuredChannel || parsed.channelId !== configuredChannel) {
|
|
237
|
+
ctx.log('warn', 'feature-guardian.event-rejected', {
|
|
238
|
+
reason: 'unauthorized-channel',
|
|
239
|
+
channel: parsed.channelId,
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (parsed.actorId === resolvedInput(ctx, 'SLACK_BOT_USER')) {
|
|
244
|
+
ctx.log('info', 'feature-guardian.event-ignored', { reason: 'configured-bot-actor' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const store = dependencies.conversationStore ?? createConversationStore(ctx);
|
|
248
|
+
const confirmationStore = dependencies.confirmationStore ?? createConfirmationStore(ctx);
|
|
249
|
+
const issueWriter = dependencies.issueWriter ?? createGithubIssueWriter(ctx);
|
|
250
|
+
const createSlackClient = dependencies.createSlackClient ?? slackClient;
|
|
251
|
+
const now = dependencies.now ?? (() => new Date());
|
|
252
|
+
let snapshot = await store.load();
|
|
253
|
+
if (!snapshot)
|
|
254
|
+
return;
|
|
255
|
+
const correlated = findCorrelatedConversation(snapshot.state, parsed);
|
|
256
|
+
if (!correlated) {
|
|
257
|
+
ctx.log('info', 'feature-guardian.event-ignored', {
|
|
258
|
+
reason: 'unrelated-thread',
|
|
259
|
+
channel: parsed.channelId,
|
|
260
|
+
threadTs: parsed.threadTs,
|
|
261
|
+
messageTs: parsed.messageTs,
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let record = correlated;
|
|
266
|
+
if (isTerminal(record.status))
|
|
267
|
+
return;
|
|
268
|
+
if (Date.parse(parsed.occurredAt) < Date.parse(record.askedAt)) {
|
|
269
|
+
ctx.log('info', 'feature-guardian.event-ignored', {
|
|
270
|
+
reason: 'event-predates-question',
|
|
271
|
+
eventId: parsed.eventId,
|
|
272
|
+
conversationId: record.id,
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// A durable pending turn always owns the conversation. Resume it before
|
|
277
|
+
// considering a later delivery so a restart or out-of-order event cannot
|
|
278
|
+
// overwrite procedure evidence, a confirmation write, or an issue receipt.
|
|
279
|
+
const pendingBeforeResume = record.pending;
|
|
280
|
+
if (pendingBeforeResume) {
|
|
281
|
+
await resumePendingTurn();
|
|
282
|
+
if (isTerminal(record.status) || record.pending)
|
|
283
|
+
return;
|
|
284
|
+
if (pendingBeforeResume.eventId === parsed.eventId || record.seenEventIds.includes(parsed.eventId)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (parsed.eventOrder <= pendingBeforeResume.eventOrder) {
|
|
288
|
+
ctx.log('info', 'feature-guardian.event-ignored', {
|
|
289
|
+
reason: 'superseded-out-of-order-event',
|
|
290
|
+
eventId: parsed.eventId,
|
|
291
|
+
conversationId: record.id,
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (record.seenEventIds.includes(parsed.eventId))
|
|
297
|
+
return;
|
|
298
|
+
if (record.lastProcessedEventOrder &&
|
|
299
|
+
parsed.eventOrder <= record.lastProcessedEventOrder) {
|
|
300
|
+
ctx.log('info', 'feature-guardian.event-ignored', {
|
|
301
|
+
reason: 'delayed-event-precedes-conversation-watermark',
|
|
302
|
+
eventId: parsed.eventId,
|
|
303
|
+
conversationId: record.id,
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const catalog = await adapters.loadCatalog(ctx);
|
|
308
|
+
if (catalog.manifestRevision !== record.manifestRevision ||
|
|
309
|
+
catalog.procedureRevision !== record.procedureRevision) {
|
|
310
|
+
const turn = pendingTurn(parsed, 'deferred', {
|
|
311
|
+
source: 'system',
|
|
312
|
+
result: 'manual',
|
|
313
|
+
summary: 'The manifest or procedure changed after this question was posted; refusing to confirm a different revision.',
|
|
314
|
+
});
|
|
315
|
+
snapshot = await checkpointPending(snapshot, record, turn, 'deferred-recorded');
|
|
316
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
317
|
+
await finishDeferred(record, turn.evidence?.summary ?? 'Revision changed before verification.');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const response = classifyGuardianResponse({ text: parsed.text, reaction: parsed.reaction });
|
|
321
|
+
const actorEvidence = {
|
|
322
|
+
source: 'actor',
|
|
323
|
+
result: response === 'affirmative'
|
|
324
|
+
? 'positive'
|
|
325
|
+
: response === 'failure'
|
|
326
|
+
? 'negative'
|
|
327
|
+
: response === 'deferred'
|
|
328
|
+
? 'manual'
|
|
329
|
+
: 'unknown',
|
|
330
|
+
summary: parsed.reaction
|
|
331
|
+
? `Slack reaction :${parsed.reaction}: from ${parsed.actorId}`
|
|
332
|
+
: `Slack reply from ${parsed.actorId}: ${bounded(parsed.text, 1_000)}`,
|
|
333
|
+
};
|
|
334
|
+
const turn = pendingTurn(parsed, response, actorEvidence);
|
|
335
|
+
if (response === 'ambiguous') {
|
|
336
|
+
await clarify(turn);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if ((response === 'failure' || response === 'untested') && record.turnCount === 0) {
|
|
340
|
+
await clarify(turn);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (response === 'affirmative' && adapters.isAuthorizedConfirmer(ctx, parsed.actorId)) {
|
|
344
|
+
if (!hasActorConfirmationEvidence(parsed.text)) {
|
|
345
|
+
await clarify(turn);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
snapshot = await checkpointPending(snapshot, record, turn, 'confirmation-recorded', true, { kind: 'actor', verifier: parsed.actorId });
|
|
349
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
350
|
+
await finishConfirmation(record, turn);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (response === 'deferred') {
|
|
354
|
+
snapshot = await checkpointPending(snapshot, record, turn, 'deferred-recorded', true);
|
|
355
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
356
|
+
await finishDeferred(record, actorEvidence.summary);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const procedure = await adapters.resolveProcedure(ctx, record.feature);
|
|
360
|
+
const gate = await adapters.gateTier(ctx, record.feature, procedure);
|
|
361
|
+
if (gate.outcome !== 'available') {
|
|
362
|
+
const gateEvidence = {
|
|
363
|
+
source: 'system',
|
|
364
|
+
result: gate.outcome,
|
|
365
|
+
summary: gate.reason,
|
|
366
|
+
};
|
|
367
|
+
const deferredTurn = { ...turn, response: 'deferred', evidence: gateEvidence };
|
|
368
|
+
snapshot = await checkpointPending(snapshot, record, deferredTurn, 'deferred-recorded', true);
|
|
369
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
370
|
+
await finishDeferred(record, gate.reason);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
snapshot = await checkpointPending(snapshot, record, turn, 'verifying', true);
|
|
374
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
375
|
+
await resumePendingTurn(procedure, true);
|
|
376
|
+
async function resumePendingTurn(preResolvedProcedure, prerequisitesValidated = false) {
|
|
377
|
+
if (!snapshot || !record.pending)
|
|
378
|
+
return;
|
|
379
|
+
const pending = record.pending;
|
|
380
|
+
if (record.status === 'confirmation-recorded') {
|
|
381
|
+
await finishConfirmation(record, pending);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (record.status === 'remediation-recorded') {
|
|
385
|
+
if (!record.issue)
|
|
386
|
+
throw new Error('remediation-recorded state is missing its issue receipt');
|
|
387
|
+
await finishRemediation(record, record.issue);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (record.status === 'deferred-recorded') {
|
|
391
|
+
await finishDeferred(record, pending.evidence?.summary ?? 'Verification deferred.');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (record.status !== 'verifying')
|
|
395
|
+
return;
|
|
396
|
+
if (!prerequisitesValidated) {
|
|
397
|
+
const currentCatalog = await adapters.loadCatalog(ctx);
|
|
398
|
+
if (currentCatalog.manifestRevision !== record.manifestRevision ||
|
|
399
|
+
currentCatalog.procedureRevision !== record.procedureRevision) {
|
|
400
|
+
await deferPendingVerification('The manifest or procedure changed while verification was pending; refusing to run or confirm a different revision.', 'manual');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const procedure = preResolvedProcedure ?? await adapters.resolveProcedure(ctx, record.feature);
|
|
405
|
+
if (!pending.verification && !prerequisitesValidated) {
|
|
406
|
+
const gate = await adapters.gateTier(ctx, record.feature, procedure);
|
|
407
|
+
if (gate.outcome !== 'available') {
|
|
408
|
+
await deferPendingVerification(gate.reason, gate.outcome);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const result = pending.verification ?? await adapters.runProcedure(ctx, record.feature, procedure, guardianReplyIdempotencyKey(record, pending.eventId, 'procedure'));
|
|
413
|
+
if (!pending.verification) {
|
|
414
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
415
|
+
...record,
|
|
416
|
+
pending: { ...pending, verification: result },
|
|
417
|
+
evidence: appendEvidence(record.evidence, result),
|
|
418
|
+
updatedAt: now().toISOString(),
|
|
419
|
+
});
|
|
420
|
+
snapshot = await store.save(next, snapshot);
|
|
421
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
422
|
+
}
|
|
423
|
+
const evidence = record.evidence;
|
|
424
|
+
if (result.outcome === 'passed') {
|
|
425
|
+
if (pending.response === 'failure') {
|
|
426
|
+
const reportedDefect = adapters.classifyDefect(record.feature, pending, evidence);
|
|
427
|
+
if (record.clarificationCount >= 1 &&
|
|
428
|
+
['test', 'manifest', 'procedure', 'documentation'].includes(reportedDefect) &&
|
|
429
|
+
adapters.isDefectEstablished(record.feature, pending, evidence)) {
|
|
430
|
+
await recordRemediation(result, reportedDefect);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const clarificationNumber = record.clarificationCount + 1;
|
|
434
|
+
const text = adapters.clarification(record.feature, procedure, { ...pending, evidence: result, verification: result }, clarificationNumber);
|
|
435
|
+
const receipt = await postThreadReply(createSlackClient, record, text, guardianReplyIdempotencyKey(record, pending.eventId, 'post-check-clarification'));
|
|
436
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
437
|
+
...record,
|
|
438
|
+
status: 'discussing',
|
|
439
|
+
clarificationCount: clarificationNumber,
|
|
440
|
+
seenEventIds: appendSeen(record.seenEventIds, pending.eventId),
|
|
441
|
+
lastProcessedEventOrder: pending.eventOrder,
|
|
442
|
+
pending: undefined,
|
|
443
|
+
finalReplyTs: receipt,
|
|
444
|
+
updatedAt: now().toISOString(),
|
|
445
|
+
});
|
|
446
|
+
snapshot = await store.save(next, snapshot);
|
|
447
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
451
|
+
...record,
|
|
452
|
+
status: 'confirmation-recorded',
|
|
453
|
+
confirmationBasis: { kind: 'procedure', verifier: result.verifier },
|
|
454
|
+
updatedAt: now().toISOString(),
|
|
455
|
+
});
|
|
456
|
+
snapshot = await store.save(next, snapshot);
|
|
457
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
458
|
+
await finishConfirmation(record, pending);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (result.outcome === 'skip' || result.outcome === 'manual') {
|
|
462
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
463
|
+
...record,
|
|
464
|
+
status: 'deferred-recorded',
|
|
465
|
+
pending: { ...pending, response: 'deferred', evidence: result },
|
|
466
|
+
updatedAt: now().toISOString(),
|
|
467
|
+
});
|
|
468
|
+
snapshot = await store.save(next, snapshot);
|
|
469
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
470
|
+
await finishDeferred(record, result.summary);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const defectKind = adapters.classifyDefect(record.feature, pending, evidence);
|
|
474
|
+
await recordRemediation(result, defectKind);
|
|
475
|
+
}
|
|
476
|
+
async function deferPendingVerification(reason, outcome) {
|
|
477
|
+
if (!snapshot || !record.pending)
|
|
478
|
+
return;
|
|
479
|
+
const deferredPending = {
|
|
480
|
+
...record.pending,
|
|
481
|
+
response: 'deferred',
|
|
482
|
+
evidence: { source: 'system', result: outcome, summary: reason },
|
|
483
|
+
};
|
|
484
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
485
|
+
...record,
|
|
486
|
+
status: 'deferred-recorded',
|
|
487
|
+
pending: deferredPending,
|
|
488
|
+
evidence: appendEvidence(record.evidence, deferredPending.evidence),
|
|
489
|
+
updatedAt: now().toISOString(),
|
|
490
|
+
});
|
|
491
|
+
snapshot = await store.save(next, snapshot);
|
|
492
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
493
|
+
await finishDeferred(record, reason);
|
|
494
|
+
}
|
|
495
|
+
async function recordRemediation(result, defectKind) {
|
|
496
|
+
if (!snapshot || !record.pending)
|
|
497
|
+
return;
|
|
498
|
+
const pending = record.pending;
|
|
499
|
+
const evidence = record.evidence;
|
|
500
|
+
const policy = adapters.issuePolicy({
|
|
501
|
+
feature: record.feature,
|
|
502
|
+
conversation: { ...record, evidence },
|
|
503
|
+
defectKind,
|
|
504
|
+
slackBacklink: slackThreadBacklink(record.channelId, record.threadTs),
|
|
505
|
+
});
|
|
506
|
+
const routedRepository = adapters.repositoryForFeature(record.feature);
|
|
507
|
+
if (policy.repository !== routedRepository) {
|
|
508
|
+
throw new Error(`guardian issue policy routed ${policy.repository}, expected ${routedRepository}`);
|
|
509
|
+
}
|
|
510
|
+
const receipt = await issueWriter.upsert(policy);
|
|
511
|
+
if (receipt.repository !== routedRepository || receipt.dedupeKey !== policy.dedupeKey) {
|
|
512
|
+
throw new Error('guardian issue receipt did not match the authorized repository/dedupe key');
|
|
513
|
+
}
|
|
514
|
+
const next = replaceRecord(snapshot.state, record.id, {
|
|
515
|
+
...record,
|
|
516
|
+
status: 'remediation-recorded',
|
|
517
|
+
issue: receipt,
|
|
518
|
+
pending: { ...pending, defectKind, evidence: result },
|
|
519
|
+
updatedAt: now().toISOString(),
|
|
520
|
+
});
|
|
521
|
+
snapshot = await store.save(next, snapshot);
|
|
522
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
523
|
+
await finishRemediation(record, receipt);
|
|
524
|
+
}
|
|
525
|
+
async function clarify(turn) {
|
|
526
|
+
if (!snapshot)
|
|
527
|
+
return;
|
|
528
|
+
if (record.turnCount >= MAX_TURNS) {
|
|
529
|
+
snapshot = await checkpointPending(snapshot, record, {
|
|
530
|
+
...turn,
|
|
531
|
+
response: 'deferred',
|
|
532
|
+
evidence: {
|
|
533
|
+
source: 'system',
|
|
534
|
+
result: 'manual',
|
|
535
|
+
summary: `Clarification stopped after the bounded ${MAX_TURNS}-turn limit.`,
|
|
536
|
+
},
|
|
537
|
+
}, 'deferred-recorded', true);
|
|
538
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
539
|
+
await finishDeferred(record, `Clarification stopped after ${MAX_TURNS} turns.`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const procedure = await adapters.resolveProcedure(ctx, record.feature);
|
|
543
|
+
const clarificationNumber = record.clarificationCount + 1;
|
|
544
|
+
const text = adapters.clarification(record.feature, procedure, turn, clarificationNumber);
|
|
545
|
+
const receipt = await postThreadReply(createSlackClient, record, text, guardianReplyIdempotencyKey(record, turn.eventId, 'clarification'));
|
|
546
|
+
const nextRecord = {
|
|
547
|
+
...record,
|
|
548
|
+
status: 'discussing',
|
|
549
|
+
turnCount: record.turnCount + 1,
|
|
550
|
+
clarificationCount: clarificationNumber,
|
|
551
|
+
seenEventIds: appendSeen(record.seenEventIds, turn.eventId),
|
|
552
|
+
lastProcessedEventOrder: turn.eventOrder,
|
|
553
|
+
evidence: appendEvidence(record.evidence, turn.evidence),
|
|
554
|
+
finalReplyTs: receipt,
|
|
555
|
+
updatedAt: now().toISOString(),
|
|
556
|
+
};
|
|
557
|
+
snapshot = await store.save(replaceRecord(snapshot.state, record.id, nextRecord), snapshot);
|
|
558
|
+
record = requireRecordById(snapshot.state, record.id);
|
|
559
|
+
}
|
|
560
|
+
async function finishConfirmation(current, turn) {
|
|
561
|
+
if (!snapshot)
|
|
562
|
+
return;
|
|
563
|
+
// Bind the immutable timestamp to the provider event, not the retry wall
|
|
564
|
+
// clock, so a post-before-checkpoint retry produces byte-identical evidence.
|
|
565
|
+
const confirmation = confirmationRecord(current, turn, turn.eventOccurredAt);
|
|
566
|
+
const confirmationPath = await confirmationStore.append(confirmation);
|
|
567
|
+
if (current.confirmationPath !== confirmationPath) {
|
|
568
|
+
const next = replaceRecord(snapshot.state, current.id, {
|
|
569
|
+
...current,
|
|
570
|
+
confirmationPath,
|
|
571
|
+
updatedAt: now().toISOString(),
|
|
572
|
+
});
|
|
573
|
+
snapshot = await store.save(next, snapshot);
|
|
574
|
+
current = requireRecordById(snapshot.state, current.id);
|
|
575
|
+
record = current;
|
|
576
|
+
}
|
|
577
|
+
const procedureEvidence = [...current.evidence]
|
|
578
|
+
.reverse()
|
|
579
|
+
.find((entry) => entry.source === 'procedure');
|
|
580
|
+
const summary = procedureEvidence
|
|
581
|
+
? `✅ Confirmed *${current.feature.name}* for manifest \`${shortRevision(current.manifestRevision)}\`. ${formatEvidenceForSlack(procedureEvidence)}`
|
|
582
|
+
: `✅ Confirmed *${current.feature.name}* for manifest \`${shortRevision(current.manifestRevision)}\` from ${turn.actorId}'s response.`;
|
|
583
|
+
const replyTs = await postThreadReply(createSlackClient, current, summary, guardianReplyIdempotencyKey(current, turn.eventId, 'confirmed'));
|
|
584
|
+
const terminal = {
|
|
585
|
+
...current,
|
|
586
|
+
status: 'confirmed',
|
|
587
|
+
pending: undefined,
|
|
588
|
+
seenEventIds: appendSeen(current.seenEventIds, turn.eventId),
|
|
589
|
+
lastProcessedEventOrder: turn.eventOrder,
|
|
590
|
+
finalReplyTs: replyTs,
|
|
591
|
+
updatedAt: now().toISOString(),
|
|
592
|
+
};
|
|
593
|
+
snapshot = await store.save(replaceRecord(snapshot.state, current.id, terminal), snapshot);
|
|
594
|
+
record = requireRecordById(snapshot.state, current.id);
|
|
595
|
+
}
|
|
596
|
+
async function finishRemediation(current, issue) {
|
|
597
|
+
if (!snapshot || !current.pending)
|
|
598
|
+
return;
|
|
599
|
+
const replyTs = await postThreadReply(createSlackClient, current, `🔧 Verification established a ${current.pending.defectKind ?? 'feature'} defect for *${current.feature.name}*. Remediation: ${issue.url}`, guardianReplyIdempotencyKey(current, current.pending.eventId, 'remediation'));
|
|
600
|
+
const terminal = {
|
|
601
|
+
...current,
|
|
602
|
+
status: 'remediation-open',
|
|
603
|
+
pending: undefined,
|
|
604
|
+
seenEventIds: appendSeen(current.seenEventIds, current.pending.eventId),
|
|
605
|
+
lastProcessedEventOrder: current.pending.eventOrder,
|
|
606
|
+
finalReplyTs: replyTs,
|
|
607
|
+
updatedAt: now().toISOString(),
|
|
608
|
+
};
|
|
609
|
+
snapshot = await store.save(replaceRecord(snapshot.state, current.id, terminal), snapshot);
|
|
610
|
+
record = requireRecordById(snapshot.state, current.id);
|
|
611
|
+
}
|
|
612
|
+
async function finishDeferred(current, reason) {
|
|
613
|
+
if (!snapshot || !current.pending)
|
|
614
|
+
return;
|
|
615
|
+
const evidenceResult = current.pending.verification?.result ?? current.pending.evidence?.result;
|
|
616
|
+
const outcome = evidenceResult === 'skip' ? 'SKIP' : 'MANUAL';
|
|
617
|
+
const replyTs = await postThreadReply(createSlackClient, current, `🟡 *${current.feature.name}* remains unconfirmed: ${reason} Result: ${outcome}.`, guardianReplyIdempotencyKey(current, current.pending.eventId, 'deferred'));
|
|
618
|
+
const terminal = {
|
|
619
|
+
...current,
|
|
620
|
+
status: 'deferred',
|
|
621
|
+
pending: undefined,
|
|
622
|
+
seenEventIds: appendSeen(current.seenEventIds, current.pending.eventId),
|
|
623
|
+
lastProcessedEventOrder: current.pending.eventOrder,
|
|
624
|
+
finalReplyTs: replyTs,
|
|
625
|
+
updatedAt: now().toISOString(),
|
|
626
|
+
};
|
|
627
|
+
snapshot = await store.save(replaceRecord(snapshot.state, current.id, terminal), snapshot);
|
|
628
|
+
record = requireRecordById(snapshot.state, current.id);
|
|
629
|
+
}
|
|
630
|
+
async function checkpointPending(currentSnapshot, current, turn, status, countTurn = false, confirmationBasis) {
|
|
631
|
+
const nextRecord = {
|
|
632
|
+
...current,
|
|
633
|
+
status,
|
|
634
|
+
turnCount: current.turnCount + (countTurn ? 1 : 0),
|
|
635
|
+
pending: turn,
|
|
636
|
+
...(confirmationBasis ? { confirmationBasis } : {}),
|
|
637
|
+
lastProcessedEventOrder: turn.eventOrder,
|
|
638
|
+
evidence: appendEvidence(current.evidence, turn.evidence),
|
|
639
|
+
updatedAt: now().toISOString(),
|
|
640
|
+
};
|
|
641
|
+
return store.save(replaceRecord(currentSnapshot.state, current.id, nextRecord), currentSnapshot);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** Exact-revision store used by the reusable state machine. */
|
|
645
|
+
export function createSdkConversationStore(credentials, options = {}) {
|
|
646
|
+
const client = relayfileClient(credentials, options.fetchImpl);
|
|
647
|
+
const timeoutMs = options.timeoutMs ?? GUARDIAN_STATE_TIMEOUT_MS;
|
|
648
|
+
return exactStore({
|
|
649
|
+
client,
|
|
650
|
+
credentials,
|
|
651
|
+
path: GUARDIAN_CONVERSATION_STATE_PATH,
|
|
652
|
+
timeoutMs,
|
|
653
|
+
parse: parseConversationState,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/** Exact create-only immutable confirmation store. */
|
|
657
|
+
export function createSdkConfirmationStore(credentials, options = {}) {
|
|
658
|
+
const client = relayfileClient(credentials, options.fetchImpl);
|
|
659
|
+
const timeoutMs = options.timeoutMs ?? GUARDIAN_STATE_TIMEOUT_MS;
|
|
660
|
+
return {
|
|
661
|
+
append: async (record) => {
|
|
662
|
+
const canonical = parseConfirmationRecord(record);
|
|
663
|
+
const path = guardianConfirmationPath(canonical);
|
|
664
|
+
const content = `${JSON.stringify(canonical)}\n`;
|
|
665
|
+
assertBoundedContent(content, 'guardian confirmation');
|
|
666
|
+
return withDeadline('guardian confirmation append', timeoutMs, async (signal) => {
|
|
667
|
+
const existing = await readExactFile(client, credentials.workspaceId, path, signal);
|
|
668
|
+
if (existing) {
|
|
669
|
+
if (existing.content !== content)
|
|
670
|
+
throw new Error('immutable guardian confirmation already differs');
|
|
671
|
+
return path;
|
|
672
|
+
}
|
|
673
|
+
await writeExactFile(client, credentials.workspaceId, path, '0', content, signal);
|
|
674
|
+
const readBack = await readExactFile(client, credentials.workspaceId, path, signal);
|
|
675
|
+
if (!readBack || readBack.content !== content) {
|
|
676
|
+
throw new Error('guardian confirmation read-back did not match');
|
|
677
|
+
}
|
|
678
|
+
return path;
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function createConversationStore(ctx) {
|
|
684
|
+
const credentials = ctx.credentials.tryRequire();
|
|
685
|
+
if (credentials)
|
|
686
|
+
return createSdkConversationStore(credentials.relayfile);
|
|
687
|
+
if (ctx.agent.id === 'sim-agent' && ctx.deployment.id === 'sim-deployment') {
|
|
688
|
+
return previewConversationStore(ctx);
|
|
689
|
+
}
|
|
690
|
+
throw new Error('exact Relayfile credentials are required for guardian conversations');
|
|
691
|
+
}
|
|
692
|
+
function createConfirmationStore(ctx) {
|
|
693
|
+
const credentials = ctx.credentials.tryRequire();
|
|
694
|
+
if (credentials)
|
|
695
|
+
return createSdkConfirmationStore(credentials.relayfile);
|
|
696
|
+
if (ctx.agent.id === 'sim-agent' && ctx.deployment.id === 'sim-deployment') {
|
|
697
|
+
return {
|
|
698
|
+
append: async (record) => {
|
|
699
|
+
const canonical = parseConfirmationRecord(record);
|
|
700
|
+
const path = guardianConfirmationPath(canonical);
|
|
701
|
+
const content = `${JSON.stringify(canonical)}\n`;
|
|
702
|
+
let existing;
|
|
703
|
+
try {
|
|
704
|
+
existing = await ctx.files.read(path);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
if (!String(error).includes('ENOENT'))
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
if (existing !== undefined && existing !== content) {
|
|
711
|
+
throw new Error('immutable guardian confirmation already differs');
|
|
712
|
+
}
|
|
713
|
+
if (existing === undefined)
|
|
714
|
+
await ctx.files.write(path, content);
|
|
715
|
+
return path;
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
throw new Error('exact Relayfile credentials are required for guardian confirmations');
|
|
720
|
+
}
|
|
721
|
+
function previewConversationStore(ctx) {
|
|
722
|
+
const load = async () => {
|
|
723
|
+
let content;
|
|
724
|
+
try {
|
|
725
|
+
content = await ctx.files.read(GUARDIAN_CONVERSATION_STATE_PATH);
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
if (String(error).includes('ENOENT'))
|
|
729
|
+
return null;
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
assertBoundedContent(content, 'guardian conversation state');
|
|
733
|
+
const state = parseConversationState(JSON.parse(content));
|
|
734
|
+
return { state, revision: previewRevision(state) };
|
|
735
|
+
};
|
|
736
|
+
return {
|
|
737
|
+
load,
|
|
738
|
+
save: async (state, expected) => {
|
|
739
|
+
const canonical = parseConversationState(state);
|
|
740
|
+
const current = await load();
|
|
741
|
+
if (current?.revision !== expected?.revision)
|
|
742
|
+
throw new GuardianStateConflictError();
|
|
743
|
+
assertConversationTransition(expected?.state ?? null, canonical);
|
|
744
|
+
const content = `${JSON.stringify(canonical)}\n`;
|
|
745
|
+
assertBoundedContent(content, 'guardian conversation state');
|
|
746
|
+
await ctx.files.write(GUARDIAN_CONVERSATION_STATE_PATH, content);
|
|
747
|
+
const readBack = await load();
|
|
748
|
+
if (!readBack || JSON.stringify(readBack.state) !== JSON.stringify(canonical)) {
|
|
749
|
+
throw new Error('guardian conversation read-back did not match');
|
|
750
|
+
}
|
|
751
|
+
return readBack;
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function exactStore(input) {
|
|
756
|
+
const load = () => withDeadline('guardian conversation load', input.timeoutMs, async (signal) => {
|
|
757
|
+
const file = await readExactFile(input.client, input.credentials.workspaceId, input.path, signal);
|
|
758
|
+
if (!file)
|
|
759
|
+
return null;
|
|
760
|
+
assertBoundedContent(file.content, 'guardian conversation state');
|
|
761
|
+
return {
|
|
762
|
+
state: input.parse(JSON.parse(file.content)),
|
|
763
|
+
revision: file.revision,
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
return {
|
|
767
|
+
load,
|
|
768
|
+
save: (state, expected) => {
|
|
769
|
+
const canonical = input.parse(state);
|
|
770
|
+
assertConversationTransition(expected?.state ?? null, canonical);
|
|
771
|
+
const content = `${JSON.stringify(canonical)}\n`;
|
|
772
|
+
assertBoundedContent(content, 'guardian conversation state');
|
|
773
|
+
return withDeadline('guardian conversation save', input.timeoutMs, async (signal) => {
|
|
774
|
+
try {
|
|
775
|
+
await writeExactFile(input.client, input.credentials.workspaceId, input.path, expected?.revision ?? '0', content, signal);
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error instanceof RevisionConflictError ||
|
|
779
|
+
(error instanceof RelayFileApiError && error.status === 409)) {
|
|
780
|
+
throw new GuardianStateConflictError();
|
|
781
|
+
}
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
const readBack = await readExactFile(input.client, input.credentials.workspaceId, input.path, signal);
|
|
785
|
+
if (!readBack || readBack.content !== content) {
|
|
786
|
+
throw new Error('guardian conversation read-back did not match');
|
|
787
|
+
}
|
|
788
|
+
return { state: canonical, revision: readBack.revision };
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
export function createGithubIssueWriter(ctx, client = githubClient({
|
|
794
|
+
writebackTimeoutMs: GUARDIAN_WRITEBACK_TIMEOUT_MS,
|
|
795
|
+
writebackPollMs: GUARDIAN_WRITEBACK_POLL_MS,
|
|
796
|
+
})) {
|
|
797
|
+
return {
|
|
798
|
+
upsert: async (policy) => {
|
|
799
|
+
const [owner, repo, extra] = policy.repository.split('/');
|
|
800
|
+
if (!owner || !repo || extra)
|
|
801
|
+
throw new Error('guardian issue repository must be owner/repo');
|
|
802
|
+
const receiptPath = remediationReceiptPath(policy.dedupeKey);
|
|
803
|
+
const savedReceipt = await readImmutableJson(ctx, receiptPath);
|
|
804
|
+
const bodyRevision = guardianContentRevision(policy.body);
|
|
805
|
+
if (savedReceipt) {
|
|
806
|
+
const receipt = parseDurableIssueReceipt(savedReceipt, policy);
|
|
807
|
+
if (providerText(asRecord(savedReceipt)?.bodyRevision) !== bodyRevision) {
|
|
808
|
+
await updateExistingIssueEvidence(ctx, client, owner, repo, receipt.number, policy);
|
|
809
|
+
}
|
|
810
|
+
return receipt;
|
|
811
|
+
}
|
|
812
|
+
const issues = await listGithubIssues(ctx, client, owner, repo);
|
|
813
|
+
const match = issues
|
|
814
|
+
.map(unwrapProviderRecord)
|
|
815
|
+
.find((issue) => providerText(issue.body).includes(remediationMarker(policy.dedupeKey)));
|
|
816
|
+
if (match) {
|
|
817
|
+
const number = providerNumber(match.number) ?? providerNumber(match.id);
|
|
818
|
+
const url = providerText(match.html_url) || providerText(match.url);
|
|
819
|
+
if (!number || !url)
|
|
820
|
+
throw new Error('deduplicated guardian issue is missing number/url');
|
|
821
|
+
if (providerText(match.body) !== policy.body) {
|
|
822
|
+
await updateExistingIssueEvidence(ctx, client, owner, repo, number, policy);
|
|
823
|
+
}
|
|
824
|
+
const receipt = { repository: policy.repository, number, url, dedupeKey: policy.dedupeKey };
|
|
825
|
+
await appendImmutableJson(ctx, receiptPath, { ...receipt, bodyRevision });
|
|
826
|
+
return receipt;
|
|
827
|
+
}
|
|
828
|
+
for (let attempt = 1; attempt <= MAX_REMEDIATION_ATTEMPTS; attempt += 1) {
|
|
829
|
+
const intentPath = remediationIntentPath(policy.dedupeKey, attempt);
|
|
830
|
+
const droppedPath = remediationDroppedPath(policy.dedupeKey, attempt);
|
|
831
|
+
if (await readImmutableJson(ctx, droppedPath))
|
|
832
|
+
continue;
|
|
833
|
+
const claimed = await claimImmutableJson(ctx, intentPath, {
|
|
834
|
+
kind: 'feature-guardian:remediation-intent',
|
|
835
|
+
version: 1,
|
|
836
|
+
repository: policy.repository,
|
|
837
|
+
dedupeKey: policy.dedupeKey,
|
|
838
|
+
bodyRevision,
|
|
839
|
+
...(attempt === 1 ? {} : { attempt }),
|
|
840
|
+
});
|
|
841
|
+
if (!claimed) {
|
|
842
|
+
if (await readImmutableJson(ctx, droppedPath))
|
|
843
|
+
continue;
|
|
844
|
+
throw new Error('GitHub remediation submission is pending provider correlation; refusing a duplicate issue');
|
|
845
|
+
}
|
|
846
|
+
const created = await client.createIssue({
|
|
847
|
+
owner,
|
|
848
|
+
repo,
|
|
849
|
+
title: policy.title,
|
|
850
|
+
body: policy.body,
|
|
851
|
+
labels: policy.labels,
|
|
852
|
+
});
|
|
853
|
+
if (created.status === 'dropped') {
|
|
854
|
+
await appendImmutableJson(ctx, droppedPath, {
|
|
855
|
+
kind: 'feature-guardian:remediation-drop',
|
|
856
|
+
version: 1,
|
|
857
|
+
repository: policy.repository,
|
|
858
|
+
dedupeKey: policy.dedupeKey,
|
|
859
|
+
attempt,
|
|
860
|
+
reason: created.reason ?? 'provider dropped the remediation draft',
|
|
861
|
+
});
|
|
862
|
+
throw new Error('GitHub remediation issue create was dropped before provider admission');
|
|
863
|
+
}
|
|
864
|
+
if (created.status !== 'confirmed' || !created.url) {
|
|
865
|
+
throw new Error(`GitHub remediation issue create was ${created.status}, not provider-confirmed`);
|
|
866
|
+
}
|
|
867
|
+
const number = issueNumberFromCreated(created.id, created.url);
|
|
868
|
+
if (!number)
|
|
869
|
+
throw new Error('GitHub remediation issue receipt is missing its issue number');
|
|
870
|
+
const receipt = {
|
|
871
|
+
repository: policy.repository,
|
|
872
|
+
number,
|
|
873
|
+
url: created.url,
|
|
874
|
+
dedupeKey: policy.dedupeKey,
|
|
875
|
+
};
|
|
876
|
+
await appendImmutableJson(ctx, receiptPath, { ...receipt, bodyRevision });
|
|
877
|
+
return receipt;
|
|
878
|
+
}
|
|
879
|
+
throw new Error('GitHub remediation retry limit was exhausted after provider drops');
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
export function remediationMarker(dedupeKey) {
|
|
884
|
+
return `<!-- feature-guardian-remediation:${dedupeKey} -->`;
|
|
885
|
+
}
|
|
886
|
+
function remediationIntentPath(dedupeKey, attempt) {
|
|
887
|
+
const identity = attempt === 1 ? `intent:${dedupeKey}` : `intent:${dedupeKey}:${attempt}`;
|
|
888
|
+
return `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(identity).digest('hex')}.json`;
|
|
889
|
+
}
|
|
890
|
+
function remediationDroppedPath(dedupeKey, attempt) {
|
|
891
|
+
return `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(`drop:${dedupeKey}:${attempt}`).digest('hex')}.json`;
|
|
892
|
+
}
|
|
893
|
+
function remediationReceiptPath(dedupeKey) {
|
|
894
|
+
return `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(`receipt:${dedupeKey}`).digest('hex')}.json`;
|
|
895
|
+
}
|
|
896
|
+
async function updateExistingIssueEvidence(ctx, client, owner, repo, issueNumber, policy) {
|
|
897
|
+
const bodyRevision = guardianContentRevision(policy.body);
|
|
898
|
+
const marker = `<!-- feature-guardian-evidence:${bodyRevision} -->`;
|
|
899
|
+
const ledgerKey = `${policy.dedupeKey}:${bodyRevision}`;
|
|
900
|
+
const receiptPath = `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(`update-receipt:${ledgerKey}`).digest('hex')}.json`;
|
|
901
|
+
if (await readImmutableJson(ctx, receiptPath))
|
|
902
|
+
return;
|
|
903
|
+
const comments = await client['issue-comments'].list({
|
|
904
|
+
owner,
|
|
905
|
+
repo,
|
|
906
|
+
issueNumber,
|
|
907
|
+
});
|
|
908
|
+
if (comments.map(unwrapProviderRecord).some((comment) => providerText(comment.body).includes(marker))) {
|
|
909
|
+
await appendImmutableJson(ctx, receiptPath, { issueNumber, bodyRevision });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
for (let attempt = 1; attempt <= MAX_REMEDIATION_ATTEMPTS; attempt += 1) {
|
|
913
|
+
const suffix = attempt === 1 ? '' : `:${attempt}`;
|
|
914
|
+
const intentPath = `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(`update-intent:${ledgerKey}${suffix}`).digest('hex')}.json`;
|
|
915
|
+
const droppedPath = `${GUARDIAN_REMEDIATIONS_PATH}/${createHash('sha256').update(`update-drop:${ledgerKey}:${attempt}`).digest('hex')}.json`;
|
|
916
|
+
if (await readImmutableJson(ctx, droppedPath))
|
|
917
|
+
continue;
|
|
918
|
+
const claimed = await claimImmutableJson(ctx, intentPath, {
|
|
919
|
+
issueNumber,
|
|
920
|
+
bodyRevision,
|
|
921
|
+
...(attempt === 1 ? {} : { attempt }),
|
|
922
|
+
});
|
|
923
|
+
if (!claimed) {
|
|
924
|
+
if (await readImmutableJson(ctx, droppedPath))
|
|
925
|
+
continue;
|
|
926
|
+
throw new Error('GitHub remediation evidence update is pending; refusing a duplicate comment');
|
|
927
|
+
}
|
|
928
|
+
const updated = await client.comment({ owner, repo, number: issueNumber }, `${marker}\n${policy.body}`);
|
|
929
|
+
if (updated.status === 'dropped') {
|
|
930
|
+
await appendImmutableJson(ctx, droppedPath, {
|
|
931
|
+
issueNumber,
|
|
932
|
+
bodyRevision,
|
|
933
|
+
attempt,
|
|
934
|
+
reason: updated.reason ?? 'provider dropped the evidence update',
|
|
935
|
+
});
|
|
936
|
+
throw new Error('GitHub remediation evidence update was dropped before provider admission');
|
|
937
|
+
}
|
|
938
|
+
if (updated.status !== 'confirmed') {
|
|
939
|
+
throw new Error(`GitHub remediation evidence update was ${updated.status}, not provider-confirmed`);
|
|
940
|
+
}
|
|
941
|
+
await appendImmutableJson(ctx, receiptPath, { issueNumber, bodyRevision });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
throw new Error('GitHub remediation evidence retry limit was exhausted after provider drops');
|
|
945
|
+
}
|
|
946
|
+
function parseDurableIssueReceipt(value, policy) {
|
|
947
|
+
const receipt = parseIssueReceipt(value);
|
|
948
|
+
if (receipt.repository !== policy.repository || receipt.dedupeKey !== policy.dedupeKey) {
|
|
949
|
+
throw new Error('durable guardian issue receipt does not match its policy');
|
|
950
|
+
}
|
|
951
|
+
return receipt;
|
|
952
|
+
}
|
|
953
|
+
async function listGithubIssues(ctx, client, owner, repo) {
|
|
954
|
+
const listed = await client.issues.list({ owner, repo });
|
|
955
|
+
const issues = listed.filter((value) => asRecord(value) !== undefined);
|
|
956
|
+
const root = `${ctx.sandbox.cwd}/github/repos/${owner}/${repo}/issues`;
|
|
957
|
+
const rawIndex = await readOptionalSandboxFile(ctx, `${root}/_index.json`);
|
|
958
|
+
if (!rawIndex)
|
|
959
|
+
return issues;
|
|
960
|
+
const index = JSON.parse(rawIndex);
|
|
961
|
+
if (!Array.isArray(index))
|
|
962
|
+
throw new Error('guardian GitHub issue index is malformed');
|
|
963
|
+
for (const entry of index) {
|
|
964
|
+
const item = asRecord(entry);
|
|
965
|
+
const number = providerNumber(item?.number) ?? providerNumber(item?.id);
|
|
966
|
+
const title = providerText(item?.title);
|
|
967
|
+
if (!number || !title)
|
|
968
|
+
continue;
|
|
969
|
+
const slug = title
|
|
970
|
+
.toLowerCase()
|
|
971
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
972
|
+
.replace(/^-|-$/gu, '');
|
|
973
|
+
const candidates = [
|
|
974
|
+
`${root}/${number}__${slug}/meta.json`,
|
|
975
|
+
`${root}/${number}/meta.json`,
|
|
976
|
+
`${root}/${number}.json`,
|
|
977
|
+
];
|
|
978
|
+
for (const path of candidates) {
|
|
979
|
+
const raw = await readOptionalSandboxFile(ctx, path);
|
|
980
|
+
if (!raw)
|
|
981
|
+
continue;
|
|
982
|
+
const record = asRecord(JSON.parse(raw));
|
|
983
|
+
if (record)
|
|
984
|
+
issues.push(record);
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return issues;
|
|
989
|
+
}
|
|
990
|
+
async function readOptionalSandboxFile(ctx, path) {
|
|
991
|
+
try {
|
|
992
|
+
return await ctx.sandbox.readFile(path);
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
const message = String(error);
|
|
996
|
+
if (message.includes('ENOENT') || message.includes('not found') || message.includes('No such file')) {
|
|
997
|
+
return undefined;
|
|
998
|
+
}
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
async function readImmutableJson(ctx, path) {
|
|
1003
|
+
const credentials = ctx.credentials.tryRequire();
|
|
1004
|
+
let content;
|
|
1005
|
+
if (credentials) {
|
|
1006
|
+
const client = relayfileClient(credentials.relayfile);
|
|
1007
|
+
const file = await withDeadline('guardian immutable ledger read', GUARDIAN_STATE_TIMEOUT_MS, (signal) => readExactFile(client, credentials.relayfile.workspaceId, path, signal));
|
|
1008
|
+
content = file?.content;
|
|
1009
|
+
}
|
|
1010
|
+
else if (ctx.agent.id === 'sim-agent' && ctx.deployment.id === 'sim-deployment') {
|
|
1011
|
+
try {
|
|
1012
|
+
content = await ctx.files.read(path);
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
if (!String(error).includes('ENOENT'))
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
throw new Error('exact Relayfile credentials are required for guardian remediation ledger');
|
|
1021
|
+
}
|
|
1022
|
+
if (content === undefined)
|
|
1023
|
+
return null;
|
|
1024
|
+
assertBoundedContent(content, 'guardian immutable ledger');
|
|
1025
|
+
return JSON.parse(content);
|
|
1026
|
+
}
|
|
1027
|
+
async function appendImmutableJson(ctx, path, value) {
|
|
1028
|
+
const content = `${JSON.stringify(value)}\n`;
|
|
1029
|
+
assertBoundedContent(content, 'guardian immutable ledger');
|
|
1030
|
+
const existing = await readImmutableJson(ctx, path);
|
|
1031
|
+
if (existing !== null) {
|
|
1032
|
+
if (`${JSON.stringify(existing)}\n` !== content) {
|
|
1033
|
+
throw new Error('immutable guardian ledger entry already differs');
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const credentials = ctx.credentials.tryRequire();
|
|
1038
|
+
if (credentials) {
|
|
1039
|
+
const client = relayfileClient(credentials.relayfile);
|
|
1040
|
+
try {
|
|
1041
|
+
await withDeadline('guardian immutable ledger append', GUARDIAN_STATE_TIMEOUT_MS, (signal) => writeExactFile(client, credentials.relayfile.workspaceId, path, '0', content, signal));
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
if (!(error instanceof RevisionConflictError) &&
|
|
1045
|
+
!(error instanceof RelayFileApiError && error.status === 409))
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
else if (ctx.agent.id === 'sim-agent' && ctx.deployment.id === 'sim-deployment') {
|
|
1050
|
+
await ctx.files.write(path, content);
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
throw new Error('exact Relayfile credentials are required for guardian remediation ledger');
|
|
1054
|
+
}
|
|
1055
|
+
const readBack = await readImmutableJson(ctx, path);
|
|
1056
|
+
if (readBack === null || `${JSON.stringify(readBack)}\n` !== content) {
|
|
1057
|
+
throw new Error('guardian immutable ledger read-back did not match');
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/** Atomically claim one immutable provider submission. Only the CAS winner may write. */
|
|
1061
|
+
async function claimImmutableJson(ctx, path, value) {
|
|
1062
|
+
const content = `${JSON.stringify(value)}\n`;
|
|
1063
|
+
assertBoundedContent(content, 'guardian immutable claim');
|
|
1064
|
+
const existing = await readImmutableJson(ctx, path);
|
|
1065
|
+
if (existing !== null) {
|
|
1066
|
+
if (`${JSON.stringify(existing)}\n` !== content) {
|
|
1067
|
+
throw new Error('immutable guardian claim already differs');
|
|
1068
|
+
}
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
const credentials = ctx.credentials.tryRequire();
|
|
1072
|
+
if (credentials) {
|
|
1073
|
+
const client = relayfileClient(credentials.relayfile);
|
|
1074
|
+
try {
|
|
1075
|
+
await withDeadline('guardian immutable claim', GUARDIAN_STATE_TIMEOUT_MS, (signal) => writeExactFile(client, credentials.relayfile.workspaceId, path, '0', content, signal));
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
if (!(error instanceof RevisionConflictError) &&
|
|
1079
|
+
!(error instanceof RelayFileApiError && error.status === 409))
|
|
1080
|
+
throw error;
|
|
1081
|
+
const winner = await readImmutableJson(ctx, path);
|
|
1082
|
+
if (winner === null || `${JSON.stringify(winner)}\n` !== content) {
|
|
1083
|
+
throw new Error('guardian immutable claim conflict did not match the winning intent');
|
|
1084
|
+
}
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
else if (ctx.agent.id === 'sim-agent' && ctx.deployment.id === 'sim-deployment') {
|
|
1089
|
+
const claimOwner = ctx.files;
|
|
1090
|
+
const claims = PREVIEW_IMMUTABLE_CLAIMS.get(claimOwner) ?? new Set();
|
|
1091
|
+
if (claims.has(path))
|
|
1092
|
+
return false;
|
|
1093
|
+
claims.add(path);
|
|
1094
|
+
PREVIEW_IMMUTABLE_CLAIMS.set(claimOwner, claims);
|
|
1095
|
+
await ctx.files.write(path, content);
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
throw new Error('exact Relayfile credentials are required for guardian remediation claims');
|
|
1099
|
+
}
|
|
1100
|
+
const readBack = await readImmutableJson(ctx, path);
|
|
1101
|
+
if (readBack === null || `${JSON.stringify(readBack)}\n` !== content) {
|
|
1102
|
+
throw new Error('guardian immutable claim read-back did not match');
|
|
1103
|
+
}
|
|
1104
|
+
return true;
|
|
1105
|
+
}
|
|
1106
|
+
export function slackThreadBacklink(channelId, threadTs) {
|
|
1107
|
+
return `https://slack.com/archives/${channelId}/p${threadTs.replace('.', '')}`;
|
|
1108
|
+
}
|
|
1109
|
+
async function postThreadReply(createSlackClient, record, text, idempotencyKey) {
|
|
1110
|
+
const client = createSlackClient({
|
|
1111
|
+
writebackTimeoutMs: GUARDIAN_WRITEBACK_TIMEOUT_MS,
|
|
1112
|
+
writebackPollMs: GUARDIAN_WRITEBACK_POLL_MS,
|
|
1113
|
+
});
|
|
1114
|
+
const result = await client.replies.write({ channelId: record.channelId, messageTs: record.threadTs }, { text, thread_ts: record.threadTs, idempotencyKey });
|
|
1115
|
+
return requireSlackReceiptTs(result);
|
|
1116
|
+
}
|
|
1117
|
+
function requireSlackReceiptTs(result) {
|
|
1118
|
+
if (result.deliveryStatus && result.deliveryStatus !== 'confirmed') {
|
|
1119
|
+
throw new Error(`guardian Slack reply was ${result.deliveryStatus}, not provider-confirmed`);
|
|
1120
|
+
}
|
|
1121
|
+
const receipt = asRecord(result.receipt);
|
|
1122
|
+
const externalId = nonEmptyString(receipt?.externalId);
|
|
1123
|
+
const fallbackTs = nonEmptyString(receipt?.ts);
|
|
1124
|
+
const ts = externalId && /^\d+\.\d+$/u.test(externalId) ? externalId : fallbackTs;
|
|
1125
|
+
if (!ts || !/^\d+\.\d+$/u.test(ts)) {
|
|
1126
|
+
throw new Error('guardian Slack reply did not receive a provider timestamp');
|
|
1127
|
+
}
|
|
1128
|
+
return ts;
|
|
1129
|
+
}
|
|
1130
|
+
function confirmationRecord(record, turn, timestamp) {
|
|
1131
|
+
if (!record.confirmationBasis) {
|
|
1132
|
+
throw new Error('guardian confirmation record is missing its confirmation basis');
|
|
1133
|
+
}
|
|
1134
|
+
const evidence = record.evidence;
|
|
1135
|
+
const commands = evidence.flatMap((entry) => entry.commands ?? []);
|
|
1136
|
+
const tests = evidence.reduce((total, entry) => ({
|
|
1137
|
+
passed: total.passed + (entry.tests?.passed ?? 0),
|
|
1138
|
+
failed: total.failed + (entry.tests?.failed ?? 0),
|
|
1139
|
+
}), { passed: 0, failed: 0 });
|
|
1140
|
+
return {
|
|
1141
|
+
kind: 'feature-guardian:confirmation',
|
|
1142
|
+
version: 1,
|
|
1143
|
+
featureId: record.feature.id,
|
|
1144
|
+
manifestRevision: record.manifestRevision,
|
|
1145
|
+
manifestVersion: record.manifestVersion,
|
|
1146
|
+
procedureRevision: record.procedureRevision,
|
|
1147
|
+
generation: record.generation,
|
|
1148
|
+
result: 'confirmed',
|
|
1149
|
+
...(turn.actorId ? { actor: { id: turn.actorId } } : {}),
|
|
1150
|
+
verifier: record.confirmationBasis.verifier,
|
|
1151
|
+
timestamp,
|
|
1152
|
+
evidence,
|
|
1153
|
+
commands,
|
|
1154
|
+
tests,
|
|
1155
|
+
slack: {
|
|
1156
|
+
channelId: record.channelId,
|
|
1157
|
+
threadTs: record.threadTs,
|
|
1158
|
+
questionTs: record.threadTs,
|
|
1159
|
+
},
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function parseConversationState(value) {
|
|
1163
|
+
const root = asRecord(value);
|
|
1164
|
+
if (!root || root.kind !== 'feature-guardian:conversations' || root.version !== 1) {
|
|
1165
|
+
throw new Error('guardian conversation state kind/version is invalid');
|
|
1166
|
+
}
|
|
1167
|
+
if (!Array.isArray(root.records) || root.records.length > MAX_CONVERSATIONS) {
|
|
1168
|
+
throw new Error('guardian conversation state records are invalid');
|
|
1169
|
+
}
|
|
1170
|
+
const records = root.records.map(parseConversationRecord);
|
|
1171
|
+
if (new Set(records.map((record) => record.id)).size !== records.length) {
|
|
1172
|
+
throw new Error('guardian conversation state contains duplicate records');
|
|
1173
|
+
}
|
|
1174
|
+
return { kind: 'feature-guardian:conversations', version: 1, records };
|
|
1175
|
+
}
|
|
1176
|
+
function parseConversationRecord(value) {
|
|
1177
|
+
const record = asRecord(value);
|
|
1178
|
+
if (!record)
|
|
1179
|
+
throw new Error('guardian conversation record must be an object');
|
|
1180
|
+
const feature = parseFeatureSnapshot(record.feature);
|
|
1181
|
+
const status = record.status;
|
|
1182
|
+
if (!isConversationStatus(status))
|
|
1183
|
+
throw new Error('guardian conversation status is invalid');
|
|
1184
|
+
const seenEventIds = stringArray(record.seenEventIds, MAX_SEEN_EVENTS, 'seen event ids');
|
|
1185
|
+
const evidence = evidenceArray(record.evidence);
|
|
1186
|
+
const turnCount = record.turnCount;
|
|
1187
|
+
if (!Number.isSafeInteger(turnCount) || turnCount < 0 || turnCount > MAX_TURNS) {
|
|
1188
|
+
throw new Error('guardian conversation turn count is invalid');
|
|
1189
|
+
}
|
|
1190
|
+
const clarificationCount = record.clarificationCount;
|
|
1191
|
+
if (!Number.isSafeInteger(clarificationCount) ||
|
|
1192
|
+
clarificationCount < 0 ||
|
|
1193
|
+
clarificationCount > turnCount) {
|
|
1194
|
+
throw new Error('guardian conversation clarification count is invalid');
|
|
1195
|
+
}
|
|
1196
|
+
const parsed = {
|
|
1197
|
+
id: requireString(record.id, 'conversation id'),
|
|
1198
|
+
feature,
|
|
1199
|
+
manifestRevision: requireString(record.manifestRevision, 'manifest revision'),
|
|
1200
|
+
manifestVersion: requireString(record.manifestVersion, 'manifest version'),
|
|
1201
|
+
procedureRevision: requireString(record.procedureRevision, 'procedure revision'),
|
|
1202
|
+
generation: requirePositiveInteger(record.generation, 'generation'),
|
|
1203
|
+
channelId: requireString(record.channelId, 'channel id'),
|
|
1204
|
+
threadTs: requireSlackTs(record.threadTs, 'thread ts'),
|
|
1205
|
+
askedAt: requireTimestamp(record.askedAt, 'askedAt'),
|
|
1206
|
+
status,
|
|
1207
|
+
turnCount: turnCount,
|
|
1208
|
+
clarificationCount: clarificationCount,
|
|
1209
|
+
seenEventIds,
|
|
1210
|
+
evidence,
|
|
1211
|
+
updatedAt: requireTimestamp(record.updatedAt, 'updatedAt'),
|
|
1212
|
+
};
|
|
1213
|
+
if (record.pending !== undefined)
|
|
1214
|
+
parsed.pending = parsePending(record.pending);
|
|
1215
|
+
if (record.confirmationBasis !== undefined) {
|
|
1216
|
+
parsed.confirmationBasis = parseConfirmationBasis(record.confirmationBasis);
|
|
1217
|
+
}
|
|
1218
|
+
if (record.confirmationPath !== undefined) {
|
|
1219
|
+
parsed.confirmationPath = requireString(record.confirmationPath, 'confirmation path');
|
|
1220
|
+
}
|
|
1221
|
+
if (record.issue !== undefined)
|
|
1222
|
+
parsed.issue = parseIssueReceipt(record.issue);
|
|
1223
|
+
if (record.finalReplyTs !== undefined) {
|
|
1224
|
+
parsed.finalReplyTs = requireSlackTs(record.finalReplyTs, 'final reply ts');
|
|
1225
|
+
}
|
|
1226
|
+
if (record.lastProcessedEventOrder !== undefined) {
|
|
1227
|
+
parsed.lastProcessedEventOrder = requireEventOrder(record.lastProcessedEventOrder, 'last processed event order');
|
|
1228
|
+
}
|
|
1229
|
+
if (guardianConversationId(parsed) !== parsed.id) {
|
|
1230
|
+
throw new Error('guardian conversation record identity is invalid');
|
|
1231
|
+
}
|
|
1232
|
+
if (isTerminal(parsed.status) && parsed.pending) {
|
|
1233
|
+
throw new Error('terminal guardian conversation cannot retain a pending turn');
|
|
1234
|
+
}
|
|
1235
|
+
if ((parsed.status === 'asked' || parsed.status === 'discussing') && parsed.pending) {
|
|
1236
|
+
throw new Error(`guardian ${parsed.status} conversation cannot retain a pending turn`);
|
|
1237
|
+
}
|
|
1238
|
+
if (isTerminal(parsed.status) && !parsed.finalReplyTs) {
|
|
1239
|
+
throw new Error('terminal guardian conversation requires a provider-confirmed final reply');
|
|
1240
|
+
}
|
|
1241
|
+
if (['verifying', 'confirmation-recorded', 'remediation-recorded', 'deferred-recorded'].includes(parsed.status) && !parsed.pending) {
|
|
1242
|
+
throw new Error(`guardian ${parsed.status} conversation requires a pending turn`);
|
|
1243
|
+
}
|
|
1244
|
+
if ((parsed.status === 'remediation-recorded' || parsed.status === 'remediation-open') &&
|
|
1245
|
+
!parsed.issue) {
|
|
1246
|
+
throw new Error(`guardian ${parsed.status} conversation requires an issue receipt`);
|
|
1247
|
+
}
|
|
1248
|
+
if (parsed.status === 'confirmed' && !parsed.confirmationPath) {
|
|
1249
|
+
throw new Error('confirmed guardian conversation requires a confirmation path');
|
|
1250
|
+
}
|
|
1251
|
+
if ((parsed.status === 'confirmation-recorded' || parsed.status === 'confirmed') &&
|
|
1252
|
+
!parsed.confirmationBasis) {
|
|
1253
|
+
throw new Error(`guardian ${parsed.status} conversation requires a confirmation basis`);
|
|
1254
|
+
}
|
|
1255
|
+
if (parsed.confirmationBasis &&
|
|
1256
|
+
parsed.status !== 'confirmation-recorded' &&
|
|
1257
|
+
parsed.status !== 'confirmed') {
|
|
1258
|
+
throw new Error('guardian confirmation basis is invalid for the conversation status');
|
|
1259
|
+
}
|
|
1260
|
+
if (parsed.status === 'confirmation-recorded' && parsed.pending && parsed.confirmationBasis) {
|
|
1261
|
+
if (parsed.confirmationBasis.kind === 'actor' &&
|
|
1262
|
+
(parsed.pending.response !== 'affirmative' ||
|
|
1263
|
+
parsed.pending.actorId !== parsed.confirmationBasis.verifier ||
|
|
1264
|
+
parsed.pending.evidence?.result !== 'positive')) {
|
|
1265
|
+
throw new Error('guardian actor confirmation basis is inconsistent with its pending turn');
|
|
1266
|
+
}
|
|
1267
|
+
if (parsed.confirmationBasis.kind === 'procedure' &&
|
|
1268
|
+
(parsed.pending.verification?.outcome !== 'passed' ||
|
|
1269
|
+
parsed.pending.verification.verifier !== parsed.confirmationBasis.verifier ||
|
|
1270
|
+
(parsed.pending.verification.tests?.failed ?? 0) !== 0)) {
|
|
1271
|
+
throw new Error('guardian procedure confirmation basis is inconsistent with its pending turn');
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (parsed.status === 'confirmed' && parsed.confirmationBasis) {
|
|
1275
|
+
const matchingEvidence = parsed.evidence.some((entry) => parsed.confirmationBasis?.kind === 'actor'
|
|
1276
|
+
? entry.source === 'actor' && entry.result === 'positive'
|
|
1277
|
+
: entry.source === 'procedure' &&
|
|
1278
|
+
entry.result === 'positive' &&
|
|
1279
|
+
(entry.tests?.failed ?? 0) === 0);
|
|
1280
|
+
if (!matchingEvidence) {
|
|
1281
|
+
throw new Error('confirmed guardian conversation has no evidence for its confirmation basis');
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (parsed.confirmationPath &&
|
|
1285
|
+
parsed.status !== 'confirmation-recorded' &&
|
|
1286
|
+
parsed.status !== 'confirmed') {
|
|
1287
|
+
throw new Error('guardian confirmation path is invalid for the conversation status');
|
|
1288
|
+
}
|
|
1289
|
+
if (parsed.issue &&
|
|
1290
|
+
parsed.status !== 'remediation-recorded' &&
|
|
1291
|
+
parsed.status !== 'remediation-open') {
|
|
1292
|
+
throw new Error('guardian issue receipt is invalid for the conversation status');
|
|
1293
|
+
}
|
|
1294
|
+
return parsed;
|
|
1295
|
+
}
|
|
1296
|
+
function parseConfirmationRecord(value) {
|
|
1297
|
+
const record = asRecord(value);
|
|
1298
|
+
if (!record || record.kind !== 'feature-guardian:confirmation' || record.version !== 1) {
|
|
1299
|
+
throw new Error('guardian confirmation kind/version is invalid');
|
|
1300
|
+
}
|
|
1301
|
+
const actor = asRecord(record.actor);
|
|
1302
|
+
const slack = asRecord(record.slack);
|
|
1303
|
+
const tests = asRecord(record.tests);
|
|
1304
|
+
if (record.result !== 'confirmed' || !slack || !tests) {
|
|
1305
|
+
throw new Error('guardian confirmation result/evidence is invalid');
|
|
1306
|
+
}
|
|
1307
|
+
const failed = requireNonNegativeInteger(tests.failed, 'failed tests');
|
|
1308
|
+
if (failed !== 0)
|
|
1309
|
+
throw new Error('confirmed guardian evidence cannot contain failed tests');
|
|
1310
|
+
return {
|
|
1311
|
+
kind: 'feature-guardian:confirmation',
|
|
1312
|
+
version: 1,
|
|
1313
|
+
featureId: requireString(record.featureId, 'feature id'),
|
|
1314
|
+
manifestRevision: requireString(record.manifestRevision, 'manifest revision'),
|
|
1315
|
+
manifestVersion: requireString(record.manifestVersion, 'manifest version'),
|
|
1316
|
+
procedureRevision: requireString(record.procedureRevision, 'procedure revision'),
|
|
1317
|
+
generation: requirePositiveInteger(record.generation, 'generation'),
|
|
1318
|
+
result: 'confirmed',
|
|
1319
|
+
...(actor ? { actor: { id: requireString(actor.id, 'actor id') } } : {}),
|
|
1320
|
+
verifier: requireString(record.verifier, 'verifier'),
|
|
1321
|
+
timestamp: requireTimestamp(record.timestamp, 'timestamp'),
|
|
1322
|
+
evidence: evidenceArray(record.evidence),
|
|
1323
|
+
commands: stringArray(record.commands, 128, 'commands'),
|
|
1324
|
+
tests: {
|
|
1325
|
+
passed: requireNonNegativeInteger(tests.passed, 'passed tests'),
|
|
1326
|
+
failed,
|
|
1327
|
+
},
|
|
1328
|
+
slack: {
|
|
1329
|
+
channelId: requireString(slack.channelId, 'Slack channel id'),
|
|
1330
|
+
threadTs: requireSlackTs(slack.threadTs, 'Slack thread ts'),
|
|
1331
|
+
questionTs: requireSlackTs(slack.questionTs, 'Slack question ts'),
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function parseConfirmationBasis(value) {
|
|
1336
|
+
const basis = asRecord(value);
|
|
1337
|
+
if (!basis || (basis.kind !== 'actor' && basis.kind !== 'procedure')) {
|
|
1338
|
+
throw new Error('guardian confirmation basis is invalid');
|
|
1339
|
+
}
|
|
1340
|
+
return { kind: basis.kind, verifier: requireString(basis.verifier, 'confirmation verifier') };
|
|
1341
|
+
}
|
|
1342
|
+
function assertConversationTransition(previous, next) {
|
|
1343
|
+
if (!previous)
|
|
1344
|
+
return;
|
|
1345
|
+
const nextById = new Map(next.records.map((record) => [record.id, record]));
|
|
1346
|
+
const removed = previous.records.filter((record) => !nextById.has(record.id));
|
|
1347
|
+
if (removed.some((record) => !isTerminal(record.status))) {
|
|
1348
|
+
throw new Error('guardian conversation records are append-only except bounded terminal pruning');
|
|
1349
|
+
}
|
|
1350
|
+
for (const prior of previous.records) {
|
|
1351
|
+
const current = nextById.get(prior.id);
|
|
1352
|
+
if (!current)
|
|
1353
|
+
continue;
|
|
1354
|
+
if (!sameQuestion(prior, current)) {
|
|
1355
|
+
throw new Error('guardian conversation question identity is immutable');
|
|
1356
|
+
}
|
|
1357
|
+
if (isTerminal(prior.status) && JSON.stringify(prior) !== JSON.stringify(current)) {
|
|
1358
|
+
throw new Error('terminal guardian conversation records are immutable');
|
|
1359
|
+
}
|
|
1360
|
+
if (!prior.seenEventIds.every((id) => current.seenEventIds.includes(id))) {
|
|
1361
|
+
throw new Error('guardian conversation seen-event history cannot regress');
|
|
1362
|
+
}
|
|
1363
|
+
if (JSON.stringify(current.evidence.slice(0, prior.evidence.length)) !==
|
|
1364
|
+
JSON.stringify(prior.evidence)) {
|
|
1365
|
+
throw new Error('guardian conversation evidence is append-only');
|
|
1366
|
+
}
|
|
1367
|
+
if (prior.confirmationPath !== undefined &&
|
|
1368
|
+
current.confirmationPath !== prior.confirmationPath) {
|
|
1369
|
+
throw new Error('guardian conversation confirmation path is immutable');
|
|
1370
|
+
}
|
|
1371
|
+
if (prior.confirmationBasis !== undefined &&
|
|
1372
|
+
JSON.stringify(current.confirmationBasis) !== JSON.stringify(prior.confirmationBasis)) {
|
|
1373
|
+
throw new Error('guardian conversation confirmation basis is immutable');
|
|
1374
|
+
}
|
|
1375
|
+
if (prior.issue !== undefined && JSON.stringify(current.issue) !== JSON.stringify(prior.issue)) {
|
|
1376
|
+
throw new Error('guardian conversation issue receipt is immutable');
|
|
1377
|
+
}
|
|
1378
|
+
if (current.turnCount < prior.turnCount) {
|
|
1379
|
+
throw new Error('guardian conversation turn count cannot regress');
|
|
1380
|
+
}
|
|
1381
|
+
if (current.clarificationCount < prior.clarificationCount) {
|
|
1382
|
+
throw new Error('guardian conversation clarification count cannot regress');
|
|
1383
|
+
}
|
|
1384
|
+
if (prior.lastProcessedEventOrder &&
|
|
1385
|
+
(!current.lastProcessedEventOrder ||
|
|
1386
|
+
current.lastProcessedEventOrder < prior.lastProcessedEventOrder)) {
|
|
1387
|
+
throw new Error('guardian conversation event-order watermark cannot regress');
|
|
1388
|
+
}
|
|
1389
|
+
if (!validStatusTransition(prior.status, current.status)) {
|
|
1390
|
+
throw new Error(`invalid guardian conversation transition ${prior.status} -> ${current.status}`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function validStatusTransition(previous, next) {
|
|
1395
|
+
if (previous === next)
|
|
1396
|
+
return true;
|
|
1397
|
+
const allowed = {
|
|
1398
|
+
asked: ['discussing', 'verifying', 'confirmation-recorded', 'deferred-recorded'],
|
|
1399
|
+
discussing: ['discussing', 'verifying', 'confirmation-recorded', 'deferred-recorded'],
|
|
1400
|
+
verifying: ['discussing', 'confirmation-recorded', 'remediation-recorded', 'deferred-recorded'],
|
|
1401
|
+
'confirmation-recorded': ['confirmed'],
|
|
1402
|
+
'remediation-recorded': ['remediation-open'],
|
|
1403
|
+
'deferred-recorded': ['deferred'],
|
|
1404
|
+
confirmed: [],
|
|
1405
|
+
'remediation-open': [],
|
|
1406
|
+
deferred: [],
|
|
1407
|
+
};
|
|
1408
|
+
return allowed[previous].includes(next);
|
|
1409
|
+
}
|
|
1410
|
+
function parseFeatureSnapshot(value) {
|
|
1411
|
+
const feature = asRecord(value);
|
|
1412
|
+
if (!feature)
|
|
1413
|
+
throw new Error('guardian feature snapshot must be an object');
|
|
1414
|
+
const tier = requirePositiveInteger(feature.tier, 'feature tier');
|
|
1415
|
+
if (tier > 6)
|
|
1416
|
+
throw new Error('guardian feature tier is invalid');
|
|
1417
|
+
return {
|
|
1418
|
+
id: requireString(feature.id, 'feature id'),
|
|
1419
|
+
name: requireString(feature.name, 'feature name'),
|
|
1420
|
+
category: requireString(feature.category, 'feature category'),
|
|
1421
|
+
...(nonEmptyString(feature.cli) ? { cli: nonEmptyString(feature.cli) } : {}),
|
|
1422
|
+
...(nonEmptyString(feature.api) ? { api: nonEmptyString(feature.api) } : {}),
|
|
1423
|
+
description: requireString(feature.description, 'feature description'),
|
|
1424
|
+
locations: stringArray(feature.locations, 32, 'feature locations'),
|
|
1425
|
+
procedure: requireString(feature.procedure, 'feature procedure'),
|
|
1426
|
+
tier,
|
|
1427
|
+
criticality: requireString(feature.criticality, 'feature criticality'),
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
function parsePending(value) {
|
|
1431
|
+
const pending = asRecord(value);
|
|
1432
|
+
if (!pending || !isResponseKind(pending.response)) {
|
|
1433
|
+
throw new Error('guardian pending turn is invalid');
|
|
1434
|
+
}
|
|
1435
|
+
return {
|
|
1436
|
+
eventId: requireString(pending.eventId, 'event id'),
|
|
1437
|
+
eventOrder: requireEventOrder(pending.eventOrder, 'pending event order'),
|
|
1438
|
+
eventOccurredAt: requireTimestamp(pending.eventOccurredAt, 'event occurredAt'),
|
|
1439
|
+
actorId: requireString(pending.actorId, 'actor id'),
|
|
1440
|
+
response: pending.response,
|
|
1441
|
+
text: typeof pending.text === 'string' ? bounded(pending.text, 4_000) : '',
|
|
1442
|
+
...(nonEmptyString(pending.reaction) ? { reaction: nonEmptyString(pending.reaction) } : {}),
|
|
1443
|
+
...(pending.evidence !== undefined ? { evidence: parseEvidence(pending.evidence) } : {}),
|
|
1444
|
+
...(pending.verification !== undefined
|
|
1445
|
+
? { verification: parseProcedureResult(pending.verification) }
|
|
1446
|
+
: {}),
|
|
1447
|
+
...(isDefectKind(pending.defectKind) ? { defectKind: pending.defectKind } : {}),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
function parseProcedureResult(value) {
|
|
1451
|
+
const result = asRecord(value);
|
|
1452
|
+
const evidence = parseEvidence(value);
|
|
1453
|
+
if (evidence.source !== 'procedure' ||
|
|
1454
|
+
!['passed', 'failed', 'skip', 'manual'].includes(String(result?.outcome))) {
|
|
1455
|
+
throw new Error('guardian procedure result is invalid');
|
|
1456
|
+
}
|
|
1457
|
+
const outcome = result?.outcome;
|
|
1458
|
+
const expectedResult = {
|
|
1459
|
+
passed: 'positive',
|
|
1460
|
+
failed: 'negative',
|
|
1461
|
+
skip: 'skip',
|
|
1462
|
+
manual: 'manual',
|
|
1463
|
+
};
|
|
1464
|
+
if (evidence.result !== expectedResult[outcome]) {
|
|
1465
|
+
throw new Error('guardian procedure outcome/result is inconsistent');
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
...evidence,
|
|
1469
|
+
source: 'procedure',
|
|
1470
|
+
outcome,
|
|
1471
|
+
verifier: requireString(result?.verifier, 'procedure verifier'),
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function parseIssueReceipt(value) {
|
|
1475
|
+
const issue = asRecord(value);
|
|
1476
|
+
if (!issue)
|
|
1477
|
+
throw new Error('guardian issue receipt is invalid');
|
|
1478
|
+
return {
|
|
1479
|
+
repository: requireString(issue.repository, 'issue repository'),
|
|
1480
|
+
number: requirePositiveInteger(issue.number, 'issue number'),
|
|
1481
|
+
url: requireString(issue.url, 'issue url'),
|
|
1482
|
+
dedupeKey: requireString(issue.dedupeKey, 'issue dedupe key'),
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function evidenceArray(value) {
|
|
1486
|
+
if (!Array.isArray(value) || value.length > MAX_EVIDENCE_ITEMS) {
|
|
1487
|
+
throw new Error('guardian evidence is invalid');
|
|
1488
|
+
}
|
|
1489
|
+
return value.map(parseEvidence);
|
|
1490
|
+
}
|
|
1491
|
+
function parseEvidence(value) {
|
|
1492
|
+
const evidence = asRecord(value);
|
|
1493
|
+
if (!evidence ||
|
|
1494
|
+
!['actor', 'procedure', 'system'].includes(String(evidence.source)) ||
|
|
1495
|
+
!['positive', 'negative', 'skip', 'manual', 'unknown'].includes(String(evidence.result))) {
|
|
1496
|
+
throw new Error('guardian evidence item is invalid');
|
|
1497
|
+
}
|
|
1498
|
+
const tests = asRecord(evidence.tests);
|
|
1499
|
+
return {
|
|
1500
|
+
source: evidence.source,
|
|
1501
|
+
result: evidence.result,
|
|
1502
|
+
summary: requireString(evidence.summary, 'evidence summary'),
|
|
1503
|
+
...(evidence.commands !== undefined
|
|
1504
|
+
? { commands: stringArray(evidence.commands, 128, 'evidence commands') }
|
|
1505
|
+
: {}),
|
|
1506
|
+
...(evidence.positiveAssertions !== undefined
|
|
1507
|
+
? {
|
|
1508
|
+
positiveAssertions: stringArray(evidence.positiveAssertions, 128, 'positive assertions'),
|
|
1509
|
+
}
|
|
1510
|
+
: {}),
|
|
1511
|
+
...(evidence.negativeAssertions !== undefined
|
|
1512
|
+
? {
|
|
1513
|
+
negativeAssertions: stringArray(evidence.negativeAssertions, 128, 'negative assertions'),
|
|
1514
|
+
}
|
|
1515
|
+
: {}),
|
|
1516
|
+
...(tests
|
|
1517
|
+
? {
|
|
1518
|
+
tests: {
|
|
1519
|
+
passed: requireNonNegativeInteger(tests.passed, 'passed tests'),
|
|
1520
|
+
failed: requireNonNegativeInteger(tests.failed, 'failed tests'),
|
|
1521
|
+
},
|
|
1522
|
+
}
|
|
1523
|
+
: {}),
|
|
1524
|
+
...(evidence.cleanup !== undefined
|
|
1525
|
+
? { cleanup: stringArray(evidence.cleanup, 32, 'cleanup evidence') }
|
|
1526
|
+
: {}),
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
function findCorrelatedConversation(state, event) {
|
|
1530
|
+
const candidates = state.records.filter((record) => record.channelId === event.channelId &&
|
|
1531
|
+
record.threadTs === event.threadTs &&
|
|
1532
|
+
(!event.isReaction || event.messageTs === record.threadTs));
|
|
1533
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
1534
|
+
}
|
|
1535
|
+
function findSlackPayload(root) {
|
|
1536
|
+
if (!root)
|
|
1537
|
+
return undefined;
|
|
1538
|
+
const queue = [root];
|
|
1539
|
+
const seen = new Set();
|
|
1540
|
+
while (queue.length > 0) {
|
|
1541
|
+
const current = queue.shift();
|
|
1542
|
+
if (seen.has(current))
|
|
1543
|
+
continue;
|
|
1544
|
+
seen.add(current);
|
|
1545
|
+
if (nonEmptyString(current.channel) ||
|
|
1546
|
+
nonEmptyString(asRecord(current.item)?.channel) ||
|
|
1547
|
+
nonEmptyString(current.thread_ts)) {
|
|
1548
|
+
return current;
|
|
1549
|
+
}
|
|
1550
|
+
for (const key of ['payload', 'event', 'message', 'data', 'resource', 'record']) {
|
|
1551
|
+
const nested = asRecord(current[key]);
|
|
1552
|
+
if (nested)
|
|
1553
|
+
queue.push(nested);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
function channelFromPath(path) {
|
|
1559
|
+
const encoded = path?.match(/^\/slack\/channels\/([^/]+)/u)?.[1];
|
|
1560
|
+
return encoded?.split('__')[0];
|
|
1561
|
+
}
|
|
1562
|
+
function messageTsFromPath(path) {
|
|
1563
|
+
const match = path?.match(/\/messages\/([^/]+)(?:\/replies\/([^/.]+))?/u);
|
|
1564
|
+
return (match?.[2] ?? match?.[1])?.replaceAll('_', '.');
|
|
1565
|
+
}
|
|
1566
|
+
function pendingTurn(parsed, response, evidence) {
|
|
1567
|
+
return {
|
|
1568
|
+
eventId: parsed.eventId,
|
|
1569
|
+
eventOrder: parsed.eventOrder,
|
|
1570
|
+
eventOccurredAt: parsed.occurredAt,
|
|
1571
|
+
actorId: parsed.actorId,
|
|
1572
|
+
response,
|
|
1573
|
+
text: parsed.text,
|
|
1574
|
+
...(parsed.reaction ? { reaction: parsed.reaction } : {}),
|
|
1575
|
+
evidence,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
function emptyConversationState() {
|
|
1579
|
+
return { kind: 'feature-guardian:conversations', version: 1, records: [] };
|
|
1580
|
+
}
|
|
1581
|
+
function retainConversationCapacity(records, incoming) {
|
|
1582
|
+
let retained = [...records];
|
|
1583
|
+
for (;;) {
|
|
1584
|
+
const candidate = {
|
|
1585
|
+
kind: 'feature-guardian:conversations',
|
|
1586
|
+
version: 1,
|
|
1587
|
+
records: [...retained, incoming],
|
|
1588
|
+
};
|
|
1589
|
+
const fitsCount = candidate.records.length <= MAX_CONVERSATIONS;
|
|
1590
|
+
const fitsBytes = UTF8_ENCODER.encode(`${JSON.stringify(candidate)}\n`).byteLength <= MAX_STATE_BYTES;
|
|
1591
|
+
if (fitsCount && fitsBytes)
|
|
1592
|
+
return retained;
|
|
1593
|
+
const removable = retained
|
|
1594
|
+
.filter((record) => isTerminal(record.status))
|
|
1595
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0];
|
|
1596
|
+
if (!removable) {
|
|
1597
|
+
throw new Error('guardian conversation state reached its bounded active-record limit');
|
|
1598
|
+
}
|
|
1599
|
+
retained = retained.filter((record) => record.id !== removable.id);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function replaceRecord(state, id, next) {
|
|
1603
|
+
return {
|
|
1604
|
+
...state,
|
|
1605
|
+
records: state.records.map((record) => (record.id === id ? next : record)),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
function requireRecordById(state, id) {
|
|
1609
|
+
const record = state.records.find((candidate) => candidate.id === id);
|
|
1610
|
+
if (!record)
|
|
1611
|
+
throw new Error('guardian conversation disappeared after checkpoint');
|
|
1612
|
+
return record;
|
|
1613
|
+
}
|
|
1614
|
+
function sameQuestion(left, right) {
|
|
1615
|
+
return JSON.stringify({
|
|
1616
|
+
feature: left.feature,
|
|
1617
|
+
manifestRevision: left.manifestRevision,
|
|
1618
|
+
manifestVersion: left.manifestVersion,
|
|
1619
|
+
procedureRevision: left.procedureRevision,
|
|
1620
|
+
generation: left.generation,
|
|
1621
|
+
channelId: left.channelId,
|
|
1622
|
+
threadTs: left.threadTs,
|
|
1623
|
+
askedAt: left.askedAt,
|
|
1624
|
+
}) === JSON.stringify({
|
|
1625
|
+
feature: right.feature,
|
|
1626
|
+
manifestRevision: right.manifestRevision,
|
|
1627
|
+
manifestVersion: right.manifestVersion,
|
|
1628
|
+
procedureRevision: right.procedureRevision,
|
|
1629
|
+
generation: right.generation,
|
|
1630
|
+
channelId: right.channelId,
|
|
1631
|
+
threadTs: right.threadTs,
|
|
1632
|
+
askedAt: right.askedAt,
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
function appendSeen(existing, eventId) {
|
|
1636
|
+
if (existing.includes(eventId))
|
|
1637
|
+
return existing;
|
|
1638
|
+
if (existing.length >= MAX_SEEN_EVENTS) {
|
|
1639
|
+
throw new Error('guardian conversation seen-event history reached its bounded limit');
|
|
1640
|
+
}
|
|
1641
|
+
return [...existing, eventId];
|
|
1642
|
+
}
|
|
1643
|
+
function appendEvidence(existing, evidence) {
|
|
1644
|
+
if (!evidence)
|
|
1645
|
+
return existing;
|
|
1646
|
+
if (existing.length >= MAX_EVIDENCE_ITEMS) {
|
|
1647
|
+
throw new Error('guardian conversation evidence reached its bounded limit');
|
|
1648
|
+
}
|
|
1649
|
+
return [...existing, evidence];
|
|
1650
|
+
}
|
|
1651
|
+
function previewRevision(state) {
|
|
1652
|
+
return `preview:${guardianContentRevision(JSON.stringify(state))}`;
|
|
1653
|
+
}
|
|
1654
|
+
function isTerminal(status) {
|
|
1655
|
+
return status === 'confirmed' || status === 'remediation-open' || status === 'deferred';
|
|
1656
|
+
}
|
|
1657
|
+
function isConversationStatus(value) {
|
|
1658
|
+
return [
|
|
1659
|
+
'asked',
|
|
1660
|
+
'discussing',
|
|
1661
|
+
'verifying',
|
|
1662
|
+
'confirmation-recorded',
|
|
1663
|
+
'remediation-recorded',
|
|
1664
|
+
'deferred-recorded',
|
|
1665
|
+
'confirmed',
|
|
1666
|
+
'remediation-open',
|
|
1667
|
+
'deferred',
|
|
1668
|
+
].includes(String(value));
|
|
1669
|
+
}
|
|
1670
|
+
function isResponseKind(value) {
|
|
1671
|
+
return ['affirmative', 'failure', 'untested', 'ambiguous', 'deferred'].includes(String(value));
|
|
1672
|
+
}
|
|
1673
|
+
function isDefectKind(value) {
|
|
1674
|
+
return ['implementation', 'test', 'manifest', 'procedure', 'documentation'].includes(String(value));
|
|
1675
|
+
}
|
|
1676
|
+
function shortRevision(revision) {
|
|
1677
|
+
return revision.replace(/^sha256:/u, '').slice(0, 12);
|
|
1678
|
+
}
|
|
1679
|
+
function hasActorConfirmationEvidence(text) {
|
|
1680
|
+
return /\b(tested|verified|confirmed)\b/iu.test(text) &&
|
|
1681
|
+
/\b(works?|working|passes?|passed|expected|good)\b/iu.test(text);
|
|
1682
|
+
}
|
|
1683
|
+
function formatEvidenceForSlack(evidence) {
|
|
1684
|
+
return [
|
|
1685
|
+
evidence.summary,
|
|
1686
|
+
...(evidence.positiveAssertions?.map((value) => `PASS: ${value}`) ?? []),
|
|
1687
|
+
...(evidence.negativeAssertions?.map((value) => `FAIL: ${value}`) ?? []),
|
|
1688
|
+
...(evidence.tests ? [`Tests: ${evidence.tests.passed} passed, ${evidence.tests.failed} failed.`] : []),
|
|
1689
|
+
...(evidence.cleanup?.map((value) => `Cleanup: ${value}`) ?? []),
|
|
1690
|
+
].join(' ');
|
|
1691
|
+
}
|
|
1692
|
+
function resolvedInput(ctx, name) {
|
|
1693
|
+
const spec = ctx.persona?.inputSpecs?.[name];
|
|
1694
|
+
const value = process.env[spec?.env ?? name] ?? ctx.persona?.inputs?.[name] ?? spec?.default;
|
|
1695
|
+
return nonEmptyString(value);
|
|
1696
|
+
}
|
|
1697
|
+
function remediationBodyHasMarker(body, key) {
|
|
1698
|
+
return body.includes(remediationMarker(key));
|
|
1699
|
+
}
|
|
1700
|
+
function unwrapProviderRecord(value) {
|
|
1701
|
+
return asRecord(value.payload) ?? asRecord(value.data) ?? value;
|
|
1702
|
+
}
|
|
1703
|
+
function providerText(value) {
|
|
1704
|
+
return typeof value === 'string' ? value : '';
|
|
1705
|
+
}
|
|
1706
|
+
function providerNumber(value) {
|
|
1707
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
1708
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
1709
|
+
}
|
|
1710
|
+
function issueNumberFromCreated(id, url) {
|
|
1711
|
+
return providerNumber(id) ?? providerNumber(url.match(/\/issues\/(\d+)(?:$|[?#])/u)?.[1]);
|
|
1712
|
+
}
|
|
1713
|
+
function relayfileClient(credentials, fetchImpl) {
|
|
1714
|
+
return new RelayFileClient({
|
|
1715
|
+
baseUrl: credentials.url,
|
|
1716
|
+
token: credentials.token,
|
|
1717
|
+
fetchImpl,
|
|
1718
|
+
readCache: false,
|
|
1719
|
+
retry: { maxRetries: 0 },
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
async function readExactFile(client, workspaceId, path, signal) {
|
|
1723
|
+
let file;
|
|
1724
|
+
try {
|
|
1725
|
+
file = await client.readFile(workspaceId, path, `guardian-read-${randomUUID()}`, signal);
|
|
1726
|
+
}
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
if (error instanceof RelayFileApiError && error.status === 404)
|
|
1729
|
+
return null;
|
|
1730
|
+
throw error;
|
|
1731
|
+
}
|
|
1732
|
+
if (!file || file.path !== path || typeof file.content !== 'string') {
|
|
1733
|
+
throw new Error('guardian exact read returned an invalid file');
|
|
1734
|
+
}
|
|
1735
|
+
const revision = nonEmptyString(file.revision);
|
|
1736
|
+
if (!revision)
|
|
1737
|
+
throw new Error('guardian exact read returned an invalid revision');
|
|
1738
|
+
return { content: file.content, revision };
|
|
1739
|
+
}
|
|
1740
|
+
async function writeExactFile(client, workspaceId, path, baseRevision, content, signal) {
|
|
1741
|
+
const correlationId = `guardian-write-${randomUUID()}`;
|
|
1742
|
+
const queued = await client.writeFile({
|
|
1743
|
+
workspaceId,
|
|
1744
|
+
path,
|
|
1745
|
+
baseRevision,
|
|
1746
|
+
content,
|
|
1747
|
+
contentType: 'application/json',
|
|
1748
|
+
encoding: 'utf-8',
|
|
1749
|
+
correlationId,
|
|
1750
|
+
signal,
|
|
1751
|
+
});
|
|
1752
|
+
if (!queued || !nonEmptyString(queued.opId)) {
|
|
1753
|
+
throw new Error('guardian exact write did not return an operation ID');
|
|
1754
|
+
}
|
|
1755
|
+
await waitForOperation(client, workspaceId, queued.opId, signal, correlationId);
|
|
1756
|
+
}
|
|
1757
|
+
async function waitForOperation(client, workspaceId, opId, signal, correlationId) {
|
|
1758
|
+
for (;;) {
|
|
1759
|
+
const operation = await client.getOp(workspaceId, opId, correlationId, signal);
|
|
1760
|
+
if (operation?.status === 'succeeded')
|
|
1761
|
+
return operation;
|
|
1762
|
+
if (['failed', 'dead_lettered', 'canceled'].includes(String(operation?.status))) {
|
|
1763
|
+
throw new Error(`guardian exact write ${operation?.status}`);
|
|
1764
|
+
}
|
|
1765
|
+
if (!['pending', 'running'].includes(String(operation?.status))) {
|
|
1766
|
+
throw new Error('guardian exact write returned an invalid operation status');
|
|
1767
|
+
}
|
|
1768
|
+
await abortableDelay(GUARDIAN_WRITEBACK_POLL_MS, signal);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async function withDeadline(label, timeoutMs, operation) {
|
|
1772
|
+
const controller = new AbortController();
|
|
1773
|
+
let timeout;
|
|
1774
|
+
const expired = new Promise((_, reject) => {
|
|
1775
|
+
timeout = setTimeout(() => {
|
|
1776
|
+
controller.abort();
|
|
1777
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
1778
|
+
}, timeoutMs);
|
|
1779
|
+
});
|
|
1780
|
+
try {
|
|
1781
|
+
return await Promise.race([operation(controller.signal), expired]);
|
|
1782
|
+
}
|
|
1783
|
+
finally {
|
|
1784
|
+
if (timeout !== undefined)
|
|
1785
|
+
clearTimeout(timeout);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
function abortableDelay(ms, signal) {
|
|
1789
|
+
return new Promise((resolve, reject) => {
|
|
1790
|
+
const onAbort = () => {
|
|
1791
|
+
clearTimeout(timeout);
|
|
1792
|
+
reject(signal.reason);
|
|
1793
|
+
};
|
|
1794
|
+
const timeout = setTimeout(() => {
|
|
1795
|
+
signal.removeEventListener('abort', onAbort);
|
|
1796
|
+
resolve();
|
|
1797
|
+
}, ms);
|
|
1798
|
+
if (signal.aborted)
|
|
1799
|
+
onAbort();
|
|
1800
|
+
else
|
|
1801
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
function assertBoundedContent(content, label) {
|
|
1805
|
+
if (UTF8_ENCODER.encode(content).byteLength > MAX_STATE_BYTES) {
|
|
1806
|
+
throw new Error(`${label} exceeds size limit`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
function asRecord(value) {
|
|
1810
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1811
|
+
? value
|
|
1812
|
+
: undefined;
|
|
1813
|
+
}
|
|
1814
|
+
function nonEmptyString(value) {
|
|
1815
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
1816
|
+
}
|
|
1817
|
+
function requireString(value, field) {
|
|
1818
|
+
const parsed = nonEmptyString(value);
|
|
1819
|
+
if (!parsed || parsed.length > 8_192)
|
|
1820
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1821
|
+
return parsed;
|
|
1822
|
+
}
|
|
1823
|
+
function stringArray(value, max, field) {
|
|
1824
|
+
if (!Array.isArray(value) || value.length > max)
|
|
1825
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1826
|
+
const parsed = value.map((entry) => requireString(entry, field));
|
|
1827
|
+
if (new Set(parsed).size !== parsed.length && field === 'seen event ids') {
|
|
1828
|
+
throw new Error(`guardian ${field} contains duplicates`);
|
|
1829
|
+
}
|
|
1830
|
+
return parsed;
|
|
1831
|
+
}
|
|
1832
|
+
function requirePositiveInteger(value, field) {
|
|
1833
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
1834
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1835
|
+
}
|
|
1836
|
+
return value;
|
|
1837
|
+
}
|
|
1838
|
+
function requireNonNegativeInteger(value, field) {
|
|
1839
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
1840
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1841
|
+
}
|
|
1842
|
+
return value;
|
|
1843
|
+
}
|
|
1844
|
+
function canonicalTimestamp(value) {
|
|
1845
|
+
if (typeof value !== 'string')
|
|
1846
|
+
return undefined;
|
|
1847
|
+
const date = new Date(value);
|
|
1848
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : undefined;
|
|
1849
|
+
}
|
|
1850
|
+
function timestampFromSlackTs(value) {
|
|
1851
|
+
if (!value || !/^\d+\.\d+$/u.test(value))
|
|
1852
|
+
return undefined;
|
|
1853
|
+
const seconds = Number(value);
|
|
1854
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
1855
|
+
return undefined;
|
|
1856
|
+
return new Date(seconds * 1_000).toISOString();
|
|
1857
|
+
}
|
|
1858
|
+
function slackTsOrder(value) {
|
|
1859
|
+
const match = value.match(/^(\d+)\.(\d{1,9})$/u);
|
|
1860
|
+
if (!match)
|
|
1861
|
+
return '00000000000000000000.000000000';
|
|
1862
|
+
return `${match[1].padStart(20, '0')}.${match[2].padEnd(9, '0')}`;
|
|
1863
|
+
}
|
|
1864
|
+
function requireTimestamp(value, field) {
|
|
1865
|
+
const timestamp = canonicalTimestamp(value);
|
|
1866
|
+
if (!timestamp || timestamp !== value)
|
|
1867
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1868
|
+
return timestamp;
|
|
1869
|
+
}
|
|
1870
|
+
function requireSlackTs(value, field) {
|
|
1871
|
+
const ts = requireString(value, field);
|
|
1872
|
+
if (!/^\d+\.\d+$/u.test(ts))
|
|
1873
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1874
|
+
return ts;
|
|
1875
|
+
}
|
|
1876
|
+
function requireEventOrder(value, field) {
|
|
1877
|
+
const order = requireString(value, field);
|
|
1878
|
+
// Event order is internal and compared lexicographically. Its exact shape
|
|
1879
|
+
// is ISO timestamp + normalized provider ts + Slack message ts + stable
|
|
1880
|
+
// response identity. Accept the prior three-part form so pre-upgrade state
|
|
1881
|
+
// remains readable.
|
|
1882
|
+
const match = order.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z):(?:(?:\d{20}\.\d{9}):)?\d+\.\d+:slack:[a-f0-9]{64}$/u);
|
|
1883
|
+
if (!match || canonicalTimestamp(match[1]) !== match[1]) {
|
|
1884
|
+
throw new Error(`guardian ${field} is invalid`);
|
|
1885
|
+
}
|
|
1886
|
+
return order;
|
|
1887
|
+
}
|
|
1888
|
+
function bounded(value, max) {
|
|
1889
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
1890
|
+
}
|
|
1891
|
+
// Retain a named predicate for issue adapters/tests without exporting provider internals.
|
|
1892
|
+
void remediationBodyHasMarker;
|
|
1893
|
+
//# sourceMappingURL=conversation.js.map
|