@yemi33/minions 0.1.2446 → 0.1.2447

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/dashboard.js CHANGED
@@ -101,6 +101,7 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
101
101
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
102
102
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
103
103
  const apiContracts = require('./engine/api-contracts');
104
+ const listPaging = require('./engine/api-contracts/paging');
104
105
 
105
106
  // Dev vs binary differentiation. When two dashboards run side-by-side (npm
106
107
  // install on 7331, local checkout on 7332), the favicon and title need to
@@ -3384,7 +3385,11 @@ async function _maxInputMtimeMs(inputs) {
3384
3385
  // cross-scope display order is post-enrichment (central + every project by
3385
3386
  // rowid), which per-scope SQL LIMIT/OFFSET cannot compose, and (c) slicing the
3386
3387
  // deterministic cached array is O(page) to serialize — the real transfer win.
3387
- const _LIST_PAGE_MAX = 1000;
3388
+ // The bounds come from engine/api-contracts/paging.js — the SAME constants the
3389
+ // route catalog publishes on GET /api/routes, so the clamp and the advertised
3390
+ // contract can never drift (review of PR #1062).
3391
+ const _LIST_PAGE_MAX = listPaging.LIST_PAGE_MAX_LIMIT;
3392
+ const _LIST_PAGE_DEFAULT_LIMIT = listPaging.LIST_PAGE_DEFAULT_LIMIT;
3388
3393
  function _parsePageParams(req) {
3389
3394
  const params = new URL((req && req.url) || '/', 'http://localhost').searchParams;
3390
3395
  const hasLimit = params.has('limit');
@@ -3394,7 +3399,7 @@ function _parsePageParams(req) {
3394
3399
  const rawOffset = Number(params.get('offset'));
3395
3400
  const limit = hasLimit && Number.isFinite(rawLimit)
3396
3401
  ? Math.max(1, Math.min(_LIST_PAGE_MAX, Math.floor(rawLimit)))
3397
- : 50;
3402
+ : _LIST_PAGE_DEFAULT_LIMIT;
3398
3403
  const offset = hasOffset && Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
3399
3404
  return { limit, offset };
3400
3405
  }
@@ -9164,14 +9169,23 @@ const server = http.createServer(async (req, res) => {
9164
9169
  }
9165
9170
 
9166
9171
  async function handlePlansList(req, res) {
9172
+ let page;
9167
9173
  try {
9168
- planPrdValidation.validateNoQuery(req);
9174
+ // Only the optional limit/offset paging params are accepted; every other
9175
+ // query param is still rejected with the canonical 400, and the error
9176
+ // blames the REJECTED param rather than the accepted paging one
9177
+ // (W-ms3ovk83000rb60f). _parsePageParams returns null on the legacy
9178
+ // no-param path so the response stays byte-identical to legacy then.
9179
+ planPrdValidation.validateNoQuery(req, { allow: listPaging.LIST_PAGING_PARAMS });
9180
+ page = _parsePageParams(req);
9169
9181
  } catch (e) {
9170
9182
  return apiErrorReply(res, e, req);
9171
9183
  }
9172
9184
  const now = Date.now();
9173
9185
  if (_plansCache && (now - _plansCacheTs) < PLANS_CACHE_TTL_MS) {
9174
- return jsonReply(res, 200, _plansCache);
9186
+ // Keep caching the full array and slice per-request AFTER the cache read
9187
+ // so paging never corrupts the cache and `total` reflects the full set.
9188
+ return jsonReply(res, 200, page ? _paginateList(_plansCache, page) : _plansCache);
9175
9189
  }
9176
9190
  const fsp = fs.promises;
9177
9191
  // W-mrffmjaf002f08f0 — PRD JSON is SQL-authoritative (engine/prd-store.js);
@@ -9325,7 +9339,7 @@ const server = http.createServer(async (req, res) => {
9325
9339
  plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
9326
9340
  _plansCache = plans;
9327
9341
  _plansCacheTs = Date.now();
9328
- return jsonReply(res, 200, plans);
9342
+ return jsonReply(res, 200, page ? _paginateList(plans, page) : plans);
9329
9343
  }
9330
9344
 
9331
9345
  async function handlePlansArchiveRead(req, res, match) {
@@ -12551,7 +12565,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12551
12565
  // ── Watches API Handlers ─────────────────────────────────────────────────
12552
12566
 
12553
12567
  async function handleWatchesList(req, res) {
12554
- return jsonReply(res, 200, { watches: watchesMod.getWatches() });
12568
+ const all = watchesMod.getWatches();
12569
+ // Optional progressive-load paging (W-ms3ovk83000rb60f). With no paging
12570
+ // params the response is byte-identical to the legacy `{ watches: [...] }`
12571
+ // shape; when a param is present we slice the SAME array and keep the
12572
+ // `watches` key (mapped from _paginateList's `items`) so existing consumers
12573
+ // keep working, adding total/hasMore/offset/limit alongside it.
12574
+ const page = _parsePageParams(req);
12575
+ if (!page) return jsonReply(res, 200, { watches: all });
12576
+ const { items, total, offset, limit, hasMore } = _paginateList(all, page);
12577
+ return jsonReply(res, 200, { watches: items, total, hasMore, offset, limit });
12555
12578
  }
12556
12579
 
12557
12580
  async function handleWatchesTargetTypes(req, res) {
@@ -14829,10 +14852,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14829
14852
  },
14830
14853
  });
14831
14854
  }},
14832
- { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
14855
+ { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14856
+ const page = _parsePageParams(req);
14833
14857
  return serveFreshJson(req, res, {
14834
14858
  tag: 'schedules',
14835
14859
  inputs: [CONFIG_PATH],
14860
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14861
+ transform: page ? (list) => _paginateList(list, page) : null,
14836
14862
  builder: () => {
14837
14863
  // Read config.json directly via queries.getConfig() rather than
14838
14864
  // calling reloadConfig() — the latter cascades into
@@ -14857,12 +14883,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14857
14883
  },
14858
14884
  });
14859
14885
  }},
14860
- { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs', handler: (req, res) => {
14886
+ { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14887
+ const page = _parsePageParams(req);
14861
14888
  return serveFreshJson(req, res, {
14862
14889
  tag: 'pipelines',
14863
14890
  inputs: [
14864
14891
  path.join(MINIONS_DIR, 'pipelines'),
14865
14892
  ],
14893
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14894
+ transform: page ? (list) => _paginateList(list, page) : null,
14866
14895
  builder: () => {
14867
14896
  try {
14868
14897
  const pl = require('./engine/pipeline');
@@ -15100,7 +15129,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15100
15129
 
15101
15130
  // Plans
15102
15131
  { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: `title, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, project? (string OR array for cross-repo plans), agent?, branch_strategy? or branchStrategy?`, handler: handlePlanCreate },
15103
- { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
15132
+ { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: handlePlansList },
15104
15133
  { method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
15105
15134
  { method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?, forceRegen?, skipRegen?', handler: handlePlansApprove },
15106
15135
  { method: 'POST', path: '/api/plans/pause', desc: 'Pause a plan (stops materialization + resets active items)', params: 'file', handler: handlePlansPause },
@@ -15996,7 +16025,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15996
16025
  { method: 'POST', path: '/api/schedules/run-now', desc: 'Manually enqueue the work item for a schedule', params: 'id', handler: handleSchedulesRunNow },
15997
16026
 
15998
16027
  // Watches
15999
- { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
16028
+ { method: 'GET', path: '/api/watches', desc: 'List all watches. Optional ?limit=&offset= returns a wrapped { watches, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the response is byte-identical to legacy { watches: [...] }.', handler: handleWatchesList },
16000
16029
  { method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
16001
16030
  { method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
16002
16031
  { method: 'GET', path: /^\/api\/watches\/([\w-]+)\/history$/, template: '/api/watches/:id/history', desc: 'Read the persisted evaluation history (last 25 checks) for a watch', handler: handleWatchHistory },
@@ -4,6 +4,23 @@ const keepProcesses = require('../keep-process-sweep');
4
4
  const managedSpawn = require('../managed-spawn');
5
5
  const managedSpecNameMax = managedSpawn.validateManagedSpecName('x').maxLength;
6
6
  const agentApiValidation = require('../agent-api-validation');
7
+ const paging = require('./paging');
8
+
9
+ // The optional progressive-load paging window is the ONLY caller-supplied input
10
+ // these list endpoints read; unknown params are ignored (not rejected) because
11
+ // they never reach a validator.
12
+ function pagedListContract() {
13
+ return {
14
+ audit: 'audited',
15
+ query: paging.listPagingQuery(
16
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
17
+ ),
18
+ constraints: [
19
+ ...paging.listPagingClampConstraints(),
20
+ { kind: 'ignores-unknown-query-params', location: 'query' },
21
+ ],
22
+ };
23
+ }
7
24
 
8
25
  function absentOrEmptyOperatorBody() {
9
26
  return {
@@ -74,9 +91,12 @@ module.exports = {
74
91
  ],
75
92
  overrides: {
76
93
  'GET /api/keep-processes': { noInput: true },
77
- 'GET /api/schedules': { noInput: true },
78
- 'GET /api/pipelines': { noInput: true },
79
- 'GET /api/watches': { noInput: true },
94
+ // These three consume the optional ?limit=&offset= paging window
95
+ // (W-ms3ovk83000rb60f) they are NOT input-less, so the catalog must publish
96
+ // the real parameters instead of certifying them noInput.
97
+ 'GET /api/schedules': pagedListContract(),
98
+ 'GET /api/pipelines': pagedListContract(),
99
+ 'GET /api/watches': pagedListContract(),
80
100
  'GET /api/watches/target-types': { noInput: true },
81
101
  'GET /api/watches/action-types': { noInput: true },
82
102
  'GET /api/watches/<id>/history': {
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth for the OPTIONAL progressive-load paging query surface
4
+ // (`?limit=&offset=`) shared by the large list endpoints.
5
+ //
6
+ // dashboard.js's `_parsePageParams` clamps incoming values with these bounds and
7
+ // the api-contract owner modules publish the same bounds on `GET /api/routes`,
8
+ // so the runtime behavior and the advertised contract cannot drift. Adding
9
+ // paging to another list endpoint means declaring `listPagingQuery()` on its
10
+ // contract — otherwise the route keeps certifying itself input-less while
11
+ // silently consuming caller input (review of PR #1062).
12
+
13
+ const LIST_PAGE_MAX_LIMIT = 1000;
14
+ const LIST_PAGE_DEFAULT_LIMIT = 50;
15
+ const LIST_PAGING_PARAMS = Object.freeze(['limit', 'offset']);
16
+
17
+ function listPagingQueryFields() {
18
+ return [
19
+ {
20
+ name: 'limit',
21
+ type: 'integer',
22
+ required: false,
23
+ min: 1,
24
+ max: LIST_PAGE_MAX_LIMIT,
25
+ default: LIST_PAGE_DEFAULT_LIMIT,
26
+ },
27
+ {
28
+ name: 'offset',
29
+ type: 'integer',
30
+ required: false,
31
+ min: 0,
32
+ default: 0,
33
+ },
34
+ ];
35
+ }
36
+
37
+ // `rationale` documents the per-route behavior for the params this contract does
38
+ // NOT list (rejected on /api/plans, ignored on the serveFreshJson endpoints).
39
+ function listPagingQuery(rationale) {
40
+ const query = { policy: 'optional', fields: listPagingQueryFields() };
41
+ if (rationale) query.rationale = String(rationale);
42
+ return query;
43
+ }
44
+
45
+ // Out-of-range/non-numeric paging values are clamped to the published bounds
46
+ // rather than rejected, so the clamp is part of the contract, not a 400 path.
47
+ function listPagingClampConstraints() {
48
+ return [
49
+ {
50
+ kind: 'clamped-limit',
51
+ field: 'limit',
52
+ location: 'query',
53
+ min: 1,
54
+ max: LIST_PAGE_MAX_LIMIT,
55
+ default: LIST_PAGE_DEFAULT_LIMIT,
56
+ },
57
+ {
58
+ kind: 'clamped-offset',
59
+ field: 'offset',
60
+ location: 'query',
61
+ min: 0,
62
+ default: 0,
63
+ },
64
+ ];
65
+ }
66
+
67
+ module.exports = {
68
+ LIST_PAGE_MAX_LIMIT,
69
+ LIST_PAGE_DEFAULT_LIMIT,
70
+ LIST_PAGING_PARAMS,
71
+ listPagingQueryFields,
72
+ listPagingQuery,
73
+ listPagingClampConstraints,
74
+ };
@@ -3,6 +3,7 @@
3
3
  const { PR_ACTIONS } = require('../pr-action');
4
4
  const { EXECUTION_TARGET_IDS } = require('../pr-fix-target');
5
5
  const { PR_FIX_CAUSE } = require('../shared');
6
+ const paging = require('./paging');
6
7
 
7
8
  const PR_REFERENCE_FORMATS = Object.freeze([
8
9
  'GitHub PR URL',
@@ -52,6 +53,15 @@ module.exports = {
52
53
  overrides: {
53
54
  'GET /api/pull-requests': {
54
55
  audit: 'audited',
56
+ // Consumes the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
57
+ // clamped rather than rejected — declared so the catalog publishes it.
58
+ query: paging.listPagingQuery(
59
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
60
+ ),
61
+ constraints: [
62
+ ...paging.listPagingClampConstraints(),
63
+ { kind: 'ignores-unknown-query-params', location: 'query' },
64
+ ],
55
65
  },
56
66
  'GET /api/prs/<id>': {
57
67
  audit: 'audited',
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const paging = require('./paging');
4
+
3
5
  const AUDITED_NEGATIVE_TESTS = Object.freeze([{
4
6
  strategy: 'isolated-http-invalid-input-state-snapshot',
5
7
  expectedStatus: '400-or-409',
@@ -57,7 +59,24 @@ module.exports = {
57
59
  query: { policy: 'none', rationale: 'Handler rejects any query string (planPrdValidation.validateNoQuery).' },
58
60
  },
59
61
  'GET /api/plans': {
60
- query: { policy: 'none', rationale: 'Handler rejects any query string (planPrdValidation.validateNoQuery).' },
62
+ audit: 'audited',
63
+ // Accepts the optional ?limit=&offset= paging window (W-ms3ovk83000rb60f);
64
+ // every OTHER query parameter is still rejected with the canonical 400 via
65
+ // planPrdValidation.validateNoQuery(req, { allow: LIST_PAGING_PARAMS }).
66
+ query: paging.listPagingQuery(
67
+ 'Optional progressive-load paging window; any other query parameter is rejected (planPrdValidation.validateNoQuery).',
68
+ ),
69
+ constraints: [
70
+ ...paging.listPagingClampConstraints(),
71
+ {
72
+ kind: 'reject-unknown-query-params',
73
+ location: 'query',
74
+ allowed: [...paging.LIST_PAGING_PARAMS],
75
+ },
76
+ ],
77
+ negativeTests: [
78
+ { strategy: 'reject-unknown-query-param-blaming-the-rejected-name', expectedStatus: 400 },
79
+ ],
61
80
  },
62
81
  'POST /api/work-items': {
63
82
  audit: 'audited',
@@ -248,10 +267,18 @@ module.exports = {
248
267
  },
249
268
  },
250
269
  'GET /api/work-items': {
251
- // Polled list endpoint: reads no query/body — returns the full slimmed
252
- // work-item collection. Affirmed input-less so it is audited rather than
253
- // defaulting to pending-audit by omission (review PR #966).
254
- noInput: true,
270
+ // Polled list endpoint. Reads no body/headers; the only caller-supplied
271
+ // input is the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
272
+ // which is clamped rather than rejected. Declared explicitly so the catalog
273
+ // publishes the real parameters instead of certifying the route input-less.
274
+ audit: 'audited',
275
+ query: paging.listPagingQuery(
276
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
277
+ ),
278
+ constraints: [
279
+ ...paging.listPagingClampConstraints(),
280
+ { kind: 'ignores-unknown-query-params', location: 'query' },
281
+ ],
255
282
  },
256
283
  'GET /api/work-items/archive': {
257
284
  // Archived-item listing: reads no query/body. Affirmed input-less.
@@ -65,11 +65,20 @@ function validateBody(body) {
65
65
  });
66
66
  }
67
67
 
68
- function validateNoQuery(req) {
68
+ // Reject caller-supplied query params. `options.allow` lists the params the
69
+ // endpoint genuinely accepts (e.g. the optional `limit`/`offset` paging window);
70
+ // those are filtered out BEFORE the offending name is chosen, so the message and
71
+ // the machine-readable `field`/`path` always blame a rejected param rather than
72
+ // an accepted one (review of PR #1062).
73
+ function validateNoQuery(req, options = {}) {
74
+ const allowed = new Set(Array.isArray(options.allow) ? options.allow : []);
69
75
  const parsed = new URL(req?.url || '/', 'http://localhost');
70
- const names = [...new Set(parsed.searchParams.keys())];
76
+ const names = [...new Set(parsed.searchParams.keys())].filter(name => !allowed.has(name));
71
77
  if (names.length > 0) {
72
- inputError(`This endpoint does not accept query parameters: ${names.join(', ')}`, {
78
+ const message = allowed.size > 0
79
+ ? `This endpoint only accepts the query parameters ${[...allowed].join(', ')}: ${names.join(', ')}`
80
+ : `This endpoint does not accept query parameters: ${names.join(', ')}`;
81
+ inputError(message, {
73
82
  code: 'unexpected-query',
74
83
  field: names[0],
75
84
  path: `query.${names[0]}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2446",
3
+ "version": "0.1.2447",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"