@twinsearth/w2m-dsh-plugin 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +709 -0
- package/bin/w2m-grant.mjs +98 -0
- package/bin/w2m-maintenance.mjs +112 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.ps1 +30 -0
- package/deploy/maintenance/bootstrap.sh +61 -0
- package/deploy/server/w2m-maintenance.path +10 -0
- package/deploy/server/w2m-maintenance.service +20 -0
- package/deploy/windows/w2m-maintenance.xml +31 -0
- package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +361 -0
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
- package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
- package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
- package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
- package/docs/COLLAB/TASK-MODEL.md +281 -0
- package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
- package/docs/RELEASE-STABLE.md +193 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
- package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
- package/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
- package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
- package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
- package/package.json +26 -5
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +141 -3
- package/src/agent/grant-gate.mjs +89 -0
- package/src/agent/maintenance-ferry.mjs +111 -0
- package/src/agent/outbox.mjs +138 -0
- package/src/agent/patch-artifact.mjs +53 -0
- package/src/agent/worktree.mjs +118 -0
- package/src/maintenance/actions.mjs +203 -0
- package/src/maintenance/grant.mjs +139 -0
- package/src/maintenance/operator-key.mjs +151 -0
- package/src/maintenance/protocol.mjs +204 -0
- package/src/maintenance/runner.mjs +189 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/blob-sessions.mjs +148 -0
- package/src/relay/merge-queue.mjs +137 -0
- package/src/relay/project-locks.mjs +179 -0
- package/src/relay/project-notifications.mjs +184 -0
- package/src/relay/project-refs.mjs +110 -0
- package/src/relay/project-reviews.mjs +161 -0
- package/src/relay/project-room.mjs +163 -0
- package/src/relay/reliability-metrics.mjs +180 -0
- package/src/relay/server.mjs +382 -1
- package/src/relay/state.mjs +308 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project refs and merge bookkeeping (v1.3.13-beta.3 slice).
|
|
3
|
+
*
|
|
4
|
+
* A workspace task produces a patch against a base; the task model needs one place that says where each branch actually is and
|
|
5
|
+
* what happened to each patch. This module records monotonic branch heads, a merge queue keyed by patch sha256, and first-class
|
|
6
|
+
* outcomes (merged / already_merged / base mismatch / conflict). git application belongs to the machine (merge-queue.mjs owns
|
|
7
|
+
* the repository work); the relay records the decision and refuses a claim it has already answered.
|
|
8
|
+
*/
|
|
9
|
+
const SHA = /^[0-9a-f]{7,64}$/;
|
|
10
|
+
const PATCH = /^[0-9a-f]{64}$/;
|
|
11
|
+
|
|
12
|
+
export class RefError extends Error {
|
|
13
|
+
constructor(code, message, detail = null) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'RefError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const keyOf = (projectId, branch) => projectId + '|' + branch;
|
|
22
|
+
|
|
23
|
+
export function createProjectRefs({ now = () => Date.now(), onEvent = null } = {}) {
|
|
24
|
+
const heads = new Map();
|
|
25
|
+
const items = new Map();
|
|
26
|
+
let counter = 0;
|
|
27
|
+
let seq = 0;
|
|
28
|
+
const emit = (type, payload) => {
|
|
29
|
+
seq += 1;
|
|
30
|
+
const event = { seq, type, at: new Date(now()).toISOString(), payload };
|
|
31
|
+
if (typeof onEvent === 'function') onEvent(event);
|
|
32
|
+
return event;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
head({ projectId, branch }) {
|
|
36
|
+
return heads.get(keyOf(projectId, branch)) ?? null;
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* Advance a branch. The caller names the head it built on; if that is not the current head the advance is refused
|
|
40
|
+
* (REF_BASE_MISMATCH), because silently moving someone else's base is how intent gets lost.
|
|
41
|
+
*/
|
|
42
|
+
advance({ projectId, branch, from = null, to, actor = null, reason = null }) {
|
|
43
|
+
if (typeof projectId !== 'string' || projectId === '') throw new RefError('INVALID_PROJECT', 'project_id is required');
|
|
44
|
+
if (typeof branch !== 'string' || branch === '') throw new RefError('INVALID_BRANCH', 'branch is required');
|
|
45
|
+
if (!SHA.test(String(to))) throw new RefError('INVALID_HEAD', 'to must be a hex commit id', { to });
|
|
46
|
+
const current = heads.get(keyOf(projectId, branch));
|
|
47
|
+
const currentHead = current?.head_commit ?? null;
|
|
48
|
+
if (from !== currentHead) {
|
|
49
|
+
return { ok: false, code: currentHead === null ? 'REF_UNINITIALIZED' : 'REF_BASE_MISMATCH', expected: currentHead, from };
|
|
50
|
+
}
|
|
51
|
+
if (from !== null && String(to) === from) return { ok: false, code: 'REF_NO_CHANGE', head: from };
|
|
52
|
+
const head = { project_id: projectId, branch, head_commit: String(to), version: (current?.version ?? 0) + 1, updated_at: new Date(now()).toISOString() };
|
|
53
|
+
heads.set(keyOf(projectId, branch), head);
|
|
54
|
+
emit('ref.advanced', { ...head, from, actor, reason });
|
|
55
|
+
return { ok: true, head: { ...head }, from };
|
|
56
|
+
},
|
|
57
|
+
listRefs({ projectId }) {
|
|
58
|
+
return [...heads.values()].filter((head) => head.project_id === projectId).map((head) => ({ ...head }));
|
|
59
|
+
},
|
|
60
|
+
/** Queue a patch. A patch already merged on that branch is answered, not queued twice. */
|
|
61
|
+
enqueue({ projectId, branch, patchSha256, baseCommit, sourceTaskId = null }) {
|
|
62
|
+
if (!PATCH.test(String(patchSha256))) return { ok: false, code: 'INVALID_PATCH_SHA' };
|
|
63
|
+
if (!SHA.test(String(baseCommit))) return { ok: false, code: 'INVALID_BASE_COMMIT' };
|
|
64
|
+
for (const item of items.values()) {
|
|
65
|
+
if (item.project_id === projectId && item.branch === branch && item.patch_sha256 === patchSha256 && item.state === 'merged') {
|
|
66
|
+
return { ok: true, already_merged: true, item: { ...item } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
counter += 1;
|
|
70
|
+
const item = {
|
|
71
|
+
queue_id: 'mq-' + String(counter).padStart(6, '0'),
|
|
72
|
+
project_id: projectId,
|
|
73
|
+
branch,
|
|
74
|
+
patch_sha256: String(patchSha256),
|
|
75
|
+
base_commit: String(baseCommit),
|
|
76
|
+
source_task_id: sourceTaskId,
|
|
77
|
+
state: 'queued',
|
|
78
|
+
created_at: new Date(now()).toISOString(),
|
|
79
|
+
};
|
|
80
|
+
items.set(item.queue_id, item);
|
|
81
|
+
emit('merge.queued', { ...item });
|
|
82
|
+
return { ok: true, already_merged: false, item: { ...item } };
|
|
83
|
+
},
|
|
84
|
+
/** Record a terminal outcome. Only 'merged' advances the branch, and only from the recorded base. */
|
|
85
|
+
complete({ queueId, outcome, headCommit = null, conflictSha256 = null }) {
|
|
86
|
+
const item = items.get(queueId);
|
|
87
|
+
if (item === undefined) return { ok: false, code: 'MERGE_ITEM_NOT_FOUND' };
|
|
88
|
+
if (item.state !== 'queued') return { ok: false, code: 'MERGE_ITEM_ALREADY_SETTLED', state: item.state };
|
|
89
|
+
if (!['merged', 'conflict', 'failed'].includes(outcome)) return { ok: false, code: 'INVALID_OUTCOME', outcome };
|
|
90
|
+
if (outcome === 'merged') {
|
|
91
|
+
const moved = this.advance({ projectId: item.project_id, branch: item.branch, from: item.base_commit, to: headCommit });
|
|
92
|
+
if (moved.ok !== true) return { ok: false, code: 'REF_ADVANCE_REFUSED', ref: moved };
|
|
93
|
+
}
|
|
94
|
+
item.state = outcome;
|
|
95
|
+
item.head_commit = outcome === 'merged' ? String(headCommit) : null;
|
|
96
|
+
item.conflict_sha256 = outcome === 'conflict' && PATCH.test(String(conflictSha256)) ? String(conflictSha256) : null;
|
|
97
|
+
item.settled_at = new Date(now()).toISOString();
|
|
98
|
+
emit('merge.' + outcome, { ...item });
|
|
99
|
+
return { ok: true, item: { ...item } };
|
|
100
|
+
},
|
|
101
|
+
listQueue({ projectId, branch = null }) {
|
|
102
|
+
return [...items.values()]
|
|
103
|
+
.filter((item) => item.project_id === projectId && (branch === null || item.branch === branch))
|
|
104
|
+
.map((item) => ({ ...item }));
|
|
105
|
+
},
|
|
106
|
+
size() {
|
|
107
|
+
return { heads: heads.size, items: items.size, events: seq };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project reviews (M5, v1.3.10-beta.16 slice).
|
|
3
|
+
*
|
|
4
|
+
* A review is a control-plane task: it points at a content-addressed patch/artifact and asks maintainers for a verdict. The
|
|
5
|
+
* verdict is a decision record, not a mutable flag: "approve" and "changes_requested" accumulate as history, and the review
|
|
6
|
+
* status follows from those records. That is what lets a merge gate ask "does this exact patch sha have an approval?" and get
|
|
7
|
+
* an answer that cannot be edited afterwards.
|
|
8
|
+
*
|
|
9
|
+
* Rules (docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md §3):
|
|
10
|
+
* - only maintainer/owner may be a reviewer, and only the listed reviewers may decide;
|
|
11
|
+
* - nobody reviews their own request (an owner may override, and the override is recorded);
|
|
12
|
+
* - a reviewer may not record the same verdict twice; a second approve on the same review is a refusal, not a no-op;
|
|
13
|
+
* - any changes_requested closes the round; the submitter opens a new review for a new revision;
|
|
14
|
+
* - approvals reach required_approvals -> approved; a due date can expire a pending review.
|
|
15
|
+
*/
|
|
16
|
+
export const REVIEW_STATUSES = Object.freeze(['pending', 'approved', 'changes_requested', 'expired']);
|
|
17
|
+
export const REVIEW_DECISIONS = Object.freeze(['approve', 'changes_requested', 'comment']);
|
|
18
|
+
const REVIEWER_ROLES = Object.freeze(['maintainer', 'owner']);
|
|
19
|
+
|
|
20
|
+
export class ReviewError extends Error {
|
|
21
|
+
constructor(code, message, detail = null) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ReviewError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.detail = detail;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const memberKey = (member) => (member && typeof member.kind === 'string' && typeof member.id === 'string' ? member.kind + ':' + member.id : null);
|
|
30
|
+
|
|
31
|
+
export function createProjectReviews({ now = () => Date.now(), onEvent = null } = {}) {
|
|
32
|
+
const reviews = new Map();
|
|
33
|
+
let counter = 0;
|
|
34
|
+
let seq = 0;
|
|
35
|
+
|
|
36
|
+
const emit = (type, payload) => {
|
|
37
|
+
seq += 1;
|
|
38
|
+
const event = { seq, type, at: new Date(now()).toISOString(), payload };
|
|
39
|
+
if (typeof onEvent === 'function') onEvent(event);
|
|
40
|
+
return event;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Expire pending reviews whose due date passed; each expiry is an event. */
|
|
44
|
+
const expireDue = () => {
|
|
45
|
+
const expired = [];
|
|
46
|
+
for (const review of reviews.values()) {
|
|
47
|
+
if (review.status === 'pending' && review.due_at_ms !== null && review.due_at_ms <= now()) {
|
|
48
|
+
review.status = 'expired';
|
|
49
|
+
expired.push(review);
|
|
50
|
+
emit('review.expired', { review_id: review.review_id, project_id: review.project_id, target: review.target });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return expired;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const describe = (review) => ({
|
|
57
|
+
review_id: review.review_id,
|
|
58
|
+
project_id: review.project_id,
|
|
59
|
+
target: { ...review.target },
|
|
60
|
+
requested_by: { ...review.requested_by },
|
|
61
|
+
reviewers: review.reviewers.map((entry) => ({ ...entry })),
|
|
62
|
+
required_approvals: review.required_approvals,
|
|
63
|
+
status: review.status,
|
|
64
|
+
decisions: review.decisions.map((decision) => ({ ...decision })),
|
|
65
|
+
created_at: review.created_at,
|
|
66
|
+
due_at: review.due_at,
|
|
67
|
+
approvals: review.decisions.filter((decision) => decision.decision === 'approve').length,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const approvalsOf = (review) => new Set(review.decisions.filter((decision) => decision.decision === 'approve').map((decision) => memberKey(decision.reviewer))).size;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
create({ projectId, target, requestedBy, reviewers, requiredApprovals = 1, dueAt = null, roleOf = null, allowSelfReview = false }) {
|
|
74
|
+
if (typeof projectId !== 'string' || projectId === '') throw new ReviewError('INVALID_PROJECT', 'project_id is required');
|
|
75
|
+
if (!target || typeof target !== 'object' || !/^[0-9a-f]{64}$/.test(String(target.sha256 ?? ''))) throw new ReviewError('INVALID_TARGET', 'target needs a sha256', { target });
|
|
76
|
+
if (!['patch', 'artifact'].includes(target.kind)) throw new ReviewError('INVALID_TARGET', 'target.kind must be patch or artifact', { kind: target.kind });
|
|
77
|
+
if (memberKey(requestedBy) === null) throw new ReviewError('INVALID_REQUESTER', 'requested_by needs kind and id');
|
|
78
|
+
if (!Array.isArray(reviewers) || reviewers.length === 0) throw new ReviewError('INVALID_REVIEWERS', 'at least one reviewer is required');
|
|
79
|
+
if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) throw new ReviewError('INVALID_APPROVALS', 'required_approvals must be a positive integer');
|
|
80
|
+
for (const reviewer of reviewers) {
|
|
81
|
+
if (memberKey(reviewer) === null) throw new ReviewError('INVALID_REVIEWERS', 'every reviewer needs kind and id', { reviewer });
|
|
82
|
+
if (memberKey(reviewer) === memberKey(requestedBy) && !allowSelfReview) {
|
|
83
|
+
return { ok: false, code: 'REVIEW_SELF_NOT_ALLOWED', reviewer };
|
|
84
|
+
}
|
|
85
|
+
if (typeof roleOf === 'function') {
|
|
86
|
+
const role = roleOf(reviewer);
|
|
87
|
+
if (!REVIEWER_ROLES.includes(role)) return { ok: false, code: 'REVIEW_PERMISSION', reviewer, role, required_role: 'maintainer' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
counter += 1;
|
|
91
|
+
const reviewId = 'rv-' + String(counter).padStart(6, '0');
|
|
92
|
+
const dueMs = dueAt === null ? null : Date.parse(dueAt);
|
|
93
|
+
const review = {
|
|
94
|
+
review_id: reviewId,
|
|
95
|
+
project_id: projectId,
|
|
96
|
+
target: { kind: target.kind, sha256: String(target.sha256), source_task_id: target.source_task_id ?? null, diffstat: target.diffstat ?? null },
|
|
97
|
+
requested_by: { ...requestedBy },
|
|
98
|
+
reviewers: reviewers.map((reviewer) => ({ kind: reviewer.kind, id: reviewer.id })),
|
|
99
|
+
required_approvals: requiredApprovals,
|
|
100
|
+
status: 'pending',
|
|
101
|
+
decisions: [],
|
|
102
|
+
created_at: new Date(now()).toISOString(),
|
|
103
|
+
due_at: dueMs === null || Number.isNaN(dueMs) ? null : new Date(dueMs).toISOString(),
|
|
104
|
+
due_at_ms: dueMs === null || Number.isNaN(dueMs) ? null : dueMs,
|
|
105
|
+
};
|
|
106
|
+
reviews.set(reviewId, review);
|
|
107
|
+
emit('review.requested', { review_id: reviewId, project_id: projectId, target: review.target, requested_by: review.requested_by, reviewers: review.reviewers, required_approvals: requiredApprovals });
|
|
108
|
+
if (allowSelfReview && reviewers.some((reviewer) => memberKey(reviewer) === memberKey(requestedBy))) {
|
|
109
|
+
emit('review.self_review_override', { review_id: reviewId, project_id: projectId, requested_by: review.requested_by });
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, review: describe(review) };
|
|
112
|
+
},
|
|
113
|
+
decide({ reviewId, reviewer, decision, comment = null, findings = [] }) {
|
|
114
|
+
expireDue();
|
|
115
|
+
const review = reviews.get(reviewId);
|
|
116
|
+
if (review === undefined) return { ok: false, code: 'REVIEW_NOT_FOUND', review_id: reviewId };
|
|
117
|
+
if (!REVIEW_DECISIONS.includes(decision)) throw new ReviewError('INVALID_DECISION', 'unknown decision', { decision });
|
|
118
|
+
if (memberKey(reviewer) === null || !review.reviewers.some((entry) => memberKey(entry) === memberKey(reviewer))) {
|
|
119
|
+
return { ok: false, code: 'REVIEW_PERMISSION', reviewer, reviewers: review.reviewers };
|
|
120
|
+
}
|
|
121
|
+
if (review.status === 'expired') return { ok: false, code: 'REVIEW_EXPIRED', review_id: reviewId };
|
|
122
|
+
const sameDirection = review.decisions.some((existing) => memberKey(existing.reviewer) === memberKey(reviewer) && existing.decision === decision);
|
|
123
|
+
if (sameDirection) return { ok: false, code: 'REVIEW_ALREADY_DECIDED', review_id: reviewId, decision };
|
|
124
|
+
if (review.status === 'changes_requested' && decision !== 'comment') {
|
|
125
|
+
return { ok: false, code: 'REVIEW_ALREADY_DECIDED', review_id: reviewId, status: review.status };
|
|
126
|
+
}
|
|
127
|
+
const record = {
|
|
128
|
+
reviewer: { kind: reviewer.kind, id: reviewer.id },
|
|
129
|
+
decision,
|
|
130
|
+
comment: typeof comment === 'string' ? comment : null,
|
|
131
|
+
findings: Array.isArray(findings) ? findings.map((finding) => ({ file: finding?.file ?? null, line: Number.isInteger(finding?.line) ? finding.line : null, severity: finding?.severity ?? null, text: finding?.text ?? null })) : [],
|
|
132
|
+
decided_at: new Date(now()).toISOString(),
|
|
133
|
+
};
|
|
134
|
+
review.decisions.push(record);
|
|
135
|
+
if (decision === 'approve' && review.status === 'pending' && approvalsOf(review) >= review.required_approvals) review.status = 'approved';
|
|
136
|
+
if (decision === 'changes_requested') review.status = 'changes_requested';
|
|
137
|
+
emit('review.decided', { review_id: reviewId, project_id: review.project_id, reviewer: record.reviewer, decision, comment: record.comment, findings: record.findings, status: review.status });
|
|
138
|
+
if (review.status === 'approved') emit('review.approved', { review_id: reviewId, project_id: review.project_id, target: review.target, approvals: approvalsOf(review) });
|
|
139
|
+
if (review.status === 'changes_requested') emit('review.changes_requested', { review_id: reviewId, project_id: review.project_id, target: review.target, reviewer: record.reviewer });
|
|
140
|
+
return { ok: true, review: describe(review), decision: record };
|
|
141
|
+
},
|
|
142
|
+
get({ reviewId }) {
|
|
143
|
+
expireDue();
|
|
144
|
+
const review = reviews.get(reviewId);
|
|
145
|
+
return review === undefined ? null : describe(review);
|
|
146
|
+
},
|
|
147
|
+
list({ projectId, status = null }) {
|
|
148
|
+
expireDue();
|
|
149
|
+
return [...reviews.values()]
|
|
150
|
+
.filter((review) => review.project_id === projectId && (status === null || review.status === status))
|
|
151
|
+
.map((review) => ({ review_id: review.review_id, target: review.target, status: review.status, approvals: approvalsOf(review), required_approvals: review.required_approvals, created_at: review.created_at, due_at: review.due_at }));
|
|
152
|
+
},
|
|
153
|
+
/** Merge gate (§3.2): is this exact patch sha approved by a review on this project? */
|
|
154
|
+
approvedFor({ projectId, sha256 }) {
|
|
155
|
+
expireDue();
|
|
156
|
+
const approved = [...reviews.values()].find((review) => review.project_id === projectId && review.status === 'approved' && review.target.sha256 === sha256);
|
|
157
|
+
return approved === undefined ? { ok: false, code: 'REVIEW_REQUIRED', sha256 } : { ok: true, review_id: approved.review_id };
|
|
158
|
+
},
|
|
159
|
+
size() { return { reviews: reviews.size, events: seq }; },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project room (M4, v1.3.10-beta.12 slice).
|
|
3
|
+
*
|
|
4
|
+
* A project is the shared object a fleet lacks today: tasks, artifacts, memory, decisions and refs belong to it, and every
|
|
5
|
+
* client reads the same membership and the same event sequence. This module owns the object, its members, the permission
|
|
6
|
+
* matrix and the snapshot/event protocol; it deliberately holds no relay state of its own so it can be replayed and tested.
|
|
7
|
+
*
|
|
8
|
+
* Permissions are deny-by-default: an action is allowed only when the member's role is at or above the role the action
|
|
9
|
+
* requires. A role change is an event, like every other mutation, so "who could do what, when" is answerable afterwards.
|
|
10
|
+
*/
|
|
11
|
+
export const PROJECT_ROLES = Object.freeze(['observer', 'worker', 'maintainer', 'owner']);
|
|
12
|
+
|
|
13
|
+
const RANK = Object.freeze({ observer: 0, worker: 1, maintainer: 2, owner: 3 });
|
|
14
|
+
|
|
15
|
+
/** action -> minimum role */
|
|
16
|
+
export const PROJECT_ACTIONS = Object.freeze({
|
|
17
|
+
read: 'observer',
|
|
18
|
+
dispatch: 'worker',
|
|
19
|
+
artifact: 'worker',
|
|
20
|
+
lock: 'worker',
|
|
21
|
+
memory: 'maintainer',
|
|
22
|
+
decide: 'maintainer',
|
|
23
|
+
merge: 'maintainer',
|
|
24
|
+
settings: 'maintainer',
|
|
25
|
+
members: 'owner',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export class ProjectRoomError extends Error {
|
|
29
|
+
constructor(code, message, detail = null) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ProjectRoomError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.detail = detail;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createProjectRoom({ now = () => Date.now(), onEvent = null } = {}) {
|
|
38
|
+
const projects = new Map();
|
|
39
|
+
const events = [];
|
|
40
|
+
let seq = 0;
|
|
41
|
+
let counter = 0;
|
|
42
|
+
|
|
43
|
+
const emit = (projectId, type, actor, payload) => {
|
|
44
|
+
seq += 1;
|
|
45
|
+
const event = { seq, project_id: projectId, type, actor, payload, at: new Date(now()).toISOString() };
|
|
46
|
+
events.push(event);
|
|
47
|
+
if (typeof onEvent === 'function') onEvent(event);
|
|
48
|
+
return event;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const requireProject = (projectId) => {
|
|
52
|
+
const project = projects.get(projectId);
|
|
53
|
+
if (!project) throw new ProjectRoomError('PROJECT_NOT_FOUND', 'unknown project', { project_id: projectId });
|
|
54
|
+
return project;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const findMember = (project, member) => {
|
|
58
|
+
if (!member || typeof member !== 'object' || typeof member.kind !== 'string' || typeof member.id !== 'string') return null;
|
|
59
|
+
return project.members.find((entry) => entry.kind === member.kind && entry.id === member.id) ?? null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
/** Create a project; the creator becomes its owner. */
|
|
64
|
+
createProject({ name, origin = null, createdBy, projectId = null }) {
|
|
65
|
+
if (typeof name !== 'string' || name.trim() === '') throw new ProjectRoomError('BAD_REQUEST', 'project name is required');
|
|
66
|
+
if (!createdBy || typeof createdBy.kind !== 'string' || typeof createdBy.id !== 'string') throw new ProjectRoomError('BAD_REQUEST', 'createdBy member is required');
|
|
67
|
+
counter += 1;
|
|
68
|
+
const assigned = typeof projectId === 'string' && projectId !== '' ? projectId : 'prj-' + String(counter).padStart(4, '0') + '-' + now().toString(36);
|
|
69
|
+
if (projects.has(assigned)) throw new ProjectRoomError('DUPLICATE_PROJECT', 'project id already exists', { project_id: assigned });
|
|
70
|
+
const project = {
|
|
71
|
+
project_id: assigned,
|
|
72
|
+
name: name.trim(),
|
|
73
|
+
origin: origin === null ? null : { ...origin },
|
|
74
|
+
created_at: new Date(now()).toISOString(),
|
|
75
|
+
created_by: { ...createdBy },
|
|
76
|
+
settings: { merge_requires_review: false, merge_requires_tests: false },
|
|
77
|
+
members: [{ ...createdBy, role: 'owner', joined_at: new Date(now()).toISOString() }],
|
|
78
|
+
};
|
|
79
|
+
projects.set(assigned, project);
|
|
80
|
+
emit(assigned, 'project.created', createdBy, { name: project.name });
|
|
81
|
+
return { ok: true, project: this.snapshot({ projectId: assigned }) };
|
|
82
|
+
},
|
|
83
|
+
addMember({ projectId, member, role }) {
|
|
84
|
+
const project = requireProject(projectId);
|
|
85
|
+
if (!PROJECT_ROLES.includes(role)) throw new ProjectRoomError('BAD_ROLE', 'unknown role', { role });
|
|
86
|
+
if (!member || typeof member.kind !== 'string' || typeof member.id !== 'string' || typeof member.id === 'undefined') throw new ProjectRoomError('BAD_REQUEST', 'member needs kind and id');
|
|
87
|
+
if (findMember(project, member) !== null) throw new ProjectRoomError('DUPLICATE_MEMBER', 'member already belongs to this project', { member });
|
|
88
|
+
const entry = { kind: member.kind, id: member.id, name: typeof member.name === 'string' ? member.name : null, role, joined_at: new Date(now()).toISOString() };
|
|
89
|
+
project.members.push(entry);
|
|
90
|
+
emit(projectId, 'member.added', member, { role });
|
|
91
|
+
return { ok: true, member: { ...entry } };
|
|
92
|
+
},
|
|
93
|
+
removeMember({ projectId, member }) {
|
|
94
|
+
const project = requireProject(projectId);
|
|
95
|
+
const index = project.members.findIndex((entry) => entry.kind === member?.kind && entry.id === member?.id);
|
|
96
|
+
if (index < 0) throw new ProjectRoomError('MEMBER_NOT_FOUND', 'member does not belong to this project', { member });
|
|
97
|
+
const [removed] = project.members.splice(index, 1);
|
|
98
|
+
if (removed.role === 'owner' && project.members.every((entry) => entry.role !== 'owner')) {
|
|
99
|
+
project.members.splice(index, 0, removed);
|
|
100
|
+
throw new ProjectRoomError('LAST_OWNER', 'a project must keep at least one owner', { member });
|
|
101
|
+
}
|
|
102
|
+
emit(projectId, 'member.removed', member, { role: removed.role });
|
|
103
|
+
return { ok: true };
|
|
104
|
+
},
|
|
105
|
+
setRole({ projectId, member, role }) {
|
|
106
|
+
const project = requireProject(projectId);
|
|
107
|
+
if (!PROJECT_ROLES.includes(role)) throw new ProjectRoomError('BAD_ROLE', 'unknown role', { role });
|
|
108
|
+
const entry = findMember(project, member);
|
|
109
|
+
if (entry === null) throw new ProjectRoomError('MEMBER_NOT_FOUND', 'member does not belong to this project', { member });
|
|
110
|
+
if (entry.role === role) return { ok: true, member: { ...entry }, changed: false };
|
|
111
|
+
const previous = entry.role;
|
|
112
|
+
entry.role = role;
|
|
113
|
+
emit(projectId, 'member.role_changed', member, { from: previous, to: role });
|
|
114
|
+
return { ok: true, member: { ...entry }, changed: true };
|
|
115
|
+
},
|
|
116
|
+
memberOf({ projectId, member }) {
|
|
117
|
+
const project = requireProject(projectId);
|
|
118
|
+
return findMember(project, member);
|
|
119
|
+
},
|
|
120
|
+
/** Deny-by-default permission check. Returns a value; the caller decides whether to throw. */
|
|
121
|
+
mayAct({ projectId, member, action }) {
|
|
122
|
+
const project = requireProject(projectId);
|
|
123
|
+
const required = PROJECT_ACTIONS[action];
|
|
124
|
+
if (!required) throw new ProjectRoomError('BAD_REQUEST', 'unknown action', { action });
|
|
125
|
+
const entry = findMember(project, member);
|
|
126
|
+
if (entry === null) return { ok: false, code: 'NOT_A_MEMBER', action, required_role: required, current_role: null };
|
|
127
|
+
if (RANK[entry.role] >= RANK[required]) return { ok: true, action, role: entry.role };
|
|
128
|
+
return { ok: false, code: 'FORBIDDEN_ACTION', action, required_role: required, current_role: entry.role };
|
|
129
|
+
},
|
|
130
|
+
/** A project snapshot carrying the event sequence it is consistent with. */
|
|
131
|
+
snapshot({ projectId, extras = {} }) {
|
|
132
|
+
const project = requireProject(projectId);
|
|
133
|
+
return {
|
|
134
|
+
project: {
|
|
135
|
+
project_id: project.project_id,
|
|
136
|
+
name: project.name,
|
|
137
|
+
origin: project.origin,
|
|
138
|
+
created_at: project.created_at,
|
|
139
|
+
created_by: project.created_by,
|
|
140
|
+
settings: { ...project.settings },
|
|
141
|
+
},
|
|
142
|
+
members: project.members.map((entry) => ({ ...entry })),
|
|
143
|
+
...extras,
|
|
144
|
+
last_event_seq: seq,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
/** Incremental events for one project, seq strictly greater than sinceSeq. */
|
|
148
|
+
events({ projectId, sinceSeq = 0, limit = 200 }) {
|
|
149
|
+
requireProject(projectId);
|
|
150
|
+
const slice = events.filter((event) => event.project_id === projectId && event.seq > sinceSeq).slice(0, limit);
|
|
151
|
+
return { events: slice.map((event) => ({ ...event, payload: { ...event.payload } })), next_seq: slice.length > 0 ? slice[slice.length - 1].seq : sinceSeq };
|
|
152
|
+
},
|
|
153
|
+
/** Projects a member belongs to. */
|
|
154
|
+
listProjects({ member }) {
|
|
155
|
+
return { projects: [...projects.values()].filter((project) => findMember(project, member) !== null).map((project) => ({ project_id: project.project_id, name: project.name, origin: project.origin })) };
|
|
156
|
+
},
|
|
157
|
+
appendEvent({ projectId, type, actor, payload = {} }) {
|
|
158
|
+
requireProject(projectId);
|
|
159
|
+
return { ok: true, event: emit(projectId, type, actor, payload) };
|
|
160
|
+
},
|
|
161
|
+
size() { return { projects: projects.size, events: events.length, last_seq: seq }; },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reliability metrics registry (M7, v1.3.10-beta.22 slice).
|
|
3
|
+
*
|
|
4
|
+
* M7 requires every capability to be measurable from relay-owned data with one definition per metric. This registry is that
|
|
5
|
+
* definition: it declares which metric names exist, which labels each one may carry, and which are histograms. An undeclared
|
|
6
|
+
* name, or a label the definition does not allow, is refused immediately rather than producing a surprise on a dashboard.
|
|
7
|
+
*
|
|
8
|
+
* Cardinality is a rule, not a suggestion: high-cardinality identifiers (task ids, dedupe keys, review ids) are REJECTED as
|
|
9
|
+
* label keys, because one label per task is how a metrics endpoint becomes a memory leak.
|
|
10
|
+
*/
|
|
11
|
+
export const FORBIDDEN_LABELS = Object.freeze(['task_id', 'dedupe_key', 'review_id', 'lock_id', 'notification_id', 'session_id']);
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000, 300_000];
|
|
14
|
+
|
|
15
|
+
/** name -> {type, labels, buckets?}: the whole contract, in one place. */
|
|
16
|
+
export const METRIC_DEFINITIONS = Object.freeze({
|
|
17
|
+
wm_tasks_total: { type: 'counter', labels: ['status', 'mode'] },
|
|
18
|
+
wm_task_duration_ms: { type: 'histogram', labels: ['status', 'mode'], buckets: DEFAULT_BUCKETS },
|
|
19
|
+
wm_task_requeued_total: { type: 'counter', labels: ['reason'] },
|
|
20
|
+
wm_lease_expired_total: { type: 'counter', labels: ['reason'] },
|
|
21
|
+
wm_result_duplicate_total: { type: 'counter', labels: [] },
|
|
22
|
+
wm_dependency_released_total: { type: 'counter', labels: [] },
|
|
23
|
+
wm_grant_issued_total: { type: 'counter', labels: ['machine_scope'] },
|
|
24
|
+
wm_grant_denied_total: { type: 'counter', labels: ['code'] },
|
|
25
|
+
wm_grant_expired_total: { type: 'counter', labels: [] },
|
|
26
|
+
wm_maintenance_requests_total: { type: 'counter', labels: ['action', 'outcome'] },
|
|
27
|
+
wm_maintenance_rollback_total: { type: 'counter', labels: [] },
|
|
28
|
+
wm_blob_upload_sessions_total: { type: 'counter', labels: ['state'] },
|
|
29
|
+
wm_blob_chunks_total: { type: 'counter', labels: ['retried'] },
|
|
30
|
+
wm_blob_resume_total: { type: 'counter', labels: [] },
|
|
31
|
+
wm_blob_bytes_total: { type: 'counter', labels: ['mode'] },
|
|
32
|
+
wm_blob_sha_mismatch_total: { type: 'counter', labels: [] },
|
|
33
|
+
wm_agent_outbox_depth: { type: 'gauge', labels: [] },
|
|
34
|
+
wm_agent_outbox_dropped_total: { type: 'counter', labels: [] },
|
|
35
|
+
wm_worktree_active: { type: 'gauge', labels: [] },
|
|
36
|
+
wm_worktree_cleaned_total: { type: 'counter', labels: [] },
|
|
37
|
+
wm_merge_queue_depth: { type: 'gauge', labels: ['project', 'branch'] },
|
|
38
|
+
wm_merge_conflict_total: { type: 'counter', labels: [] },
|
|
39
|
+
wm_merge_merged_total: { type: 'counter', labels: [] },
|
|
40
|
+
wm_project_events_total: { type: 'counter', labels: ['type'] },
|
|
41
|
+
wm_event_lag_ms: { type: 'histogram', labels: ['client'], buckets: DEFAULT_BUCKETS },
|
|
42
|
+
wm_sse_reconnects_total: { type: 'counter', labels: [] },
|
|
43
|
+
wm_lock_acquired_total: { type: 'counter', labels: ['kind'] },
|
|
44
|
+
wm_lock_conflict_total: { type: 'counter', labels: ['kind'] },
|
|
45
|
+
wm_lock_force_released_total: { type: 'counter', labels: [] },
|
|
46
|
+
wm_review_pending: { type: 'gauge', labels: [] },
|
|
47
|
+
wm_review_decision_total: { type: 'counter', labels: ['decision'] },
|
|
48
|
+
wm_review_latency_ms: { type: 'histogram', labels: [], buckets: DEFAULT_BUCKETS },
|
|
49
|
+
wm_notification_sent_total: { type: 'counter', labels: ['kind'] },
|
|
50
|
+
wm_notification_digest_total: { type: 'counter', labels: [] },
|
|
51
|
+
wm_notification_unread: { type: 'gauge', labels: [] },
|
|
52
|
+
wm_p2p_push_attempts_total: { type: 'counter', labels: [] },
|
|
53
|
+
wm_p2p_push_success_total: { type: 'counter', labels: [] },
|
|
54
|
+
wm_p2p_fallback_total: { type: 'counter', labels: [] },
|
|
55
|
+
wm_p2p_crypto_refusals_total: { type: 'counter', labels: [] },
|
|
56
|
+
wm_relay_restarts_total: { type: 'counter', labels: [] },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export class MetricsError extends Error {
|
|
60
|
+
constructor(code, message, detail = null) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'MetricsError';
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.detail = detail;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const labelKey = (labels) => Object.keys(labels ?? {}).sort().map((name) => name + "=" + labels[name]).join(",");
|
|
69
|
+
|
|
70
|
+
const escapeLabel = (value) => String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
|
|
71
|
+
|
|
72
|
+
export function normalizeLabels(name, labels) {
|
|
73
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
74
|
+
if (definition === undefined) throw new MetricsError('UNKNOWN_METRIC', 'metric is not declared in METRIC_DEFINITIONS', { name });
|
|
75
|
+
const allowed = new Set(definition.labels);
|
|
76
|
+
const result = {};
|
|
77
|
+
for (const [key, value] of Object.entries(labels ?? {})) {
|
|
78
|
+
if (FORBIDDEN_LABELS.includes(key)) throw new MetricsError('FORBIDDEN_LABEL', 'high-cardinality label is not allowed', { name, label: key });
|
|
79
|
+
if (!allowed.has(key)) throw new MetricsError('UNKNOWN_LABEL', 'label is not declared for this metric', { name, label: key, allowed: definition.labels });
|
|
80
|
+
const text = String(value);
|
|
81
|
+
if (text === '' || text.length > 128 || /[\n\r]/.test(text)) throw new MetricsError('INVALID_LABEL_VALUE', 'label value must be 1..128 characters on one line', { name, label: key });
|
|
82
|
+
result[key] = text;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function labelsText(labels) {
|
|
88
|
+
const names = Object.keys(labels ?? {}).sort();
|
|
89
|
+
if (names.length === 0) return '';
|
|
90
|
+
return '{' + names.map((name) => name + '="' + escapeLabel(labels[name]) + '"').join(',') + '}';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createMetrics() {
|
|
94
|
+
const counters = new Map();
|
|
95
|
+
const gauges = new Map();
|
|
96
|
+
const histograms = new Map();
|
|
97
|
+
let seq = 0;
|
|
98
|
+
|
|
99
|
+
const ensure = (store, name) => {
|
|
100
|
+
if (!store.has(name)) store.set(name, new Map());
|
|
101
|
+
return store.get(name);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
/** Add to a counter. The increment must be a non-negative integer so a counter can never go backwards. */
|
|
106
|
+
inc(name, labels = {}, by = 1) {
|
|
107
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
108
|
+
if (definition === undefined) throw new MetricsError('UNKNOWN_METRIC', 'metric is not declared', { name });
|
|
109
|
+
if (definition.type !== 'counter') throw new MetricsError('WRONG_METRIC_TYPE', 'not a counter', { name, type: definition.type });
|
|
110
|
+
if (!Number.isInteger(by) || by < 0) throw new MetricsError('INVALID_INCREMENT', 'counter increment must be a non-negative integer', { by });
|
|
111
|
+
const normalized = normalizeLabels(name, labels);
|
|
112
|
+
const map = ensure(counters, name);
|
|
113
|
+
const key = labelKey(normalized);
|
|
114
|
+
const current = map.get(key) ?? { labels: normalized, value: 0 };
|
|
115
|
+
current.value += by;
|
|
116
|
+
map.set(key, current);
|
|
117
|
+
return current.value;
|
|
118
|
+
},
|
|
119
|
+
/** Set a gauge to the current value. */
|
|
120
|
+
set(name, labels = {}, value) {
|
|
121
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
122
|
+
if (definition === undefined) throw new MetricsError('UNKNOWN_METRIC', 'metric is not declared', { name });
|
|
123
|
+
if (definition.type !== 'gauge') throw new MetricsError('WRONG_METRIC_TYPE', 'not a gauge', { name, type: definition.type });
|
|
124
|
+
if (!Number.isFinite(value)) throw new MetricsError('INVALID_GAUGE_VALUE', 'gauge value must be finite', { value });
|
|
125
|
+
const normalized = normalizeLabels(name, labels);
|
|
126
|
+
ensure(gauges, name).set(labelKey(normalized), { labels: normalized, value });
|
|
127
|
+
return value;
|
|
128
|
+
},
|
|
129
|
+
/** Record one observation into a histogram. */
|
|
130
|
+
observe(name, labels = {}, valueMs) {
|
|
131
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
132
|
+
if (definition === undefined) throw new MetricsError('UNKNOWN_METRIC', 'metric is not declared', { name });
|
|
133
|
+
if (definition.type !== 'histogram') throw new MetricsError('WRONG_METRIC_TYPE', 'not a histogram', { name, type: definition.type });
|
|
134
|
+
if (!Number.isFinite(valueMs) || valueMs < 0) throw new MetricsError('INVALID_OBSERVATION', 'observation must be a non-negative finite number', { value: valueMs });
|
|
135
|
+
const normalized = normalizeLabels(name, labels);
|
|
136
|
+
const map = ensure(histograms, name);
|
|
137
|
+
const key = labelKey(normalized);
|
|
138
|
+
const entry = map.get(key) ?? { labels: normalized, bucketCounts: definition.buckets.map(() => 0), sum: 0, count: 0 };
|
|
139
|
+
for (let index = 0; index < definition.buckets.length; index += 1) if (valueMs <= definition.buckets[index]) entry.bucketCounts[index] += 1;
|
|
140
|
+
entry.sum += valueMs;
|
|
141
|
+
entry.count += 1;
|
|
142
|
+
map.set(key, entry);
|
|
143
|
+
seq += 1;
|
|
144
|
+
return entry.count;
|
|
145
|
+
},
|
|
146
|
+
snapshot() {
|
|
147
|
+
const out = { counters: {}, gauges: {}, histograms: {} };
|
|
148
|
+
for (const [name, map] of counters) out.counters[name] = Object.fromEntries([...map.values()].map((entry) => [labelKey(entry.labels), { labels: { ...entry.labels }, value: entry.value }]));
|
|
149
|
+
for (const [name, map] of gauges) out.gauges[name] = Object.fromEntries([...map.values()].map((entry) => [labelKey(entry.labels), { labels: { ...entry.labels }, value: entry.value }]));
|
|
150
|
+
for (const [name, map] of histograms) {
|
|
151
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
152
|
+
out.histograms[name] = [...map.values()].map((entry) => ({ labels: { ...entry.labels }, count: entry.count, sum: entry.sum, buckets: definition.buckets.map((le, index) => ({ le, count: entry.bucketCounts[index] })) }));
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
},
|
|
156
|
+
renderPrometheus() {
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const [name, map] of counters) {
|
|
159
|
+
lines.push('# TYPE ' + name + ' counter');
|
|
160
|
+
for (const entry of map.values()) lines.push(name + labelsText(entry.labels) + ' ' + entry.value);
|
|
161
|
+
}
|
|
162
|
+
for (const [name, map] of gauges) {
|
|
163
|
+
lines.push('# TYPE ' + name + ' gauge');
|
|
164
|
+
for (const entry of map.values()) lines.push(name + labelsText(entry.labels) + ' ' + entry.value);
|
|
165
|
+
}
|
|
166
|
+
for (const [name, map] of histograms) {
|
|
167
|
+
const definition = METRIC_DEFINITIONS[name];
|
|
168
|
+
lines.push('# TYPE ' + name + ' histogram');
|
|
169
|
+
for (const entry of map.values()) {
|
|
170
|
+
for (let index = 0; index < definition.buckets.length; index += 1) lines.push(name + "_bucket" + labelsText({ ...entry.labels, le: String(definition.buckets[index]) }) + " " + entry.bucketCounts[index]);
|
|
171
|
+
lines.push(name + "_bucket" + labelsText({ ...entry.labels, le: "+Inf" }) + " " + entry.count);
|
|
172
|
+
lines.push(name + "_sum" + labelsText(entry.labels) + " " + entry.sum);
|
|
173
|
+
lines.push(name + "_count" + labelsText(entry.labels) + " " + entry.count);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return lines.join('\n') + '\n';
|
|
177
|
+
},
|
|
178
|
+
size() { return { counters: counters.size, gauges: gauges.size, histograms: histograms.size, observations: seq }; },
|
|
179
|
+
};
|
|
180
|
+
}
|