create-qpq-app 0.1.24 → 0.1.26

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 (70) hide show
  1. package/package.json +2 -2
  2. package/template/README.md +4 -0
  3. package/template/apps/qpqjs/account.qpq.ts +4 -0
  4. package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
  5. package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
  6. package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
  7. package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
  8. package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
  9. package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
  10. package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
  11. package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
  12. package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
  13. package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
  14. package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
  15. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
  16. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
  17. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
  18. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
  19. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
  20. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
  21. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
  22. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
  23. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
  24. package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
  25. package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
  26. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
  27. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
  28. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
  29. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
  30. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
  31. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
  32. package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
  33. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
  34. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
  35. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
  36. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
  37. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
  38. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
  39. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
  40. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
  41. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
  42. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt-stream.md +2 -2
  43. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -0
  44. package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
  45. package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
  46. package/template/docusaurus/docs/actions/core/system/ask-get-runtime-remaining-time.md +52 -0
  47. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
  48. package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
  49. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
  50. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
  51. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
  52. package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
  53. package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
  54. package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
  55. package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
  56. package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
  57. package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
  58. package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
  59. package/template/docusaurus/docs/config/core/storage-drive.md +6 -4
  60. package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
  61. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
  62. package/template/docusaurus/docs/config/features/event-doc.md +2 -1
  63. package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
  64. package/template/docusaurus/docs/config/features/tenant.md +36 -12
  65. package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
  66. package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
  67. package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
  68. package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
  69. package/template/scripts/smoke/runSmokeRun.mjs +1 -1
  70. package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
@@ -0,0 +1,41 @@
1
+ import { askDnsResolveHosts, askEmailSendEmail, AskResponse } from 'quidproquo';
2
+
3
+ import { smokeEmailMarkerId } from '../../email/smokeEmailMarkerId';
4
+ import { askSmokeAssert } from '../askSmokeAssert';
5
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
6
+
7
+ // The inbound email path end to end: send to this app's own receiving domain
8
+ // with its own sender, SES receives it through the account rule set and the
9
+ // app's rule, the receiver parses it and hands it to onSmokeEmailReceived,
10
+ // which writes the marker. Deployed only: nothing receives mail locally.
11
+ export function* askRunEmailReceiveTest(runId: string): AskResponse<void> {
12
+ const [receivingHost] = yield* askDnsResolveHosts({ subdomain: 'inbox' });
13
+ const [siteHost] = yield* askDnsResolveHosts();
14
+
15
+ const recipient = `${runId}@${receivingHost}`;
16
+ const subject = `smoke ${runId}`;
17
+
18
+ yield* askEmailSendEmail({
19
+ from: `smoke@${siteHost}`,
20
+ to: [recipient],
21
+ subject,
22
+ bodyText: `smoke probe ${runId}`,
23
+ });
24
+
25
+ // Mail delivery is the slowest path in the suite: SES receives, scans and
26
+ // writes to S3 before the drive event fires.
27
+ const marker = yield* askSmokePollForMarker(
28
+ smokeEmailMarkerId(recipient),
29
+ 'the inbound email path',
30
+ 45
31
+ );
32
+
33
+ yield* askSmokeAssert(
34
+ marker.path === subject,
35
+ `received subject [${marker.path}] did not match [${subject}]`
36
+ );
37
+ yield* askSmokeAssert(
38
+ marker.authentication === 'pass/pass',
39
+ `received message was not authenticated: spf/dkim [${marker.authentication}]`
40
+ );
41
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunEmailReceiveTest';
@@ -0,0 +1,63 @@
1
+ import {
2
+ askFileDelete,
3
+ askFileReadTextContents,
4
+ askFileWriteTextContents,
5
+ askKeyValueStoreDelete,
6
+ askKeyValueStoreGet,
7
+ askKeyValueStoreUpsert,
8
+ askNewGuid,
9
+ AskResponse,
10
+ } from 'quidproquo';
11
+
12
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
13
+
14
+ import {
15
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
16
+ SMOKE_ENCRYPTED_PROBE_STORE,
17
+ } from '../../constants/smokeProbe';
18
+ import { askSmokeAssert } from '../askSmokeAssert';
19
+
20
+ // A drive and a store encrypted with a defineCryptoKey. Deployed, every write
21
+ // needs kms:GenerateDataKey and every read kms:Decrypt on that key through
22
+ // the alias-conditioned grant, so a plain round trip on each is the proof.
23
+ // Locally there is no encryption at rest and this is a plain round trip.
24
+ export function* askRunEncryptedResourcesTest(): AskResponse<void> {
25
+ const probeId = yield* askNewGuid();
26
+
27
+ const record: SmokeProbeRecord = { probeId, category: 'encrypted', value: 1 };
28
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
29
+ SMOKE_ENCRYPTED_PROBE_STORE,
30
+ record
31
+ );
32
+
33
+ const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
34
+ SMOKE_ENCRYPTED_PROBE_STORE,
35
+ probeId
36
+ );
37
+ yield* askSmokeAssert(
38
+ written?.value === 1,
39
+ 'get after upsert on the encrypted store did not return the record'
40
+ );
41
+
42
+ yield* askKeyValueStoreDelete(SMOKE_ENCRYPTED_PROBE_STORE, probeId);
43
+
44
+ const filepath = `encrypted/${probeId}.txt`;
45
+ const contents = `encrypted smoke probe ${probeId}`;
46
+
47
+ yield* askFileWriteTextContents(
48
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
49
+ filepath,
50
+ contents
51
+ );
52
+
53
+ const readBack = yield* askFileReadTextContents(
54
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
55
+ filepath
56
+ );
57
+ yield* askSmokeAssert(
58
+ readBack === contents,
59
+ 'file on the encrypted drive did not read back as written'
60
+ );
61
+
62
+ yield* askFileDelete(SMOKE_ENCRYPTED_PROBE_DRIVE, [filepath]);
63
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunEncryptedResourcesTest';
@@ -1,26 +1,16 @@
1
1
  import {
2
- askDelay,
3
2
  askEventBusSendMessages,
4
- askKeyValueStoreDelete,
5
- askKeyValueStoreGet,
6
3
  askNewGuid,
7
4
  AskResponse,
8
5
  EventBusMessage,
9
- Nullable,
10
6
  } from 'quidproquo';
11
7
 
12
- import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
13
- import { SmokeProbeRecord } from '@qpqjs/test-models';
14
-
15
8
  import {
16
9
  SMOKE_PROBE_EVENT_BUS,
17
10
  SMOKE_PROBE_EVENT_TYPE,
18
11
  } from '../../constants/smokeProbe';
19
12
  import { SmokeProbeEventPayload } from '../../models/SmokeProbeEventQueueEvent';
20
- import { askSmokeAssert } from '../askSmokeAssert';
21
-
22
- const POLL_ATTEMPTS = 15;
23
- const POLL_INTERVAL_MS = 2000;
13
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
24
14
 
25
15
  // The full async fan-out / fan-in path: publish to the owned event bus, the
26
16
  // subscribed queue's entry (onSmokeProbeEvent) writes a marker row, and this
@@ -39,19 +29,5 @@ export function* askRunEventBusTest(): AskResponse<void> {
39
29
  eventBusMessages: [message],
40
30
  });
41
31
 
42
- let marker: Nullable<SmokeProbeRecord> = null;
43
- for (let attempt = 0; attempt < POLL_ATTEMPTS && !marker; attempt += 1) {
44
- yield* askDelay(POLL_INTERVAL_MS);
45
- marker = yield* askKeyValueStoreGet<SmokeProbeRecord>(
46
- SMOKE_PROBE_STORE,
47
- markerId
48
- );
49
- }
50
-
51
- yield* askSmokeAssert(
52
- !!marker,
53
- `marker [${markerId}] never arrived via the event bus and queue`
54
- );
55
-
56
- yield* askKeyValueStoreDelete(SMOKE_PROBE_STORE, markerId);
32
+ yield* askSmokePollForMarker(markerId, 'the event bus and queue');
57
33
  }
@@ -3,7 +3,8 @@ import { askEventDocEventListAll, EventDocEvent } from 'quidproquo-features';
3
3
 
4
4
  import { askSmokeAssert } from '../askSmokeAssert';
5
5
 
6
- const POLL_ATTEMPTS = 20;
6
+ // Long enough to cover a writer's retry laps plus one queue redelivery.
7
+ const POLL_ATTEMPTS = 45;
7
8
  const POLL_INTERVAL_MS = 2000;
8
9
 
9
10
  // Poll a probe doc's log until it holds `expectedLength` events (INIT_STATE included),
@@ -1,8 +1,12 @@
1
1
  export * from './askSmokeAssert';
2
+ export * from './askSmokePollForMarker';
2
3
  export * from './crossService';
4
+ export * from './emailReceive';
5
+ export * from './encryptedResources';
3
6
  export * from './eventBus';
4
7
  export * from './eventDoc';
5
8
  export * from './keyValueStore';
9
+ export * from './keyValueStoreStream';
6
10
  export * from './noop';
7
11
  export * from './openApi';
8
12
  export * from './parameter';
@@ -11,3 +15,4 @@ export * from './signingKey';
11
15
  export * from './SmokeTestDefinition';
12
16
  export * from './smokeTestRegistry';
13
17
  export * from './storageDrive';
18
+ export * from './storageDriveEvent';
@@ -0,0 +1,60 @@
1
+ import {
2
+ askKeyValueStoreDelete,
3
+ askKeyValueStoreUpsert,
4
+ askNewGuid,
5
+ AskResponse,
6
+ KvsStreamEventType,
7
+ } from 'quidproquo';
8
+
9
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
10
+
11
+ import { SMOKE_STREAM_PROBE_STORE } from '../../constants/smokeProbe';
12
+ import { smokeStreamMarkerId } from '../../kvsStream/smokeStreamMarkerId';
13
+ import { askSmokeAssert } from '../askSmokeAssert';
14
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
15
+
16
+ // The kvs stream path end to end on a scoped store: an upsert streams an
17
+ // Insert and a delete streams a Remove to the handler. On dynamo the scope is
18
+ // composed into the stored partition key, so the record must hand it back as
19
+ // its own field with the key stripped, the same as the dev server does.
20
+ export function* askRunKeyValueStoreStreamTest(): AskResponse<void> {
21
+ const probeId = yield* askNewGuid();
22
+ const scope = `smoke-st-${probeId}`;
23
+
24
+ const record: SmokeProbeRecord = {
25
+ probeId,
26
+ category: 'stream',
27
+ value: 1,
28
+ };
29
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
30
+ SMOKE_STREAM_PROBE_STORE,
31
+ record,
32
+ { scope }
33
+ );
34
+
35
+ const inserted = yield* askSmokePollForMarker(
36
+ smokeStreamMarkerId(probeId, KvsStreamEventType.Insert),
37
+ 'the kvs stream insert'
38
+ );
39
+ yield* askSmokeAssert(
40
+ inserted.scope === scope,
41
+ `stream insert carried scope [${inserted.scope}], expected [${scope}]`
42
+ );
43
+ yield* askSmokeAssert(
44
+ inserted.path === probeId,
45
+ `stream insert key [${inserted.path}] was not the raw key`
46
+ );
47
+
48
+ yield* askKeyValueStoreDelete(SMOKE_STREAM_PROBE_STORE, probeId, undefined, {
49
+ scope,
50
+ });
51
+
52
+ const removed = yield* askSmokePollForMarker(
53
+ smokeStreamMarkerId(probeId, KvsStreamEventType.Remove),
54
+ 'the kvs stream remove'
55
+ );
56
+ yield* askSmokeAssert(
57
+ removed.scope === scope && removed.path === probeId,
58
+ 'stream remove did not carry the scope and raw key'
59
+ );
60
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunKeyValueStoreStreamTest';
@@ -0,0 +1,102 @@
1
+ import {
2
+ askCatch,
3
+ askKeyValueStoreDelete,
4
+ askKeyValueStoreGet,
5
+ askKeyValueStoreGetBase,
6
+ askKeyValueStoreQuery,
7
+ askKeyValueStoreUpsert,
8
+ askKeyValueStoreUpsertBase,
9
+ askNewGuid,
10
+ AskResponse,
11
+ kvsEqual,
12
+ } from 'quidproquo';
13
+
14
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
15
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
16
+
17
+ import { SMOKE_SCOPED_PROBE_STORE } from '../../constants/smokeProbe';
18
+ import { askSmokeAssert } from '../askSmokeAssert';
19
+
20
+ // The scope gate on a store, on whichever backend is running: a scoped store
21
+ // serves scoped calls (including a query) and keeps two scopes apart,
22
+ // refuses an unscoped call, and an unscoped store refuses a scoped one. On
23
+ // dynamo the scope is composed into the partition key, so the read-back also
24
+ // proves the composed form never leaks to the caller.
25
+ export function* askRunScopedKeyValueStoreTest(): AskResponse<void> {
26
+ const probeId = yield* askNewGuid();
27
+ const scopeA = `smoke-a-${probeId}`;
28
+ const scopeB = `smoke-b-${probeId}`;
29
+ const category = `scoped-kvs-${probeId}`;
30
+
31
+ const record: SmokeProbeRecord = { probeId, category, value: 1 };
32
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
33
+ SMOKE_SCOPED_PROBE_STORE,
34
+ record,
35
+ { scope: scopeA }
36
+ );
37
+
38
+ const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
39
+ SMOKE_SCOPED_PROBE_STORE,
40
+ probeId,
41
+ { scope: scopeA }
42
+ );
43
+ yield* askSmokeAssert(
44
+ written?.probeId === probeId && written.value === 1,
45
+ 'scoped get did not return the record with its raw key'
46
+ );
47
+
48
+ // A scoped query must constrain the partition key (the scope is composed
49
+ // into it on dynamo), so the query is on probeId, not the category index.
50
+ const byKey = yield* askKeyValueStoreQuery<SmokeProbeRecord>(
51
+ SMOKE_SCOPED_PROBE_STORE,
52
+ kvsEqual('probeId', probeId),
53
+ { scope: scopeA }
54
+ );
55
+ yield* askSmokeAssert(
56
+ byKey.items.some((item) => item.category === category),
57
+ 'scoped query by partition key did not return the record'
58
+ );
59
+
60
+ const fromB = yield* askKeyValueStoreGet<SmokeProbeRecord>(
61
+ SMOKE_SCOPED_PROBE_STORE,
62
+ probeId,
63
+ { scope: scopeB }
64
+ );
65
+ yield* askSmokeAssert(
66
+ !fromB,
67
+ 'record written under scope A is visible from scope B'
68
+ );
69
+
70
+ const unscopedGet = yield* askCatch(
71
+ askKeyValueStoreGet<SmokeProbeRecord>(SMOKE_SCOPED_PROBE_STORE, probeId)
72
+ );
73
+ yield* askSmokeAssert(
74
+ !unscopedGet.success &&
75
+ unscopedGet.error.errorType ===
76
+ askKeyValueStoreGetBase.errorType.InvalidScope,
77
+ 'unscoped get on the scoped store was not refused with InvalidScope'
78
+ );
79
+
80
+ const scopedUpsertOnUnscopedStore = yield* askCatch(
81
+ askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, record, {
82
+ scope: scopeA,
83
+ })
84
+ );
85
+ yield* askSmokeAssert(
86
+ !scopedUpsertOnUnscopedStore.success &&
87
+ scopedUpsertOnUnscopedStore.error.errorType ===
88
+ askKeyValueStoreUpsertBase.errorType.InvalidScope,
89
+ 'scoped upsert on the unscoped store was not refused with InvalidScope'
90
+ );
91
+
92
+ yield* askKeyValueStoreDelete(SMOKE_SCOPED_PROBE_STORE, probeId, undefined, {
93
+ scope: scopeA,
94
+ });
95
+
96
+ const deleted = yield* askKeyValueStoreGet<SmokeProbeRecord>(
97
+ SMOKE_SCOPED_PROBE_STORE,
98
+ probeId,
99
+ { scope: scopeA }
100
+ );
101
+ yield* askSmokeAssert(!deleted, 'scoped record still present after delete');
102
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunScopedKeyValueStoreTest';
@@ -0,0 +1,116 @@
1
+ import {
2
+ askCatch,
3
+ askFileDelete,
4
+ askFileExists,
5
+ askFileListDirectory,
6
+ askFileReadTextContents,
7
+ askFileWriteTextContents,
8
+ askNewGuid,
9
+ AskResponse,
10
+ } from 'quidproquo';
11
+
12
+ import { SMOKE_PROBE_DRIVE } from '@qpqjs/constants';
13
+
14
+ import { SMOKE_SCOPED_PROBE_DRIVE } from '../../constants/smokeProbe';
15
+ import { askSmokeAssert } from '../askSmokeAssert';
16
+
17
+ // The scope gate on a drive, on whichever backend is running: a scoped drive
18
+ // serves scoped calls and keeps two scopes apart, refuses an unscoped call,
19
+ // and an unscoped drive refuses a scoped one. Each refusal must be the typed
20
+ // InvalidScope error, not a generic failure.
21
+ export function* askRunScopedStorageDriveTest(): AskResponse<void> {
22
+ const fileId = yield* askNewGuid();
23
+ const scopeA = `smoke-a-${fileId}`;
24
+ const scopeB = `smoke-b-${fileId}`;
25
+ const folder = 'scoped';
26
+ const filepath = `${folder}/${fileId}.txt`;
27
+ const contents = `scoped smoke probe ${fileId}`;
28
+
29
+ yield* askFileWriteTextContents(
30
+ SMOKE_SCOPED_PROBE_DRIVE,
31
+ filepath,
32
+ contents,
33
+ undefined,
34
+ scopeA
35
+ );
36
+
37
+ const readBack = yield* askFileReadTextContents(
38
+ SMOKE_SCOPED_PROBE_DRIVE,
39
+ filepath,
40
+ scopeA
41
+ );
42
+ yield* askSmokeAssert(
43
+ readBack === contents,
44
+ 'scoped file contents did not read back as written'
45
+ );
46
+
47
+ const listingA = yield* askFileListDirectory(
48
+ SMOKE_SCOPED_PROBE_DRIVE,
49
+ folder,
50
+ undefined,
51
+ undefined,
52
+ scopeA
53
+ );
54
+ yield* askSmokeAssert(
55
+ listingA.fileInfos.some((info) => info.filepath === filepath),
56
+ 'scoped listing does not show the file under its own scope'
57
+ );
58
+
59
+ const existsInB = yield* askFileExists(
60
+ SMOKE_SCOPED_PROBE_DRIVE,
61
+ filepath,
62
+ scopeB
63
+ );
64
+ yield* askSmokeAssert(
65
+ !existsInB,
66
+ 'file written under scope A is visible from scope B'
67
+ );
68
+
69
+ const unscopedRead = yield* askCatch(
70
+ askFileReadTextContents(SMOKE_SCOPED_PROBE_DRIVE, filepath)
71
+ );
72
+ yield* askSmokeAssert(
73
+ !unscopedRead.success &&
74
+ unscopedRead.error.errorType ===
75
+ askFileReadTextContents.errorType.InvalidScope,
76
+ 'unscoped read on the scoped drive was not refused with InvalidScope'
77
+ );
78
+
79
+ const unscopedList = yield* askCatch(
80
+ askFileListDirectory(SMOKE_SCOPED_PROBE_DRIVE, folder)
81
+ );
82
+ yield* askSmokeAssert(
83
+ !unscopedList.success &&
84
+ unscopedList.error.errorType ===
85
+ askFileListDirectory.errorType.InvalidScope,
86
+ 'unscoped listing on the scoped drive was not refused with InvalidScope'
87
+ );
88
+
89
+ const scopedWriteOnUnscopedDrive = yield* askCatch(
90
+ askFileWriteTextContents(
91
+ SMOKE_PROBE_DRIVE,
92
+ filepath,
93
+ contents,
94
+ undefined,
95
+ scopeA
96
+ )
97
+ );
98
+ yield* askSmokeAssert(
99
+ !scopedWriteOnUnscopedDrive.success &&
100
+ scopedWriteOnUnscopedDrive.error.errorType ===
101
+ askFileWriteTextContents.errorType.InvalidScope,
102
+ 'scoped write on the unscoped drive was not refused with InvalidScope'
103
+ );
104
+
105
+ yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scopeA);
106
+
107
+ const existsAfterDelete = yield* askFileExists(
108
+ SMOKE_SCOPED_PROBE_DRIVE,
109
+ filepath,
110
+ scopeA
111
+ );
112
+ yield* askSmokeAssert(
113
+ !existsAfterDelete,
114
+ 'scoped file still exists after delete'
115
+ );
116
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunScopedStorageDriveTest';
@@ -1,18 +1,24 @@
1
1
  import { askRunCrossServiceKeyValueStoreTest } from './crossService/askRunCrossServiceKeyValueStoreTest';
2
2
  import { askRunCrossServiceSigningKeyTest } from './crossService/askRunCrossServiceSigningKeyTest';
3
3
  import { askRunCrossServiceStorageDriveTest } from './crossService/askRunCrossServiceStorageDriveTest';
4
+ import { askRunEmailReceiveTest } from './emailReceive/askRunEmailReceiveTest';
5
+ import { askRunEncryptedResourcesTest } from './encryptedResources/askRunEncryptedResourcesTest';
4
6
  import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
5
7
  import { askRunEventDocConcurrentAppendTest } from './eventDoc/askRunEventDocConcurrentAppendTest';
6
8
  import { askRunEventDocInterleavedBatchAppendTest } from './eventDoc/askRunEventDocInterleavedBatchAppendTest';
7
9
  import { askRunKeyValueStoreTest } from './keyValueStore/askRunKeyValueStoreTest';
10
+ import { askRunKeyValueStoreStreamTest } from './keyValueStoreStream/askRunKeyValueStoreStreamTest';
8
11
  import { askRunNoopTest } from './noop/askRunNoopTest';
9
12
  import { askRunEchoRoundTripTest } from './openApi/askRunEchoRoundTripTest';
10
13
  import { askRunOpenApiDocumentTest } from './openApi/askRunOpenApiDocumentTest';
11
14
  import { askRunParameterTest } from './parameter/askRunParameterTest';
12
15
  import { askRunScheduleTest } from './schedule/askRunScheduleTest';
16
+ import { askRunScopedKeyValueStoreTest } from './scopedKeyValueStore/askRunScopedKeyValueStoreTest';
17
+ import { askRunScopedStorageDriveTest } from './scopedStorageDrive/askRunScopedStorageDriveTest';
13
18
  import { askRunSecretTest } from './secret/askRunSecretTest';
14
19
  import { askRunSigningKeyTest } from './signingKey/askRunSigningKeyTest';
15
20
  import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
21
+ import { askRunStorageDriveEventTest } from './storageDriveEvent/askRunStorageDriveEventTest';
16
22
  import { SmokeTestDefinition } from './SmokeTestDefinition';
17
23
 
18
24
  // Every smoke test a run executes. They run in parallel, one queue message
@@ -51,4 +57,10 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
51
57
  name: 'crossServiceSigningKey',
52
58
  askRun: askRunCrossServiceSigningKeyTest,
53
59
  },
60
+ { name: 'scopedStorageDrive', askRun: askRunScopedStorageDriveTest },
61
+ { name: 'scopedKeyValueStore', askRun: askRunScopedKeyValueStoreTest },
62
+ { name: 'storageDriveEvent', askRun: askRunStorageDriveEventTest },
63
+ { name: 'keyValueStoreStream', askRun: askRunKeyValueStoreStreamTest },
64
+ { name: 'encryptedResources', askRun: askRunEncryptedResourcesTest },
65
+ { name: 'emailReceive', askRun: askRunEmailReceiveTest, deployedOnly: true },
54
66
  ];
@@ -0,0 +1,80 @@
1
+ import {
2
+ askFileDelete,
3
+ askFileWriteTextContents,
4
+ askNewGuid,
5
+ AskResponse,
6
+ StorageDriveEventType,
7
+ } from 'quidproquo';
8
+
9
+ import {
10
+ SMOKE_FILE_EVENT_DRIVE,
11
+ SMOKE_SCOPED_PROBE_DRIVE,
12
+ } from '../../constants/smokeProbe';
13
+ import { smokeFileEventMarkerId } from '../../storageDrive/smokeFileEventMarkerId';
14
+ import { askSmokeAssert } from '../askSmokeAssert';
15
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
16
+
17
+ // The storage drive event path end to end: a write fires the create handler
18
+ // and a delete fires the delete handler, locally through the file watcher and
19
+ // deployed through the S3 notification. On the scoped drive the event must
20
+ // arrive with the scope as its own field, the path made scope-relative, and
21
+ // the handler running under that scope.
22
+ export function* askRunStorageDriveEventTest(): AskResponse<void> {
23
+ const fileId = yield* askNewGuid();
24
+ const filepath = `events/${fileId}.txt`;
25
+ const scope = `smoke-ev-${fileId}`;
26
+
27
+ yield* askFileWriteTextContents(SMOKE_FILE_EVENT_DRIVE, filepath, fileId);
28
+
29
+ const created = yield* askSmokePollForMarker(
30
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
31
+ 'the unscoped drive create event'
32
+ );
33
+ yield* askSmokeAssert(
34
+ created.path === filepath && created.scope === undefined,
35
+ 'unscoped create event did not carry the bare path with no scope'
36
+ );
37
+
38
+ yield* askFileDelete(SMOKE_FILE_EVENT_DRIVE, [filepath]);
39
+
40
+ yield* askSmokePollForMarker(
41
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
42
+ 'the unscoped drive delete event'
43
+ );
44
+
45
+ yield* askFileWriteTextContents(
46
+ SMOKE_SCOPED_PROBE_DRIVE,
47
+ filepath,
48
+ fileId,
49
+ undefined,
50
+ scope
51
+ );
52
+
53
+ const scopedCreated = yield* askSmokePollForMarker(
54
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
55
+ 'the scoped drive create event'
56
+ );
57
+ yield* askSmokeAssert(
58
+ scopedCreated.scope === scope,
59
+ `scoped create event carried scope [${scopedCreated.scope}], expected [${scope}]`
60
+ );
61
+ yield* askSmokeAssert(
62
+ scopedCreated.path === filepath,
63
+ `scoped create event path [${scopedCreated.path}] was not scope-relative`
64
+ );
65
+ yield* askSmokeAssert(
66
+ scopedCreated.ambientScope === scope,
67
+ 'scoped create handler did not run under the event scope'
68
+ );
69
+
70
+ yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scope);
71
+
72
+ const scopedDeleted = yield* askSmokePollForMarker(
73
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
74
+ 'the scoped drive delete event'
75
+ );
76
+ yield* askSmokeAssert(
77
+ scopedDeleted.scope === scope && scopedDeleted.path === filepath,
78
+ 'scoped delete event did not carry the scope and scope-relative path'
79
+ );
80
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunStorageDriveEventTest';
@@ -42,7 +42,7 @@ The parameters are identical to [askAiPrompt](./ask-ai-prompt.md) — see there
42
42
  | --- | --- | --- |
43
43
  | `model` | `AiModel` | Which model to prompt. |
44
44
  | `prompt` | `string` | The user prompt. Ignored when `options.messages` is set. |
45
- | `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
45
+ | `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching?, maxDurationMs?, maxSteps?, maxOutputTokens? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
46
46
 
47
47
  ## Returns
48
48
 
@@ -92,7 +92,7 @@ Within a step, text / reasoning / tool-input events arrive as matched `*Start
92
92
  | `stop` | `stop` | The model completed its answer naturally. |
93
93
  | `length` | `length` | The response hit the output token limit. |
94
94
  | `contentFilter` | `content-filter` | The provider's content filter stopped the response. |
95
- | `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (e.g. a step limit) rather than finishing naturally, and can be resumed by re-sending the recorded history. |
95
+ | `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (`maxSteps` or `maxDurationMs`) rather than finishing naturally, and can be resumed by re-sending the recorded history. |
96
96
  | `error` | `error` | The stream errored. |
97
97
  | `other` | `other` | The provider reported a reason outside this catalog. |
98
98
  | `unknown` | `unknown` | The provider reported no reason, or one this version does not recognise. |
@@ -50,6 +50,9 @@ function* askAiPrompt(
50
50
  | `messages` | [`AiMessage[]`](#aimessage) | – | A full conversation history. When present, this is sent instead of `prompt`, letting you carry a multi-turn dialogue (including prior assistant turns and tool results). |
51
51
  | `reasoning` | [`AiReasoningConfig`](#aireasoningconfig) | – | Enables extended thinking. Its presence turns reasoning on; `budgetTokens` caps how many tokens the model may spend thinking before it answers (defaults to `4096` on AWS). |
52
52
  | `caching` | `boolean` | – | Marks the system prompt and the last message (or the last `messages` entry) with a Bedrock cache point, so a following call in the same conversation can read everything up to there from cache instead of reprocessing it. |
53
+ | `maxSteps` | `number` | – | Cap on model/tool steps in one call. Unset means no cap: the loop runs until the model stops on its own or `maxDurationMs` trips. A client-side tool call (a tool with no executor) still halts it immediately. |
54
+ | `maxOutputTokens` | `number` | provider default | Output token cap per model call. Bedrock defaults to 8192, which a reasoning block plus a large tool input can exceed; the step then finishes with `length` and the tool call arrives truncated. Raise it for agentic workloads (Claude Sonnet allows 64k). |
55
+ | `maxDurationMs` | `number` | – | Wall-clock budget for the tool loop. Checked between steps, so the loop can overrun by one step; leave headroom. When it trips with tool calls still outstanding the result finishes with `toolCalls`, and re-sending the recorded history resumes the turn. Pair it with [askGetRuntimeRemainingTime](../system/ask-get-runtime-remaining-time.md) to stop before the platform deadline. |
53
56
 
54
57
  ### `AiModel`
55
58
 
@@ -60,7 +60,7 @@ function* askFileWriteTextContents(
60
60
  ## Notes
61
61
 
62
62
  - If the drive declares an `onEvent.create` handler in [defineStorageDrive](../../../config/core/storage-drive.md#file-events-onevent), that story is triggered by this write.
63
- - Writes to a drive with `encryption: true` are encrypted at rest automatically — nothing extra is needed at the call site.
63
+ - Every drive is encrypted at rest; `cryptoKeyName` swaps in one of your own `defineCryptoKey`s. Nothing changes at the call site either way.
64
64
 
65
65
  ## Related
66
66