@pikku/core 0.12.89 → 0.12.91

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/dist/services/http-personas.d.ts +21 -7
  3. package/dist/services/http-personas.js +39 -28
  4. package/dist/services/persona-sign-in.d.ts +107 -0
  5. package/dist/services/persona-sign-in.js +179 -0
  6. package/dist/types/core.types.d.ts +7 -0
  7. package/dist/wirings/persona/index.d.ts +1 -0
  8. package/dist/wirings/persona/index.js +1 -0
  9. package/dist/wirings/rpc/rpc-runner.js +4 -5
  10. package/dist/wirings/virtual-user/index.d.ts +3 -1
  11. package/dist/wirings/virtual-user/index.js +1 -0
  12. package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
  13. package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
  14. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
  15. package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
  16. package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
  17. package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
  18. package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
  19. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
  20. package/dist/wirings/workflow/pikku-workflow-service.js +3 -2
  21. package/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
  22. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
  23. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
  24. package/knowledge/decisions/internals/index.md +1 -0
  25. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
  26. package/package.json +1 -1
  27. package/src/app-leaf-surface.test.ts +2 -2
  28. package/src/ecosystem-tier-removed.test.ts +69 -0
  29. package/src/public-surface.json +10 -0
  30. package/src/services/http-personas-converse.test.ts +16 -2
  31. package/src/services/http-personas.ts +60 -33
  32. package/src/services/persona-sign-in.test.ts +209 -0
  33. package/src/services/persona-sign-in.ts +284 -0
  34. package/src/types/core.types.ts +7 -0
  35. package/src/wirings/persona/index.ts +9 -0
  36. package/src/wirings/rpc/rpc-runner.test.ts +100 -0
  37. package/src/wirings/rpc/rpc-runner.ts +8 -5
  38. package/src/wirings/virtual-user/index.ts +18 -0
  39. package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
  40. package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
  41. package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
  42. package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
  43. package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
  44. package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
  45. package/src/wirings/workflow/pikku-workflow-service.ts +3 -2
  46. package/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -17,4 +17,5 @@ export type { CorePersona, CorePersonas, PersonaAccountMeta, PersonaDefinitions,
17
17
  * Lambda deploy would load outright.
18
18
  */
19
19
  export { HttpPersona, createHttpPersonas, type HttpPersonasConfig, } from '../../services/http-personas.js';
20
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
20
21
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -13,4 +13,5 @@ export { personaEmail, personaEmails } from './persona-email.js';
13
13
  * Lambda deploy would load outright.
14
14
  */
15
15
  export { HttpPersona, createHttpPersonas, } from '../../services/http-personas.js';
16
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, } from '../../services/persona-sign-in.js';
16
17
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -270,10 +270,9 @@ export class ContextAwareRPCService {
270
270
  };
271
271
  if (rpcName.includes(':')) {
272
272
  const addonCall = this.resolveAddonFunction(rpcName);
273
- if (addonCall === NOT_RESOLVED) {
274
- throw new RPCNotFoundError(rpcName);
273
+ if (addonCall !== NOT_RESOLVED) {
274
+ return await this.executeAddonFunction(addonCall, data, mergedWire);
275
275
  }
276
- return this.executeAddonFunction(addonCall, data, mergedWire);
277
276
  }
278
277
  let resolved;
279
278
  try {
@@ -281,8 +280,8 @@ export class ContextAwareRPCService {
281
280
  }
282
281
  catch (e) {
283
282
  if (e instanceof RPCNotFoundError && this.services.deploymentService) {
284
- const session = await resolveWireSession(this.wire);
285
- return this.services.deploymentService.invoke(rpcName, data, session, this.wire.traceId);
283
+ const session = await resolveWireSession(mergedWire);
284
+ return this.services.deploymentService.invoke(rpcName, data, session, mergedWire.traceId);
286
285
  }
287
286
  throw e;
288
287
  }
@@ -16,11 +16,13 @@
16
16
  * {@link VirtualUserTarget}, which in production is an `HttpPersona`
17
17
  * signed in as a real user against staging or production.
18
18
  */
19
- export type { ApiCatalogueEntry, IntentSource, VirtualUserDisposition, VirtualUserFinding, VirtualUserRunResult, VirtualUserTarget, } from './virtual-user.types.js';
19
+ export type { ApiCatalogueEntry, IntentRecord, IntentSource, StepRecord, VirtualUserBudget, VirtualUserDisposition, VirtualUserFinding, VirtualUserRunResult, VirtualUserTarget, } from './virtual-user.types.js';
20
20
  export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
21
21
  export { runVirtualUser, type RunVirtualUserParams, } from './run-virtual-user.js';
22
22
  export { personaScopes, prepareVirtualUserRun, type VirtualUserPreparation, } from './prepare-virtual-user-run.js';
23
23
  export type { VirtualUserRunOutcome, VirtualUserRunRecord, VirtualUserRunStart, VirtualUserRunStore, } from './virtual-user-run-store.js';
24
+ export type { VirtualUserScheduleInput, VirtualUserScheduleRecord, VirtualUserScheduleStore, } from './virtual-user-schedule-store.js';
25
+ export { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, isDue, nextRunAt, STALE_RUN_AFTER_MS, tickVirtualUserSchedules, type VirtualUserTickParams, type VirtualUserTickResult, } from './virtual-user-schedule.js';
24
26
  export { DISPOSITIONS, dispositionProfile, type DispositionProfile, type VirtualUserTuning, } from './virtual-user-dispositions.js';
25
27
  export { catalogueClassification, catalogueLookup, isReadOnly, reachableCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
26
28
  export { type AgentReachability, type ReachableAgent, } from './virtual-user-agents.js';
@@ -1,6 +1,7 @@
1
1
  export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
2
2
  export { runVirtualUser, } from './run-virtual-user.js';
3
3
  export { personaScopes, prepareVirtualUserRun, } from './prepare-virtual-user-run.js';
4
+ export { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, isDue, nextRunAt, STALE_RUN_AFTER_MS, tickVirtualUserSchedules, } from './virtual-user-schedule.js';
4
5
  export { DISPOSITIONS, dispositionProfile, } from './virtual-user-dispositions.js';
5
6
  export { catalogueClassification, catalogueLookup, isReadOnly, reachableCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
6
7
  export { IntentStack, intentsForPersona } from './virtual-user-intents.js';
@@ -13,7 +13,6 @@
13
13
  */
14
14
  /** The part of an agent's meta this needs. */
15
15
  export interface AgentReachability {
16
- name?: string;
17
16
  description?: string;
18
17
  scopes?: readonly string[];
19
18
  auth?: boolean;
@@ -24,7 +23,13 @@ export interface ReachableAgent {
24
23
  description?: string;
25
24
  }
26
25
  /**
27
- * The agents to offer, keyed by the name they are declared under.
26
+ * The agents to offer, named by the key they are registered under.
27
+ *
28
+ * That key is the export's own name, which is what `addAgent` stores and what
29
+ * `resolveAgent` looks up. The `name` an agent declares in its config is a
30
+ * display label and is frequently something else entirely — offering that one
31
+ * hands the persona a name the server cannot resolve, and the run dies on a
32
+ * 500 the moment it takes the offer.
28
33
  *
29
34
  * Like {@link reachableCatalogue}, this narrows *what is offered* and never
30
35
  * what is enforced: the server decides who may talk to what, and an agent
@@ -1,6 +1,12 @@
1
1
  import { hasScopes } from '../../scopes.js';
2
2
  /**
3
- * The agents to offer, keyed by the name they are declared under.
3
+ * The agents to offer, named by the key they are registered under.
4
+ *
5
+ * That key is the export's own name, which is what `addAgent` stores and what
6
+ * `resolveAgent` looks up. The `name` an agent declares in its config is a
7
+ * display label and is frequently something else entirely — offering that one
8
+ * hands the persona a name the server cannot resolve, and the run dies on a
9
+ * 500 the moment it takes the offer.
4
10
  *
5
11
  * Like {@link reachableCatalogue}, this narrows *what is offered* and never
6
12
  * what is enforced: the server decides who may talk to what, and an agent
@@ -19,6 +25,6 @@ export const reachableAgents = (agents, scopes) => Object.entries(agents)
19
25
  return hasScopes(agent.scopes, scopes);
20
26
  })
21
27
  .map(([id, agent]) => ({
22
- name: agent.name ?? id,
28
+ name: id,
23
29
  ...(agent.description ? { description: agent.description } : {}),
24
30
  }));
@@ -1,4 +1,4 @@
1
- import type { VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
1
+ import type { IntentRecord, StepRecord, VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
2
2
  /**
3
3
  * One recorded run: who ran, what they were told, and what came back.
4
4
  *
@@ -34,6 +34,16 @@ export interface VirtualUserRunRecord {
34
34
  */
35
35
  memory: Record<string, string>;
36
36
  findings: VirtualUserFinding[];
37
+ /**
38
+ * What the user set out to do and how far each one got, which is the spine a
39
+ * transcript hangs off — the steps alone are a list of calls with no account
40
+ * of what they were for.
41
+ *
42
+ * Small and bounded, so it rides on the run row rather than in a table of its
43
+ * own: a run has as many intents as the app has scenarios, and every read of
44
+ * the run wants them.
45
+ */
46
+ intents: IntentRecord[];
37
47
  tally: VirtualUserTally | null;
38
48
  /** Which budget or stopping rule ended the run. */
39
49
  stoppedBy: string | null;
@@ -62,6 +72,16 @@ export interface VirtualUserRunOutcome {
62
72
  tally: VirtualUserTally;
63
73
  memory: Record<string, string>;
64
74
  stoppedBy: string | null;
75
+ intents: readonly IntentRecord[];
76
+ /**
77
+ * Every turn the run took. Kept because a finding is an assertion until you
78
+ * can see what the user did before it, and because a run that found nothing
79
+ * is only readable as work through its steps.
80
+ *
81
+ * Stored apart from the run — see {@link VirtualUserRunStore.steps} — so
82
+ * listing runs does not drag a budget's worth of turns along with it.
83
+ */
84
+ steps: readonly StepRecord[];
65
85
  }
66
86
  /**
67
87
  * Where runs are kept. Declared here rather than in a database package so the
@@ -87,4 +107,15 @@ export interface VirtualUserRunStore {
87
107
  limit?: number;
88
108
  offset?: number;
89
109
  }): Promise<VirtualUserRunRecord[]>;
110
+ /**
111
+ * One run's turns, in the order they happened.
112
+ *
113
+ * Its own call rather than a field on the record: a run at a 500-step budget
114
+ * carries more transcript than every other column put together, and `list`
115
+ * would pay for it on every row.
116
+ */
117
+ steps(runId: string, options?: {
118
+ limit?: number;
119
+ offset?: number;
120
+ }): Promise<StepRecord[]>;
90
121
  }
@@ -0,0 +1,89 @@
1
+ import type { VirtualUserBudget, VirtualUserDisposition } from './virtual-user.types.js';
2
+ /**
3
+ * One persona's standing instruction to keep using the app.
4
+ *
5
+ * A virtual user that runs once tells you about one afternoon. What an app
6
+ * actually wants to know is what a persona hits over a fortnight, and that is
7
+ * a cadence, not a longer run — a budget already caps how far a single run
8
+ * goes, and raising it only buys a more tired user.
9
+ *
10
+ * The row is the schedule. There is deliberately no timer, interval or
11
+ * in-memory loop anywhere near it: a process that holds the next run in its own
12
+ * heap forgets it on the next deploy, and a persona silently stops. Something
13
+ * outside asks which rows are due; the answer survives restarts because it is
14
+ * written down.
15
+ */
16
+ export interface VirtualUserScheduleRecord {
17
+ persona: string;
18
+ /**
19
+ * Off by default. A schedule that ran the moment it was written would start
20
+ * spending an app's model budget as a side effect of a migration.
21
+ */
22
+ enabled: boolean;
23
+ disposition: VirtualUserDisposition;
24
+ goals: string[];
25
+ budget: VirtualUserBudget | null;
26
+ /**
27
+ * The gap to the next run is drawn between these, not fixed. A persona that
28
+ * appears at exactly 09:00 every day exercises one cache state and one cron
29
+ * neighbourhood; a real one does not keep an appointment.
30
+ */
31
+ minIntervalMs: number;
32
+ maxIntervalMs: number;
33
+ /** When this persona is next allowed to run. The whole schedule, in one field. */
34
+ nextRunAt: Date;
35
+ lastRunId: string | null;
36
+ lastRunAt: Date | null;
37
+ }
38
+ /** A partial write — anything left out keeps whatever the row already had. */
39
+ export interface VirtualUserScheduleInput {
40
+ persona: string;
41
+ enabled?: boolean;
42
+ disposition?: VirtualUserDisposition;
43
+ goals?: readonly string[];
44
+ budget?: VirtualUserBudget | null;
45
+ minIntervalMs?: number;
46
+ maxIntervalMs?: number;
47
+ nextRunAt?: Date;
48
+ }
49
+ /**
50
+ * Where cadences are kept, alongside {@link VirtualUserRunStore} and separate
51
+ * from it: a host can want the history of runs it started by hand without
52
+ * wanting unattended ones, and wiring nothing is how it says so.
53
+ *
54
+ * SECURITY: writing a row here spends money on every future tick, without a
55
+ * caller present to see it happen. The scaffold gates writes behind a scope of
56
+ * their own for that reason — reading what the virtual users found is a much
57
+ * smaller permission than deciding they should keep going.
58
+ */
59
+ export interface VirtualUserScheduleStore {
60
+ /** Creates or updates one persona's cadence. Returns the row as it now stands. */
61
+ set(schedule: VirtualUserScheduleInput): Promise<VirtualUserScheduleRecord>;
62
+ get(persona: string): Promise<VirtualUserScheduleRecord | null>;
63
+ list(): Promise<VirtualUserScheduleRecord[]>;
64
+ /** Enabled rows whose `nextRunAt` has passed. */
65
+ due(now: Date): Promise<VirtualUserScheduleRecord[]>;
66
+ /**
67
+ * Pushes the persona's next run out, and records which run this was.
68
+ *
69
+ * Called *before* the run is dispatched, so a tick that dies halfway does not
70
+ * leave a row due and get re-dispatched by the next one. The cost is that a
71
+ * dispatch which throws waits a full interval rather than retrying, which is
72
+ * the right way round: a persona that is failing to start should not be
73
+ * retried every minute for a week.
74
+ *
75
+ * `from` is the `nextRunAt` the caller read, and the write must match it to
76
+ * land — the claim is how a tick wins the persona, not just how it records
77
+ * winning. Two processes on the same cron read the same due row, and without
78
+ * the compare-and-set both would dispatch: the same user acting twice over,
79
+ * at twice the budget, producing findings neither run can reproduce. The
80
+ * loser is told `false` and leaves the persona to whoever got there first.
81
+ */
82
+ claim(persona: string, claim: {
83
+ from: Date;
84
+ nextRunAt: Date;
85
+ runId: string | null;
86
+ at: Date;
87
+ }): Promise<boolean>;
88
+ remove(persona: string): Promise<void>;
89
+ }
@@ -0,0 +1,71 @@
1
+ import type { VirtualUserRunStore } from './virtual-user-run-store.js';
2
+ import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
3
+ /**
4
+ * How long a run may sit at `running` before it is read as dead rather than
5
+ * busy.
6
+ *
7
+ * A run holds no process across a restart — see {@link VirtualUserRunRecord} —
8
+ * so a deploy mid-run strands the record, and a stranded record would block its
9
+ * persona's schedule for good. Twice the longest duration budget anyone sets in
10
+ * practice, because the failure this guards against is cheap to recover from
11
+ * and expensive to trigger early: reaping a run that was still working loses
12
+ * its findings.
13
+ */
14
+ export declare const STALE_RUN_AFTER_MS: number;
15
+ /**
16
+ * The cadence a schedule gets when it is written without one: roughly a run a
17
+ * day, at an hour nobody can predict.
18
+ *
19
+ * Sparse on purpose. Every tick spends model budget with no caller present to
20
+ * notice, so the default is the one an app can leave switched on and forget,
21
+ * not the one that finds the most.
22
+ */
23
+ export declare const DEFAULT_MIN_INTERVAL_MS: number;
24
+ export declare const DEFAULT_MAX_INTERVAL_MS: number;
25
+ /** Whether a row is the tick's business, for stores that cannot ask in a query. */
26
+ export declare const isDue: (schedule: VirtualUserScheduleRecord, now: Date) => boolean;
27
+ /**
28
+ * When this persona should next appear, drawn from its own interval.
29
+ *
30
+ * Uniform between the two bounds. Reversed bounds are read as a range rather
31
+ * than rejected, because a schedule is configuration and a swapped pair is a
32
+ * typo, not an attack.
33
+ */
34
+ export declare const nextRunAt: (schedule: Pick<VirtualUserScheduleRecord, "minIntervalMs" | "maxIntervalMs">, now: Date, random: () => number) => Date;
35
+ export type VirtualUserSkipReason = 'in-flight' | 'dispatch-failed' | 'claimed-elsewhere';
36
+ export interface VirtualUserTickResult {
37
+ dispatched: {
38
+ persona: string;
39
+ runId: string;
40
+ }[];
41
+ skipped: {
42
+ persona: string;
43
+ reason: VirtualUserSkipReason;
44
+ }[];
45
+ /** Runs found stranded at `running` and marked failed. */
46
+ reaped: string[];
47
+ }
48
+ export interface VirtualUserTickParams {
49
+ schedules: VirtualUserScheduleStore;
50
+ runs: VirtualUserRunStore;
51
+ /** Starts one run and answers with its id. Nothing here knows how. */
52
+ dispatch: (schedule: VirtualUserScheduleRecord) => Promise<string>;
53
+ now?: Date;
54
+ random?: () => number;
55
+ staleAfterMs?: number;
56
+ }
57
+ /**
58
+ * Acts on whichever personas are due, once.
59
+ *
60
+ * The whole cadence lives in this one call, so what schedules it is the host's
61
+ * choice — a cron wiring, a platform scheduler, or a person clicking a button.
62
+ * Pikku does not start a timer on an app's behalf; a scaffold that did would
63
+ * begin spending model budget the moment a project ran `pikku all`.
64
+ *
65
+ * A persona is skipped, not queued, while its previous run is still going. Two
66
+ * copies of the same user acting at once is not a heavier test, it is a
67
+ * different one, and every finding it produces is unreproducible. Two ticks
68
+ * running at once are held to the same rule by the claim, which only lands for
69
+ * whichever of them still sees the `nextRunAt` it read.
70
+ */
71
+ export declare const tickVirtualUserSchedules: ({ schedules, runs, dispatch, now, random, staleAfterMs, }: VirtualUserTickParams) => Promise<VirtualUserTickResult>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * How long a run may sit at `running` before it is read as dead rather than
3
+ * busy.
4
+ *
5
+ * A run holds no process across a restart — see {@link VirtualUserRunRecord} —
6
+ * so a deploy mid-run strands the record, and a stranded record would block its
7
+ * persona's schedule for good. Twice the longest duration budget anyone sets in
8
+ * practice, because the failure this guards against is cheap to recover from
9
+ * and expensive to trigger early: reaping a run that was still working loses
10
+ * its findings.
11
+ */
12
+ export const STALE_RUN_AFTER_MS = 2 * 60 * 60 * 1000;
13
+ /**
14
+ * The cadence a schedule gets when it is written without one: roughly a run a
15
+ * day, at an hour nobody can predict.
16
+ *
17
+ * Sparse on purpose. Every tick spends model budget with no caller present to
18
+ * notice, so the default is the one an app can leave switched on and forget,
19
+ * not the one that finds the most.
20
+ */
21
+ export const DEFAULT_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000;
22
+ export const DEFAULT_MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
+ /** Whether a row is the tick's business, for stores that cannot ask in a query. */
24
+ export const isDue = (schedule, now) => schedule.enabled && schedule.nextRunAt.getTime() <= now.getTime();
25
+ /**
26
+ * When this persona should next appear, drawn from its own interval.
27
+ *
28
+ * Uniform between the two bounds. Reversed bounds are read as a range rather
29
+ * than rejected, because a schedule is configuration and a swapped pair is a
30
+ * typo, not an attack.
31
+ */
32
+ export const nextRunAt = (schedule, now, random) => {
33
+ const low = Math.max(0, Math.min(schedule.minIntervalMs, schedule.maxIntervalMs));
34
+ const high = Math.max(0, Math.max(schedule.minIntervalMs, schedule.maxIntervalMs));
35
+ return new Date(now.getTime() + low + random() * (high - low));
36
+ };
37
+ /**
38
+ * Acts on whichever personas are due, once.
39
+ *
40
+ * The whole cadence lives in this one call, so what schedules it is the host's
41
+ * choice — a cron wiring, a platform scheduler, or a person clicking a button.
42
+ * Pikku does not start a timer on an app's behalf; a scaffold that did would
43
+ * begin spending model budget the moment a project ran `pikku all`.
44
+ *
45
+ * A persona is skipped, not queued, while its previous run is still going. Two
46
+ * copies of the same user acting at once is not a heavier test, it is a
47
+ * different one, and every finding it produces is unreproducible. Two ticks
48
+ * running at once are held to the same rule by the claim, which only lands for
49
+ * whichever of them still sees the `nextRunAt` it read.
50
+ */
51
+ export const tickVirtualUserSchedules = async ({ schedules, runs, dispatch, now = new Date(), random = Math.random, staleAfterMs = STALE_RUN_AFTER_MS, }) => {
52
+ const result = {
53
+ dispatched: [],
54
+ skipped: [],
55
+ reaped: [],
56
+ };
57
+ for (const schedule of await schedules.due(now)) {
58
+ const [latest] = await runs.list({ persona: schedule.persona, limit: 1 });
59
+ if (latest?.status === 'running') {
60
+ if (now.getTime() - latest.createdAt.getTime() < staleAfterMs) {
61
+ result.skipped.push({ persona: schedule.persona, reason: 'in-flight' });
62
+ continue;
63
+ }
64
+ await runs.fail(latest.runId, `Abandoned: still running ${Math.round((now.getTime() - latest.createdAt.getTime()) / 60000)}m after it started, which is longer than any budget allows.`);
65
+ result.reaped.push(latest.runId);
66
+ }
67
+ const due = nextRunAt(schedule, now, random);
68
+ const acquired = await schedules.claim(schedule.persona, {
69
+ from: schedule.nextRunAt,
70
+ nextRunAt: due,
71
+ runId: null,
72
+ at: now,
73
+ });
74
+ if (!acquired) {
75
+ result.skipped.push({
76
+ persona: schedule.persona,
77
+ reason: 'claimed-elsewhere',
78
+ });
79
+ continue;
80
+ }
81
+ let runId;
82
+ try {
83
+ runId = await dispatch(schedule);
84
+ }
85
+ catch {
86
+ result.skipped.push({
87
+ persona: schedule.persona,
88
+ reason: 'dispatch-failed',
89
+ });
90
+ continue;
91
+ }
92
+ await schedules.claim(schedule.persona, {
93
+ from: due,
94
+ nextRunAt: due,
95
+ runId,
96
+ at: now,
97
+ });
98
+ result.dispatched.push({ persona: schedule.persona, runId });
99
+ }
100
+ return result;
101
+ };
@@ -109,8 +109,9 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
109
109
  * overriding this, or no concurrency for one to exclude: the relay makes
110
110
  * duplicate dispatch routine, and the claim is what keeps a duplicate from
111
111
  * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
112
- * status-guarded claim, `in-memory` on being inline and single-process;
113
- * `mongodb` still qualifies on neither.
112
+ * status-guarded claim, `mongodb` on the same claim expressed as a
113
+ * single-document update, `in-memory` on being inline and single-process —
114
+ * none of them overrides this yet.
114
115
  */
115
116
  protected findUndispatchedSteps(_before: Date, _limit: number): Promise<Array<{
116
117
  runId: string;
@@ -266,8 +266,9 @@ export class PikkuWorkflowService {
266
266
  * overriding this, or no concurrency for one to exclude: the relay makes
267
267
  * duplicate dispatch routine, and the claim is what keeps a duplicate from
268
268
  * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
269
- * status-guarded claim, `in-memory` on being inline and single-process;
270
- * `mongodb` still qualifies on neither.
269
+ * status-guarded claim, `mongodb` on the same claim expressed as a
270
+ * single-document update, `in-memory` on being inline and single-process —
271
+ * none of them overrides this yet.
271
272
  */
272
273
  async findUndispatchedSteps(_before, _limit) {
273
274
  return [];
@@ -5,8 +5,17 @@ export const createCookieJar = (apiUrl) => {
5
5
  fetch: async (input, init) => {
6
6
  const headers = new Headers(init?.headers);
7
7
  headers.set('origin', origin);
8
- const held = [...jar].map(([name, value]) => `${name}=${value}`);
8
+ // The caller's own cookie header wins per name: a request that already
9
+ // carries a session is stating which one it means, and emitting the jar's
10
+ // copy alongside it sends the same name twice.
9
11
  const caller = headers.get('cookie');
12
+ const named = new Set((caller ?? '')
13
+ .split(';')
14
+ .map((pair) => pair.split('=')[0]?.trim())
15
+ .filter(Boolean));
16
+ const held = [...jar]
17
+ .filter(([name]) => !named.has(name))
18
+ .map(([name, value]) => `${name}=${value}`);
10
19
  if (held.length > 0 || caller) {
11
20
  headers.set('cookie', [caller, ...held].filter(Boolean).join('; '));
12
21
  }
@@ -0,0 +1,66 @@
1
+ ---
2
+ type: decision
3
+ title: A virtual user cadence is a row, not a timer
4
+ description: How often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
5
+ tags: virtual-user, storage, scheduling
6
+ ---
7
+
8
+ # A virtual user cadence is a row, not a timer
9
+
10
+ A run has a budget; a persona has a cadence. The two get confused because both
11
+ answer "how often", and neither answers the other's question: a budget caps one
12
+ outing, and raising it only buys a more tired user. What tells you about a
13
+ product is the same person coming back over a fortnight.
14
+
15
+ That cadence is one row per persona in `virtualUserSchedule`, holding
16
+ `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due. There is
17
+ no timer, no interval, and no in-memory loop.
18
+
19
+ **Not a timer**, because a process holding the next run in its own heap forgets
20
+ it on the next deploy, and the persona silently stops — with nothing anywhere
21
+ saying it used to run. The row survives restarts, and any instance can act on
22
+ it.
23
+
24
+ **Not reschedule-on-completion**, which is the tempting shape: finish a run,
25
+ draw a delay, schedule the next. It has exactly one failure mode and it is
26
+ fatal — a crash between the two ends the persona forever, and the evidence is an
27
+ absence. A due time written down before the run starts cannot be lost by the run
28
+ failing.
29
+
30
+ **Not a scaffolded cron.** The tick is generated as an ordinary function and
31
+ wired by nobody. A `wireScheduler` emitted by codegen would start spending an
32
+ application's model budget the moment somebody ran `pikku all`, on a host that
33
+ may not even run schedulers. One line in the project turns it on:
34
+
35
+ ```ts
36
+ wireScheduler({ name: 'virtualUsers', schedule: '0 * * * *', func: tickVirtualUserSchedules })
37
+ ```
38
+
39
+ Tick resolution bounds how *late* a due persona is, never how often it runs — a
40
+ persona due at 09:07 under an hourly tick starts at 10:00. Running the tick more
41
+ often costs one indexed query and changes no cadence.
42
+
43
+ Three rules make a tick safe to run at any resolution, from any number of
44
+ instances:
45
+
46
+ - **The due time is written before the run is dispatched**, so a tick that dies
47
+ halfway cannot leave the row due for the next one to pick up again. A dispatch
48
+ that throws therefore waits a full interval, which is the right way round: a
49
+ persona failing to start should not be retried every minute for a week.
50
+ - **A persona whose previous run is still `running` is skipped, not queued.**
51
+ Two copies of the same user acting at once is not a heavier test, it is a
52
+ different one, and every finding it produces is unreproducible.
53
+ - **A run still `running` after `STALE_RUN_AFTER_MS` is failed and the persona
54
+ runs again.** This is where the stranded-record cost of
55
+ [a virtual user run being neither a workflow nor a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md)
56
+ gets paid: without it, one restart mid-run would block that persona's schedule
57
+ permanently.
58
+
59
+ The interval is a range (`minIntervalMs`, `maxIntervalMs`), drawn per run. A
60
+ user who arrives at exactly 09:00 every day exercises one cache state and one
61
+ cron neighbourhood; a real one does not keep an appointment.
62
+
63
+ **What this rules out:** a `setTimeout` or interval anywhere in the run path;
64
+ the engine scheduling its own next run; a scaffolded scheduled task; a queue
65
+ holding the next run; and a cadence that lives only in a config file, which
66
+ cannot record when the persona last actually went.
@@ -37,9 +37,14 @@ left at `running` is neither.
37
37
 
38
38
  The cost is real and is stated on the type: **a restart mid-run strands a record
39
39
  at `running` with nothing left to finish it.** A run older than its budget
40
- window and still `running` is dead, not working — that is a read-side rule, and
41
- it is cheaper than the two dependencies avoided. Nothing retries; a stranded run
42
- is started again, with its seed if the caller wants the same exploration.
40
+ window and still `running` is dead, not working — a read-side rule, and cheaper
41
+ than the two dependencies avoided. Nothing retries; a stranded run is started
42
+ again, with its seed if the caller wants the same exploration.
43
+
44
+ Where that rule is actually applied is
45
+ [the schedule tick](a-virtual-user-cadence-is-a-row-not-a-timer.md), which has
46
+ to: a record stuck at `running` would otherwise block its persona's cadence
47
+ forever.
43
48
 
44
49
  **What this rules out:** dispatching the run through `startWorkflow`; a
45
50
  scaffolded queue worker; awaiting the engine inside the request (a run takes
@@ -16,6 +16,7 @@ caller is entitled to assume.
16
16
  - [A scenario step's prose template is offered to a virtual user unfilled](a-scenario-step-template-is-offered-unfilled.md) — A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
17
17
  - [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
18
18
  - [A virtual user decides whether to trust its notes once per turn, by one roll](a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md) — The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
19
+ - [A virtual user cadence is a row, not a timer](a-virtual-user-cadence-is-a-row-not-a-timer.md) — how often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
19
20
  - [A virtual user run is not a workflow and not a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md) — runVirtualUser writes its record, dispatches the run without awaiting it, and returns the id — because an exploratory run has nothing to replay and the record already carries what a queue would be holding
20
21
  - [A wall-clock threshold is a load test in disguise](a-wall-clock-threshold-is-a-load-test-in-disguise.md) — The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
21
22
  - [A workflow's wire is built from the run record, not from the RPC service](a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md) — The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
@@ -32,12 +32,13 @@ The stability distinction the split was built to express is now carried by
32
32
  exports. Moving a symbol across an area boundary is still a visible diff; it
33
33
  just no longer requires a parallel tree of re-export files to be visible.
34
34
 
35
- Two modules survive at the old specifiers and are not entry points:
36
- `bootstrap-compat/root.ts` and `bootstrap-compat/ecosystem.ts` exist because
37
- `packages/cli` is generated by the published CLI pinned in its `build.sh`, which
38
- still emits `@pikku/core/ecosystem` and a bare `@pikku/core`. A test pins their
39
- exact contents so neither can grow, and both go when the pin moves to a CLI
40
- released from this branch.
35
+ One module survives at an old specifier and is not an entry point:
36
+ `bootstrap-compat/root.ts` exists because `packages/cli` is generated by the
37
+ published CLI pinned in its `build.sh`, which still emits a bare `@pikku/core`.
38
+ The matching `bootstrap-compat/ecosystem.ts` has already gone, along with every
39
+ `@pikku/core/ecosystem` import in the repo a guard test now fails if one comes
40
+ back. A test pins the root shim's exact contents so it cannot grow, and it goes
41
+ when the pin moves to a CLI released from this branch.
41
42
 
42
43
  **What this rules out:** re-introducing any specifier that re-exports another
43
44
  subpath's names. A curated facade over a module that is already published is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.89",
3
+ "version": "0.12.91",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
28
28
  /**
29
29
  * Membership is discovered, not listed: every directory holding a
30
30
  * `pikku.config.json` is a Pikku project, so a project added later arrives
31
- * guarded rather than invisible. Listing them by hand is how the ecosystem
32
- * guard reported green on four packages it had never scanned.
31
+ * guarded rather than invisible. Listing them by hand is how an earlier
32
+ * version of this guard reported green on four packages it had never scanned.
33
33
  */
34
34
  const skipped = new Set([
35
35
  'node_modules',