@lotics/app-sdk 0.98.4 → 0.100.0
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/AGENTS.md +2 -2
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mock.d.ts +11 -0
- package/dist/src/mock.js +10 -0
- package/dist/src/recording.d.ts +59 -0
- package/dist/src/recording.js +30 -0
- package/dist/src/recording_state.d.ts +59 -0
- package/dist/src/recording_state.js +94 -0
- package/dist/src/rpc.d.ts +10 -1
- package/dist/src/rpc.js +25 -0
- package/dist/src/viewer.d.ts +1 -0
- package/dist/src/viewer.js +13 -1
- package/docs/members_and_options.md +5 -5
- package/docs/mutations.md +70 -2
- package/docs/runtime.md +11 -4
- package/docs/workflows.md +46 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -17,8 +17,8 @@ signature; open the file.**
|
|
|
17
17
|
| [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
|
|
18
18
|
| [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
|
|
19
19
|
| [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` and `row.bool` answer `null` for an EMPTY cell, so none is distinguishable from zero and an unanswered checkbox from a "no" — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
|
|
20
|
-
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
|
|
21
|
-
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
|
|
20
|
+
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write), `useRecording` (the HOST records the member — mic, system sound, screen — and files the transcribed recording through a receiving workflow exactly once; `available` gates the control, `state` follows the recording). |
|
|
21
|
+
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the `recording` input a workflow declares to receive a recording, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
|
|
22
22
|
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted; and a LOOKUP of a files field is one list per linked row, which the reader opens itself), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
23
23
|
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
|
|
24
24
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
package/dist/src/index.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type { AttachedFile, AttachmentsOptions, AttachmentsState } from "./attac
|
|
|
21
21
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
23
23
|
export { useViewer } from "./viewer.js";
|
|
24
|
+
export { useRecording } from "./recording.js";
|
|
25
|
+
export type { UseRecording } from "./recording.js";
|
|
26
|
+
export type { RecordingState, RecordingInputs } from "./recording_state.js";
|
|
24
27
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
25
28
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
26
29
|
export { rpc, isEmbedded } from "./rpc.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { mount } from "./mount.js";
|
|
|
17
17
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
|
|
18
18
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
19
19
|
export { useViewer } from "./viewer.js";
|
|
20
|
+
export { useRecording } from "./recording.js";
|
|
20
21
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
21
22
|
export { rpc, isEmbedded } from "./rpc.js";
|
|
22
23
|
export { openExternal } from "./open_external.js";
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -35,10 +35,14 @@
|
|
|
35
35
|
* a static row array cannot, so a master/detail drawer under `?__mock=1` would
|
|
36
36
|
* otherwise show every parent's children under every parent.
|
|
37
37
|
*
|
|
38
|
+
* `recordings` hands `useRecording` a canned state per alias, so each state a
|
|
39
|
+
* recording act passes through can be drawn without a host that records.
|
|
40
|
+
*
|
|
38
41
|
* What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
|
|
39
42
|
* different shape from a request/response pair, and nothing has needed it.
|
|
40
43
|
*/
|
|
41
44
|
import type { WorkflowResult } from "./hooks.js";
|
|
45
|
+
import type { RecordingState } from "./recording_state.js";
|
|
42
46
|
/**
|
|
43
47
|
* What a mocked workflow answers with: a fixed result, or a function of the
|
|
44
48
|
* inputs it was called with. Return a promise from the function to hold the
|
|
@@ -72,6 +76,10 @@ export interface AppFixture {
|
|
|
72
76
|
/** Map of workflow alias → the result it resolves with when mock mode is on.
|
|
73
77
|
* The workflow never executes, so nothing it would have written is written. */
|
|
74
78
|
workflows?: Record<string, MockWorkflow>;
|
|
79
|
+
/** Map of workflow alias → the state `useRecording(alias)` reports when mock
|
|
80
|
+
* mode is on. It reads as available, and `start`/`stop` resolve without
|
|
81
|
+
* recording anything or changing the state. */
|
|
82
|
+
recordings?: Record<string, RecordingState>;
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
85
|
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
@@ -105,3 +113,6 @@ export declare function getMockRows(alias: string, call: MockQueryCall): Array<R
|
|
|
105
113
|
* app that never calls the workflow pays nothing.
|
|
106
114
|
*/
|
|
107
115
|
export declare function getMockWorkflow(alias: string): MockWorkflow | null;
|
|
116
|
+
/** The canned recording states when mock mode is active and the fixture names
|
|
117
|
+
* any; otherwise null, and `useRecording` reads the host. */
|
|
118
|
+
export declare function getMockRecordings(): Record<string, RecordingState> | null;
|
package/dist/src/mock.js
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* a static row array cannot, so a master/detail drawer under `?__mock=1` would
|
|
36
36
|
* otherwise show every parent's children under every parent.
|
|
37
37
|
*
|
|
38
|
+
* `recordings` hands `useRecording` a canned state per alias, so each state a
|
|
39
|
+
* recording act passes through can be drawn without a host that records.
|
|
40
|
+
*
|
|
38
41
|
* What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
|
|
39
42
|
* different shape from a request/response pair, and nothing has needed it.
|
|
40
43
|
*/
|
|
@@ -112,3 +115,10 @@ export function getMockWorkflow(alias) {
|
|
|
112
115
|
return null;
|
|
113
116
|
return registeredFixture?.workflows?.[alias] ?? null;
|
|
114
117
|
}
|
|
118
|
+
/** The canned recording states when mock mode is active and the fixture names
|
|
119
|
+
* any; otherwise null, and `useRecording` reads the host. */
|
|
120
|
+
export function getMockRecordings() {
|
|
121
|
+
if (!isMockMode())
|
|
122
|
+
return null;
|
|
123
|
+
return registeredFixture?.recordings ?? null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type RecordingState } from "./recording_state.js";
|
|
2
|
+
import type { AppWorkflows } from "./types.js";
|
|
3
|
+
/** The platform fills a receiving workflow's `recording` input; the app passes
|
|
4
|
+
* the rest of what the workflow declares. */
|
|
5
|
+
type ActInputsOf<K extends keyof AppWorkflows & string> = AppWorkflows[K] extends Record<string, unknown> ? Omit<AppWorkflows[K], "recording"> : Record<string, unknown>;
|
|
6
|
+
export interface UseRecording<I> {
|
|
7
|
+
/**
|
|
8
|
+
* Whether this surface can record. False standalone, in a host that
|
|
9
|
+
* predates recording, on an instance without transcription, and until the
|
|
10
|
+
* host's context has answered. A control that starts a recording is drawn
|
|
11
|
+
* only when this is true.
|
|
12
|
+
*/
|
|
13
|
+
available: boolean;
|
|
14
|
+
/** The most recent recording this app started through this alias. */
|
|
15
|
+
state: RecordingState;
|
|
16
|
+
/** Whether a recording this app started is live, through any alias. The
|
|
17
|
+
* host records one at a time, so a start while this is true is refused. */
|
|
18
|
+
busy: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Ask the host to record. The host opens its own capture dialog, and nothing
|
|
21
|
+
* records until the member presses Start there. Resolves `{ started: true }`
|
|
22
|
+
* once capture runs, `{ started: false }` when the member closed the dialog;
|
|
23
|
+
* rejects, with the reason, when the host refuses (another recording is
|
|
24
|
+
* running, the inputs fail the workflow's contract, recording is unavailable).
|
|
25
|
+
* When the recording is transcribed, the host runs the alias's workflow with
|
|
26
|
+
* these inputs plus `recording`.
|
|
27
|
+
*/
|
|
28
|
+
start: {} extends I ? (inputs?: I) => Promise<{
|
|
29
|
+
started: boolean;
|
|
30
|
+
}> : (inputs: I) => Promise<{
|
|
31
|
+
started: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
/** Stop this alias's live recording. Resolves whether or not one was live. */
|
|
34
|
+
stop: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Record a call, a visit, a meeting — and file it through a workflow.
|
|
38
|
+
*
|
|
39
|
+
* `alias` names a workflow that declares the platform's `recording` input; the
|
|
40
|
+
* host records as the signed-in member and, once the transcript is ready, runs
|
|
41
|
+
* that workflow once with the app's inputs and the recording. Its write reaches
|
|
42
|
+
* the screen like any workflow's.
|
|
43
|
+
*
|
|
44
|
+
* ```tsx
|
|
45
|
+
* const visit = useRecording("log_visit");
|
|
46
|
+
* if (!visit.available) return null;
|
|
47
|
+
* if (visit.state.phase === "live" && visit.state.inputs.site === siteId) {
|
|
48
|
+
* return <Button onPress={() => visit.stop()}>Stop</Button>;
|
|
49
|
+
* }
|
|
50
|
+
* return (
|
|
51
|
+
* <Button disabled={visit.busy} onPress={() => visit.start({ site: siteId })}>
|
|
52
|
+
* Record the visit
|
|
53
|
+
* </Button>
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function useRecording<K extends keyof AppWorkflows & string>(alias: K): UseRecording<ActInputsOf<K>>;
|
|
58
|
+
export declare function useRecording(alias: string): UseRecording<Record<string, unknown>>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { rpc } from "./rpc.js";
|
|
3
|
+
import { getMockRecordings } from "./mock.js";
|
|
4
|
+
import { useAppContext } from "./viewer.js";
|
|
5
|
+
import { anyRecordingLive, recordingStateOf, subscribeRecordings, } from "./recording_state.js";
|
|
6
|
+
const NO_MOCKS = {};
|
|
7
|
+
export function useRecording(alias) {
|
|
8
|
+
const { recordingEnabled } = useAppContext();
|
|
9
|
+
const mocks = getMockRecordings() ?? NO_MOCKS;
|
|
10
|
+
const mocked = mocks[alias];
|
|
11
|
+
const hostState = useSyncExternalStore(subscribeRecordings, () => recordingStateOf(alias));
|
|
12
|
+
const hostBusy = useSyncExternalStore(subscribeRecordings, anyRecordingLive);
|
|
13
|
+
const start = useCallback(async (inputs) => {
|
|
14
|
+
if (getMockRecordings()?.[alias])
|
|
15
|
+
return { started: true };
|
|
16
|
+
return rpc("recording.start", { alias, inputs: inputs ?? {} });
|
|
17
|
+
}, [alias]);
|
|
18
|
+
const stop = useCallback(async () => {
|
|
19
|
+
if (getMockRecordings()?.[alias])
|
|
20
|
+
return;
|
|
21
|
+
await rpc("recording.stop", { alias });
|
|
22
|
+
}, [alias]);
|
|
23
|
+
return {
|
|
24
|
+
available: mocked !== undefined || recordingEnabled,
|
|
25
|
+
state: mocked ?? hostState,
|
|
26
|
+
busy: hostBusy || Object.values(mocks).some((state) => state.phase === "live"),
|
|
27
|
+
start,
|
|
28
|
+
stop,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host says about the recordings THIS app started, one per workflow
|
|
3
|
+
* alias: the most recently started recording through that alias.
|
|
4
|
+
*
|
|
5
|
+
* A leaf module, because two sides write it and one reads it: the bridge
|
|
6
|
+
* listener in `rpc.ts` applies the host's `recordingChanged` push, the context
|
|
7
|
+
* fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
|
|
8
|
+
* and `useRecording` reads it. Both writes arrive on the one postMessage channel
|
|
9
|
+
* in the order the host sent them, so the later arrival is always the newer
|
|
10
|
+
* state.
|
|
11
|
+
*/
|
|
12
|
+
/** The act's own inputs the recording was started with — what `start` was
|
|
13
|
+
* passed, echoed back so a screen drawing one act per row can tell which row
|
|
14
|
+
* the recording is about. */
|
|
15
|
+
export type RecordingInputs = Record<string, unknown>;
|
|
16
|
+
/**
|
|
17
|
+
* One recording, from the host's side. `idle`: nothing of this alias's is
|
|
18
|
+
* running or waiting. `live`: capturing. `interrupted`: the tab closed mid-call
|
|
19
|
+
* and the member has not yet resumed or finished it from the recording bar.
|
|
20
|
+
* `processing`: stopped, the transcript is on its way. `filed`: the workflow
|
|
21
|
+
* ran and succeeded. `failed`: transcription failed, the platform refused to
|
|
22
|
+
* file (the member lost access, the alias or its declaration changed), or the
|
|
23
|
+
* run failed; the recording is kept, and the member retries from the recording
|
|
24
|
+
* bar.
|
|
25
|
+
*/
|
|
26
|
+
export type RecordingState = {
|
|
27
|
+
phase: "idle";
|
|
28
|
+
} | {
|
|
29
|
+
phase: "live";
|
|
30
|
+
elapsed_seconds: number;
|
|
31
|
+
inputs: RecordingInputs;
|
|
32
|
+
} | {
|
|
33
|
+
phase: "interrupted";
|
|
34
|
+
elapsed_seconds: number;
|
|
35
|
+
inputs: RecordingInputs;
|
|
36
|
+
} | {
|
|
37
|
+
phase: "processing";
|
|
38
|
+
inputs: RecordingInputs;
|
|
39
|
+
} | {
|
|
40
|
+
phase: "filed";
|
|
41
|
+
execution_id: string;
|
|
42
|
+
inputs: RecordingInputs;
|
|
43
|
+
} | {
|
|
44
|
+
phase: "failed";
|
|
45
|
+
message: string;
|
|
46
|
+
inputs: RecordingInputs;
|
|
47
|
+
};
|
|
48
|
+
export declare const IDLE_RECORDING: RecordingState;
|
|
49
|
+
export declare function subscribeRecordings(listener: () => void): () => void;
|
|
50
|
+
export declare function recordingStateOf(alias: string): RecordingState;
|
|
51
|
+
export declare function anyRecordingLive(): boolean;
|
|
52
|
+
/** The host's push for one alias. */
|
|
53
|
+
export declare function applyRecordingChange(alias: string, state: RecordingState): void;
|
|
54
|
+
/** The snapshot `context` carries: every alias it omits is idle. */
|
|
55
|
+
export declare function replaceRecordings(snapshot: Record<string, RecordingState>): void;
|
|
56
|
+
/** A host message's `state`, or null when it is not one. */
|
|
57
|
+
export declare function readRecordingState(raw: unknown): RecordingState | null;
|
|
58
|
+
/** A context's `recordings`, keeping each well-formed entry. */
|
|
59
|
+
export declare function readRecordingSnapshot(raw: unknown): Record<string, RecordingState>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host says about the recordings THIS app started, one per workflow
|
|
3
|
+
* alias: the most recently started recording through that alias.
|
|
4
|
+
*
|
|
5
|
+
* A leaf module, because two sides write it and one reads it: the bridge
|
|
6
|
+
* listener in `rpc.ts` applies the host's `recordingChanged` push, the context
|
|
7
|
+
* fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
|
|
8
|
+
* and `useRecording` reads it. Both writes arrive on the one postMessage channel
|
|
9
|
+
* in the order the host sent them, so the later arrival is always the newer
|
|
10
|
+
* state.
|
|
11
|
+
*/
|
|
12
|
+
export const IDLE_RECORDING = { phase: "idle" };
|
|
13
|
+
const states = new Map();
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
function emit() {
|
|
16
|
+
for (const listener of listeners)
|
|
17
|
+
listener();
|
|
18
|
+
}
|
|
19
|
+
export function subscribeRecordings(listener) {
|
|
20
|
+
listeners.add(listener);
|
|
21
|
+
return () => {
|
|
22
|
+
listeners.delete(listener);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function recordingStateOf(alias) {
|
|
26
|
+
return states.get(alias) ?? IDLE_RECORDING;
|
|
27
|
+
}
|
|
28
|
+
export function anyRecordingLive() {
|
|
29
|
+
for (const state of states.values())
|
|
30
|
+
if (state.phase === "live")
|
|
31
|
+
return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** The host's push for one alias. */
|
|
35
|
+
export function applyRecordingChange(alias, state) {
|
|
36
|
+
if (state.phase === "idle")
|
|
37
|
+
states.delete(alias);
|
|
38
|
+
else
|
|
39
|
+
states.set(alias, state);
|
|
40
|
+
emit();
|
|
41
|
+
}
|
|
42
|
+
/** The snapshot `context` carries: every alias it omits is idle. */
|
|
43
|
+
export function replaceRecordings(snapshot) {
|
|
44
|
+
states.clear();
|
|
45
|
+
for (const [alias, state] of Object.entries(snapshot)) {
|
|
46
|
+
if (state.phase !== "idle")
|
|
47
|
+
states.set(alias, state);
|
|
48
|
+
}
|
|
49
|
+
emit();
|
|
50
|
+
}
|
|
51
|
+
function isRecord(value) {
|
|
52
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
/** A host message's `state`, or null when it is not one. */
|
|
55
|
+
export function readRecordingState(raw) {
|
|
56
|
+
if (!isRecord(raw))
|
|
57
|
+
return null;
|
|
58
|
+
if (raw.phase === "idle")
|
|
59
|
+
return IDLE_RECORDING;
|
|
60
|
+
const inputs = raw.inputs;
|
|
61
|
+
if (!isRecord(inputs))
|
|
62
|
+
return null;
|
|
63
|
+
switch (raw.phase) {
|
|
64
|
+
case "live":
|
|
65
|
+
case "interrupted":
|
|
66
|
+
return typeof raw.elapsed_seconds === "number"
|
|
67
|
+
? { phase: raw.phase, elapsed_seconds: raw.elapsed_seconds, inputs }
|
|
68
|
+
: null;
|
|
69
|
+
case "processing":
|
|
70
|
+
return { phase: "processing", inputs };
|
|
71
|
+
case "filed":
|
|
72
|
+
return typeof raw.execution_id === "string"
|
|
73
|
+
? { phase: "filed", execution_id: raw.execution_id, inputs }
|
|
74
|
+
: null;
|
|
75
|
+
case "failed":
|
|
76
|
+
return typeof raw.message === "string" ? { phase: "failed", message: raw.message, inputs } : null;
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** A context's `recordings`, keeping each well-formed entry. */
|
|
82
|
+
export function readRecordingSnapshot(raw) {
|
|
83
|
+
if (!isRecord(raw))
|
|
84
|
+
return {};
|
|
85
|
+
const entries = Object.entries(raw).flatMap(([alias, value]) => {
|
|
86
|
+
const state = readRecordingState(value);
|
|
87
|
+
if (state === null) {
|
|
88
|
+
console.error(`The host's context carried a malformed recording for "${alias}"; it reads as idle.`, value);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return [[alias, state]];
|
|
92
|
+
});
|
|
93
|
+
return Object.fromEntries(entries);
|
|
94
|
+
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
21
21
|
* app → host: { id, op, payload }
|
|
22
22
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
23
23
|
*/
|
|
24
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
24
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop";
|
|
25
25
|
/** Payload for starting a streaming agent run. */
|
|
26
26
|
export interface AgentRunPayload {
|
|
27
27
|
alias: string;
|
|
@@ -82,6 +82,15 @@ export interface AppContext {
|
|
|
82
82
|
* app that didn't opt in, and (vacuously) for standalone visitors.
|
|
83
83
|
*/
|
|
84
84
|
comments_enabled: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Whether this host records for the app: true only from a host that has the
|
|
87
|
+
* `recording.*` ops, for a signed-in member, on an instance that transcribes.
|
|
88
|
+
* A host that predates recording omits it, which reads as false.
|
|
89
|
+
*/
|
|
90
|
+
recording_enabled?: boolean;
|
|
91
|
+
/** The recordings this app started that the host still tracks, by alias —
|
|
92
|
+
* the state a newly loaded frame starts from. */
|
|
93
|
+
recordings?: Record<string, unknown>;
|
|
85
94
|
}
|
|
86
95
|
/**
|
|
87
96
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
package/dist/src/rpc.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runUploadPipeline } from "./upload/pipeline.js";
|
|
2
|
+
import { applyRecordingChange, readRecordingState, recordingStateOf } from "./recording_state.js";
|
|
2
3
|
import { parseSearch, serializeMerge, } from "./url_params.js";
|
|
3
4
|
/**
|
|
4
5
|
* The embedding Lotics host's origin — present iff the app is bridged.
|
|
@@ -192,6 +193,24 @@ function ensureListener() {
|
|
|
192
193
|
cb(aliases);
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
196
|
+
// Broadcast (no id): a recording THIS app started moved — the host sends
|
|
197
|
+
// no other app's and not the chat's.
|
|
198
|
+
if (msg.type === "recordingChanged") {
|
|
199
|
+
const state = readRecordingState(msg.state);
|
|
200
|
+
if (typeof msg.alias !== "string" || state === null) {
|
|
201
|
+
console.error("The host sent a malformed recordingChanged push; it is ignored.", msg);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const before = recordingStateOf(msg.alias);
|
|
205
|
+
applyRecordingChange(msg.alias, state);
|
|
206
|
+
// A filed recording is this app's own successful write, so the screen
|
|
207
|
+
// re-reads now, as after any workflow, not on the realtime push.
|
|
208
|
+
const newlyFiled = state.phase === "filed" &&
|
|
209
|
+
!(before.phase === "filed" && before.execution_id === state.execution_id);
|
|
210
|
+
if (newlyFiled)
|
|
211
|
+
notifyLocalWrite();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
195
214
|
if (typeof msg.id !== "number")
|
|
196
215
|
return;
|
|
197
216
|
// Single-response ops.
|
|
@@ -738,6 +757,11 @@ function rpcStandalone(op, payload) {
|
|
|
738
757
|
case "comments.delete":
|
|
739
758
|
case "comments.counts":
|
|
740
759
|
return rejectCommentsStandalone();
|
|
760
|
+
case "recording.start":
|
|
761
|
+
case "recording.stop":
|
|
762
|
+
// The host records, as the signed-in member; a standalone visitor is
|
|
763
|
+
// anonymous and has no host to capture in.
|
|
764
|
+
return Promise.reject(new Error("Recording needs the Lotics host and a signed-in member. Gate the control on useRecording's `available`."));
|
|
741
765
|
}
|
|
742
766
|
}
|
|
743
767
|
/**
|
|
@@ -794,6 +818,7 @@ async function standaloneContext() {
|
|
|
794
818
|
// comments are unavailable regardless of the capability flag.
|
|
795
819
|
member_id: null,
|
|
796
820
|
comments_enabled: info.comments_enabled,
|
|
821
|
+
recording_enabled: false,
|
|
797
822
|
};
|
|
798
823
|
}
|
|
799
824
|
/**
|
package/dist/src/viewer.d.ts
CHANGED
package/dist/src/viewer.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import useSWR from "swr";
|
|
2
2
|
import { rpc } from "./rpc.js";
|
|
3
|
+
import { readRecordingSnapshot, replaceRecordings } from "./recording_state.js";
|
|
4
|
+
/**
|
|
5
|
+
* The context, with its recording snapshot applied in the same task the
|
|
6
|
+
* answer arrived in — before any later host push is handled, so the snapshot
|
|
7
|
+
* can never overwrite a newer push.
|
|
8
|
+
*/
|
|
9
|
+
async function fetchContext() {
|
|
10
|
+
const context = await rpc("context", {});
|
|
11
|
+
replaceRecordings(readRecordingSnapshot(context.recordings));
|
|
12
|
+
return context;
|
|
13
|
+
}
|
|
3
14
|
/**
|
|
4
15
|
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
5
16
|
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
6
17
|
* member and the app's declared capabilities.
|
|
7
18
|
*/
|
|
8
19
|
export function useAppContext() {
|
|
9
|
-
const { data } = useSWR("app-context",
|
|
20
|
+
const { data } = useSWR("app-context", fetchContext, {
|
|
10
21
|
revalidateOnFocus: false,
|
|
11
22
|
revalidateIfStale: false,
|
|
12
23
|
revalidateOnReconnect: false,
|
|
@@ -15,6 +26,7 @@ export function useAppContext() {
|
|
|
15
26
|
return {
|
|
16
27
|
memberId: data?.member_id ?? null,
|
|
17
28
|
commentsEnabled: data?.comments_enabled ?? false,
|
|
29
|
+
recordingEnabled: data?.recording_enabled === true,
|
|
18
30
|
resolved: data !== undefined,
|
|
19
31
|
};
|
|
20
32
|
}
|
|
@@ -265,7 +265,7 @@ client-side up front, and re-checked server-side.
|
|
|
265
265
|
- **The author is always the real signed-in member** — correct attribution, enforced server-side.
|
|
266
266
|
**Warning:** under "View as", comments still author as the real member (the admin), not the
|
|
267
267
|
view-as target — unlike `useViewer` and `is_current_member` scoping, which follow the target.
|
|
268
|
-
- **Edit and delete are author-only**, checked server-side against the viewer. `
|
|
268
|
+
- **Edit and delete are author-only**, checked server-side against the viewer. `Timeline`'s
|
|
269
269
|
`currentMemberId` prop drives the matching affordance client-side.
|
|
270
270
|
|
|
271
271
|
### Reading & writing
|
|
@@ -286,13 +286,13 @@ renders the panel. State: `{ comments, loading, error, available, createComment,
|
|
|
286
286
|
updateComment, deleteComment, refetch }`.
|
|
287
287
|
|
|
288
288
|
- `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
|
|
289
|
-
`
|
|
289
|
+
`Timeline`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
|
|
290
290
|
table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
291
291
|
(`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
|
|
292
292
|
the server re-reads every attachment from storage by that id, so nothing else you hold about a
|
|
293
293
|
file can affect what is stored. The `url` / `thumbnail_url` / `preview_url` fields exist on the type
|
|
294
294
|
but the server does not populate them today — render attachments by name and type (what
|
|
295
|
-
`
|
|
295
|
+
`Timeline`'s default file row does), never by counting on a fetchable URL.
|
|
296
296
|
- `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
|
|
297
297
|
`useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
|
|
298
298
|
least one file (empty input is a client no-op; the server enforces the same rule). Content max
|
|
@@ -308,7 +308,7 @@ updateComment, deleteComment, refetch }`.
|
|
|
308
308
|
app by someone the record references nowhere. So a thread that crosses a role boundary is legible without the app declaring member
|
|
309
309
|
access it does not otherwise need, and a per-viewer app never widens its reach for a display
|
|
310
310
|
string. `name` is `null` when the id no longer resolves in the org (a removed member) — render a
|
|
311
|
-
fallback (`
|
|
311
|
+
fallback (`Timeline` has an `unknownMember` label for exactly this). The one comment with no
|
|
312
312
|
`author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
|
|
313
313
|
not their name, and it will not invent one. The server's row replaces it when the refetch lands.
|
|
314
314
|
- **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
|
|
@@ -336,7 +336,7 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
|
|
|
336
336
|
| A select value (stored or picker option) | `Status` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
|
|
337
337
|
| A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
|
|
338
338
|
| A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
|
|
339
|
-
| A comment thread | `
|
|
339
|
+
| A comment thread | `Timeline` + `Composer` (`@lotics/ui/composer`) | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
|
|
340
340
|
|
|
341
341
|
The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
|
|
342
342
|
|
package/docs/mutations.md
CHANGED
|
@@ -4,8 +4,8 @@ Everything about writing data from an app: `useWorkflow` (the only write path),
|
|
|
4
4
|
`WorkflowResult` contract and its resolve-never-throw failure model, declaring typed workflow
|
|
5
5
|
inputs (file/member/optional inputs), returning structured data with `return({ data })`,
|
|
6
6
|
the automatic re-read a successful write triggers, the diff-before-update discipline, locked records
|
|
7
|
-
(`readLocked` + `request_locked_record_change`),
|
|
8
|
-
(`useOptimistic`). Read this before building any screen that creates, updates, or deletes
|
|
7
|
+
(`readLocked` + `request_locked_record_change`), optimistic reconciliation
|
|
8
|
+
(`useOptimistic`), and `useRecording` — a recording the host files through a workflow. Read this before building any screen that creates, updates, or deletes
|
|
9
9
|
records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
|
|
10
10
|
traps — is [workflows](./workflows.md). Reading data is [queries](./queries.md) +
|
|
11
11
|
[data_fetching](./data_fetching.md); uploads are [files](./files.md); who a write runs as is
|
|
@@ -686,3 +686,71 @@ workflow cannot be used to clobber another record by guessing its id.
|
|
|
686
686
|
|
|
687
687
|
Use `newRecordId()` directly if you need the id outside a hook (routing to the surface before
|
|
688
688
|
mounting it, say). It mints the same `rec_*` shape the server validates.
|
|
689
|
+
|
|
690
|
+
## Recording into a workflow: `useRecording`
|
|
691
|
+
|
|
692
|
+
A recording act — record a site visit, a call, a meeting, and file it on the row it is about —
|
|
693
|
+
is a workflow whose run waits for the transcript. Exact signature: `dist/src/recording.d.ts`.
|
|
694
|
+
|
|
695
|
+
```tsx
|
|
696
|
+
import { useRecording } from "@lotics/app-sdk";
|
|
697
|
+
|
|
698
|
+
const visit = useRecording("log_visit");
|
|
699
|
+
if (!visit.available) return null; // draw nothing that cannot record
|
|
700
|
+
const mine = visit.state.phase !== "idle" && visit.state.inputs.site === siteId;
|
|
701
|
+
|
|
702
|
+
const { started } = await visit.start({ site: siteId }); // false: the member closed the dialog
|
|
703
|
+
await visit.stop();
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
- **The alias names a receiving workflow** — one that declares the platform's `recording` input
|
|
707
|
+
([workflows](./workflows.md#a-workflow-that-receives-a-recording)). `start` takes the rest of
|
|
708
|
+
its declared inputs, typed from the declaration without `recording`; passing `recording` is a
|
|
709
|
+
`tsc` error and a refusal. Such a workflow runs only from a recording: `useWorkflow` on its
|
|
710
|
+
alias is refused, because the platform alone fills `recording`.
|
|
711
|
+
- **The host records, never the app.** `start` asks the Lotics host, which opens its own capture
|
|
712
|
+
dialog — microphone, and where the browser supports it system sound and the screen — named
|
|
713
|
+
with the app's name. Nothing records until the member presses Start there. The app touches no
|
|
714
|
+
media device and needs no permission. The recording belongs to the tab, not the frame: the
|
|
715
|
+
member may leave the app, and a bar in the product carries the recording until it is filed.
|
|
716
|
+
- **`start` resolves `{ started: true }`** once capture runs and **`{ started: false }`** when the
|
|
717
|
+
member closed the dialog. It **rejects with the host's reason** when refused: a recording is
|
|
718
|
+
already running (one per tab, across every app and the chat), the member has three recordings
|
|
719
|
+
still open, the inputs fail the workflow's declaration (a deleted record is refused before
|
|
720
|
+
anything is recorded, not after), or recording is unavailable. Show the reason.
|
|
721
|
+
- **`stop`** stops this alias's live recording, and resolves whether or not one was live. The
|
|
722
|
+
member can also stop from the product's bar.
|
|
723
|
+
- **Filing.** Once transcribed, the host runs the workflow **once** with `{ ...inputs,
|
|
724
|
+
recording }`, under the app owner's authority with the recording member as
|
|
725
|
+
`runtime.triggered_by_member_id` ([security](./security.md)). A silent recording files with
|
|
726
|
+
`transcript: ""`. Filing is re-checked at run time: the member still holds the app, the alias
|
|
727
|
+
is still bound, and its declaration still receives recordings. A refused or failed filing is
|
|
728
|
+
never retried by itself and the recording is never lost: the member retries from the
|
|
729
|
+
product's bar, and a failed run also sends the app's owner the failed-run notice.
|
|
730
|
+
- **`available`** is what the host states — false standalone, in `lotics app dev`, in a host
|
|
731
|
+
that predates recording, on an instance without transcription, and until the context has
|
|
732
|
+
answered. A standalone app has no member to record as; both calls reject there.
|
|
733
|
+
|
|
734
|
+
`state` is the most recent recording this app started through the alias (a second recording
|
|
735
|
+
replaces the first's state):
|
|
736
|
+
|
|
737
|
+
| `phase` | Carries | Means |
|
|
738
|
+
|---|---|---|
|
|
739
|
+
| `idle` | — | nothing through this alias is running or waiting |
|
|
740
|
+
| `live` | `elapsed_seconds`, `inputs` | capturing; the host updates it each second |
|
|
741
|
+
| `interrupted` | `elapsed_seconds`, `inputs` | the tab closed mid-recording; the member resumes or finishes it from the product's bar |
|
|
742
|
+
| `processing` | `inputs` | stopped; the transcript is on its way |
|
|
743
|
+
| `filed` | `execution_id`, `inputs` | the workflow ran and succeeded |
|
|
744
|
+
| `failed` | `message`, `inputs` | transcription failed, or filing was refused or its run failed |
|
|
745
|
+
|
|
746
|
+
`inputs` is what `start` was passed, so a screen with one act per row can tell which row the
|
|
747
|
+
recording is about. **`busy`** is true while any recording this app started is live, through
|
|
748
|
+
any alias: stand the other acts down, since the host would refuse them. A recording in another
|
|
749
|
+
app or the chat is invisible to this app, so `start` can still be refused while `busy` is false.
|
|
750
|
+
|
|
751
|
+
**Filed means written.** When `state` becomes `filed`, every mounted query re-reads, exactly as
|
|
752
|
+
after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation)). Draw the filed row's
|
|
753
|
+
files and transcript with `@lotics/ui/recording`.
|
|
754
|
+
|
|
755
|
+
A mock fixture's `recordings` map (alias → `state`) draws each phase without a host
|
|
756
|
+
([runtime](./runtime.md#the-mock-harness-optionsfixture--__mock1)).
|
package/docs/runtime.md
CHANGED
|
@@ -66,7 +66,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
|
|
|
66
66
|
bundle never leaks into normal traffic:
|
|
67
67
|
|
|
68
68
|
1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
|
|
69
|
-
`dist/src/mock.d.ts` — `{ queries?, workflows? }`).
|
|
69
|
+
`dist/src/mock.d.ts` — `{ queries?, workflows?, recordings? }`).
|
|
70
70
|
2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
|
|
71
71
|
is completely inert.
|
|
72
72
|
|
|
@@ -99,6 +99,10 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
|
|
|
99
99
|
whose only AI surface is a workflow calling `agent(...)` otherwise has no
|
|
100
100
|
non-billing path to its own thinking / done / error screens, which is how
|
|
101
101
|
those three ship unreviewed.
|
|
102
|
+
- **A mocked recording is a canned state.** `recordings: { log_visit: { phase:
|
|
103
|
+
"live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
|
|
104
|
+
`useRecording("log_visit")` available with that `state`; `start` and `stop`
|
|
105
|
+
resolve without recording anything or moving the state.
|
|
102
106
|
- **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
|
|
103
107
|
mock mode those still hit the real transport.
|
|
104
108
|
- **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
|
|
@@ -202,6 +206,7 @@ surfaces:
|
|
|
202
206
|
| `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
|
|
203
207
|
| `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
|
|
204
208
|
| `openApp` | yes — routes to the sibling in the same tab | yes — opens the sibling on the web app in a new tab (one app is served locally) | rejects — `"openApp needs the Lotics host …"` |
|
|
209
|
+
| `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` | **no** — `"Unknown RPC op: …"`; the context omits `recording_enabled` | rejects — `"Recording needs the Lotics host and a signed-in member …"` |
|
|
205
210
|
|
|
206
211
|
**Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
|
|
207
212
|
state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
|
|
@@ -250,13 +255,15 @@ semantics are documented:
|
|
|
250
255
|
| `agentRun.cancel` | `{ run_id }` | `{ ok: true }` | [ai](./ai.md) |
|
|
251
256
|
| `urlState.get` / `urlState.set` | — / `{ params }` | `UrlParams` / `void` | [navigation & state](./navigation_and_state.md) |
|
|
252
257
|
| `comments.list/create/update/delete/counts` | comment ops | comment payloads | [members & options](./members_and_options.md) |
|
|
258
|
+
| `recording.start` / `recording.stop` | `{ alias, inputs }` / `{ alias }` | `{ started }` / `void` | [mutations](./mutations.md#recording-into-a-workflow-userecording) |
|
|
253
259
|
|
|
254
260
|
The **streaming** agent-run op is *not* reachable through `rpc()` — its
|
|
255
261
|
response is a chunk stream, not a single value; it's internal to `useAgentRun`.
|
|
256
262
|
|
|
257
|
-
`rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled
|
|
258
|
-
`member_id` is the signed-in member when
|
|
259
|
-
through `useViewer()` rather than this op
|
|
263
|
+
`rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled,
|
|
264
|
+
recording_enabled?, recordings? }`. `member_id` is the signed-in member when
|
|
265
|
+
embedded, `null` standalone — read it through `useViewer()` rather than this op;
|
|
266
|
+
the recording fields are `useRecording`'s. **Limitation:** the context type is
|
|
260
267
|
not exported from the package root, so type the result yourself via the `rpc<T>`
|
|
261
268
|
generic.
|
|
262
269
|
|
package/docs/workflows.md
CHANGED
|
@@ -861,6 +861,52 @@ refuses, up front, exactly the lists a live run refuses (over 10 000 items). The
|
|
|
861
861
|
is the one `live_reads: true` makes reachable: a loop body that reads issues one real query per
|
|
862
862
|
iteration.
|
|
863
863
|
|
|
864
|
+
## A workflow that receives a recording
|
|
865
|
+
|
|
866
|
+
A workflow that files a recording ([mutations](./mutations.md#recording-into-a-workflow-userecording))
|
|
867
|
+
declares the platform's `recording` input in exactly this shape, beside its own inputs:
|
|
868
|
+
|
|
869
|
+
```jsonc
|
|
870
|
+
// package.json#lotics.workflows.log_visit
|
|
871
|
+
"inputs": {
|
|
872
|
+
"site": { "type": "record_link", "table_id": "tbl_sites" },
|
|
873
|
+
"recording": { "type": "object", "fields": {
|
|
874
|
+
"session_id": { "type": "text" },
|
|
875
|
+
"audio": { "type": "file" },
|
|
876
|
+
"video": { "type": "file", "required": false },
|
|
877
|
+
"transcript": { "type": "text" },
|
|
878
|
+
"duration_seconds": { "type": "number" },
|
|
879
|
+
"recorded_at": { "type": "datetime" }
|
|
880
|
+
} }
|
|
881
|
+
}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
A declaration that differs in any field, type or `required` is not a receiver: the host refuses
|
|
885
|
+
to start a recording for it, naming the first difference. The platform alone fills `recording`,
|
|
886
|
+
so a call through `useWorkflow` is refused.
|
|
887
|
+
|
|
888
|
+
- `audio` and `video` are file ids in the workspace. `video` is absent when the screen was not
|
|
889
|
+
recorded or its video failed to assemble. Both carry the same sound, so file **one**: the
|
|
890
|
+
video when present, else the audio.
|
|
891
|
+
- `transcript` is the verbatim text, one `Speaker A: …` line per turn, and `""` for a silent
|
|
892
|
+
recording. A text field holds it whole, with no length limit: three hours is about 200,000
|
|
893
|
+
characters.
|
|
894
|
+
- `recorded_at` is when capture started; `duration_seconds` is how long it ran.
|
|
895
|
+
|
|
896
|
+
```js
|
|
897
|
+
const i = trigger.app_workflow.inputs;
|
|
898
|
+
const r = i.recording;
|
|
899
|
+
await create_records({
|
|
900
|
+
table_id: "tbl_visits",
|
|
901
|
+
records: [{
|
|
902
|
+
fld_site: [i.site],
|
|
903
|
+
fld_recording: [r.video ?? r.audio],
|
|
904
|
+
fld_transcript: r.transcript,
|
|
905
|
+
fld_visited_at: r.recorded_at,
|
|
906
|
+
}],
|
|
907
|
+
});
|
|
908
|
+
```
|
|
909
|
+
|
|
864
910
|
## A worked body
|
|
865
911
|
|
|
866
912
|
An app action that creates an order after checking for a duplicate, then returns the new id.
|