@pikku/core 0.12.142 → 0.12.143

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/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.12.143
2
+
3
+ ### Patch Changes
4
+
5
+ - de9af6a: RPCs for Pikku Studio's migrated screens. Runtime state that must be visible on a deployed app lives in `@pikku/addon-admin`; local-copy tools stay in `@pikku/addon-console`.
6
+
7
+ `@pikku/addon-admin`:
8
+
9
+ - `admin:getSchedulerHistory` returns each scheduled task's last run, next run and recent runs, from a new `SchedulerService.getTaskRuns()` (empty by default). `InMemorySchedulerService` records the last 50 runs per cron task with status, duration and error. Scope `admin:schedules:read`.
10
+ - `admin:getQueueHistory({ queueNames?, limit? })` returns live counts and recent jobs per queue, from a new optional `QueueService.inspect(queueName, { limit })`, implemented by `InMemoryQueueService` and `BullQueueService` and forwarded by `SignedQueueService`. Scope `admin:queues:read`.
11
+ - `admin:listIncomingWebhookReceipts` / `admin:getIncomingWebhookReceipt` expose received webhooks, backed by a new `IncomingWebhookService.getReceipt(receiptId)` (null by default; implemented by `KyselyIncomingWebhookService`). Scope `admin:webhooks:read`.
12
+
13
+ `@pikku/addon-console`:
14
+
15
+ - `console:variableGet` / `console:variableSet` read and write a project variable, gated on `pikku:console:variables:read` / `:write`.
16
+ - `console:runScenarios({ names?, environment? })` starts `pikku scenario run` as a detached child process and returns `{ runId }` to follow through `listScenarioRuns` / `getScenarioRun`, gated on the new `pikku:console:scenarios:run` scope. `pikku scenario run` takes `--run-id <id>` to record under a caller-chosen id.
17
+
1
18
  ## 0.12.142
2
19
 
3
20
  ### Patch Changes
@@ -1,8 +1,15 @@
1
- import type { QueueService, JobOptions } from '../wirings/queue/queue.types.js';
1
+ import type { QueueService, JobOptions, QueueInspection } from '../wirings/queue/queue.types.js';
2
2
  export declare class InMemoryQueueService implements QueueService {
3
3
  readonly supportsResults = false;
4
4
  private jobCounter;
5
+ private jobs;
6
+ private finishedTotals;
5
7
  add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>;
6
8
  private backoffDelay;
7
9
  getJob(): Promise<null>;
10
+ inspect(queueName: string, opts?: {
11
+ limit?: number;
12
+ }): Promise<QueueInspection>;
13
+ private track;
14
+ private finish;
8
15
  }
@@ -1,16 +1,34 @@
1
1
  import { runQueueJob } from '../wirings/queue/queue-runner.js';
2
+ const FINISHED_JOBS_KEPT = 100;
2
3
  // knowledge: decisions/internals/the-dev-queue-copies-prod-timing-and-serialization-semantics.md
3
4
  export class InMemoryQueueService {
4
5
  supportsResults = false;
5
6
  jobCounter = 0;
7
+ jobs = new Map();
8
+ finishedTotals = new Map();
6
9
  async add(queueName, data, options) {
7
10
  const jobId = `inmem-${++this.jobCounter}`;
8
11
  const maxAttempts = Math.max(1, options?.attempts ?? 1);
9
12
  let attemptsMade = 0;
10
13
  const createdAt = new Date();
11
14
  const payload = data === undefined ? data : JSON.parse(JSON.stringify(data));
15
+ const record = {
16
+ jobId,
17
+ status: options?.delay ? 'delayed' : 'waiting',
18
+ attemptsMade: 0,
19
+ maxAttempts,
20
+ createdAt,
21
+ processedAt: null,
22
+ finishedAt: null,
23
+ durationMs: null,
24
+ error: null,
25
+ };
26
+ this.track(queueName, record);
12
27
  const runAttempt = async () => {
13
28
  attemptsMade++;
29
+ record.attemptsMade = attemptsMade;
30
+ record.status = 'active';
31
+ record.processedAt = new Date();
14
32
  const job = {
15
33
  id: jobId,
16
34
  queueName,
@@ -21,13 +39,17 @@ export class InMemoryQueueService {
21
39
  };
22
40
  try {
23
41
  await runQueueJob({ job });
42
+ this.finish(queueName, record, 'completed', null);
24
43
  }
25
44
  catch (e) {
45
+ record.error = e?.message ?? String(e);
26
46
  if (attemptsMade < maxAttempts) {
47
+ record.status = 'delayed';
27
48
  // Fire-and-forget retry: runAttempt catches every error itself.
28
49
  setTimeout(() => void runAttempt(), this.backoffDelay(options?.backoff, attemptsMade));
29
50
  }
30
51
  else {
52
+ this.finish(queueName, record, 'failed', record.error);
31
53
  console.error(`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`, e.message);
32
54
  }
33
55
  }
@@ -51,4 +73,49 @@ export class InMemoryQueueService {
51
73
  async getJob() {
52
74
  return null;
53
75
  }
76
+ async inspect(queueName, opts) {
77
+ const records = this.jobs.get(queueName) ?? [];
78
+ const totals = this.finishedTotals.get(queueName);
79
+ const counts = {
80
+ waiting: 0,
81
+ active: 0,
82
+ delayed: 0,
83
+ completed: totals?.completed ?? 0,
84
+ failed: totals?.failed ?? 0,
85
+ };
86
+ for (const record of records) {
87
+ if (record.status !== 'completed' && record.status !== 'failed') {
88
+ counts[record.status]++;
89
+ }
90
+ }
91
+ return {
92
+ counts,
93
+ recent: records
94
+ .slice(0, opts?.limit ?? 50)
95
+ .map((record) => ({ ...record })),
96
+ };
97
+ }
98
+ track(queueName, record) {
99
+ const records = this.jobs.get(queueName) ?? [];
100
+ records.unshift(record);
101
+ this.jobs.set(queueName, records);
102
+ }
103
+ finish(queueName, record, status, error) {
104
+ record.status = status;
105
+ record.error = error;
106
+ record.finishedAt = new Date();
107
+ record.durationMs =
108
+ record.finishedAt.getTime() -
109
+ (record.processedAt ?? record.createdAt).getTime();
110
+ const totals = this.finishedTotals.get(queueName) ?? {
111
+ completed: 0,
112
+ failed: 0,
113
+ };
114
+ totals[status]++;
115
+ this.finishedTotals.set(queueName, totals);
116
+ const records = this.jobs.get(queueName) ?? [];
117
+ let finished = 0;
118
+ this.jobs.set(queueName, records.filter((r) => (r.status !== 'completed' && r.status !== 'failed') ||
119
+ ++finished <= FINISHED_JOBS_KEPT));
120
+ }
54
121
  }
@@ -45,4 +45,6 @@ export declare class IncomingWebhookService {
45
45
  source?: string;
46
46
  limit?: number;
47
47
  }): Promise<IncomingWebhookReceiptRecord[]>;
48
+ /** Null when nothing is stored or no receipt has this id. */
49
+ getReceipt(_receiptId: string): Promise<IncomingWebhookReceiptRecord | null>;
48
50
  }
@@ -43,4 +43,8 @@ export class IncomingWebhookService {
43
43
  async listReceipts(_opts) {
44
44
  return [];
45
45
  }
46
+ /** Null when nothing is stored or no receipt has this id. */
47
+ async getReceipt(_receiptId) {
48
+ return null;
49
+ }
46
50
  }
@@ -1,4 +1,5 @@
1
1
  import type { CoreUserSession } from '../types/core.types.js';
2
+ import type { ScheduledTaskRuns } from '../wirings/scheduler/scheduler.types.js';
2
3
  export interface ScheduledTaskSummary {
3
4
  taskId: string;
4
5
  rpcName: string;
@@ -17,6 +18,8 @@ export declare abstract class SchedulerService {
17
18
  abstract getTask(taskId: string): Promise<ScheduledTaskInfo | null>;
18
19
  abstract getAllTasks(): Promise<ScheduledTaskSummary[]>;
19
20
  abstract close(): Promise<void>;
21
+ /** Per wired cron task. Empty when this scheduler keeps no run history. */
22
+ getTaskRuns(): Promise<Record<string, ScheduledTaskRuns>>;
20
23
  start(): Promise<void>;
21
24
  stop(): Promise<void>;
22
25
  }
@@ -1,4 +1,8 @@
1
1
  export class SchedulerService {
2
+ /** Per wired cron task. Empty when this scheduler keeps no run history. */
3
+ async getTaskRuns() {
4
+ return {};
5
+ }
2
6
  async start() { }
3
7
  async stop() { }
4
8
  }
@@ -1,4 +1,4 @@
1
- export type { ConfigValidationResult, CoreQueueWorker, GroupConcurrencyConfig, JobGroup, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueJob, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
1
+ export type { ConfigValidationResult, CoreQueueWorker, GroupConcurrencyConfig, JobGroup, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueInspection, QueueJob, QueueJobCounts, QueueJobRecord, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
2
2
  export { wireQueueWorker, runQueueJob, QueueJobDiscardedError, QueueJobFailedError, } from './queue-runner.js';
3
3
  export type { QueueIdentityBinding } from './queue-identity.js';
4
4
  export { SignedQueueService } from './signed-queue-service.js';
@@ -94,8 +94,29 @@ export interface JobOptions {
94
94
  /** Counts against the worker's {@link PikkuWorkerConfig.groupConcurrency}. */
95
95
  group?: JobGroup;
96
96
  }
97
+ export type QueueJobCounts = Record<QueueJobStatus, number>;
98
+ export type QueueJobRecord = {
99
+ jobId: string;
100
+ status: QueueJobStatus;
101
+ attemptsMade: number;
102
+ maxAttempts: number;
103
+ createdAt: Date;
104
+ processedAt: Date | null;
105
+ finishedAt: Date | null;
106
+ durationMs: number | null;
107
+ error: string | null;
108
+ };
109
+ export type QueueInspection = {
110
+ counts: QueueJobCounts;
111
+ /** Newest first. */
112
+ recent: QueueJobRecord[];
113
+ };
97
114
  export interface QueueService {
98
115
  readonly supportsResults: boolean;
116
+ /** Live counts and recent jobs for one queue. Absent on a queue service that cannot inspect its backlog. */
117
+ inspect?(queueName: string, opts?: {
118
+ limit?: number;
119
+ }): Promise<QueueInspection>;
99
120
  add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
100
121
  getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
101
122
  }
@@ -1,7 +1,7 @@
1
1
  import type { Safe } from '../../classification/secret-value.js';
2
2
  import type { Logger } from '../../services/logger.js';
3
3
  import type { SecretService } from '../../services/secret-service.js';
4
- import type { JobOptions, QueueJob, QueueService } from './queue.types.js';
4
+ import type { JobOptions, QueueInspection, QueueJob, QueueService } from './queue.types.js';
5
5
  /**
6
6
  * Wraps any {@link QueueService} so `JobOptions.pikkuUserId` leaves the process
7
7
  * as a signed claim that `runQueueJob` can verify.
@@ -14,4 +14,7 @@ export declare class SignedQueueService implements QueueService {
14
14
  get supportsResults(): boolean;
15
15
  add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
16
16
  getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
17
+ get inspect(): ((queueName: string, opts?: {
18
+ limit?: number;
19
+ }) => Promise<QueueInspection>) | undefined;
17
20
  }
@@ -39,4 +39,7 @@ export class SignedQueueService {
39
39
  async getJob(queueName, jobId) {
40
40
  return this.queueService.getJob(queueName, jobId);
41
41
  }
42
+ get inspect() {
43
+ return this.queueService.inspect?.bind(this.queueService);
44
+ }
42
45
  }
@@ -1,3 +1,3 @@
1
1
  export { ScheduledTaskNotFoundError, runScheduledTask, wireScheduler, getScheduledTasks, } from './scheduler-runner.js';
2
2
  export { logSchedulers } from './log-schedulers.js';
3
- export type { CoreScheduledTask, ScheduledTasksMeta, } from './scheduler.types.js';
3
+ export type { CoreScheduledTask, ScheduledTasksMeta, ScheduledTaskRun, ScheduledTaskRuns, } from './scheduler.types.js';
@@ -25,3 +25,15 @@ export interface PikkuScheduledTask {
25
25
  /** Never returns — throws to abort the run. */
26
26
  skip: (reason?: string) => void;
27
27
  }
28
+ export interface ScheduledTaskRun {
29
+ startedAt: Date;
30
+ status: 'completed' | 'failed' | 'skipped';
31
+ durationMs: number;
32
+ error: string | null;
33
+ }
34
+ export interface ScheduledTaskRuns {
35
+ nextRun: Date | null;
36
+ lastRun: ScheduledTaskRun | null;
37
+ /** Newest first. */
38
+ history: ScheduledTaskRun[];
39
+ }
@@ -0,0 +1,32 @@
1
+ ---
2
+ type: decision
3
+ title: A latency budget is asserted by its cause, not by the clock
4
+ description: The per-request key derivation and remote RPC round-trip tests count WebCrypto calls and check the algorithm instead of timing them, because a millisecond budget fails under CI load while the regression it guards is a choice of KDF
5
+ tags: core, testing, crypto
6
+ ---
7
+
8
+ # A latency budget is asserted by its cause, not by the clock
9
+
10
+ Two tests guarded the per-request crypto path with wall-clock budgets:
11
+ `encryptWithKeyMaterial` + `decryptWithKeyMaterial` under 5ms a pair, and a
12
+ remote RPC round trip (`buildRemoteHeaders` → `pikkuRemoteAuthMiddleware`)
13
+ under 20ms. Both went red on loaded CI runners (8.6ms, 151ms) with nothing
14
+ wrong, blocking unrelated PRs (#2112), after #1175 had already loosened them.
15
+
16
+ What the budgets protected is a single decision: the per-request path expands
17
+ key material with HKDF, never with the PBKDF2 passphrase KDF `encryptJSON`
18
+ uses (600k iterations, ~170ms). The remote session used to go through
19
+ `encryptJSON`, which made every hop pay two password derivations.
20
+
21
+ **So the tests spy on `crypto.subtle` with `t.mock.method` and assert the
22
+ calls**: exactly one `importKey` and one `deriveKey` per encrypt and per
23
+ decrypt, both named `HKDF`, and no `deriveBits`. Switching the KDF to PBKDF2,
24
+ adding a second derivation, or routing the remote session back through
25
+ `encryptJSON`/`decryptJSON` each fails them — checked by hand when they were
26
+ written. None of that depends on how busy the machine is.
27
+
28
+ **What this rules out:** a looser constant kept as a fallback, or a ratio
29
+ against a measurement when the regression is a structural choice. A ratio
30
+ ([[a-wall-clock-threshold-is-a-load-test-in-disguise]]) is the right tool when
31
+ the property really is "fewer of an expensive thing" and nothing countable is
32
+ exposed; when the cause is observable, assert the cause.
@@ -44,3 +44,6 @@ and quiet rather than to delete it.
44
44
 
45
45
  Related: [[an-unref-d-timer-cannot-be-awaited-under-node-test]], the other
46
46
  source of nondeterminism found in the same pass.
47
+
48
+ Where the cause is observable, prefer asserting it outright:
49
+ [[a-latency-budget-is-asserted-by-its-cause]].
@@ -13,6 +13,7 @@ caller is entitled to assume.
13
13
  - [A credential name declared twice must agree on its schema and its type](a-credential-name-declared-twice-must-agree-on-schema-and-type.md) — validateAndBuildCredentialDefinitionsMeta throws when two declarations of one credential name differ in schema or in type, and otherwise keeps the first declaration
14
14
  - [declareScopes lets an app grant a scope whose addon it does not wire](a-grant-can-name-a-scope-declared-without-its-addon.md) — defineScope is a no-op read by the CLI, and declareScopes declares scope ids so a grant is valid without wiring the addon that owns the scope tree
15
15
  - [A held run is woken later, not retried](a-held-run-is-woken-later-not-retried.md) — An orchestrator message that finds its run held enqueues a fresh wake-up a second later instead of failing, because the queue's retry budget is for real failures and a long pass outlasts it
16
+ - [A latency budget is asserted by its cause, not by the clock](a-latency-budget-is-asserted-by-its-cause.md) — The per-request key derivation and remote RPC round-trip tests count WebCrypto calls and check the algorithm instead of timing them, because a millisecond budget fails under CI load while the regression it guards is a choice of KDF
16
17
  - [A non-streaming agent run registers with agentRunState on the same terms as a streaming one](a-non-streaming-agent-run-registers-with-agentrunstate-too.md) — Otherwise interruptAgent finds the run, passes the ownership check, then cannot stop it — and reports that as if the run were on another host
17
18
  - [With scenarios.reset on, the dev seed is the fixture every assertion is written against](a-reset-suite-makes-the-dev-seed-the-fixture.md) — The rollback restores the database as the suite found it, so seed rows are the shared baseline — which makes an absolute date in the seed a test that expires
18
19
  - [A resumed turn is as interruptible as the first one](a-resumed-agent-turn-is-as-interruptible-as-the-first.md) — It is the same person listening to the same voice, and after an approval it is where most of the reply actually gets spoken
@@ -16,9 +16,8 @@ following is built or confirmed. Pikku Studio Cloud must:
16
16
  base64), and accept that name, which starts with neither `PIKKU_` nor `PIKKUSTUDIO_`.
17
17
  3. Set `VIRTUAL_USER_API_URL` on every stage that runs virtual users, to the public
18
18
  URL including any HTTP prefix.
19
- 4. Replace the operator sign-in route and `PIKKUSTUDIO_OPERATOR_TOKEN` handling: sign
20
- statements with the stage's private key for callers acting on a stage, or hand the
21
- key to the CLI as `VIRTUAL_USER_SIGNING_KEY` through the channel it already uses
19
+ 4. Sign statements with the stage's private key for callers acting on a stage, or hand
20
+ the key to the CLI as `VIRTUAL_USER_SIGNING_KEY` through the channel it already uses
22
21
  for stage credentials.
23
22
  5. Stop sending the operator token field in `runVirtualUser` requests; the field no longer
24
23
  exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.142",
3
+ "version": "0.12.143",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",