@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 +17 -0
- package/dist/services/in-memory-queue-service.d.ts +8 -1
- package/dist/services/in-memory-queue-service.js +67 -0
- package/dist/services/incoming-webhook-service.d.ts +2 -0
- package/dist/services/incoming-webhook-service.js +4 -0
- package/dist/services/scheduler-service.d.ts +3 -0
- package/dist/services/scheduler-service.js +4 -0
- package/dist/wirings/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +21 -0
- package/dist/wirings/queue/signed-queue-service.d.ts +4 -1
- package/dist/wirings/queue/signed-queue-service.js +3 -0
- package/dist/wirings/scheduler/index.d.ts +1 -1
- package/dist/wirings/scheduler/scheduler.types.d.ts +12 -0
- package/knowledge/decisions/internals/a-latency-budget-is-asserted-by-its-cause.md +32 -0
- package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +3 -0
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/questions/virtual-user-keys-on-cloud-stages-are-pikku-studios-to-build.md +2 -3
- package/package.json +1 -1
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
|
}
|
|
@@ -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,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
|
}
|
|
@@ -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.
|
|
20
|
-
|
|
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.
|