@bongos/core 1.19.888 → 1.19.890
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/.bongos-core.json +53 -28
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +30 -2
- package/migrations/core_249_session_token_hash.sql +103 -0
- package/modules/grading/grader-rubric.json +17 -7
- package/modules/grading/grader-score.js +7 -1
- package/modules/grading/grader-workers/extract-json.js +7 -1
- package/modules/grading/grader-workers/index.js +43 -0
- package/modules/grading/grader-workers/reader.js +218 -0
- package/modules/grading/grader.js +47 -11
- package/modules/lifecycle/cascade.js +23 -13
- package/modules/lifecycle/tests/cascade.mjs +18 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/db-backup-offsite.sh +105 -9
- package/scripts/gds/run-unit-tests.js +8 -0
- package/scripts/gds/ship-grade.js +28 -2
- package/src/bongos/db-kernel.js +42 -6
- package/src/bongos/db.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/auth_session_expiry_db.mjs +26 -21
- package/tests/extract_json.mjs +8 -0
- package/tests/government_live_proof.mjs +8 -2
- package/tests/grader_reader_lens.mjs +257 -0
- package/tests/own_session_revoke.mjs +13 -1
- package/tests/session_token_hash.mjs +139 -0
- package/tests/session_token_hash_db.mjs +193 -0
|
@@ -171,6 +171,14 @@ const INTEGRATION = new Set([
|
|
|
171
171
|
// real engine can show the CASE guard leaving a NULL alone on an actual use,
|
|
172
172
|
// which is exactly where the removed timeout would come back.
|
|
173
173
|
'auth_session_expiry_db',
|
|
174
|
+
// task 1003372 (audit A1, core_249): that hashing the bearer at rest does not
|
|
175
|
+
// sign anybody out. The DB-free sibling (session_token_hash) stands in front of
|
|
176
|
+
// the pool and proves the plaintext never reaches a query — the negative that
|
|
177
|
+
// matters — but it cannot show that Postgres accepts the migration, nor that
|
|
178
|
+
// the digest the backfill computes in SQL equals the one the kernel computes in
|
|
179
|
+
// Node. That mismatch would leave every new session working and every migrated
|
|
180
|
+
// one dead, which is the failure a source-reading test walks straight past.
|
|
181
|
+
'session_token_hash_db',
|
|
174
182
|
'scouting_rollup_fanout', 'session_records',
|
|
175
183
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
176
184
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
|
@@ -12,6 +12,14 @@ const crypto = require('node:crypto');
|
|
|
12
12
|
const { spawn } = require('node:child_process');
|
|
13
13
|
const { cliClient } = require('./cli-lib');
|
|
14
14
|
const grader = require('../../modules/grading/grader');
|
|
15
|
+
// task 1003977: the ONE definition of "does this ship put copy in front of a
|
|
16
|
+
// person". It lives in the lifecycle module and the grading module may not
|
|
17
|
+
// require a sibling (ADR 0083), so this script — which is not a module and
|
|
18
|
+
// already requires grading directly — is the seam where the two meet. Writing a
|
|
19
|
+
// second predicate inside grading would let the at-ship reader lens and the
|
|
20
|
+
// after-the-fact artist review disagree about what they cover, and widening the
|
|
21
|
+
// set would then have to be done twice.
|
|
22
|
+
const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
|
|
15
23
|
const { resolveBuilderModel } = require('./ship-memory-sync.js');
|
|
16
24
|
const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
|
|
17
25
|
const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
|
|
@@ -285,9 +293,16 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
|
|
|
285
293
|
lenses_dropped: ['hacker', 'efficiency'],
|
|
286
294
|
};
|
|
287
295
|
} else {
|
|
288
|
-
|
|
296
|
+
// task 1003977: the advisory Reader rides along on copy-bearing diffs only.
|
|
297
|
+
// Where the predicate says no, `panel` is byte-identical to DEFAULT_PANEL
|
|
298
|
+
// and the ship costs exactly what it costs today.
|
|
299
|
+
const copyBearing = hasCopyOrVisualWork(task);
|
|
300
|
+
const panel = grader.WORKERS.panelFor({ copyBearing });
|
|
289
301
|
console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
|
|
290
|
-
console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
|
|
302
|
+
console.log(` gate: every GATING worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
|
|
303
|
+
console.log(copyBearing
|
|
304
|
+
? ` copy lens: ON — this task is copy-bearing, so the advisory 'reader' reads the changed strings against docs/voice.md. It is NON-GATING: nothing it reports, and no error or timeout it hits, can stop this ship.`
|
|
305
|
+
: ` copy lens: off — not a copy-bearing task (no copy-proposal source, not artist-discipline, module not in COPY_VISUAL_MODULES), so the panel is the usual four.`);
|
|
291
306
|
if (priorRound) {
|
|
292
307
|
console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
|
|
293
308
|
}
|
|
@@ -300,8 +315,19 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
|
|
|
300
315
|
builderRank, // SEC #863: drives the permission-path pre-pass
|
|
301
316
|
builderModel, // task 1002659: real session model → cross-model grader pick
|
|
302
317
|
priorRound, // task 1002662: delta-aware re-grade context (panel path only)
|
|
318
|
+
workers: panel, // task 1003977: DEFAULT_PANEL, plus the advisory reader when copy-bearing
|
|
303
319
|
runOpts,
|
|
304
320
|
});
|
|
321
|
+
// A SKIPPED LENS IS RECORDED, NEVER SILENT. Without this a grade with no
|
|
322
|
+
// copy_score is indistinguishable from one where the copy was read and
|
|
323
|
+
// found clean — a missing score would read as a passing one. Same pattern
|
|
324
|
+
// and same reason as the trivial path's `lenses_dropped` above.
|
|
325
|
+
if (!copyBearing) {
|
|
326
|
+
result.signals = {
|
|
327
|
+
...(result.signals || {}),
|
|
328
|
+
reader_skipped: 'not_copy_bearing',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
305
331
|
}
|
|
306
332
|
const wallMs = Date.now() - t0;
|
|
307
333
|
// Decorate signals with the local shortstat — useful in audit-trail.
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
'use strict';
|
|
25
25
|
|
|
26
|
+
const crypto = require('node:crypto');
|
|
26
27
|
const { pool } = require('./pool');
|
|
27
28
|
const seams = require('../module-seams');
|
|
28
29
|
|
|
@@ -277,8 +278,37 @@ async function accessStateByLogin(login) {
|
|
|
277
278
|
// code agree on one number.
|
|
278
279
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
279
280
|
|
|
281
|
+
// sessionTokenHash — the ONE place a bearer becomes the value this table stores
|
|
282
|
+
// (task 1003372, audit A1, migration core_249). SHA-256 hex, matching the
|
|
283
|
+
// migration's `encode(sha256(token::bytea), 'hex')` byte for byte, so a row
|
|
284
|
+
// backfilled in SQL and a row minted here resolve identically.
|
|
285
|
+
//
|
|
286
|
+
// WHY A FAST HASH. A session token is 256 bits of CSPRNG, not a password: there
|
|
287
|
+
// is no dictionary to run, so a KDF would buy nothing and cost real latency on
|
|
288
|
+
// every authenticated request. Same reasoning, written out at length, in
|
|
289
|
+
// modules/platform-identity/platform-identity.js (`sha256hex`).
|
|
290
|
+
//
|
|
291
|
+
// WHY NO timingSafeEqual HERE, THOUGH THE TASK NAMED IT. The pattern the task
|
|
292
|
+
// points at compares a presented secret against one it already fetched by
|
|
293
|
+
// client_id, so the comparison happens in JS and must be constant-time. A session
|
|
294
|
+
// has no second identifier — the bearer IS the lookup key — so the comparison is
|
|
295
|
+
// the indexed `WHERE token_hash = $1` below, and there is no JS compare to make
|
|
296
|
+
// constant-time. Adding a timingSafeEqual after that lookup would compare the
|
|
297
|
+
// hash to itself: vacuously true, and worse than nothing, because it would read
|
|
298
|
+
// as a control that is doing something. The property the task wanted — a guessed
|
|
299
|
+
// token leaks no information about how close it was — holds here because the
|
|
300
|
+
// index is probed with a full digest, never a prefix.
|
|
301
|
+
function sessionTokenHash(token) {
|
|
302
|
+
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
// createSession — mint a builder_sessions row.
|
|
281
306
|
//
|
|
307
|
+
// THE PLAINTEXT BEARER IS NEVER STORED (task 1003372). `token` is hashed on the
|
|
308
|
+
// way in and the column that held it is gone (core_249). The raw value is
|
|
309
|
+
// returned to the caller — it is their input, handed back so the mint response
|
|
310
|
+
// can carry it to the client — and after that it exists only in the client.
|
|
311
|
+
//
|
|
282
312
|
// A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
|
|
283
313
|
// lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
|
|
284
314
|
// restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
|
|
@@ -290,12 +320,15 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
290
320
|
async function createSession({ builderId, token, source, userAgent, expiresAt }) {
|
|
291
321
|
const effectiveExpiresAt = expiresAt ?? null;
|
|
292
322
|
const { rows } = await pool.query(
|
|
293
|
-
`INSERT INTO builder_sessions (builder_id,
|
|
323
|
+
`INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
|
|
294
324
|
VALUES ($1, $2, $3, $4, $5)
|
|
295
|
-
RETURNING id, builder_id,
|
|
296
|
-
[builderId, token, source, userAgent ?? null, effectiveExpiresAt]
|
|
325
|
+
RETURNING id, builder_id, source, created_at, expires_at`,
|
|
326
|
+
[builderId, sessionTokenHash(token), source, userAgent ?? null, effectiveExpiresAt]
|
|
297
327
|
);
|
|
298
|
-
|
|
328
|
+
// `token` is re-attached from the ARGUMENT, never read back from the row — the
|
|
329
|
+
// row no longer holds it. Callers that pass a token and read one back are
|
|
330
|
+
// unchanged; what changed is that the database never saw the plaintext.
|
|
331
|
+
return { ...rows[0], token };
|
|
299
332
|
}
|
|
300
333
|
|
|
301
334
|
// lookupSession — resolve a live (unrevoked, unexpired) session token to its
|
|
@@ -346,8 +379,8 @@ async function lookupSession(token) {
|
|
|
346
379
|
b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
|
|
347
380
|
FROM builder_sessions s
|
|
348
381
|
JOIN builders b ON b.id = s.builder_id
|
|
349
|
-
WHERE s.
|
|
350
|
-
[token]
|
|
382
|
+
WHERE s.token_hash = $1`,
|
|
383
|
+
[sessionTokenHash(token)]
|
|
351
384
|
);
|
|
352
385
|
const row = rows[0];
|
|
353
386
|
if (!row) return null;
|
|
@@ -376,6 +409,9 @@ async function lookupSession(token) {
|
|
|
376
409
|
module.exports = {
|
|
377
410
|
withTx,
|
|
378
411
|
sessionPermsSelect,
|
|
412
|
+
// Exported for db.js's revokeSession (which is handed a bearer, not an id) and
|
|
413
|
+
// for the tests that assert the plaintext never reaches a query parameter.
|
|
414
|
+
sessionTokenHash,
|
|
379
415
|
getBuilderById,
|
|
380
416
|
getFoundingBuilder,
|
|
381
417
|
listActiveBuilders,
|
package/src/bongos/db.js
CHANGED
|
@@ -44,6 +44,9 @@ const {
|
|
|
44
44
|
// internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
|
|
45
45
|
RANKS, SESSION_TTL_MS, upsertBuilder, seatFirstAdminIfUnclaimed,
|
|
46
46
|
getBuilderByGithubId, hasInvitedAccessRequest, createSession, lookupSession,
|
|
47
|
+
// The bearer → stored-value transform (task 1003372, core_249). revokeSession
|
|
48
|
+
// below is the one helper here still handed a raw bearer rather than an id.
|
|
49
|
+
sessionTokenHash,
|
|
47
50
|
} = require('./db-kernel');
|
|
48
51
|
|
|
49
52
|
// gradePassCount removed with builderRollingAvgGrade (its only core user) — that
|
|
@@ -728,10 +731,14 @@ async function hasCliSession(builderId) {
|
|
|
728
731
|
// lookupSession — carved to ./db-kernel (ADR 0102). Re-exported above.
|
|
729
732
|
|
|
730
733
|
|
|
734
|
+
// Takes the raw bearer (its callers are sign-out paths holding a cookie or an
|
|
735
|
+
// Authorization header, not a session id) and matches on the stored hash —
|
|
736
|
+
// core_249 dropped the plaintext column, so the bearer itself is no longer
|
|
737
|
+
// something this table can be searched by.
|
|
731
738
|
async function revokeSession(token) {
|
|
732
739
|
await pool.query(
|
|
733
|
-
`UPDATE builder_sessions SET revoked_at = now() WHERE
|
|
734
|
-
[token]
|
|
740
|
+
`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
|
|
741
|
+
[sessionTokenHash(token)]
|
|
735
742
|
);
|
|
736
743
|
}
|
|
737
744
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.890'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -67,7 +67,7 @@ await pool.query(`
|
|
|
67
67
|
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
68
68
|
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
69
69
|
CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
|
|
70
|
-
|
|
70
|
+
token_hash text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
|
|
71
71
|
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
72
72
|
INSERT INTO builders (github_login) VALUES ('probe');
|
|
73
73
|
`);
|
|
@@ -86,6 +86,11 @@ const t = async (name, fn) => {
|
|
|
86
86
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
87
87
|
};
|
|
88
88
|
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
89
|
+
// core_249: the bearer is no longer a column, so a probe that wants "the row for
|
|
90
|
+
// this token" has to ask by the stored digest. Uses the kernel's OWN helper, so a
|
|
91
|
+
// change to the hash cannot leave this file quietly matching nothing — a mismatch
|
|
92
|
+
// would make every assertion below read a missing row and fail loudly.
|
|
93
|
+
const h = (token) => kernel.sessionTokenHash(token);
|
|
89
94
|
// refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
|
|
90
95
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
91
96
|
|
|
@@ -110,67 +115,67 @@ await t('lookupSession resolves a CLI bearer to its builder', async () => {
|
|
|
110
115
|
await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
|
|
111
116
|
await kernel.lookupSession('cli-tok');
|
|
112
117
|
await settle();
|
|
113
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
118
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).expires_at, null);
|
|
114
119
|
});
|
|
115
120
|
|
|
116
121
|
await t('refresh-on-use still bumps last_used_at on that same row', async () => {
|
|
117
|
-
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE
|
|
118
|
-
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE
|
|
122
|
+
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
|
|
123
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token_hash='${h('cli-tok')}'`);
|
|
119
124
|
await kernel.lookupSession('cli-tok');
|
|
120
125
|
await settle();
|
|
121
|
-
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE
|
|
126
|
+
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
|
|
122
127
|
assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
|
|
126
131
|
await kernel.lookupSession('web-tok');
|
|
127
132
|
await settle();
|
|
128
|
-
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
133
|
+
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('web-tok')}'`)).expires_at;
|
|
129
134
|
assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
|
|
130
135
|
});
|
|
131
136
|
|
|
132
137
|
await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
|
|
133
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
134
|
-
VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
|
|
138
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at)
|
|
139
|
+
VALUES (1,'${h('dead-tok')}','web', now() - interval '1 hour')`);
|
|
135
140
|
assert.equal(await kernel.lookupSession('dead-tok'), null);
|
|
136
141
|
});
|
|
137
142
|
|
|
138
143
|
await t('a revoked CLI session is still REFUSED', async () => {
|
|
139
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
140
|
-
VALUES (1,'revoked-tok','cli', now())`);
|
|
144
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, revoked_at)
|
|
145
|
+
VALUES (1,'${h('revoked-tok')}','cli', now())`);
|
|
141
146
|
assert.equal(await kernel.lookupSession('revoked-tok'), null);
|
|
142
147
|
});
|
|
143
148
|
|
|
144
149
|
// The migration, against rows shaped like the ones live instances actually hold.
|
|
145
150
|
const migrationSql = readFileSync(MIGRATION, 'utf8');
|
|
146
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
147
|
-
(1,'old-cli','cli', now() + interval '24 hours'),
|
|
148
|
-
(1,'old-web','web', now() + interval '30 days'),
|
|
149
|
-
(1,'old-revoked','cli', now() + interval '24 hours')`);
|
|
150
|
-
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE
|
|
151
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at) VALUES
|
|
152
|
+
(1,'${h('old-cli')}','cli', now() + interval '24 hours'),
|
|
153
|
+
(1,'${h('old-web')}','web', now() + interval '30 days'),
|
|
154
|
+
(1,'${h('old-revoked')}','cli', now() + interval '24 hours')`);
|
|
155
|
+
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash='${h('old-revoked')}'`);
|
|
151
156
|
await client.query(migrationSql);
|
|
152
157
|
|
|
153
158
|
await t('core_243 clears a pre-existing CLI expiry', async () => {
|
|
154
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
159
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
|
|
155
160
|
});
|
|
156
161
|
|
|
157
162
|
await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
|
|
158
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
163
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
|
|
159
164
|
});
|
|
160
165
|
|
|
161
166
|
await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
|
|
162
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
167
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-revoked')}'`)).expires_at, null);
|
|
163
168
|
});
|
|
164
169
|
|
|
165
170
|
await t('core_243 is idempotent — a re-run changes nothing', async () => {
|
|
166
171
|
await client.query(migrationSql);
|
|
167
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
168
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
172
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
|
|
173
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
// The whole point of the task, stated as a test.
|
|
172
177
|
await t('a CLI session idle for 21 DAYS still authenticates', async () => {
|
|
173
|
-
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE
|
|
178
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token_hash='${h('cli-tok')}'`);
|
|
174
179
|
assert.notEqual(await kernel.lookupSession('cli-tok'), null);
|
|
175
180
|
});
|
|
176
181
|
|
package/tests/extract_json.mjs
CHANGED
|
@@ -128,11 +128,19 @@ test('nothing parseable is null', () => {
|
|
|
128
128
|
test('GRADE_SHAPE_KEYS is the exported score-key list', () => {
|
|
129
129
|
// Exported so workers and grader.js agree on what "grade-shaped" means; the
|
|
130
130
|
// preference test above depends on functional_fidelity being in this list.
|
|
131
|
+
//
|
|
132
|
+
// ONE ENTRY PER WORKER THAT REPORTS A SCORE. task 1003977 added copy_score
|
|
133
|
+
// with the reader: reader.js re-exports this extractJson, so a worker missing
|
|
134
|
+
// from this list silently loses the score-based disambiguation every other
|
|
135
|
+
// worker gets. Kept as an exact list rather than loosened, because that
|
|
136
|
+
// silence is the whole reason to pin it — a new worker must appear here in
|
|
137
|
+
// the same change that adds it.
|
|
131
138
|
assert.deepEqual(GRADE_SHAPE_KEYS, [
|
|
132
139
|
'functional_fidelity',
|
|
133
140
|
'code_quality',
|
|
134
141
|
'security_score',
|
|
135
142
|
'efficiency_score',
|
|
143
|
+
'copy_score',
|
|
136
144
|
]);
|
|
137
145
|
});
|
|
138
146
|
|
|
@@ -42,6 +42,9 @@ if (!process.env.DATABASE_URL) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const { Pool } = require('pg');
|
|
45
|
+
// The kernel's own bearer → stored-digest transform (core_249), so this fixture
|
|
46
|
+
// cannot drift from how production mints a session.
|
|
47
|
+
const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
|
|
45
48
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
46
49
|
|
|
47
50
|
// ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
|
|
@@ -137,9 +140,12 @@ async function seed() {
|
|
|
137
140
|
[ARCHON, THETES]
|
|
138
141
|
);
|
|
139
142
|
await q(
|
|
140
|
-
|
|
143
|
+
// core_249: the bearer is stored as its SHA-256, so a fixture session has to
|
|
144
|
+
// be seeded the same way the kernel mints one — otherwise the HTTP calls
|
|
145
|
+
// below authenticate as nobody.
|
|
146
|
+
`INSERT INTO builder_sessions (builder_id, token_hash, source)
|
|
141
147
|
VALUES ($1,$2,'web'), ($3,$4,'web')`,
|
|
142
|
-
[ARCHON, ARCHON_TOKEN, THETES, THETES_TOKEN]
|
|
148
|
+
[ARCHON, sessionTokenHash(ARCHON_TOKEN), THETES, sessionTokenHash(THETES_TOKEN)]
|
|
143
149
|
);
|
|
144
150
|
const [idea] = await q(
|
|
145
151
|
`INSERT INTO idea_inbox (title, body_md, captured_by)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// tests/grader_reader_lens.mjs — the advisory Reader lens (task 1003977, ADR 0298).
|
|
2
|
+
//
|
|
3
|
+
// The owner settled three terms on 2026-09-17 and each is pinned here:
|
|
4
|
+
// SCOPE copy-bearing diffs only, keyed off the ONE existing predicate.
|
|
5
|
+
// AUTHORITY advisory, full stop — it cannot fail, cannot reach 'concerns',
|
|
6
|
+
// and cannot block via an error or a timeout.
|
|
7
|
+
// STANDARD docs/voice.md, cited by rule id.
|
|
8
|
+
//
|
|
9
|
+
// The task names the error exemption as "the single most important test in this
|
|
10
|
+
// task", because a worker error normally HARD-FAILS the panel
|
|
11
|
+
// (panel_gate: 'ANY worker.error != null → passed=false'). It is §2 below.
|
|
12
|
+
//
|
|
13
|
+
// The subtlest case is §3: a HEALTHY reader must not mask a total grader
|
|
14
|
+
// outage. Before the non-gating partition, a passing fifth worker would make
|
|
15
|
+
// "every gating worker is missing signal" false, and a total outage would
|
|
16
|
+
// present as FAIL · 0/10 with every axis null — the exact regression class
|
|
17
|
+
// task 1003574 documents, where a builder sat dark on an unclearable re-grade.
|
|
18
|
+
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const grader = require('../modules/grading/grader.js');
|
|
29
|
+
const WORKERS = require('../modules/grading/grader-workers/index.js');
|
|
30
|
+
const reader = require('../modules/grading/grader-workers/reader.js');
|
|
31
|
+
const { hasPanelEvidence } = require('../modules/grading/grader-score.js');
|
|
32
|
+
const { hasCopyOrVisualWork } = require('../modules/lifecycle/cascade.js');
|
|
33
|
+
|
|
34
|
+
const W = (kind, verdict, extra = {}) => ({
|
|
35
|
+
kind, name: kind, verdict, scores: {}, issues: [], confidence: 0.9, error: null, ...extra,
|
|
36
|
+
});
|
|
37
|
+
const healthyFour = () => [
|
|
38
|
+
W('narc', 'pass', { scores: { functional_fidelity: 9 } }),
|
|
39
|
+
W('quality', 'pass', { scores: { code_quality: 9, economy: 9 } }),
|
|
40
|
+
W('hacker', 'pass', { scores: { security_score: 9 } }),
|
|
41
|
+
W('efficiency', 'pass', { scores: { efficiency_score: 9 } }),
|
|
42
|
+
];
|
|
43
|
+
const agg = (ws) => grader.aggregatePanel(ws, {});
|
|
44
|
+
|
|
45
|
+
// ---- 1. AUTHORITY: the verdict itself cannot be a blocking one -------------
|
|
46
|
+
|
|
47
|
+
test('computeVerdict returns "advisory" on EVERY path, including its own failures', () => {
|
|
48
|
+
// Structural, not a filter: there is no branch that returns pass/concerns/fail,
|
|
49
|
+
// so "it cannot block" is a property of the function rather than a promise
|
|
50
|
+
// kept by its callers.
|
|
51
|
+
const paths = [
|
|
52
|
+
['clean', [{ copy_score: 10, issues: [] }, {}]],
|
|
53
|
+
['bad copy', [{ copy_score: 0, issues: [{ severity: 'major', kind: 'break', rule: 'V1' }] }, {}]],
|
|
54
|
+
['subagent error', [null, { subprocess_error: 'SUBAGENT_TIMEOUT' }]],
|
|
55
|
+
['spawn failure', [null, { subprocess_error: 'SUBAGENT_SPAWN_FAILED' }]],
|
|
56
|
+
['unparseable', ['not json at all', {}]],
|
|
57
|
+
['schema error', [{ copy_score: 'banana', issues: [] }, {}]],
|
|
58
|
+
['null parse', [null, {}]],
|
|
59
|
+
];
|
|
60
|
+
for (const [name, [parsed, meta]] of paths) {
|
|
61
|
+
const v = reader.computeVerdict(parsed, meta);
|
|
62
|
+
assert.equal(v.verdict, 'advisory', `${name} → ${v.verdict}`);
|
|
63
|
+
assert.equal(v.verdict, reader.ADVISORY_VERDICT);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('an errored Reader reports NO score rather than a zero', () => {
|
|
68
|
+
// 0/10 is a real reading ("would embarrass the project in front of a user").
|
|
69
|
+
// An outage is missing signal, not a bad signal — the distinction #943 draws
|
|
70
|
+
// for the whole panel, applied to one worker.
|
|
71
|
+
const v = reader.computeVerdict(null, { subprocess_error: 'SUBAGENT_TIMEOUT' });
|
|
72
|
+
assert.deepEqual(v.scores, {});
|
|
73
|
+
assert.equal(v.error, 'SUBAGENT_TIMEOUT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a blocker severity is FLOORED to major, not dropped', () => {
|
|
77
|
+
// Dropping would hide a real observation; honouring would hand this lens the
|
|
78
|
+
// veto the owner's term denies it.
|
|
79
|
+
const v = reader.computeVerdict({ copy_score: 3, issues: [{ severity: 'blocker', kind: 'break', rule: 'V2' }] }, {});
|
|
80
|
+
assert.equal(v.issues.length, 1, 'the finding survives');
|
|
81
|
+
assert.equal(v.issues[0].severity, 'major');
|
|
82
|
+
assert.equal(v.issues[0].severity_floored_from, 'blocker');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- 2. THE EXEMPTION THE TASK CALLS THE MOST IMPORTANT --------------------
|
|
86
|
+
|
|
87
|
+
test('NO Reader outcome can stop a ship — including an error, which normally hard-fails', () => {
|
|
88
|
+
const baseline = agg(healthyFour());
|
|
89
|
+
assert.equal(baseline.passed, true);
|
|
90
|
+
|
|
91
|
+
const cases = {
|
|
92
|
+
'clean reader': W('reader', 'advisory', { scores: { copy_score: 9 } }),
|
|
93
|
+
'copy_score 0': W('reader', 'advisory', { scores: { copy_score: 0 } }),
|
|
94
|
+
'errored (timeout)': W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' }),
|
|
95
|
+
'errored (prompt too long)': W('reader', 'advisory', { error: 'PROMPT_TOO_LONG' }),
|
|
96
|
+
'two major findings': W('reader', 'advisory', { scores: { copy_score: 2 }, issues: [{ severity: 'major' }, { severity: 'major' }] }),
|
|
97
|
+
// Belt AND braces: even if a future edit made the worker emit a gating
|
|
98
|
+
// verdict, the panel partition still refuses to read it.
|
|
99
|
+
'ILLEGALLY emits fail': W('reader', 'fail', { issues: [{ severity: 'blocker' }] }),
|
|
100
|
+
};
|
|
101
|
+
for (const [name, row] of Object.entries(cases)) {
|
|
102
|
+
const r = agg([...healthyFour(), row]);
|
|
103
|
+
assert.equal(r.passed, true, `${name} must not block the ship`);
|
|
104
|
+
assert.equal(r.signals.panel_outcome, 'pass', `${name} must not move the outcome`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the Reader is absent from every gate count', () => {
|
|
109
|
+
const r = agg([...healthyFour(), W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' })]);
|
|
110
|
+
assert.equal(r.signals.worker_count, 4, 'the gate denominator counts gating workers only');
|
|
111
|
+
assert.equal(r.signals.error_count, 0, "an errored Reader is not a gate error");
|
|
112
|
+
assert.equal(r.signals.fail_count, 0);
|
|
113
|
+
assert.equal(r.signals.pass_count, 4);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---- 3. THE SUBTLE ONE -----------------------------------------------------
|
|
117
|
+
|
|
118
|
+
test('a HEALTHY Reader does not mask a total grader outage', () => {
|
|
119
|
+
const allErrored = ['narc', 'quality', 'hacker', 'efficiency'].map((k) => W(k, 'fail', { error: 'SUBAGENT_SPAWN_FAILED' }));
|
|
120
|
+
const without = agg(allErrored);
|
|
121
|
+
assert.equal(without.signals.panel_outcome, 'unavailable', 'baseline: a total outage is an outage');
|
|
122
|
+
|
|
123
|
+
const with_ = agg([...allErrored, W('reader', 'advisory', { scores: { copy_score: 9 } })]);
|
|
124
|
+
assert.equal(with_.signals.panel_outcome, 'unavailable',
|
|
125
|
+
'a passing fifth worker must not turn an outage into a FAIL 0/10 (the task-1003574 class)');
|
|
126
|
+
assert.equal(with_.passed, false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the Reader's confidence cannot move the server-gate median", () => {
|
|
130
|
+
const four = healthyFour();
|
|
131
|
+
const noReader = agg(four).signals.confidence;
|
|
132
|
+
const withReader = agg([...four, W('reader', 'advisory', { confidence: 0.01, scores: { copy_score: 1 } })]).signals.confidence;
|
|
133
|
+
assert.equal(withReader, noReader, 'a low-confidence Reader must not drag the forwarded confidence');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---- 4. THE SIGNAL STILL LANDS ---------------------------------------------
|
|
137
|
+
|
|
138
|
+
test('copy_score and the findings reach the RECORD even though the gate ignores them', () => {
|
|
139
|
+
const r = agg([...healthyFour(), W('reader', 'advisory', { scores: { copy_score: 4 }, issues: [{ severity: 'major', rule: 'V1' }] })]);
|
|
140
|
+
assert.equal(r.rubric_scores.copy_score, 4, 'the score is recorded');
|
|
141
|
+
assert.ok(r.signals.workers.some((w) => w.kind === 'reader'), 'the Reader row is in the record');
|
|
142
|
+
assert.equal(agg(healthyFour()).rubric_scores.copy_score, null, 'and is null when no Reader ran');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a Reader row still counts as evidence that the panel ran', () => {
|
|
146
|
+
// hasPanelEvidence is an `every` over the worker verdicts. Leave 'advisory'
|
|
147
|
+
// out of WORKER_VERDICTS and a fully-healthy five-worker panel reads as NO
|
|
148
|
+
// evidence, firing the implausible-pass guard on exactly the ships carrying
|
|
149
|
+
// the most lenses.
|
|
150
|
+
const rows = (extra = []) => ({ workers: [
|
|
151
|
+
{ kind: 'narc', verdict: 'pass' }, { kind: 'quality', verdict: 'pass' },
|
|
152
|
+
{ kind: 'hacker', verdict: 'pass' }, { kind: 'efficiency', verdict: 'pass' }, ...extra,
|
|
153
|
+
] });
|
|
154
|
+
assert.equal(hasPanelEvidence(rows()), true);
|
|
155
|
+
assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'advisory' }])), true);
|
|
156
|
+
assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'nonsense' }])), false, 'the vocabulary is still closed');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---- 5. SCOPE --------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
test('the panel is byte-identical to today when the diff is not copy-bearing', () => {
|
|
162
|
+
assert.deepEqual(WORKERS.panelFor({ copyBearing: false }), WORKERS.DEFAULT_PANEL);
|
|
163
|
+
assert.deepEqual(WORKERS.panelFor(), WORKERS.DEFAULT_PANEL, 'and when the caller says nothing');
|
|
164
|
+
assert.deepEqual(WORKERS.panelFor({ copyBearing: true }), [...WORKERS.DEFAULT_PANEL, 'reader']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('scope keys off the ONE existing predicate, not a second copy', () => {
|
|
168
|
+
// The at-ship lens and the after-the-fact artist review must never disagree
|
|
169
|
+
// about what they cover. Widening COPY_VISUAL_MODULES must widen both at once,
|
|
170
|
+
// which it only does while there is one predicate.
|
|
171
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
|
|
172
|
+
assert.match(src, /hasCopyOrVisualWork/, 'the caller asks lifecycle for the answer');
|
|
173
|
+
assert.match(src, /panelFor\(\{ copyBearing \}\)/, 'and passes it down');
|
|
174
|
+
// RECURSIVE. A readdirSync of modules/grading alone skips grader-workers/,
|
|
175
|
+
// which holds reader.js — the file this assertion is most about. The first
|
|
176
|
+
// version of this test did exactly that and proved nothing it claimed.
|
|
177
|
+
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
178
|
+
const full = path.join(dir, e.name);
|
|
179
|
+
return e.isDirectory() ? walk(full) : (e.name.endsWith('.js') ? [full] : []);
|
|
180
|
+
});
|
|
181
|
+
const gradingFiles = walk(path.join(ROOT, 'modules', 'grading'));
|
|
182
|
+
assert.ok(gradingFiles.some((f) => f.endsWith(`grader-workers${path.sep}reader.js`)),
|
|
183
|
+
'the scan reaches grader-workers/reader.js — otherwise it checks everything except the file in question');
|
|
184
|
+
for (const f of gradingFiles) {
|
|
185
|
+
const body = fs.readFileSync(f, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
186
|
+
assert.ok(!/COPY_VISUAL_MODULES|copy-proposal/.test(body),
|
|
187
|
+
`${path.relative(ROOT, f)} must not re-implement the copy predicate — grading cannot require lifecycle (ADR 0083), so the CALLER decides`);
|
|
188
|
+
}
|
|
189
|
+
// Sanity: the predicate really does discriminate.
|
|
190
|
+
assert.equal(hasCopyOrVisualWork({ discipline: 'artist' }), true);
|
|
191
|
+
assert.equal(hasCopyOrVisualWork({ module_key: 'hall-ui' }), true);
|
|
192
|
+
assert.equal(hasCopyOrVisualWork({ module_key: 'grading', discipline: 'engineer' }), false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---- 6. STANDARD -----------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
test('the prompt sends the worker to the written guide, and forbids an uncited finding', () => {
|
|
198
|
+
const p = reader.PROMPT_HEADER;
|
|
199
|
+
assert.match(p, /docs\/voice\.md/, 'the rubric is the voice guide');
|
|
200
|
+
assert.match(p, /cites no rule id is not a finding/i, "the guide's own rule travels with it");
|
|
201
|
+
// The hard/judgement split is what stops an advisory lens recording taste as
|
|
202
|
+
// fact, so it is pinned rather than left to the prompt's good intentions.
|
|
203
|
+
assert.match(p, /V1-V5/, 'hard rules may be asserted');
|
|
204
|
+
assert.match(p, /V6-V11/, 'judgement rules may only be flagged');
|
|
205
|
+
assert.match(p, /ADVISORY/, 'the worker is told it cannot block');
|
|
206
|
+
assert.ok(!/blocker/.test(p.split('## Return ONLY JSON')[1] || ''),
|
|
207
|
+
'the JSON schema offers no blocker severity');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('docs/voice.md exists — the lens has a standard to judge against', () => {
|
|
211
|
+
// The hard dependency (task 1003976). A copy lens with no written standard
|
|
212
|
+
// records taste and calls it a finding.
|
|
213
|
+
const guide = fs.readFileSync(path.join(ROOT, 'docs', 'voice.md'), 'utf8');
|
|
214
|
+
for (const id of ['V1', 'V2', 'V3', 'V4', 'V5']) {
|
|
215
|
+
assert.ok(guide.includes(`## ${id} —`) || guide.includes(`## ${id} `), `${id} is a rule in the guide`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---- 7. THE DONE-WHEN CLAUSE THAT LIVES IN ANOTHER FILE ---------------------
|
|
220
|
+
|
|
221
|
+
test('the artist review now coaches the reader, because there is a lens listening', () => {
|
|
222
|
+
// The task's own DONE WHEN: "Then STEP 1 of the artist review reverts to true
|
|
223
|
+
// coaching — see the comment in modules/lifecycle/cascade.js, which says so
|
|
224
|
+
// and asks not to be reverted early." The interim wording was explicitly a
|
|
225
|
+
// signal for which world we are in, so shipping the lens without turning it
|
|
226
|
+
// leaves the instruction to artists describing a panel that no longer exists.
|
|
227
|
+
const { artistReviewBody } = require('../modules/lifecycle/cascade.js');
|
|
228
|
+
const body = artistReviewBody({ id: '1234' });
|
|
229
|
+
assert.match(body, /COACH THE READER/, 'STEP 1 asks for coaching again');
|
|
230
|
+
assert.match(body, /docs\/voice\.md/, 'and names the standard to coach against');
|
|
231
|
+
assert.ok(!/none of them reads a sentence/.test(body),
|
|
232
|
+
'the interim claim that no lens reads a sentence is gone — it is no longer true');
|
|
233
|
+
// Still a request, never a gate (ADR 0241) — unchanged by this task.
|
|
234
|
+
assert.match(body, /REQUEST, not a gate/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('the cascade and the copy lens cover the SAME set by construction', () => {
|
|
238
|
+
// Both key off hasCopyOrVisualWork, so an artist review exists for exactly
|
|
239
|
+
// the ships a reader ran on. If these ever diverge, an artist is asked to
|
|
240
|
+
// coach a lens that never looked.
|
|
241
|
+
const cascadeSrc = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'), 'utf8');
|
|
242
|
+
const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
|
|
243
|
+
assert.match(cascadeSrc, /hasCopyOrVisualWork/);
|
|
244
|
+
assert.match(shipSrc, /hasCopyOrVisualWork/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---- 8. COST ---------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
test('the Reader sits at the heavy timeout tier, so it cannot change worst-case wall time', () => {
|
|
250
|
+
// The four already sit at 360s and workers run in parallel, so the panel's
|
|
251
|
+
// wall time is max() and a fifth at the same budget does not move it.
|
|
252
|
+
const t = WORKERS.timeoutForWorker(reader, {});
|
|
253
|
+
assert.equal(t, 360_000);
|
|
254
|
+
for (const k of ['narc', 'quality', 'hacker', 'efficiency']) {
|
|
255
|
+
assert.equal(WORKERS.timeoutForWorker(WORKERS.REGISTRY[k], {}), t, `${k} shares the tier`);
|
|
256
|
+
}
|
|
257
|
+
});
|