@aloud/runner 0.2.2 → 0.2.4

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/dist/cli.js CHANGED
@@ -59,6 +59,16 @@ var init_ids = __esm({
59
59
  ledgerEntry: "led",
60
60
  // BILLING_PLAN 2.1: a grant of credit with its own expiry, which is what a balance is made of.
61
61
  creditLot: "clt",
62
+ // BILLING_PLAN 3: the estimate range and approved ceiling a run was authorised under. Stored
63
+ // rather than displayed, because a variable meter with no recorded agreement is a dispute.
64
+ quote: "qte",
65
+ // BILLING_PLAN 4: the billing period a workspace was on. The tier belongs to the period rather
66
+ // than to the workspace row, because a workspace outlives its plan.
67
+ plan: "pln",
68
+ // BILLING_PLAN 4: one meter event owed to Stripe. This is the row identity the drain worker
69
+ // uses, NOT what is sent as the dedup identifier: that is the ledger entry id the row carries,
70
+ // so that Stripe deduplication and the local unique index agree on what a duplicate is.
71
+ outboxEvent: "obx",
62
72
  rateCard: "rtc",
63
73
  auditEvent: "aud",
64
74
  job: "job",
@@ -1107,41 +1117,110 @@ var init_moment = __esm({
1107
1117
  }
1108
1118
  });
1109
1119
 
1110
- // ../core/src/schemas/study.ts
1120
+ // ../core/src/schemas/session.ts
1111
1121
  import { z as z9 } from "zod";
1122
+ var SessionLimits, SessionError, SessionEndReason, Session;
1123
+ var init_session = __esm({
1124
+ "../core/src/schemas/session.ts"() {
1125
+ "use strict";
1126
+ init_persona();
1127
+ init_patience();
1128
+ init_machines();
1129
+ init_action();
1130
+ SessionLimits = z9.object({
1131
+ maxActions: z9.number().int().positive(),
1132
+ maxElapsedMs: z9.number().int().positive(),
1133
+ maxTokens: z9.number().int().positive()
1134
+ });
1135
+ SessionError = z9.object({
1136
+ kind: z9.enum(["browser", "model", "policy", "timeout", "storage", "internal"]),
1137
+ message: z9.string(),
1138
+ retryable: z9.boolean(),
1139
+ occurredAt: z9.string()
1140
+ });
1141
+ SessionEndReason = z9.enum([
1142
+ "finished",
1143
+ "abandoned",
1144
+ "cap_actions",
1145
+ "cap_elapsed",
1146
+ "cap_tokens",
1147
+ "error",
1148
+ "canceled"
1149
+ ]);
1150
+ Session = z9.object({
1151
+ id: z9.string(),
1152
+ runId: z9.string(),
1153
+ workspaceId: z9.string(),
1154
+ persona: PersonaBrief,
1155
+ personaId: z9.string().nullable(),
1156
+ status: SessionStatus,
1157
+ /** SPEC FR-054: recorded as evidence, never the reported result. */
1158
+ selfReportedOutcome: SessionOutcome.nullable(),
1159
+ selfReportedReason: z9.string().nullable(),
1160
+ patienceBudget: PatienceBudget,
1161
+ patienceState: PatienceState,
1162
+ limits: SessionLimits,
1163
+ startedAt: z9.string().nullable(),
1164
+ endedAt: z9.string().nullable(),
1165
+ error: SessionError.nullable(),
1166
+ /** SPEC 14.4: set when integrity checks exclude or flag this session. */
1167
+ contamination: z9.object({
1168
+ contaminated: z9.boolean(),
1169
+ flaggedOnly: z9.boolean(),
1170
+ signals: z9.array(z9.string())
1171
+ }).nullable(),
1172
+ momentCount: z9.number().int().nonnegative().default(0),
1173
+ /**
1174
+ * Why the loop stopped. Null only for a session recorded before this field existed.
1175
+ *
1176
+ * Nullable rather than defaulted, per BILLING_PLAN Part 4 ("new nullable columns", additive only):
1177
+ * a historical session has no honest value, and inventing one from its status would manufacture
1178
+ * exactly the signal this field exists to stop anybody guessing at. Null therefore means "not
1179
+ * recorded", never "ended cleanly".
1180
+ */
1181
+ endReason: SessionEndReason.nullable(),
1182
+ /** Which browser worker ran it, for the isolation audit trail (SPEC FR-040). */
1183
+ workerId: z9.string().nullable()
1184
+ });
1185
+ }
1186
+ });
1187
+
1188
+ // ../core/src/schemas/study.ts
1189
+ import { z as z10 } from "zod";
1112
1190
  var EnvironmentType, ActionPolicy, Viewport, VIEWPORT_PRESETS, Environment, Product, SuccessCriterion, StudyStatus, Study, StudySnapshot;
1113
1191
  var init_study = __esm({
1114
1192
  "../core/src/schemas/study.ts"() {
1115
1193
  "use strict";
1116
1194
  init_persona();
1117
- EnvironmentType = z9.enum(["production", "staging", "preview", "local", "benchmark"]);
1118
- ActionPolicy = z9.object({
1119
- blockConsequentialActions: z9.boolean().default(true),
1120
- allowFormSubmission: z9.boolean().default(false),
1121
- allowedDomains: z9.array(z9.string()).default([]),
1195
+ init_session();
1196
+ EnvironmentType = z10.enum(["production", "staging", "preview", "local", "benchmark"]);
1197
+ ActionPolicy = z10.object({
1198
+ blockConsequentialActions: z10.boolean().default(true),
1199
+ allowFormSubmission: z10.boolean().default(false),
1200
+ allowedDomains: z10.array(z10.string()).default([]),
1122
1201
  /** SPEC FR-014: redirects outside allowed domains are blocked or need approval. */
1123
- blockOffDomainNavigation: z9.boolean().default(true),
1202
+ blockOffDomainNavigation: z10.boolean().default(true),
1124
1203
  /** SPEC FR-050/FR-055: raw secret entry by the participant is always blocked. */
1125
- allowSecretEntry: z9.literal(false).default(false)
1204
+ allowSecretEntry: z10.literal(false).default(false)
1126
1205
  });
1127
- Viewport = z9.object({
1128
- width: z9.number().int().min(320).max(3840),
1129
- height: z9.number().int().min(480).max(2160),
1130
- deviceScaleFactor: z9.number().min(1).max(3).default(1),
1131
- isMobile: z9.boolean().default(false)
1206
+ Viewport = z10.object({
1207
+ width: z10.number().int().min(320).max(3840),
1208
+ height: z10.number().int().min(480).max(2160),
1209
+ deviceScaleFactor: z10.number().min(1).max(3).default(1),
1210
+ isMobile: z10.boolean().default(false)
1132
1211
  });
1133
1212
  VIEWPORT_PRESETS = {
1134
1213
  desktop: { width: 1440, height: 900, deviceScaleFactor: 1, isMobile: false },
1135
1214
  mobile_web: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true }
1136
1215
  };
1137
- Environment = z9.object({
1138
- id: z9.string(),
1139
- workspaceId: z9.string(),
1140
- productId: z9.string(),
1141
- name: z9.string().min(1).max(120),
1142
- baseUrl: z9.string().url(),
1216
+ Environment = z10.object({
1217
+ id: z10.string(),
1218
+ workspaceId: z10.string(),
1219
+ productId: z10.string(),
1220
+ name: z10.string().min(1).max(120),
1221
+ baseUrl: z10.string().url(),
1143
1222
  type: EnvironmentType,
1144
- allowedDomains: z9.array(z9.string()).default([]),
1223
+ allowedDomains: z10.array(z10.string()).default([]),
1145
1224
  defaultViewport: DeviceContext.default("desktop"),
1146
1225
  actionPolicy: ActionPolicy,
1147
1226
  /**
@@ -1149,147 +1228,163 @@ var init_study = __esm({
1149
1228
  * A promise that secrets never appear in a screenshot is not enforceable against a live app, so
1150
1229
  * the policy is environment-scoped rather than absolute.
1151
1230
  */
1152
- redactionRequired: z9.boolean(),
1153
- createdAt: z9.string(),
1231
+ redactionRequired: z10.boolean(),
1232
+ createdAt: z10.string(),
1154
1233
  /** Marked when the system inferred this rather than the user configuring it (SPEC 9.0). */
1155
- inferred: z9.boolean().default(false)
1234
+ inferred: z10.boolean().default(false)
1156
1235
  });
1157
- Product = z9.object({
1158
- id: z9.string(),
1159
- workspaceId: z9.string(),
1160
- name: z9.string().min(1).max(120),
1161
- description: z9.string().max(2e3).default(""),
1162
- createdAt: z9.string(),
1163
- inferred: z9.boolean().default(false)
1236
+ Product = z10.object({
1237
+ id: z10.string(),
1238
+ workspaceId: z10.string(),
1239
+ name: z10.string().min(1).max(120),
1240
+ description: z10.string().max(2e3).default(""),
1241
+ createdAt: z10.string(),
1242
+ inferred: z10.boolean().default(false)
1164
1243
  });
1165
- SuccessCriterion = z9.object({
1166
- id: z9.string(),
1244
+ SuccessCriterion = z10.object({
1245
+ id: z10.string(),
1167
1246
  /** What a judge could confirm from a screenshot, in plain language. */
1168
- statement: z9.string().min(1).max(400),
1247
+ statement: z10.string().min(1).max(400),
1169
1248
  /** Which visible signal confirms it. */
1170
- observableSignal: z9.string().min(1).max(400),
1171
- required: z9.boolean().default(true),
1249
+ observableSignal: z10.string().min(1).max(400),
1250
+ required: z10.boolean().default(true),
1172
1251
  /** Marked when the system proposed it (SPEC FR-026). */
1173
- proposed: z9.boolean().default(false)
1252
+ proposed: z10.boolean().default(false)
1174
1253
  });
1175
- StudyStatus = z9.enum(["draft", "ready", "archived"]);
1176
- Study = z9.object({
1177
- id: z9.string(),
1178
- workspaceId: z9.string(),
1179
- productId: z9.string(),
1180
- environmentId: z9.string(),
1181
- name: z9.string().min(1).max(160),
1254
+ StudyStatus = z10.enum(["draft", "ready", "archived"]);
1255
+ Study = z10.object({
1256
+ id: z10.string(),
1257
+ workspaceId: z10.string(),
1258
+ productId: z10.string(),
1259
+ environmentId: z10.string(),
1260
+ name: z10.string().min(1).max(160),
1182
1261
  /** SPEC FR-020: the flow under test. */
1183
- targetFlow: z9.string().min(1).max(400),
1184
- scenario: z9.string().min(1).max(2e3),
1262
+ targetFlow: z10.string().min(1).max(400),
1263
+ scenario: z10.string().min(1).max(2e3),
1185
1264
  /** In user language. Never a step-by-step script (SPEC 26 research integrity). */
1186
- goal: z9.string().min(1).max(600),
1187
- successCriteria: z9.array(SuccessCriterion).min(1),
1265
+ goal: z10.string().min(1).max(600),
1266
+ successCriteria: z10.array(SuccessCriterion).min(1),
1188
1267
  device: DeviceContext,
1189
- participantCount: z9.number().int().min(1).max(12),
1268
+ participantCount: z10.number().int().min(1).max(12),
1190
1269
  /** Drives patience budget compilation (SPEC 14.5). */
1191
- expectedSteps: z9.number().int().min(1).max(60).default(6),
1192
- startUrl: z9.string().url(),
1270
+ expectedSteps: z10.number().int().min(1).max(60).default(6),
1271
+ startUrl: z10.string().url(),
1193
1272
  status: StudyStatus,
1194
- createdAt: z9.string(),
1195
- updatedAt: z9.string(),
1196
- inferred: z9.boolean().default(false)
1273
+ createdAt: z10.string(),
1274
+ updatedAt: z10.string(),
1275
+ inferred: z10.boolean().default(false)
1197
1276
  });
1198
- StudySnapshot = z9.object({
1277
+ StudySnapshot = z10.object({
1199
1278
  study: Study,
1200
1279
  environment: Environment,
1201
- cast: z9.array(PersonaBrief).min(1),
1280
+ cast: z10.array(PersonaBrief).min(1),
1202
1281
  viewport: Viewport,
1203
- promptVersion: z9.string(),
1204
- actionSchemaVersion: z9.string(),
1205
- capturedAt: z9.string()
1282
+ promptVersion: z10.string(),
1283
+ actionSchemaVersion: z10.string(),
1284
+ /**
1285
+ * BILLING_PLAN 3: the per-session ceiling this run was authorised under, derived from the
1286
+ * approved quote's cap by `maxActionsForCap`.
1287
+ *
1288
+ * It belongs in the snapshot for the same reason the cast and the environment do. FR-025 freezes
1289
+ * a run's configuration so that editing the study afterwards cannot change what an existing
1290
+ * report claims was tested, and the ceiling an operator approved is part of that configuration:
1291
+ * re-quoting a study next month must not retroactively widen or narrow a run that already
1292
+ * happened. It is also what carries the cap to a local runner, which works from the lease's
1293
+ * snapshot and never sees `study_quotes`.
1294
+ *
1295
+ * Optional, and null-safe on read. Runs that predate BILLING_PLAN 3 carry no quote, and a stored
1296
+ * snapshot is not rewritten to add one; those fall back to the platform default in the
1297
+ * coordinator rather than being refused years later.
1298
+ */
1299
+ sessionLimits: SessionLimits.optional(),
1300
+ capturedAt: z10.string()
1206
1301
  });
1207
1302
  }
1208
1303
  });
1209
1304
 
1210
1305
  // ../core/src/schemas/product-context.ts
1211
- import { z as z10 } from "zod";
1306
+ import { z as z11 } from "zod";
1212
1307
  var ProductEvidenceKind, ProductEvidenceRef, AudienceSegment, ProductFlow, ProductProfile, PersonaProposal, DiscoveryLink, DiscoveryPage, ProductDiscoveryEvidence, StudySetupStatus, StudySetupJob;
1213
1308
  var init_product_context = __esm({
1214
1309
  "../core/src/schemas/product-context.ts"() {
1215
1310
  "use strict";
1216
1311
  init_persona();
1217
- ProductEvidenceKind = z10.enum([
1312
+ ProductEvidenceKind = z11.enum([
1218
1313
  "rendered_page",
1219
1314
  "user_statement",
1220
1315
  "agent_summary",
1221
1316
  "local_manifest"
1222
1317
  ]);
1223
- ProductEvidenceRef = z10.object({
1224
- id: z10.string(),
1318
+ ProductEvidenceRef = z11.object({
1319
+ id: z11.string(),
1225
1320
  kind: ProductEvidenceKind,
1226
1321
  /** A human-readable source name, such as "Pricing" or "Project summary". */
1227
- label: z10.string().min(1).max(160),
1322
+ label: z11.string().min(1).max(160),
1228
1323
  /** URL for rendered evidence; a non-sensitive logical locator for local context. */
1229
- locator: z10.string().min(1).max(1e3),
1324
+ locator: z11.string().min(1).max(1e3),
1230
1325
  /** Only customer-facing copy may be retained here. Raw repository source never belongs here. */
1231
- excerpt: z10.string().max(800).nullable().default(null),
1232
- contentHash: z10.string().max(128).nullable().default(null),
1233
- capturedAt: z10.string()
1326
+ excerpt: z11.string().max(800).nullable().default(null),
1327
+ contentHash: z11.string().max(128).nullable().default(null),
1328
+ capturedAt: z11.string()
1234
1329
  });
1235
- AudienceSegment = z10.object({
1236
- id: z10.string(),
1237
- label: z10.string().min(1).max(120),
1238
- description: z10.string().min(1).max(600),
1239
- roles: z10.array(z10.string().min(1).max(120)).max(12).default([]),
1240
- jobsToBeDone: z10.array(z10.string().min(1).max(400)).max(12).default([]),
1241
- evidenceIds: z10.array(z10.string()).min(1),
1242
- confidence: z10.number().min(0).max(1)
1330
+ AudienceSegment = z11.object({
1331
+ id: z11.string(),
1332
+ label: z11.string().min(1).max(120),
1333
+ description: z11.string().min(1).max(600),
1334
+ roles: z11.array(z11.string().min(1).max(120)).max(12).default([]),
1335
+ jobsToBeDone: z11.array(z11.string().min(1).max(400)).max(12).default([]),
1336
+ evidenceIds: z11.array(z11.string()).min(1),
1337
+ confidence: z11.number().min(0).max(1)
1243
1338
  });
1244
- ProductFlow = z10.object({
1245
- label: z10.string().min(1).max(160),
1246
- outcome: z10.string().min(1).max(400),
1247
- evidenceIds: z10.array(z10.string()).min(1)
1339
+ ProductFlow = z11.object({
1340
+ label: z11.string().min(1).max(160),
1341
+ outcome: z11.string().min(1).max(400),
1342
+ evidenceIds: z11.array(z11.string()).min(1)
1248
1343
  });
1249
- ProductProfile = z10.object({
1250
- id: z10.string(),
1251
- workspaceId: z10.string(),
1252
- productId: z10.string(),
1253
- version: z10.number().int().positive(),
1254
- name: z10.string().min(1).max(120),
1255
- category: z10.string().min(1).max(160),
1256
- summary: z10.string().min(1).max(1200),
1257
- valueProposition: z10.string().max(800).default(""),
1258
- customerTypes: z10.array(z10.string().min(1).max(160)).max(12).default([]),
1259
- audiences: z10.array(AudienceSegment).max(16).default([]),
1260
- keyFlows: z10.array(ProductFlow).max(20).default([]),
1261
- domainVocabulary: z10.array(z10.string().min(1).max(100)).max(80).default([]),
1262
- evidence: z10.array(ProductEvidenceRef).max(80).default([]),
1263
- confidence: z10.number().min(0).max(1),
1264
- sourceFingerprint: z10.string().min(1).max(128),
1265
- supersedesId: z10.string().nullable(),
1266
- createdBy: z10.enum(["system", "user", "agent"]),
1267
- createdAt: z10.string()
1344
+ ProductProfile = z11.object({
1345
+ id: z11.string(),
1346
+ workspaceId: z11.string(),
1347
+ productId: z11.string(),
1348
+ version: z11.number().int().positive(),
1349
+ name: z11.string().min(1).max(120),
1350
+ category: z11.string().min(1).max(160),
1351
+ summary: z11.string().min(1).max(1200),
1352
+ valueProposition: z11.string().max(800).default(""),
1353
+ customerTypes: z11.array(z11.string().min(1).max(160)).max(12).default([]),
1354
+ audiences: z11.array(AudienceSegment).max(16).default([]),
1355
+ keyFlows: z11.array(ProductFlow).max(20).default([]),
1356
+ domainVocabulary: z11.array(z11.string().min(1).max(100)).max(80).default([]),
1357
+ evidence: z11.array(ProductEvidenceRef).max(80).default([]),
1358
+ confidence: z11.number().min(0).max(1),
1359
+ sourceFingerprint: z11.string().min(1).max(128),
1360
+ supersedesId: z11.string().nullable(),
1361
+ createdBy: z11.enum(["system", "user", "agent"]),
1362
+ createdAt: z11.string()
1268
1363
  });
1269
- PersonaProposal = z10.object({
1364
+ PersonaProposal = z11.object({
1270
1365
  brief: PersonaBrief,
1271
- audienceSegmentId: z10.string().nullable(),
1272
- rationale: z10.string().min(1).max(600),
1273
- evidenceIds: z10.array(z10.string()).default([]),
1274
- confidence: z10.number().min(0).max(1)
1366
+ audienceSegmentId: z11.string().nullable(),
1367
+ rationale: z11.string().min(1).max(600),
1368
+ evidenceIds: z11.array(z11.string()).default([]),
1369
+ confidence: z11.number().min(0).max(1)
1275
1370
  });
1276
- DiscoveryLink = z10.object({
1277
- href: z10.string().url(),
1278
- text: z10.string().max(240)
1371
+ DiscoveryLink = z11.object({
1372
+ href: z11.string().url(),
1373
+ text: z11.string().max(240)
1279
1374
  });
1280
- DiscoveryPage = z10.object({
1281
- url: z10.string().url(),
1282
- title: z10.string().max(300),
1283
- headings: z10.array(z10.string().max(300)).max(80),
1284
- visibleText: z10.string().max(3e4),
1285
- links: z10.array(DiscoveryLink).max(200)
1375
+ DiscoveryPage = z11.object({
1376
+ url: z11.string().url(),
1377
+ title: z11.string().max(300),
1378
+ headings: z11.array(z11.string().max(300)).max(80),
1379
+ visibleText: z11.string().max(3e4),
1380
+ links: z11.array(DiscoveryLink).max(200)
1286
1381
  });
1287
- ProductDiscoveryEvidence = z10.object({
1288
- pages: z10.array(DiscoveryPage).min(1).max(8),
1289
- sourceFingerprint: z10.string().min(1).max(128),
1290
- capturedAt: z10.string()
1382
+ ProductDiscoveryEvidence = z11.object({
1383
+ pages: z11.array(DiscoveryPage).min(1).max(8),
1384
+ sourceFingerprint: z11.string().min(1).max(128),
1385
+ capturedAt: z11.string()
1291
1386
  });
1292
- StudySetupStatus = z10.enum([
1387
+ StudySetupStatus = z11.enum([
1293
1388
  "awaiting_runner",
1294
1389
  "discovering",
1295
1390
  "analysing",
@@ -1299,77 +1394,27 @@ var init_product_context = __esm({
1299
1394
  "failed",
1300
1395
  "expired"
1301
1396
  ]);
1302
- StudySetupJob = z10.object({
1303
- id: z10.string(),
1304
- workspaceId: z10.string(),
1305
- url: z10.string().url(),
1306
- goal: z10.string().min(1).max(600),
1397
+ StudySetupJob = z11.object({
1398
+ id: z11.string(),
1399
+ workspaceId: z11.string(),
1400
+ url: z11.string().url(),
1401
+ goal: z11.string().min(1).max(600),
1307
1402
  device: DeviceContext,
1308
- participantCount: z10.number().int().min(1).max(8),
1309
- allowedHosts: z10.array(z10.string()).min(1),
1403
+ participantCount: z11.number().int().min(1).max(8),
1404
+ allowedHosts: z11.array(z11.string()).min(1),
1310
1405
  status: StudySetupStatus,
1311
- runnerId: z10.string().nullable(),
1406
+ runnerId: z11.string().nullable(),
1312
1407
  discoveryEvidence: ProductDiscoveryEvidence.nullable(),
1313
- productId: z10.string().nullable(),
1314
- profileId: z10.string().nullable(),
1315
- studyId: z10.string().nullable(),
1316
- personaProposals: z10.array(PersonaProposal).default([]),
1317
- degradedReason: z10.string().max(1e3).nullable(),
1318
- failureReason: z10.string().max(1e3).nullable(),
1319
- createdAt: z10.string(),
1320
- claimedAt: z10.string().nullable(),
1321
- expiresAt: z10.string(),
1322
- completedAt: z10.string().nullable()
1323
- });
1324
- }
1325
- });
1326
-
1327
- // ../core/src/schemas/session.ts
1328
- import { z as z11 } from "zod";
1329
- var SessionLimits, SessionError, Session;
1330
- var init_session = __esm({
1331
- "../core/src/schemas/session.ts"() {
1332
- "use strict";
1333
- init_persona();
1334
- init_patience();
1335
- init_machines();
1336
- init_action();
1337
- SessionLimits = z11.object({
1338
- maxActions: z11.number().int().positive(),
1339
- maxElapsedMs: z11.number().int().positive(),
1340
- maxTokens: z11.number().int().positive()
1341
- });
1342
- SessionError = z11.object({
1343
- kind: z11.enum(["browser", "model", "policy", "timeout", "storage", "internal"]),
1344
- message: z11.string(),
1345
- retryable: z11.boolean(),
1346
- occurredAt: z11.string()
1347
- });
1348
- Session = z11.object({
1349
- id: z11.string(),
1350
- runId: z11.string(),
1351
- workspaceId: z11.string(),
1352
- persona: PersonaBrief,
1353
- personaId: z11.string().nullable(),
1354
- status: SessionStatus,
1355
- /** SPEC FR-054: recorded as evidence, never the reported result. */
1356
- selfReportedOutcome: SessionOutcome.nullable(),
1357
- selfReportedReason: z11.string().nullable(),
1358
- patienceBudget: PatienceBudget,
1359
- patienceState: PatienceState,
1360
- limits: SessionLimits,
1361
- startedAt: z11.string().nullable(),
1362
- endedAt: z11.string().nullable(),
1363
- error: SessionError.nullable(),
1364
- /** SPEC 14.4: set when integrity checks exclude or flag this session. */
1365
- contamination: z11.object({
1366
- contaminated: z11.boolean(),
1367
- flaggedOnly: z11.boolean(),
1368
- signals: z11.array(z11.string())
1369
- }).nullable(),
1370
- momentCount: z11.number().int().nonnegative().default(0),
1371
- /** Which browser worker ran it, for the isolation audit trail (SPEC FR-040). */
1372
- workerId: z11.string().nullable()
1408
+ productId: z11.string().nullable(),
1409
+ profileId: z11.string().nullable(),
1410
+ studyId: z11.string().nullable(),
1411
+ personaProposals: z11.array(PersonaProposal).default([]),
1412
+ degradedReason: z11.string().max(1e3).nullable(),
1413
+ failureReason: z11.string().max(1e3).nullable(),
1414
+ createdAt: z11.string(),
1415
+ claimedAt: z11.string().nullable(),
1416
+ expiresAt: z11.string(),
1417
+ completedAt: z11.string().nullable()
1373
1418
  });
1374
1419
  }
1375
1420
  });
@@ -1850,7 +1895,7 @@ var init_recommendation = __esm({
1850
1895
 
1851
1896
  // ../core/src/schemas/billing.ts
1852
1897
  import { z as z18 } from "zod";
1853
- var RateCard, ChargeLineKind, ChargeLine;
1898
+ var RateCard, ChargeLineKind, ChargeLine, StudyEstimate, EstimateBasis, StudyQuote;
1854
1899
  var init_billing = __esm({
1855
1900
  "../core/src/schemas/billing.ts"() {
1856
1901
  "use strict";
@@ -1892,6 +1937,44 @@ var init_billing = __esm({
1892
1937
  */
1893
1938
  idempotencyKey: z18.string()
1894
1939
  });
1940
+ StudyEstimate = z18.object({
1941
+ /** The optimistic end: participants who move through the flow faster than predicted. */
1942
+ low: z18.number().int().nonnegative(),
1943
+ /** The midpoint, priced at the predicted moment count exactly. */
1944
+ expected: z18.number().int().nonnegative(),
1945
+ /** The pessimistic end: participants who flounder, which is also where the findings are. */
1946
+ high: z18.number().int().nonnegative(),
1947
+ /** At or above `high` with headroom. See CAP_HEADROOM in ../billing.ts for the policy. */
1948
+ cap: z18.number().int().nonnegative()
1949
+ });
1950
+ EstimateBasis = z18.enum(["archetype", "history"]);
1951
+ StudyQuote = z18.object({
1952
+ id: z18.string(),
1953
+ workspaceId: z18.string(),
1954
+ /** The run this quote priced. Not a foreign key in the schema: an agreement outlives its run. */
1955
+ runId: z18.string(),
1956
+ /** I6: the immutable card the range was computed under, so the range stays reproducible. */
1957
+ rateCardVersion: z18.string(),
1958
+ /** Non-negative rather than positive: a zero-participant study still reaches synthesis, and a
1959
+ * rate card is free to price any fixed line at zero. Negative is the only impossible value. */
1960
+ estimateLowCredits: z18.number().int().nonnegative(),
1961
+ estimateHighCredits: z18.number().int().nonnegative(),
1962
+ /** The ceiling the operator approves, and the number SessionLimits.maxActions is derived from. */
1963
+ approvedCapCredits: z18.number().int().nonnegative(),
1964
+ /** Null until an operator agrees. Both approval fields are set together or not at all. */
1965
+ approvedBy: z18.string().nullable(),
1966
+ approvedAt: z18.string().nullable(),
1967
+ createdAt: z18.string()
1968
+ }).refine((q) => q.estimateLowCredits <= q.estimateHighCredits, {
1969
+ message: "estimateLowCredits must not exceed estimateHighCredits",
1970
+ path: ["estimateLowCredits"]
1971
+ }).refine((q) => q.approvedCapCredits >= q.estimateHighCredits, {
1972
+ message: "approvedCapCredits must be at least estimateHighCredits",
1973
+ path: ["approvedCapCredits"]
1974
+ }).refine((q) => q.approvedBy === null === (q.approvedAt === null), {
1975
+ message: "approvedBy and approvedAt are set together or not at all",
1976
+ path: ["approvedAt"]
1977
+ });
1895
1978
  }
1896
1979
  });
1897
1980
 
@@ -2666,7 +2749,7 @@ function alive(pid) {
2666
2749
  }
2667
2750
 
2668
2751
  // src/version.ts
2669
- var RUNNER_VERSION = "0.2.2";
2752
+ var RUNNER_VERSION = "0.2.4";
2670
2753
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
2671
2754
 
2672
2755
  // src/protocol/client.ts
@@ -3988,6 +4071,7 @@ var ParticipantSession = class {
3988
4071
  const startedAt = this.deps.clock.nowIso();
3989
4072
  this.startedAtMs = this.deps.clock.monotonicMs();
3990
4073
  let status2 = "active";
4074
+ let endReason = "error";
3991
4075
  let error = null;
3992
4076
  try {
3993
4077
  this.emit({ type: "session.started", sessionId: this.input.sessionId, persona: this.persona.name });
@@ -3995,12 +4079,14 @@ var ParticipantSession = class {
3995
4079
  this.lastCapture = first;
3996
4080
  this.visited.push(first.digest);
3997
4081
  await this.recordFirstImpression(first);
3998
- status2 = await this.loop();
4082
+ ({ status: status2, endReason } = await this.loop());
3999
4083
  } catch (thrown) {
4000
4084
  if (this.deps.signal?.aborted) {
4001
4085
  status2 = "canceled";
4086
+ endReason = "canceled";
4002
4087
  } else {
4003
4088
  status2 = "error";
4089
+ endReason = "error";
4004
4090
  const modelError = thrown instanceof ModelError ? thrown : null;
4005
4091
  error = {
4006
4092
  kind: modelError ? `model:${modelError.kind}` : "browser",
@@ -4015,6 +4101,7 @@ var ParticipantSession = class {
4015
4101
  return {
4016
4102
  sessionId: this.input.sessionId,
4017
4103
  status: status2,
4104
+ endReason,
4018
4105
  moments: this.moments,
4019
4106
  evidenceAssets: this.evidenceAssets,
4020
4107
  selfReportedOutcome: this.selfReportedOutcome,
@@ -4037,12 +4124,23 @@ var ParticipantSession = class {
4037
4124
  throw error;
4038
4125
  }
4039
4126
  }
4127
+ /**
4128
+ * Returns both how the session ended and why.
4129
+ *
4130
+ * Both, rather than the status alone, because the status cannot carry the why: three of the exits
4131
+ * below are all `partial`, and MONETIZATION "Refunds" bills two of them and refunds the third
4132
+ * whole. This function is the only place in the system that knows which one was taken, so the
4133
+ * reason leaves here as data rather than being reconstructed later from a moment count that reads
4134
+ * identically either way.
4135
+ */
4040
4136
  async loop() {
4041
4137
  const { limits } = this.input;
4042
4138
  for (let step = 0; step < limits.maxActions; step += 1) {
4043
4139
  this.checkCancelled();
4044
- if (this.elapsedMs() > limits.maxElapsedMs) return "partial";
4045
- if (this.deps.gateway.totalUsage().outputTokens > limits.maxTokens) return "partial";
4140
+ if (this.elapsedMs() > limits.maxElapsedMs) return { status: "partial", endReason: "cap_elapsed" };
4141
+ if (this.deps.gateway.totalUsage().outputTokens > limits.maxTokens) {
4142
+ return { status: "partial", endReason: "cap_tokens" };
4143
+ }
4046
4144
  const capture = this.lastCapture;
4047
4145
  const momentId = newId("moment");
4048
4146
  const decision = await this.decide(capture, momentId);
@@ -4058,12 +4156,12 @@ var ParticipantSession = class {
4058
4156
  confidence: decision.confidence,
4059
4157
  capture
4060
4158
  });
4061
- return decision.action.outcome === "abandoned" ? "abandoned" : "partial";
4159
+ return decision.action.outcome === "abandoned" ? { status: "abandoned", endReason: "abandoned" } : { status: "partial", endReason: "finished" };
4062
4160
  }
4063
4161
  const outcome = await this.act(decision, capture, momentId);
4064
- if (outcome === "stop") return "abandoned";
4162
+ if (outcome === "stop") return { status: "abandoned", endReason: "abandoned" };
4065
4163
  }
4066
- return "partial";
4164
+ return { status: "partial", endReason: "cap_actions" };
4067
4165
  }
4068
4166
  async decide(capture, momentId) {
4069
4167
  const result = await this.deps.gateway.generate({
@@ -5022,6 +5120,11 @@ var RunEventLog = class {
5022
5120
 
5023
5121
  // ../engine/src/orchestrator/coordinator.ts
5024
5122
  init_src();
5123
+ var DEFAULT_SESSION_LIMITS = {
5124
+ maxActions: 40,
5125
+ maxElapsedMs: 15 * 6e4,
5126
+ maxTokens: 4e5
5127
+ };
5025
5128
  var RunCoordinator = class {
5026
5129
  constructor(input, deps) {
5027
5130
  this.input = input;
@@ -5160,6 +5263,7 @@ var RunCoordinator = class {
5160
5263
  */
5161
5264
  async runSessions(budgets) {
5162
5265
  const { snapshot } = this.input;
5266
+ const limits = snapshot.sessionLimits ?? DEFAULT_SESSION_LIMITS;
5163
5267
  const limit = Math.max(1, this.deps.maxConcurrentSessions ?? snapshot.cast.length);
5164
5268
  const results = new Array(snapshot.cast.length);
5165
5269
  let cursor = 0;
@@ -5181,7 +5285,7 @@ var RunCoordinator = class {
5181
5285
  persona,
5182
5286
  snapshot,
5183
5287
  patienceBudget: budgets[index],
5184
- limits: { maxActions: 40, maxElapsedMs: 15 * 6e4, maxTokens: 4e5 }
5288
+ limits
5185
5289
  },
5186
5290
  {
5187
5291
  gateway: this.deps.gateway,
@@ -5223,12 +5327,17 @@ var RunCoordinator = class {
5223
5327
  selfReportedReason: result.selfReportedReason,
5224
5328
  patienceBudget: budgets[index],
5225
5329
  patienceState: result.patienceState,
5226
- limits: { maxActions: 40, maxElapsedMs: 15 * 6e4, maxTokens: 4e5 },
5330
+ limits,
5227
5331
  startedAt: result.startedAt,
5228
5332
  endedAt: result.endedAt,
5229
5333
  error: result.error ? { kind: "internal", message: result.error.message, retryable: result.error.retryable, occurredAt: result.endedAt } : null,
5230
5334
  contamination: null,
5231
5335
  momentCount: result.moments.length,
5336
+ // Carried straight from the loop rather than derived from `status`, which cannot say: a
5337
+ // clean finish and an exhausted action budget are both `partial`. MONETIZATION "Refunds"
5338
+ // bills the first and refunds the second whole, so the distinction has to survive the trip
5339
+ // into the persisted record.
5340
+ endReason: result.endReason,
5232
5341
  workerId: worker.id
5233
5342
  };
5234
5343
  this.sessionsByPersonaIndex.set(index, session);
@@ -6246,6 +6355,9 @@ function failureFrom(outcome) {
6246
6355
  }
6247
6356
  function describeOutcome(result) {
6248
6357
  if (result.status === "error") return "the browser stopped unexpectedly";
6358
+ if (result.endReason === "cap_actions") return "ran out of actions before finishing";
6359
+ if (result.endReason === "cap_elapsed") return "ran out of time before finishing";
6360
+ if (result.endReason === "cap_tokens") return "ran out of its token budget before finishing";
6249
6361
  if (result.selfReportedOutcome === "success") return "reached the goal";
6250
6362
  if (result.selfReportedOutcome === "abandoned") return result.selfReportedReason ?? "gave up";
6251
6363
  return result.selfReportedOutcome ?? "finished";
@@ -6639,6 +6751,10 @@ var StudyDesignProposalResponse = z23.object({
6639
6751
  // ../app/src/services/billing.ts
6640
6752
  init_src();
6641
6753
 
6754
+ // ../app/src/services/billing-outbox.ts
6755
+ init_src();
6756
+ var DAY_MS = 24 * 60 * 60 * 1e3;
6757
+
6642
6758
  // ../app/src/application.ts
6643
6759
  var PreflightFailedError = class extends Error {
6644
6760
  constructor(preflight2) {
@@ -6651,6 +6767,9 @@ var PreflightFailedError = class extends Error {
6651
6767
  preflight;
6652
6768
  };
6653
6769
 
6770
+ // ../app/src/services/billing-webhooks.ts
6771
+ init_src();
6772
+
6654
6773
  // ../app/src/services/export.ts
6655
6774
  var DEFAULT_MAX_EMBEDDED_BYTES = 24 * 1024 * 1024;
6656
6775
 
@@ -6875,6 +6994,8 @@ var MCP_TOOLS = [
6875
6994
  "propose_personas",
6876
6995
  "approve_personas",
6877
6996
  "run_preflight",
6997
+ "quote_study",
6998
+ "approve_quote",
6878
6999
  "start_study",
6879
7000
  "get_run_status",
6880
7001
  "cancel_run",
@@ -6890,6 +7011,12 @@ var MUTATING_TOOLS = {
6890
7011
  propose_personas: { mutates: false, capability: "study.write", idempotent: true },
6891
7012
  approve_personas: { mutates: true, capability: "study.write", idempotent: true },
6892
7013
  run_preflight: { mutates: false, capability: "study.write", idempotent: true },
7014
+ // BILLING_PLAN 3. Quoting is a builder operation and changes no money, so it is authorised the
7015
+ // same way `run_preflight` is. Approving the ceiling is what commits a run to spend, so it is
7016
+ // authorised as `run.start`: an operator can start runs and cannot manage billing, and requiring
7017
+ // `billing.manage` here would mean no operator could ever start a study.
7018
+ quote_study: { mutates: true, capability: "study.write", idempotent: false },
7019
+ approve_quote: { mutates: true, capability: "run.start", idempotent: true },
6893
7020
  start_study: { mutates: true, capability: "run.start", idempotent: false },
6894
7021
  get_run_status: { mutates: false, capability: "report.read", idempotent: true },
6895
7022
  cancel_run: { mutates: true, capability: "run.cancel", idempotent: true },
@@ -6937,8 +7064,25 @@ var StartStudyInput = z25.object({
6937
7064
  productId: z25.string(),
6938
7065
  /** SPEC FR-116: supplied by the client so a retry finds the run rather than making a second. */
6939
7066
  idempotencyKey: z25.string().min(1).max(120),
6940
- maxCostCents: z25.number().positive().nullable().default(null)
7067
+ maxCostCents: z25.number().positive().nullable().default(null),
7068
+ /**
7069
+ * BILLING_PLAN 3: the run `quote_study` priced and `approve_quote` authorised.
7070
+ *
7071
+ * Optional in the schema and required in effect: a start with no run id gets a fresh one, which
7072
+ * by construction carries no approved quote, and `@aloud/app` refuses it. Typed as optional so
7073
+ * the refusal comes from the one authorization path rather than from an argument parser here,
7074
+ * which is SPEC FR-110: two enforcement points is one too many.
7075
+ */
7076
+ runId: z25.string().optional()
6941
7077
  });
7078
+ var QuoteStudyInput = z25.object({
7079
+ studyId: z25.string(),
7080
+ /** Re-quotes an existing prospective run instead of pricing a new one. */
7081
+ runId: z25.string().optional(),
7082
+ /** Overrides the archetype prediction of moments per participant. */
7083
+ momentsEach: z25.number().int().min(1).max(400).optional()
7084
+ });
7085
+ var ApproveQuoteInput = z25.object({ runId: z25.string() });
6942
7086
  var RunIdInput = z25.object({ runId: z25.string() });
6943
7087
  var FindingInput = z25.object({ runId: z25.string(), findingId: z25.string() });
6944
7088
  var ShareLinkInput = z25.object({
@@ -7025,7 +7169,7 @@ async function handleTool(context, name, input, execute) {
7025
7169
  castComposition: draft.cast.compositionFloor,
7026
7170
  preflight: summarisePreflight(draft.preflight),
7027
7171
  estimateCents: app.estimateFor(draft.study),
7028
- note: "Nothing has run yet. Approve the cast, then start the study."
7172
+ note: "Nothing has run yet. Approve the cast, quote the study, get the ceiling approved, then start the run the quote named."
7029
7173
  });
7030
7174
  }
7031
7175
  case "get_product_profile": {
@@ -7077,6 +7221,50 @@ async function handleTool(context, name, input, execute) {
7077
7221
  const preflight2 = await app.preflight(actor, { workspaceId, studyId: parsed.studyId });
7078
7222
  return json(summarisePreflight(preflight2));
7079
7223
  }
7224
+ /**
7225
+ * BILLING_PLAN 3: "The MCP path enforces the same gate as the web path, through the one
7226
+ * authorization path in `@aloud/app`."
7227
+ *
7228
+ * That sentence is why this is three calls rather than one convenient one. An agent that could
7229
+ * start a study without a recorded agreement would be a way around the gate, and the whole
7230
+ * point of the gate is that a variable meter needs an agreement somebody actually made. So the
7231
+ * agent quotes, shows the human the range, gets the ceiling approved, and starts the run the
7232
+ * quote named.
7233
+ */
7234
+ case "quote_study": {
7235
+ const parsed = QuoteStudyInput.parse(input);
7236
+ const quoted = await app.quoteStudy(actor, { workspaceId, ...parsed });
7237
+ return json({
7238
+ quoteId: quoted.quote.id,
7239
+ // Carried into approve_quote and start_study. The agreement names the run, so the run has
7240
+ // to be named before it exists.
7241
+ runId: quoted.quote.runId,
7242
+ resource: RESOURCE_SCHEMES.run(quoted.quote.runId),
7243
+ rateCardVersion: quoted.quote.rateCardVersion,
7244
+ estimateLowCredits: quoted.quote.estimateLowCredits,
7245
+ estimateHighCredits: quoted.quote.estimateHighCredits,
7246
+ approvedCapCredits: quoted.quote.approvedCapCredits,
7247
+ // MONETIZATION.md, "Quoting a study": the range is what a customer is promised and the
7248
+ // basis is how much that promise is worth, so the two travel together.
7249
+ basis: quoted.basis,
7250
+ observations: quoted.observations,
7251
+ approved: false,
7252
+ note: "Nothing is committed. Moment count is not knowable in advance, so this is a range and a ceiling rather than a price. Show it to a person, then call approve_quote with this runId, then start_study with the same runId."
7253
+ });
7254
+ }
7255
+ case "approve_quote": {
7256
+ const parsed = ApproveQuoteInput.parse(input);
7257
+ const quote = await app.approveQuote(actor, { workspaceId, runId: parsed.runId });
7258
+ return json({
7259
+ quoteId: quote.id,
7260
+ runId: quote.runId,
7261
+ approvedCapCredits: quote.approvedCapCredits,
7262
+ approvedBy: quote.approvedBy,
7263
+ approvedAt: quote.approvedAt,
7264
+ rateCardVersion: quote.rateCardVersion,
7265
+ note: "This run may never spend more than the approved ceiling. It becomes the per-session action limit, so the cost control and the honest-abandonment guarantee are one mechanism."
7266
+ });
7267
+ }
7080
7268
  case "start_study": {
7081
7269
  const parsed = StartStudyInput.parse(input);
7082
7270
  let outcome;
@@ -7085,7 +7273,10 @@ async function handleTool(context, name, input, execute) {
7085
7273
  workspaceId,
7086
7274
  studyId: parsed.studyId,
7087
7275
  idempotencyKey: parsed.idempotencyKey,
7088
- maxCostCents: parsed.maxCostCents
7276
+ maxCostCents: parsed.maxCostCents,
7277
+ // BILLING_PLAN 3: the run the approved quote priced. Omitted, the gate in `@aloud/app`
7278
+ // refuses, which is the same refusal the web path gets.
7279
+ ...parsed.runId ? { runId: parsed.runId } : {}
7089
7280
  });
7090
7281
  } catch (error) {
7091
7282
  if (error instanceof PreflightFailedError) {
@@ -7238,7 +7429,9 @@ var TOOL_DESCRIPTIONS = {
7238
7429
  propose_personas: "Propose a cast of behaviourally distinct participants for an existing study, derived only from what a visitor to the site could see.",
7239
7430
  approve_personas: "Approve the cast a study will run with. Required before a study can start.",
7240
7431
  run_preflight: "Check a study is safe and ready to run. Names the exact failing check rather than a generic failure.",
7241
- start_study: "Start a study. Returns a run id immediately; the study runs in the background. Requires an idempotency key so a retry finds the existing run instead of starting a second.",
7432
+ quote_study: "Price a study before it runs. Moment count is not knowable in advance, so this returns an estimate range and a ceiling rather than a price, plus the runId the quote is recorded against. Show the range to a person before approving it.",
7433
+ approve_quote: "Record that the operator agreed to a study's ceiling. Required before the run can start, and it becomes the per-session action limit so the run can never exceed what was approved.",
7434
+ start_study: "Start a study. Returns a run id immediately; the study runs in the background. Requires an idempotency key so a retry finds the existing run instead of starting a second, and the runId from an approved quote.",
7242
7435
  get_run_status: "Read a run's current status, its participants, and its report if one exists yet.",
7243
7436
  cancel_run: "Stop a run. Browsers are closed and no further model spend happens.",
7244
7437
  list_findings: "List the findings from a completed run, with the participant count behind each one and the study's stated limitations.",
@@ -7299,11 +7492,18 @@ var TOOL_SHAPES = {
7299
7492
  },
7300
7493
  approve_personas: { studyId: z26.string(), cast: z26.array(z26.record(z26.string(), z26.unknown())) },
7301
7494
  run_preflight: { studyId: z26.string() },
7495
+ quote_study: {
7496
+ studyId: z26.string(),
7497
+ runId: z26.string().describe("Re-quote a prospective run instead of pricing a new one.").optional(),
7498
+ momentsEach: z26.number().int().min(1).max(400).describe("Override the predicted moments per participant.").optional()
7499
+ },
7500
+ approve_quote: { runId: z26.string().describe("The runId quote_study returned.") },
7302
7501
  start_study: {
7303
7502
  studyId: z26.string(),
7304
7503
  productId: z26.string(),
7305
7504
  idempotencyKey: z26.string().describe("Reuse this on a retry so it finds the run rather than starting a second."),
7306
- maxCostCents: z26.number().positive().nullable().optional()
7505
+ maxCostCents: z26.number().positive().nullable().optional(),
7506
+ runId: z26.string().describe("The runId from the approved quote. A run cannot start without one.").optional()
7307
7507
  },
7308
7508
  get_run_status: { runId: z26.string() },
7309
7509
  cancel_run: { runId: z26.string(), reason: z26.string().optional() },
@@ -7328,6 +7528,11 @@ function createMcpServer(options) {
7328
7528
  "not reports from real customers, and every response says how many participants hit a thing",
7329
7529
  "rather than what share of users would.",
7330
7530
  "",
7531
+ "A study costs money to run, so it cannot start until somebody has agreed to a ceiling.",
7532
+ "Call quote_study, show a person the range and the cap it returns, call approve_quote, then",
7533
+ "start_study with the same runId. The estimate is a range because how far a participant gets",
7534
+ "is the thing being measured, so there is no single price to quote.",
7535
+ "",
7331
7536
  "start_study returns immediately. Poll get_run_status; nothing is lost if this connection drops.",
7332
7537
  "",
7333
7538
  "When you are running beside a product repository, call upsert_product_context with derived",
@@ -7658,6 +7863,25 @@ async function login(argv) {
7658
7863
  const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
7659
7864
  let token = stringOption(argv, "--token");
7660
7865
  if (!token) {
7866
+ if (!process.stdin.isTTY) {
7867
+ process.stderr.write(
7868
+ [
7869
+ "",
7870
+ "There is no terminal attached here, so there is nowhere to paste a token.",
7871
+ "",
7872
+ "If you are an agent: stop and hand this back. The person runs `aloud login` in their",
7873
+ "own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
7874
+ "",
7875
+ "To connect without a terminal, set both of these instead and skip login entirely:",
7876
+ ` export ALOUD_SERVER=${server}`,
7877
+ " export ALOUD_RUNNER_TOKEN=utar_...",
7878
+ "",
7879
+ `A token comes from ${server}/app/settings/runners and is shown once.`,
7880
+ ""
7881
+ ].join("\n")
7882
+ );
7883
+ return 1;
7884
+ }
7661
7885
  process.stdout.write(`
7662
7886
  Open ${server}/app/settings/runners and create a runner.
7663
7887
  `);
@@ -7718,27 +7942,46 @@ async function setup() {
7718
7942
  const installed = onPath("aloud");
7719
7943
  const latest = await latestVersion();
7720
7944
  const stale = latest !== null && latest !== RUNNER_VERSION;
7945
+ const signedIn = await signedInState(credentials);
7721
7946
  const out = (line = "") => process.stdout.write(line + "\n");
7722
7947
  out();
7723
7948
  out("Aloud runner setup. You are looking at the state of this machine.");
7724
7949
  out();
7725
7950
  out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
7726
7951
  out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
7727
- out(` signed in ${credentials ? credentials.runnerName : "no"}`);
7952
+ out(
7953
+ ` signed in ${signedIn.state === "ok" ? signedIn.name : signedIn.state === "revoked" ? `no. The saved token for ${credentials?.runnerName ?? "this machine"} was revoked` : signedIn.state === "unreachable" ? `cannot tell, ${signedIn.server} did not answer` : "no"}`
7954
+ );
7728
7955
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
7729
7956
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
7730
7957
  out();
7731
7958
  const steps = [];
7732
7959
  if (!installed) {
7733
- steps.push("npm install -g @aloud/runner");
7960
+ steps.push(["npm install -g @aloud/runner"]);
7734
7961
  } else if (stale) {
7735
- steps.push(`npm install -g @aloud/runner@latest # ${RUNNER_VERSION} is installed, ${latest} is out`);
7962
+ steps.push([`npm install -g @aloud/runner@latest`, `${RUNNER_VERSION} is installed, ${latest} is out.`]);
7736
7963
  }
7737
- if (!credentials) {
7738
- steps.push("aloud login # prompts for a token. The person, not you, pastes it in here.");
7964
+ if (signedIn.state === "revoked") {
7965
+ steps.push([
7966
+ `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
7967
+ "The saved one was revoked and cannot be reused."
7968
+ ]);
7969
+ }
7970
+ if (signedIn.state === "none" || signedIn.state === "revoked") {
7971
+ steps.push([
7972
+ "aloud login",
7973
+ "Needs a terminal. If you are an agent, hand this step to the person: they run it",
7974
+ "themselves and paste the token at the prompt. Do not ask them to paste it to you."
7975
+ ]);
7739
7976
  }
7740
7977
  if (!running) {
7741
- steps.push("aloud start # in a background shell. It never exits.");
7978
+ steps.push(["aloud start", "In a background shell. It never exits, so do not block on it."]);
7979
+ }
7980
+ if (steps.length === 0 && signedIn.state === "unreachable") {
7981
+ out(`Cannot reach ${signedIn.server}, so there is nothing useful to say about what is left.`);
7982
+ out("Check the connection and run this again.");
7983
+ out();
7984
+ return 1;
7742
7985
  }
7743
7986
  if (steps.length === 0) {
7744
7987
  out("Nothing to do. This machine is set up and waiting for studies.");
@@ -7747,8 +7990,12 @@ async function setup() {
7747
7990
  }
7748
7991
  out("Do these, in order:");
7749
7992
  out();
7750
- for (const [index, step] of steps.entries()) out(` ${index + 1}. ${step}`);
7751
- out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
7993
+ for (const [index, [command, ...notes]] of steps.entries()) {
7994
+ out(` ${index + 1}. ${command}`);
7995
+ for (const note of notes) out(` ${note}`);
7996
+ }
7997
+ out(` ${steps.length + 1}. aloud status`);
7998
+ out(" Exits non-zero until all of the above are true.");
7752
7999
  out();
7753
8000
  out("Rules, if you are an agent doing this:");
7754
8001
  out();
@@ -7772,6 +8019,20 @@ async function setup() {
7772
8019
  }
7773
8020
  return 1;
7774
8021
  }
8022
+ async function signedInState(credentials) {
8023
+ if (!credentials) return { state: "none" };
8024
+ try {
8025
+ const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
8026
+ headers: { authorization: `Bearer ${credentials.token}` },
8027
+ signal: AbortSignal.timeout(5e3)
8028
+ });
8029
+ if (response.status === 401 || response.status === 403) return { state: "revoked" };
8030
+ if (!response.ok) return { state: "unreachable", server: credentials.server };
8031
+ return { state: "ok", name: credentials.runnerName };
8032
+ } catch {
8033
+ return { state: "unreachable", server: credentials.server };
8034
+ }
8035
+ }
7775
8036
  async function npmPrefix() {
7776
8037
  const path = await new Promise((resolve) => {
7777
8038
  const child = spawn2("npm", ["config", "get", "prefix"], { stdio: ["ignore", "pipe", "ignore"] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
5
5
  "license": "ISC",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -106,6 +106,30 @@ async function login(argv: readonly string[]): Promise<number> {
106
106
  let token = stringOption(argv, "--token");
107
107
 
108
108
  if (!token) {
109
+ // An agent runs commands in a shell with nothing attached to stdin, so the prompt below would
110
+ // read end-of-file and the token would come back empty. That used to surface as "that does not
111
+ // look like a runner token", which blames the wrong thing and tells nobody what to do. This is
112
+ // the single step of setup that a person has to perform, so it is worth saying so precisely.
113
+ if (!process.stdin.isTTY) {
114
+ process.stderr.write(
115
+ [
116
+ "",
117
+ "There is no terminal attached here, so there is nowhere to paste a token.",
118
+ "",
119
+ "If you are an agent: stop and hand this back. The person runs `aloud login` in their",
120
+ "own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
121
+ "",
122
+ "To connect without a terminal, set both of these instead and skip login entirely:",
123
+ ` export ALOUD_SERVER=${server}`,
124
+ " export ALOUD_RUNNER_TOKEN=utar_...",
125
+ "",
126
+ `A token comes from ${server}/app/settings/runners and is shown once.`,
127
+ "",
128
+ ].join("\n"),
129
+ );
130
+ return 1;
131
+ }
132
+
109
133
  process.stdout.write(`\nOpen ${server}/app/settings/runners and create a runner.\n`);
110
134
  process.stdout.write("It shows you a token once. Paste it here.\n\n");
111
135
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -186,6 +210,7 @@ async function setup(): Promise<number> {
186
210
  const installed = onPath("aloud");
187
211
  const latest = await latestVersion();
188
212
  const stale = latest !== null && latest !== RUNNER_VERSION;
213
+ const signedIn = await signedInState(credentials);
189
214
 
190
215
  const out = (line = "") => process.stdout.write(line + "\n");
191
216
 
@@ -194,22 +219,52 @@ async function setup(): Promise<number> {
194
219
  out();
195
220
  out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
196
221
  out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
197
- out(` signed in ${credentials ? credentials.runnerName : "no"}`);
222
+ out(
223
+ ` signed in ${
224
+ signedIn.state === "ok"
225
+ ? signedIn.name
226
+ : signedIn.state === "revoked"
227
+ ? `no. The saved token for ${credentials?.runnerName ?? "this machine"} was revoked`
228
+ : signedIn.state === "unreachable"
229
+ ? `cannot tell, ${signedIn.server} did not answer`
230
+ : "no"
231
+ }`,
232
+ );
198
233
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
199
234
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
200
235
  out();
201
236
 
202
- const steps: string[] = [];
237
+ // Each step is a command plus the lines that qualify it. Only the command gets a number, or a
238
+ // continuation reads as a step of its own, and an agent following "step 2" ends up pasting a URL
239
+ // into a shell.
240
+ const steps: string[][] = [];
203
241
  if (!installed) {
204
- steps.push("npm install -g @aloud/runner");
242
+ steps.push(["npm install -g @aloud/runner"]);
205
243
  } else if (stale) {
206
- steps.push(`npm install -g @aloud/runner@latest # ${RUNNER_VERSION} is installed, ${latest} is out`);
244
+ steps.push([`npm install -g @aloud/runner@latest`, `${RUNNER_VERSION} is installed, ${latest} is out.`]);
207
245
  }
208
- if (!credentials) {
209
- steps.push("aloud login # prompts for a token. The person, not you, pastes it in here.");
246
+ if (signedIn.state === "revoked") {
247
+ steps.push([
248
+ `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
249
+ "The saved one was revoked and cannot be reused.",
250
+ ]);
251
+ }
252
+ if (signedIn.state === "none" || signedIn.state === "revoked") {
253
+ steps.push([
254
+ "aloud login",
255
+ "Needs a terminal. If you are an agent, hand this step to the person: they run it",
256
+ "themselves and paste the token at the prompt. Do not ask them to paste it to you.",
257
+ ]);
210
258
  }
211
259
  if (!running) {
212
- steps.push("aloud start # in a background shell. It never exits.");
260
+ steps.push(["aloud start", "In a background shell. It never exits, so do not block on it."]);
261
+ }
262
+
263
+ if (steps.length === 0 && signedIn.state === "unreachable") {
264
+ out(`Cannot reach ${signedIn.server}, so there is nothing useful to say about what is left.`);
265
+ out("Check the connection and run this again.");
266
+ out();
267
+ return 1;
213
268
  }
214
269
 
215
270
  if (steps.length === 0) {
@@ -220,8 +275,12 @@ async function setup(): Promise<number> {
220
275
 
221
276
  out("Do these, in order:");
222
277
  out();
223
- for (const [index, step] of steps.entries()) out(` ${index + 1}. ${step}`);
224
- out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
278
+ for (const [index, [command, ...notes]] of steps.entries()) {
279
+ out(` ${index + 1}. ${command}`);
280
+ for (const note of notes) out(` ${note}`);
281
+ }
282
+ out(` ${steps.length + 1}. aloud status`);
283
+ out(" Exits non-zero until all of the above are true.");
225
284
  out();
226
285
  out("Rules, if you are an agent doing this:");
227
286
  out();
@@ -248,6 +307,32 @@ async function setup(): Promise<number> {
248
307
  return 1;
249
308
  }
250
309
 
310
+
311
+ /**
312
+ * Whether the saved credential still works, asked of the server rather than assumed from the file.
313
+ *
314
+ * A credentials file proves a token was written here once, not that it is still good. Revoking a
315
+ * machine from the web app leaves the file exactly as it was, so reporting "signed in" from its
316
+ * presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
317
+ * explanation of what to do. This is the same endpoint `login` checks a token against.
318
+ */
319
+ async function signedInState(
320
+ credentials: Credentials | null,
321
+ ): Promise<{ state: "none" } | { state: "ok"; name: string } | { state: "revoked" } | { state: "unreachable"; server: string }> {
322
+ if (!credentials) return { state: "none" };
323
+ try {
324
+ const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
325
+ headers: { authorization: `Bearer ${credentials.token}` },
326
+ signal: AbortSignal.timeout(5_000),
327
+ });
328
+ if (response.status === 401 || response.status === 403) return { state: "revoked" };
329
+ if (!response.ok) return { state: "unreachable", server: credentials.server };
330
+ return { state: "ok", name: credentials.runnerName };
331
+ } catch {
332
+ return { state: "unreachable", server: credentials.server };
333
+ }
334
+ }
335
+
251
336
  /**
252
337
  * Where npm would put a global install, and whether it can be written to.
253
338
  *
@@ -336,8 +336,24 @@ function failureFrom(outcome: RunOutcome): string | null {
336
336
  return null;
337
337
  }
338
338
 
339
- function describeOutcome(result: { status: string; selfReportedOutcome?: string | null; selfReportedReason?: string | null }): string {
339
+ /**
340
+ * The one line the person watching their own machine sees when a participant stops.
341
+ *
342
+ * The cap cases are named rather than folded into "finished", which is what they used to read as.
343
+ * A session the runtime cut off produced truncated evidence, and MONETIZATION treats that as a
344
+ * platform failure rather than a delivery, so the operator watching it happen should be told that
345
+ * plainly instead of being shown a word that means the opposite.
346
+ */
347
+ function describeOutcome(result: {
348
+ status: string;
349
+ endReason?: string | null;
350
+ selfReportedOutcome?: string | null;
351
+ selfReportedReason?: string | null;
352
+ }): string {
340
353
  if (result.status === "error") return "the browser stopped unexpectedly";
354
+ if (result.endReason === "cap_actions") return "ran out of actions before finishing";
355
+ if (result.endReason === "cap_elapsed") return "ran out of time before finishing";
356
+ if (result.endReason === "cap_tokens") return "ran out of its token budget before finishing";
341
357
  if (result.selfReportedOutcome === "success") return "reached the goal";
342
358
  if (result.selfReportedOutcome === "abandoned") return result.selfReportedReason ?? "gave up";
343
359
  return result.selfReportedOutcome ?? "finished";
package/src/version.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * package.json beside it to read, and importing one into the source trips the composite build's
11
11
  * rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
12
12
  */
13
- export const RUNNER_VERSION = "0.2.2";
13
+ export const RUNNER_VERSION = "0.2.4";
14
14
 
15
15
  /** The header the server reads it from. */
16
16
  export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";