create-flowdular 0.2.6 → 0.3.1
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/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/cli-extension/SKILL.md +1 -1
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +114 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +29 -0
- package/agent-template/.agents/skills/module-update/SKILL.md +9 -1
- package/agent-template/.agents/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.agents/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/README.md +2 -1
- package/agent-template/.ai/agents/sandbox/business-manager.md +5 -1
- package/agent-template/.ai/blueprints/author-spec/README.md +1 -1
- package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +44 -0
- package/agent-template/.ai/blueprints/author-spec/steps.yaml +5 -5
- package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +99 -3
- package/agent-template/.ai/blueprints/edit-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/edit-module/required-files.yaml +9 -0
- package/agent-template/.ai/blueprints/new-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +2 -2
- package/agent-template/.ai/blueprints/release/gates.yaml +4 -0
- package/agent-template/.ai/platform-capabilities.md +128 -0
- package/agent-template/.ai/policies/capabilities.yaml +130 -3
- package/agent-template/.ai/policies/path-ownership.yaml +5 -2
- package/agent-template/.ai/policies/task-budgets.yaml +5 -3
- package/agent-template/.ai/references/catalog/module.json +4 -4
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +5 -3
- package/agent-template/.ai/references/catalog/src/platform.ts +2 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +89 -1
- package/agent-template/.ai/references/catalog/src/services/data-classes.ts +47 -0
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +98 -1
- package/agent-template/.ai/references/catalog/src/services/repository.ts +22 -1
- package/agent-template/.ai/references/catalog/tests/data-classes.test.ts +157 -0
- package/agent-template/.ai/references/catalog.provenance.json +12 -10
- package/agent-template/.ai/rules/flowdular.md +4 -0
- package/agent-template/.ai/skills/README.md +10 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -2
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.ai/skills/business-agent-design/SKILL.md +0 -1
- package/agent-template/.ai/skills/cli-extension/SKILL.md +1 -1
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +119 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +29 -3
- package/agent-template/.ai/skills/module-update/SKILL.md +9 -3
- package/agent-template/.ai/skills/perf-audit/SKILL.md +0 -1
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +0 -1
- package/agent-template/.ai/skills/spec-interview/SKILL.md +120 -0
- package/agent-template/.ai/skills/test-hardening/SKILL.md +1 -0
- package/agent-template/.ai/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/skills/variables/SKILL.md +0 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +0 -1
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/cli-extension/SKILL.md +1 -1
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +114 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +29 -0
- package/agent-template/.claude/skills/module-update/SKILL.md +9 -1
- package/agent-template/.claude/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.claude/skills/ux-design/SKILL.md +34 -3
- package/agent-template/AGENTS.md +4 -0
- package/agent-template/CLAUDE.md +4 -0
- package/agent-template/docs/adr/0003-module-settings.md +1 -1
- package/agent-template/docs/adr/0006-agentic-workflows.md +24 -21
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli-extensions.md +82 -0
- package/agent-template/docs/cli.md +195 -0
- package/agent-template/docs/configuration.md +593 -36
- package/agent-template/docs/design-system.md +185 -31
- package/agent-template/docs/getting-started.md +118 -0
- package/agent-template/docs/module-distribution.md +96 -0
- package/agent-template/docs/module-web-surfaces.md +221 -0
- package/agent-template/docs/modules.md +216 -0
- package/agent-template/docs/operations.md +545 -0
- package/agent-template/docs/sandbox.md +212 -0
- package/agent-template/platform/scripts/build.mjs +11 -0
- package/dist/bin.js +29 -0
- package/package.json +1 -1
- package/template/default/.dockerignore +14 -0
- package/template/default/.env.example +96 -0
- package/template/default/README.md +37 -1
- package/template/default/flowdular.json +15 -4
- package/template/default/infra/README.md +116 -0
- package/template/default/infra/docker/Dockerfile +37 -0
- package/template/default/infra/docker/compose.yaml +158 -0
- package/template/default/infra/docker/postgres/10-roles.sh +31 -0
- package/template/default/infra/docker/postgres/tls-init.sh +28 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +15 -0
- package/template/default/infra/kubernetes/deployment.yaml +211 -0
- package/template/default/infra/kubernetes/kustomization.yaml +9 -0
- package/template/default/infra/kubernetes/secrets.example.yaml +52 -0
- package/template/default/infra/kubernetes/service.yaml +13 -0
- package/template/default/modules/example/module.json +2 -1
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/database-repository.ts +2 -12
- package/template/default/package.json +3 -2
- package/template/default/platform/octane.config.ts +99 -9
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/src/generated/modules.client.ts +26 -2
- package/template/default/platform/src/generated/modules.server.ts +241 -10
- package/template/default/platform/src/server/health.ts +47 -0
- package/template/default/platform/src/server/metrics.ts +100 -0
- package/template/default/platform/src/server/storage.ts +172 -0
- package/template/default/platform/src/server/tracing.ts +85 -0
- package/template/default/specs/application.yaml +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
defineEndpoint,
|
|
6
|
+
jsonResponse,
|
|
7
|
+
serverMetrics,
|
|
8
|
+
type MetricsRegistry,
|
|
9
|
+
} from '@flowdular/sdk/server';
|
|
10
|
+
import type { ServerRoute } from '@octanejs/app-core';
|
|
11
|
+
|
|
12
|
+
const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
13
|
+
const BEARER = /^Bearer (.+)$/i;
|
|
14
|
+
|
|
15
|
+
export interface MetricsRoutesOptions {
|
|
16
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
17
|
+
readonly metrics?: MetricsRegistry;
|
|
18
|
+
readonly version?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function exposed(value: string | undefined): boolean {
|
|
22
|
+
if (value === undefined || value.trim() === '') return false;
|
|
23
|
+
if (value === 'true') return true;
|
|
24
|
+
if (value === 'false') return false;
|
|
25
|
+
throw new Error('FD_METRICS must be true or false.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Both this source file and the bundled server entry sit two directories below
|
|
29
|
+
platform/package.json, which the container image ships next to dist/. */
|
|
30
|
+
export function platformVersion(): string {
|
|
31
|
+
try {
|
|
32
|
+
const manifest: unknown = JSON.parse(
|
|
33
|
+
readFileSync(
|
|
34
|
+
join(import.meta.dirname, '..', '..', 'package.json'),
|
|
35
|
+
'utf8',
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
const version = (manifest as { version?: unknown }).version;
|
|
39
|
+
return typeof version === 'string' && version ? version : 'unknown';
|
|
40
|
+
} catch {
|
|
41
|
+
return 'unknown';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Comparing digests keeps the operands the same length, so neither the timing
|
|
46
|
+
nor a length check tells a caller how long the configured token is. */
|
|
47
|
+
function tokenMatches(header: string | null, token: string): boolean {
|
|
48
|
+
const presented = BEARER.exec(header ?? '')?.[1];
|
|
49
|
+
if (presented === undefined) return false;
|
|
50
|
+
return timingSafeEqual(
|
|
51
|
+
createHash('sha256').update(presented).digest(),
|
|
52
|
+
createHash('sha256').update(token).digest(),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Prometheus exposition for this process. Composed only when `FD_METRICS=true`,
|
|
58
|
+
* and readable only with `FD_METRICS_TOKEN` when that is set. The series carry
|
|
59
|
+
* endpoint ids, never a request path, a session or tenant data.
|
|
60
|
+
*/
|
|
61
|
+
export function createMetricsRoutes(
|
|
62
|
+
options: MetricsRoutesOptions = {},
|
|
63
|
+
): readonly ServerRoute[] {
|
|
64
|
+
const environment = options.environment ?? process.env;
|
|
65
|
+
if (!exposed(environment.FD_METRICS)) return [];
|
|
66
|
+
const token = environment.FD_METRICS_TOKEN?.trim() || null;
|
|
67
|
+
const metrics = options.metrics ?? serverMetrics();
|
|
68
|
+
metrics.setBuildVersion(options.version ?? platformVersion());
|
|
69
|
+
const endpoint = defineEndpoint({
|
|
70
|
+
id: 'system.metrics',
|
|
71
|
+
path: '/api/metrics',
|
|
72
|
+
methods: ['GET'],
|
|
73
|
+
access: { kind: 'public' },
|
|
74
|
+
handler: ({ octane }) => {
|
|
75
|
+
if (
|
|
76
|
+
token &&
|
|
77
|
+
!tokenMatches(octane.request.headers.get('authorization'), token)
|
|
78
|
+
) {
|
|
79
|
+
const denied = jsonResponse(
|
|
80
|
+
{
|
|
81
|
+
error: {
|
|
82
|
+
code: 'UNAUTHENTICATED',
|
|
83
|
+
message: 'Authentication is required.',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
401,
|
|
87
|
+
);
|
|
88
|
+
denied.headers.set('www-authenticate', 'Bearer');
|
|
89
|
+
return denied;
|
|
90
|
+
}
|
|
91
|
+
return new Response(metrics.expose(), {
|
|
92
|
+
headers: {
|
|
93
|
+
'content-type': CONTENT_TYPE,
|
|
94
|
+
'cache-control': 'no-store',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
return [endpoint.serverRoute];
|
|
100
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { defineEndpoint } from '@flowdular/sdk/server';
|
|
2
|
+
import {
|
|
3
|
+
createStoragePort,
|
|
4
|
+
createStorageKeyring,
|
|
5
|
+
openStorageReadToken,
|
|
6
|
+
StorageError,
|
|
7
|
+
storageConfigFromEnvironment,
|
|
8
|
+
STORAGE_READ_ROUTE_PREFIX,
|
|
9
|
+
type StorageConfig,
|
|
10
|
+
type StoragePort,
|
|
11
|
+
type StoragePortOptions,
|
|
12
|
+
} from '@flowdular/sdk/storage';
|
|
13
|
+
import type { Keyring } from '@flowdular/sdk/kernel';
|
|
14
|
+
import type { ServerRoute } from '@octanejs/app-core';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
createStoragePort,
|
|
18
|
+
createStorageKeyring,
|
|
19
|
+
storageConfigFromEnvironment,
|
|
20
|
+
STORAGE_READ_ROUTE_PREFIX,
|
|
21
|
+
};
|
|
22
|
+
export type {
|
|
23
|
+
StorageConfig as PlatformStorageConfig,
|
|
24
|
+
StoragePort as PlatformStoragePort,
|
|
25
|
+
StoragePortOptions as PlatformStoragePortOptions,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RATE_LIMIT = 600;
|
|
29
|
+
const RATE_WINDOW_MS = 60_000;
|
|
30
|
+
const RATE_CAPACITY = 4_096;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fixed window per caller, bounded. Expired windows are dropped once the map
|
|
34
|
+
* reaches capacity, so a flood of distinct tokens cannot grow it without limit.
|
|
35
|
+
*/
|
|
36
|
+
class ReadRateLimiter {
|
|
37
|
+
readonly #windows = new Map<string, { count: number; startedAt: number }>();
|
|
38
|
+
|
|
39
|
+
allow(key: string, now: number): boolean {
|
|
40
|
+
const current = this.#windows.get(key);
|
|
41
|
+
if (current && now - current.startedAt < RATE_WINDOW_MS) {
|
|
42
|
+
current.count += 1;
|
|
43
|
+
return current.count <= RATE_LIMIT;
|
|
44
|
+
}
|
|
45
|
+
if (this.#windows.size >= RATE_CAPACITY) this.#evict(now);
|
|
46
|
+
this.#windows.set(key, { count: 1, startedAt: now });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#evict(now: number): void {
|
|
51
|
+
for (const [key, window] of this.#windows) {
|
|
52
|
+
if (now - window.startedAt >= RATE_WINDOW_MS) this.#windows.delete(key);
|
|
53
|
+
}
|
|
54
|
+
while (this.#windows.size >= RATE_CAPACITY) {
|
|
55
|
+
const oldest = this.#windows.keys().next();
|
|
56
|
+
if (oldest.done) return;
|
|
57
|
+
this.#windows.delete(oldest.value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ADDRESS_PATTERN = /^[0-9a-fA-F.:]{3,45}$/;
|
|
63
|
+
|
|
64
|
+
/* Octane hands routes a Web Request without the socket address, so the client
|
|
65
|
+
is only known behind a proxy this deployment trusts. Without one the window
|
|
66
|
+
is keyed by the token, which still bounds replay of a single read URL. The
|
|
67
|
+
capability itself is what resists guessing: a token carries a 16 byte
|
|
68
|
+
authentication tag. */
|
|
69
|
+
function limiterKey(
|
|
70
|
+
request: Request,
|
|
71
|
+
trustProxy: boolean,
|
|
72
|
+
token: string,
|
|
73
|
+
): string {
|
|
74
|
+
if (trustProxy) {
|
|
75
|
+
const first = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
76
|
+
if (first && ADDRESS_PATTERN.test(first))
|
|
77
|
+
return `ip:${first.toLowerCase()}`;
|
|
78
|
+
}
|
|
79
|
+
return `token:${token.slice(0, 32)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function notFound(requestId: string): Response {
|
|
83
|
+
return Response.json(
|
|
84
|
+
{
|
|
85
|
+
error: {
|
|
86
|
+
code: 'NOT_FOUND',
|
|
87
|
+
message: 'The requested object is not available.',
|
|
88
|
+
},
|
|
89
|
+
requestId,
|
|
90
|
+
},
|
|
91
|
+
{ status: 404, headers: { 'cache-control': 'private, no-store' } },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface StorageRoutesOptions {
|
|
96
|
+
readonly storage: StoragePort;
|
|
97
|
+
readonly keyring: Keyring;
|
|
98
|
+
readonly environment?: NodeJS.ProcessEnv | undefined;
|
|
99
|
+
readonly clock?: (() => Date) | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `GET /api/storage/objects/:token` streams the decrypted body of one object.
|
|
104
|
+
* The token is the capability: it is sealed under the storage keyring, names
|
|
105
|
+
* the tenant, module, object and expiry, and needs no session, which is why
|
|
106
|
+
* this route has no 401 and answers 404 to everything it cannot serve. A
|
|
107
|
+
* different answer for an expired token, a forged one and a deleted object
|
|
108
|
+
* would tell a caller which of the three it holds.
|
|
109
|
+
*/
|
|
110
|
+
export function createStorageRoutes(
|
|
111
|
+
options: StorageRoutesOptions,
|
|
112
|
+
): readonly ServerRoute[] {
|
|
113
|
+
const environment = options.environment ?? process.env;
|
|
114
|
+
const trustProxy = environment.FD_TRUST_PROXY === 'true';
|
|
115
|
+
const clock = options.clock ?? (() => new Date());
|
|
116
|
+
const limiter = new ReadRateLimiter();
|
|
117
|
+
const endpoint = defineEndpoint({
|
|
118
|
+
id: 'system.storage.read',
|
|
119
|
+
path: `${STORAGE_READ_ROUTE_PREFIX}:token`,
|
|
120
|
+
methods: ['GET'],
|
|
121
|
+
access: { kind: 'public' },
|
|
122
|
+
handler: async ({ octane, requestId }) => {
|
|
123
|
+
const token = octane.params.token ?? '';
|
|
124
|
+
const now = clock();
|
|
125
|
+
if (
|
|
126
|
+
!limiter.allow(
|
|
127
|
+
limiterKey(octane.request, trustProxy, token),
|
|
128
|
+
now.getTime(),
|
|
129
|
+
)
|
|
130
|
+
) {
|
|
131
|
+
return Response.json(
|
|
132
|
+
{
|
|
133
|
+
error: { code: 'RATE_LIMITED', message: 'Too many object reads.' },
|
|
134
|
+
requestId,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
status: 429,
|
|
138
|
+
headers: {
|
|
139
|
+
'cache-control': 'private, no-store',
|
|
140
|
+
'retry-after': '60',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const reference = openStorageReadToken(options.keyring, token, now);
|
|
146
|
+
if (!reference) return notFound(requestId);
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await options.storage.get(reference);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
/* A frame this keyring cannot open is indistinguishable from an absent
|
|
152
|
+
object to the reader, and saying so would leak that it exists. An
|
|
153
|
+
adapter outage is a real 500 and stays one. */
|
|
154
|
+
if (error instanceof StorageError && error.code === 'OBJECT_CORRUPT') {
|
|
155
|
+
return notFound(requestId);
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
if (!result) return notFound(requestId);
|
|
160
|
+
return new Response(result.body, {
|
|
161
|
+
headers: {
|
|
162
|
+
'content-type': result.object.contentType,
|
|
163
|
+
'content-length': String(result.object.bytes),
|
|
164
|
+
'content-disposition': 'attachment',
|
|
165
|
+
'cache-control': 'private, no-store',
|
|
166
|
+
'x-content-type-options': 'nosniff',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
return [endpoint.serverRoute];
|
|
172
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOtlpSpanExporter,
|
|
3
|
+
errorSinkConfigFromEnvironment,
|
|
4
|
+
serverErrorSink,
|
|
5
|
+
traceConfigFromEnvironment,
|
|
6
|
+
type ErrorSink,
|
|
7
|
+
type SpanExporter,
|
|
8
|
+
} from '@flowdular/sdk/server';
|
|
9
|
+
import { platformVersion } from './metrics.ts';
|
|
10
|
+
|
|
11
|
+
export interface PlatformObservabilityOptions {
|
|
12
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
13
|
+
readonly version?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Batches one disposal drains from the error sink: the queue bound divided by
|
|
17
|
+
the batch, and one over, because a report may be queued while it drains. */
|
|
18
|
+
const SINK_FLUSHES = 4;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sends what the error sink still holds. One flush sends one batch and the
|
|
22
|
+
* queue holds more than one, so a single flush on the way out would strand the
|
|
23
|
+
* rest. The loop ends on an empty queue, on a flush that moved nothing, or on
|
|
24
|
+
* the bound, so an unreachable webhook can never hold a shutdown open.
|
|
25
|
+
*/
|
|
26
|
+
export async function drainErrorSink(sink: ErrorSink): Promise<void> {
|
|
27
|
+
for (let batch = 0; batch < SINK_FLUSHES; batch += 1) {
|
|
28
|
+
const queued = sink.stats().queued;
|
|
29
|
+
if (queued === 0) return;
|
|
30
|
+
await sink.flush();
|
|
31
|
+
if (sink.stats().queued >= queued) return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PlatformObservability {
|
|
36
|
+
/** Null when `FD_TRACE_EXPORTER` is none, which is the default. */
|
|
37
|
+
readonly exporter: SpanExporter | null;
|
|
38
|
+
/** The sink logged errors are reported through; `none` holds nothing. */
|
|
39
|
+
readonly errorSink: ErrorSink;
|
|
40
|
+
/** Drains both egresses. Safe to call more than once. */
|
|
41
|
+
dispose(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Composes the two optional observability egresses for this deployment.
|
|
46
|
+
*
|
|
47
|
+
* Both configurations are read here, at boot, so a deployment that misspelled
|
|
48
|
+
* a variable fails to start rather than quietly sending nothing. Spans are
|
|
49
|
+
* always recorded into the bounded in-process buffer; only the export is
|
|
50
|
+
* optional, and neither egress ever runs on a request path.
|
|
51
|
+
*
|
|
52
|
+
* The error sink is the process singleton the logger already reports through,
|
|
53
|
+
* so this only validates it and drains it on shutdown: a process that stops
|
|
54
|
+
* must not orphan the reports it queued.
|
|
55
|
+
*/
|
|
56
|
+
export function createPlatformObservability(
|
|
57
|
+
options: PlatformObservabilityOptions = {},
|
|
58
|
+
): PlatformObservability {
|
|
59
|
+
const environment = options.environment ?? process.env;
|
|
60
|
+
const trace = traceConfigFromEnvironment(environment);
|
|
61
|
+
/* Validated here and discarded: `serverErrorSink()` builds the live one from
|
|
62
|
+
the same variables, and a second sink would double every report. */
|
|
63
|
+
errorSinkConfigFromEnvironment(environment);
|
|
64
|
+
const errorSink = serverErrorSink();
|
|
65
|
+
const exporter =
|
|
66
|
+
trace.exporter === 'otlp' && trace.url
|
|
67
|
+
? createOtlpSpanExporter({
|
|
68
|
+
url: trace.url,
|
|
69
|
+
headers: trace.headers,
|
|
70
|
+
environment,
|
|
71
|
+
serviceName: 'flowdular',
|
|
72
|
+
serviceVersion: options.version ?? platformVersion(),
|
|
73
|
+
})
|
|
74
|
+
: null;
|
|
75
|
+
return {
|
|
76
|
+
exporter,
|
|
77
|
+
errorSink,
|
|
78
|
+
async dispose(): Promise<void> {
|
|
79
|
+
/* Disposal drains the span buffer, so what this process recorded while
|
|
80
|
+
it stopped is still evidence about the stop. */
|
|
81
|
+
await exporter?.dispose();
|
|
82
|
+
await drainErrorSink(errorSink);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# The platform spec of this application. "flowdular spec validate --all"
|
|
2
|
+
# validates it together with every modules/*/spec/module.yaml.
|
|
3
|
+
schemaVersion: 1
|
|
4
|
+
id: application.app-name
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
status: approved
|
|
7
|
+
principles:
|
|
8
|
+
- spec-before-implementation
|
|
9
|
+
- default-deny
|
|
10
|
+
- tenant-scoped-data
|
|
11
|
+
repositoryRoots:
|
|
12
|
+
platform: platform
|
|
13
|
+
modules: modules
|
|
14
|
+
infrastructure: infra
|
|
15
|
+
agentConfiguration: .ai
|