Add Zapier interface simulator, tree-node Merkle lookups, and user docs
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
In-process trace console validates hops, faults, recoveries, and suggested changes before zapier-platform push. User guide covers signup through central-chain and bulk-summary tree-node hash lookup.
This commit is contained in:
parent
3daa88866d
commit
f3dc0e6eee
56 changed files with 2341 additions and 8 deletions
220
packages/verae-zapier-simulator/src/monitors.js
Normal file
220
packages/verae-zapier-simulator/src/monitors.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Scan a trace for assumption breaks, delays, failures, recoveries,
|
||||
* and suggest changes before the Zapier app goes live.
|
||||
* @module monitors
|
||||
*/
|
||||
|
||||
const ASSUMPTIONS = [
|
||||
{
|
||||
id: 'zapier-no-nats',
|
||||
title: 'Zapier never publishes or subscribes to NATS',
|
||||
check(events) {
|
||||
const bad = events.filter(
|
||||
(e) =>
|
||||
e.module === 'zapier-platform-app' &&
|
||||
(String(e.address).startsWith('verae.') || String(e.hop).includes('nats')),
|
||||
);
|
||||
return bad.length
|
||||
? { ok: false, detail: `Zapier hop used NATS address ${bad[0].address}` }
|
||||
: { ok: true, detail: 'Zapier hops are HTTPS only' };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chain-no-bytes',
|
||||
title: 'Main chain never receives file bytes or private metadata',
|
||||
check(events) {
|
||||
const chain = events.filter((e) => e.module === 'verae-chain-client' && e.payload);
|
||||
for (const e of chain) {
|
||||
const p = e.payload;
|
||||
if (p.contentBase64 || p.files || p.privateMetadata) {
|
||||
return { ok: false, detail: `chain payload included ${Object.keys(p).join(',')}` };
|
||||
}
|
||||
}
|
||||
return { ok: true, detail: 'chain payloads are hash/root only' };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bloom-silence',
|
||||
title: 'Bloom miss produces no archive.reply packet',
|
||||
check(events) {
|
||||
const leaks = events.filter(
|
||||
(e) => e.module.startsWith('archive-') || e.module.startsWith('tree-node'),
|
||||
).filter((e) => e.status === 'silent' && e.address.includes('archive.reply'));
|
||||
return leaks.length
|
||||
? { ok: false, detail: 'silent hop still addressed a reply subject' }
|
||||
: { ok: true, detail: 'misses logged as silent with no reply address' };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'archive-query-broadcast',
|
||||
title: 'archive.query is broadcast (not a shared queue group)',
|
||||
check(events) {
|
||||
const queries = events.filter((e) => e.address === 'verae.archive.query');
|
||||
const replies = events.filter((e) => String(e.address).startsWith('verae.archive.reply.'));
|
||||
if (!queries.length) return { ok: true, detail: 'no archive query this run' };
|
||||
return { ok: true, detail: `${queries.length} query hop(s), ${replies.length} reply hop(s)` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DELAY_MS = 200;
|
||||
|
||||
/**
|
||||
* @param {object[]} events
|
||||
* @param {object} [ctx]
|
||||
*/
|
||||
export function analyze(events, ctx = {}) {
|
||||
const findings = [];
|
||||
const suggestions = [];
|
||||
|
||||
for (const a of ASSUMPTIONS) {
|
||||
const r = a.check(events);
|
||||
findings.push({
|
||||
kind: 'assumption',
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
ok: r.ok,
|
||||
detail: r.detail,
|
||||
});
|
||||
}
|
||||
|
||||
const errors = events.filter((e) => e.status === 'error');
|
||||
const retries = events.filter((e) => e.status === 'retry');
|
||||
const delays = events.filter((e) => e.status === 'delay' || (e.durationMs || 0) >= DELAY_MS);
|
||||
|
||||
for (const e of errors) {
|
||||
findings.push({
|
||||
kind: 'error',
|
||||
id: `err-${e.seq}`,
|
||||
title: `${e.module} ${e.hop} failed`,
|
||||
ok: false,
|
||||
detail: e.note || e.address,
|
||||
});
|
||||
}
|
||||
for (const e of delays) {
|
||||
findings.push({
|
||||
kind: 'delay',
|
||||
id: `delay-${e.seq}`,
|
||||
title: `${e.module} took ${e.durationMs || e.tMs}ms`,
|
||||
ok: false,
|
||||
detail: e.note || 'exceeds 200ms hop budget used for Zapier wait steps',
|
||||
});
|
||||
}
|
||||
if (retries.length) {
|
||||
findings.push({
|
||||
kind: 'recovery',
|
||||
id: 'retries',
|
||||
title: `${retries.length} recovery retry hop(s)`,
|
||||
ok: true,
|
||||
detail: retries.map((e) => `${e.module}:${e.hop}`).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const last = events[events.length - 1];
|
||||
if (last?.status === 'error' && retries.length === 0) {
|
||||
suggestions.push({
|
||||
id: 'add-retry',
|
||||
severity: 'high',
|
||||
text: 'Add a single retry on jobs.watch / jobs.events drop before returning pending to Zapier. Zapier will otherwise mark the step failed with no jobId recovery path.',
|
||||
});
|
||||
}
|
||||
|
||||
const lookup = events.find((e) => e.hop === 'hash_lookup' || e.module === 'hash-lookup');
|
||||
const treeQuery = events.some((e) => e.payload?.kinds?.includes?.('tree') || e.hop === 'tree_query');
|
||||
const chainMiss = events.some(
|
||||
(e) => e.module === 'verae-chain-client' && e.hop === 'lookup' && e.status === 'silent',
|
||||
);
|
||||
if (chainMiss && !treeQuery) {
|
||||
suggestions.push({
|
||||
id: 'tree-lookup-action',
|
||||
severity: 'high',
|
||||
text: 'Central chain miss for a SHA-256 that may live only in a bulk Merkle summary. Add Zapier search “Find Hash (tree nodes)” that sets includeTree=true so middleware broadcasts verae.archive.query kinds=["tree"].',
|
||||
});
|
||||
}
|
||||
|
||||
const puts = events.filter((e) => e.address === 'verae.archive.put');
|
||||
const queries = events.filter((e) => e.address === 'verae.archive.query');
|
||||
if (puts.length && !queries.length && ctx.includeAttached) {
|
||||
suggestions.push({
|
||||
id: 'query-after-put',
|
||||
severity: 'high',
|
||||
text: 'Archive puts ran but wait path never published verae.archive.query. Wire aggregator into createTimestampAndWait when includeAttached is true.',
|
||||
});
|
||||
}
|
||||
if (puts.length && !ctx.includeAttached && ctx.action !== 'batch_timestamp') {
|
||||
suggestions.push({
|
||||
id: 'include-attached-default',
|
||||
severity: 'medium',
|
||||
text: 'Metadata/files were stored off-chain but the Zapier step did not request includeAttached. Document the flag and default it on for “Create Timestamp and Wait”.',
|
||||
});
|
||||
}
|
||||
|
||||
const billing = events.find((e) => e.payload?.status === 402 || e.note?.includes?.('402'));
|
||||
if (billing) {
|
||||
suggestions.push({
|
||||
id: '402-upgrade-url',
|
||||
severity: 'medium',
|
||||
text: 'Map HTTP 402 to Zapier QuotaExceeded and always include details.upgradeUrl from zappier-edge so the user can reload credit without leaving the Zap editor.',
|
||||
});
|
||||
}
|
||||
|
||||
const waitPending = events.some((e) => e.payload?.status === 'pending' && e.hop === 'wait');
|
||||
if (waitPending) {
|
||||
suggestions.push({
|
||||
id: 'wait-timeout',
|
||||
severity: 'medium',
|
||||
text: 'Wait returned pending. Pair every Create Timestamp (async) Zap with the Timestamp Completed REST Hook, or raise WAIT_TIMEOUT_MS only after measuring chain poll interval.',
|
||||
});
|
||||
}
|
||||
|
||||
const natsFromZapier = events.filter(
|
||||
(e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'),
|
||||
);
|
||||
if (natsFromZapier.length) {
|
||||
suggestions.push({
|
||||
id: 'never-bind-nats',
|
||||
severity: 'high',
|
||||
text: 'Zapier cloud must not reach NATS. Keep NS1 nats-server on 127.0.0.1:4222; only middleware workers subscribe.',
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === 'batch_timestamp') {
|
||||
suggestions.push({
|
||||
id: 'batch-docs',
|
||||
severity: 'low',
|
||||
text: 'User docs must say batch items are not individually itemized on the main chain. Lookup of a member hash requires the tree-node search, not Find Timestamp by SHA256 alone.',
|
||||
});
|
||||
}
|
||||
|
||||
const silentAll =
|
||||
queries.length > 0 &&
|
||||
!events.some((e) => String(e.address).startsWith('verae.archive.reply.') && e.status === 'ok');
|
||||
if (silentAll && (ctx.includeAttached || ctx.includeTree)) {
|
||||
findings.push({
|
||||
kind: 'error',
|
||||
id: 'all-archives-silent',
|
||||
title: 'All archives/tree nodes silent during query',
|
||||
ok: false,
|
||||
detail: 'Aggregator completed with zero replies. Distinguish “hash unknown” from “archives unreachable”.',
|
||||
});
|
||||
suggestions.push({
|
||||
id: 'archive-quorum',
|
||||
severity: 'high',
|
||||
text: 'Treat “zero replies + known puts” as archive outage, not a miss. Require at least one reply OR an explicit bloom-miss count from a live node heartbeat.',
|
||||
});
|
||||
}
|
||||
|
||||
const ok = findings.filter((f) => f.ok === false).length === 0;
|
||||
return {
|
||||
ok,
|
||||
findings,
|
||||
suggestions,
|
||||
stats: {
|
||||
hops: events.length,
|
||||
errors: errors.length,
|
||||
retries: retries.length,
|
||||
delays: delays.length,
|
||||
natsHops: events.filter((e) => String(e.address).startsWith('verae.')).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue