job-application-agent 3.1.2 → 3.2.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/README.md +4 -3
- package/job-application-agent/SKILL.md +5 -4
- package/job-application-agent/references/ANALYTICS.md +1 -1
- package/job-application-agent/references/SCHEMAS.md +1 -1
- package/job-application-agent/references/SOURCES.md +14 -7
- package/job-application-agent/scripts/job-application.mjs +92 -14
- package/job-application-agent/scripts/source-community-client.mjs +99 -15
- package/job-application-agent/scripts/source-community-schema.mjs +147 -1
- package/job-application-agent/scripts/telemetry-client.mjs +3 -1
- package/job-application-agent/scripts/version.mjs +1 -0
- package/job-application-agent/tests/job-application.test.mjs +15 -6
- package/job-application-agent/tests/privacy-audit.test.mjs +24 -1
- package/job-application-agent/tests/source-community-client.test.mjs +108 -2
- package/job-application-agent/tests/source-community-schema.test.mjs +106 -1
- package/job-application-agent/tests/version.test.mjs +12 -0
- package/job-application-agent/tests/workflow-state.test.mjs +115 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ flowchart LR
|
|
|
102
102
|
|
|
103
103
|
The bundled CLI handles private profile storage, résumé import, scoring, duplicate checks, resumable rounds, attention queues, and application/outcome ledgers. The coding agent handles discovery and browser interaction under the rules in [`SKILL.md`](job-application-agent/SKILL.md).
|
|
104
104
|
|
|
105
|
-
Discovery combines the reviewed [`SOURCES.json`](job-application-agent/references/SOURCES.json) catalog with an anonymous community registry.
|
|
105
|
+
Discovery combines the reviewed [`SOURCES.json`](job-application-agent/references/SOURCES.json) catalog with an anonymous community registry. Every confirmed application automatically contributes its canonical public job URL, company, role, application channel, and provider; prior confirmed ledger entries backfill during later commands after a one-command disclosure grace period. Jobs, repeatable boards, and feeds are logged pending and become visible in the public dashboard or CLI only after maintainer review. Disable both forms of community sharing independently from analytics with `sources sharing disable`.
|
|
106
106
|
|
|
107
107
|
## 🔐 Privacy
|
|
108
108
|
|
|
@@ -111,20 +111,21 @@ Discovery combines the reviewed [`SOURCES.json`](job-application-agent/reference
|
|
|
111
111
|
| Profile | macOS Keychain or Windows Credential Manager |
|
|
112
112
|
| Résumé and ledgers | Owner-only local state directory |
|
|
113
113
|
| Browser login | Existing browser session |
|
|
114
|
-
|
|
|
114
|
+
| Community-sharing preference and delivery receipts | Owner-only local state directory |
|
|
115
115
|
| Skill code | Version-controlled installation directory |
|
|
116
116
|
|
|
117
117
|
Candidate data, résumés, application history, credentials, and browser sessions are never committed to this repository.
|
|
118
118
|
|
|
119
119
|
Anonymous structured analytics are enabled by default to improve the agent. They may include job and workflow categories, but never candidate identity, résumé content, prompts, answers, browser data, IP addresses, or raw errors.
|
|
120
120
|
|
|
121
|
-
Anonymous community
|
|
121
|
+
Anonymous community sharing is also enabled by default, separately from analytics. Confirmed applications share only a canonical public job URL, company, role, application channel, optional coarse discovery source, and derived provider URL—never candidate identity, answers, résumé, score, referral parameters, or submission timestamp. Repeatable discovery surfaces share their bounded catalog metadata through a maintainer-review queue. The registry stores no raw installation IDs; record-scoped contributor hashes are used only for deduplication and counts, never as identity or publication authority.
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
124
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry status
|
|
125
125
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry disable
|
|
126
126
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs sources sharing status
|
|
127
127
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs sources sharing disable
|
|
128
|
+
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs sources jobs
|
|
128
129
|
```
|
|
129
130
|
|
|
130
131
|
See [`ANALYTICS.md`](job-application-agent/references/ANALYTICS.md) for the event contract and retention policy, or view the [public aggregate dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/).
|
|
@@ -24,7 +24,7 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
|
|
|
24
24
|
5. Use `review-each` for per-application approval. Use `routine-auto` only when the current request authorizes the destination or batch and every automatic-eligibility condition passes.
|
|
25
25
|
6. When the candidate explicitly grants continuing autonomy, read [references/AUTONOMY.md](references/AUTONOMY.md) and persist it with `autonomy grant --stdin`. Do not repeat skill-level upload or submission approval prompts while the active grant and profile both use `routine-auto`.
|
|
26
26
|
7. Obey browser and tool confirmation requirements regardless of the stored mode or autonomy grant.
|
|
27
|
-
8. Disclose default-enabled structured anonymous analytics and the `telemetry disable` control. Disclose default-enabled anonymous community
|
|
27
|
+
8. Disclose default-enabled structured anonymous analytics and the `telemetry disable` control. Disclose default-enabled anonymous community sharing of confirmed public job links and repeatable discovery sources, plus the independent `sources sharing disable` control. The CLI also displays these disclosures before the first eligible transmission.
|
|
28
28
|
|
|
29
29
|
Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answers, browser session data, or inferred candidate facts.
|
|
30
30
|
|
|
@@ -32,8 +32,8 @@ Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answ
|
|
|
32
32
|
|
|
33
33
|
Read [references/SOURCES.md](references/SOURCES.md) before the first discovery pass in a workflow.
|
|
34
34
|
|
|
35
|
-
1. Run `sources list` (optionally filtered)
|
|
36
|
-
2. Attribute the lead with coarse `discoverySource`, stable packaged
|
|
35
|
+
1. Run `sources jobs` for recently confirmed direct job links and `sources list` (optionally filtered) for the highest-signal packaged and maintainer-reviewed discovery sources. Resolve every lead to the direct employer or ATS page.
|
|
36
|
+
2. Attribute the lead with coarse `discoverySource`, stable packaged or community `discoverySourceId` when known, and independent `applicationChannel`. Treat a one-off user link as `user-supplied`. Whenever a user or agent discovers a repeatable public board, feed, directory, or careers index that is not already listed, run `sources suggest --stdin`; the CLI contributes its sanitized metadata by default unless community sharing has been disabled.
|
|
37
37
|
3. Verify the application channel immediately before assessment. Mark it `active`, `closed`, or `unclear`.
|
|
38
38
|
4. Classify eligibility only after checking residence, location, work authorization, sponsorship, schedule, and employment type.
|
|
39
39
|
5. Extract explicit seniority, experience range, work mode, locations, comparable published salary maximum, and all must-have requirements.
|
|
@@ -62,7 +62,7 @@ For batches, scheduled work, or resumable handoffs, read [references/RUNS.md](re
|
|
|
62
62
|
8. Upload only the canonical resume unless the candidate explicitly provides another attachment. Resolve its absolute path with `resume path`, then follow [references/BROWSER_UPLOADS.md](references/BROWSER_UPLOADS.md). Use the browser's privileged path-based upload capability first; treat a visible native file picker as a fallback.
|
|
63
63
|
9. Do not answer demographic questions. Stop for login/SSO/MFA, CAPTCHA, legal attestations, unclear authorization or compensation, sensitive identifiers, and judgment-only questions.
|
|
64
64
|
10. Verify every required field, answer, attachment, and disclosure. Submit when the current request or active autonomy grant authorizes it.
|
|
65
|
-
11. Record `submitted` only after visible success confirmation, using independent `discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` values. Record no submission when confirmation is missing or ambiguous.
|
|
65
|
+
11. Record `submitted` only after visible success confirmation, using independent `discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` values. `ledger add` automatically shares the sanitized public job metadata and durably retries on relay failure; do not run a separate manual contribution. Record no submission when confirmation is missing or ambiguous.
|
|
66
66
|
12. Record workflow telemetry with `telemetry record --stdin`. Let `ledger add` emit `application_submitted`; do not emit it twice. Pass job URLs and structured metrics only through documented transient fields.
|
|
67
67
|
13. Queue hard stops with `attention add --stdin` and continue elsewhere. Record reproducible general-purpose failures with `friction record --stdin`; improvement work must never delay application work.
|
|
68
68
|
|
|
@@ -98,6 +98,7 @@ node scripts/job-application.mjs autonomy status|preview|revoke
|
|
|
98
98
|
node scripts/job-application.mjs round start|complete --stdin
|
|
99
99
|
node scripts/job-application.mjs round status [round-id]
|
|
100
100
|
node scripts/job-application.mjs sources list [--stdin]
|
|
101
|
+
node scripts/job-application.mjs sources jobs [--stdin]
|
|
101
102
|
node scripts/job-application.mjs sources suggest --stdin
|
|
102
103
|
node scripts/job-application.mjs sources pending
|
|
103
104
|
node scripts/job-application.mjs sources sync
|
|
@@ -25,7 +25,7 @@ node scripts/job-application.mjs telemetry record --stdin
|
|
|
25
25
|
|
|
26
26
|
Telemetry is best effort. It has no offline queue, uses a short network timeout, and never changes the result of a job-application command.
|
|
27
27
|
|
|
28
|
-
Community
|
|
28
|
+
Community sharing is a separate default-enabled feature with independent `sources sharing status|enable|disable|reset` controls. Confirmed applications contribute bounded public job metadata, while repeatable discovery sources use maintainer review. Neither sends analytics events nor stores a raw contributor identity with a record; a record-scoped HMAC is used only for deduplication and unique-system counting. See [`SOURCES.md`](SOURCES.md) for the exact public metadata contracts.
|
|
29
29
|
|
|
30
30
|
## Identity boundary
|
|
31
31
|
|
|
@@ -131,7 +131,7 @@ Add only after visible success confirmation.
|
|
|
131
131
|
|
|
132
132
|
Use `duplicateOverride` only for a verified distinct requisition after a possible-duplicate warning. Use `companyReapplyOverride` only when the candidate explicitly approves a different-role reapplication during the cooldown or after a recorded outcome. Both override phrases and `telemetry` are transient. An accepted company reapplication override stores only `reapplicationApproval: "candidate-explicit"` in the private ledger. Use approval `APPROVE SUBMIT` for per-application approval or `STANDING AUTHORIZATION` when the current request authorizes routine batch submission.
|
|
133
133
|
|
|
134
|
-
`discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `discoverySourceId` remains local and is not included in telemetry. `ledger check` returns hard duplicate status, bounded same-company history, and the same `companyReapply` decision enforced by `ledger add` while holding the application lock.
|
|
134
|
+
`discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `discoverySourceId` accepts stable packaged and `community-…` source IDs; it remains local and is not included in telemetry. After a visibly confirmed submission, `ledger add` automatically contributes only the canonical public URL, company, role, application channel, optional coarse discovery source, and derived provider URL to the pending community job registry. Existing installations receive a one-command disclosure grace period before historical backfill; `sources sharing disable` opts out. The private answers, score, submission time, IDs, and round remain local, and no job appears publicly before maintainer review. `ledger check` returns hard duplicate status, bounded same-company history, and the same `companyReapply` decision enforced by `ledger add` while holding the application lock.
|
|
135
135
|
|
|
136
136
|
## Autonomy grant input
|
|
137
137
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Discovery source registry
|
|
2
2
|
|
|
3
|
-
The packaged [`SOURCES.json`](SOURCES.json) catalog is the reviewed, versioned list of repeatable discovery surfaces available to every installation. A community registry supplements it with
|
|
3
|
+
The packaged [`SOURCES.json`](SOURCES.json) catalog is the reviewed, versioned list of repeatable discovery surfaces available to every installation. A community registry supplements it with maintainer-reviewed discovery sources and direct public job links from confirmed applications. Both remain distinct from application channels such as Greenhouse, Ashby, Lever, and Workday.
|
|
4
4
|
|
|
5
5
|
## Use the catalog
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
node scripts/job-application.mjs sources list
|
|
9
9
|
node scripts/job-application.mjs sources list --stdin
|
|
10
|
+
node scripts/job-application.mjs sources jobs
|
|
11
|
+
echo '{"limit":25,"cursor":"opaque-cursor"}' | node scripts/job-application.mjs sources jobs --stdin
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
Optional filter input:
|
|
@@ -20,24 +22,27 @@ Optional filter input:
|
|
|
20
22
|
}
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
`sources list` combines the packaged catalog with the live
|
|
25
|
+
`sources list` combines the packaged catalog with the live repeatable-source registry. Public v1 community entries are always maintainer-approved and have `registryStatus: "community-reviewed"`. Their `id` and `communitySourceId` are the same stable community ID and may be stored as `discoverySourceId`. `sources jobs` returns the newest maintainer-reviewed confirmed job links, 50 at a time by default, with an opaque `nextCursor` for pagination. Both are discovery leads, not endorsements or eligibility claims. Resolve every lead to the direct employer or ATS page and verify that posting immediately before assessment and submission.
|
|
24
26
|
|
|
25
27
|
Store three independent attribution fields when available:
|
|
26
28
|
|
|
27
29
|
- `discoverySource`: coarse compatible channel such as `linkedin`, `x`, `yc`, `job-board`, or `user-supplied`.
|
|
28
|
-
- `discoverySourceId`: stable
|
|
30
|
+
- `discoverySourceId`: stable packaged or community ID such as `yc-work-at-a-startup` or `community-abcdef1234567890`.
|
|
29
31
|
- `applicationChannel`: actual submission channel such as `greenhouse`, `ashby`, `lever`, `company`, or `email`.
|
|
30
32
|
|
|
31
33
|
`discoverySourceId` remains local in v1 and is not transmitted by telemetry.
|
|
32
34
|
|
|
33
35
|
## Community sharing
|
|
34
36
|
|
|
35
|
-
Community
|
|
37
|
+
Community sharing is enabled by default and independent of usage analytics. A confirmed `ledger add` queues and attempts the sanitized public job metadata automatically. Installations with historical applications consume one full disclosure command without transmission before backfill, giving them time to run `sources sharing disable`; an explicit `sources sharing enable` opts in immediately. New installations have no historical backfill and contribute the first newly confirmed job after displaying the notice. Existing confirmed ledger entries are the durable backfill/retry queue and are retried in bounded batches during later commands. No separate contribution command is required for applied jobs.
|
|
38
|
+
|
|
39
|
+
Whenever the user or agent discovers a repeatable public discovery surface—not a recruiter profile, referral link, personal URL, or one-off job detail route—queue it for maintainer review:
|
|
36
40
|
|
|
37
41
|
```text
|
|
38
42
|
node scripts/job-application.mjs sources suggest --stdin
|
|
39
43
|
node scripts/job-application.mjs sources pending
|
|
40
44
|
node scripts/job-application.mjs sources sync
|
|
45
|
+
node scripts/job-application.mjs sources jobs
|
|
41
46
|
node scripts/job-application.mjs sources sharing status
|
|
42
47
|
node scripts/job-application.mjs sources sharing disable
|
|
43
48
|
```
|
|
@@ -55,12 +60,14 @@ Suggestion input:
|
|
|
55
60
|
}
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
The first contribution displays a disclosure and sends during that command. `disable` stops
|
|
63
|
+
The first eligible contribution displays a disclosure and sends during that command. `disable` stops both job and discovery-source sharing; `enable` resumes and retries it; `reset` disables sharing and removes its anonymous relay credentials. The preference, anonymous credential, and delivery receipts are stored in owner-only local files.
|
|
64
|
+
|
|
65
|
+
The applied-job contract contains only the canonical HTTPS job URL, company, role, application channel, optional coarse discovery source, and a server-derived provider URL. Referral, tracking, and fragment data are removed; a small allowlist of stable job/requisition query identifiers is retained so query-addressed jobs do not collapse together. The server adds day-bucketed first/last-seen dates and an anonymous agent-report count. Candidate identity, résumé, form answers, score, application timestamp, referral parameters, raw installation ID, and contributor hash are never public or stored with a community job. Every accepted record is logged as pending; only a maintainer-reviewed destination and matching company/role can become public. Canonical URL deduplication prevents repeated rows, and rejected records never republish automatically.
|
|
59
66
|
|
|
60
67
|
The client and relay use the same fail-closed source-route classifier. They remove query parameters and fragments; reject embedded credentials, credential-like opaque path segments, identity-like names and paths, personal profiles, local/private hosts, unknown fields, oversized payloads, and known detail routes from Workday, LinkedIn Jobs, Greenhouse, Lever, Ashby, Workable, and SmartRecruiters. Unknown domains are accepted only at the root or on explicit collection, directory, feed, careers, openings, or job-index routes. Only the source name, canonical public base URL, kind, regions, role families, and session requirement are shared.
|
|
61
68
|
|
|
62
69
|
The raw anonymous installation ID authenticates and rate-limits a request but is never stored in the registry. The relay stores a source-scoped HMAC only to deduplicate contributions and help a maintainer prioritize review. One system contributes at most once to a canonical source, and `contributionCount` always means unique contributing systems—not people. It is never identity, trust, authority, or a condition for publication. The first valid contribution owns the canonical metadata; later contributions cannot rewrite it.
|
|
63
70
|
|
|
64
|
-
If sharing is disabled or offline, suggestions
|
|
71
|
+
If sharing is disabled or offline, repeatable-source suggestions remain in their owner-only queue and confirmed jobs remain pending in the canonical ledger until a minimal delivery receipt exists. `sources pending` reports both kinds of locally unsent work. `sources sync` retries both, and `sources list` performs a best-effort retry before reading the registry. A server-accepted source suggestion is delivered even while pending moderation. Network failure never blocks discovery or an application.
|
|
65
72
|
|
|
66
|
-
Every accepted
|
|
73
|
+
Every accepted repeatable-source contribution remains in the private moderation queue until an owner explicitly approves it with owner-only D1 commands. Rejected sources remain hidden after later contributions and cannot republish automatically. Maintainer procedures are documented in [`telemetry-worker/COMMUNITY_SOURCE_MODERATION.md`](https://github.com/vaibhavarora14/job-application-agent/blob/main/telemetry-worker/COMMUNITY_SOURCE_MODERATION.md). This moderation rule does not delay sanitized confirmed-job links.
|
|
@@ -9,7 +9,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
9
9
|
|
|
10
10
|
import { createSecretStore, migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
11
11
|
import { SourceCommunityClient } from './source-community-client.mjs';
|
|
12
|
-
import { normalizeCommunitySource } from './source-community-schema.mjs';
|
|
12
|
+
import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
|
|
13
13
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
14
14
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
15
15
|
|
|
@@ -37,6 +37,7 @@ const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url
|
|
|
37
37
|
const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
|
|
38
38
|
if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
|
|
39
39
|
const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
|
|
40
|
+
const COMMUNITY_SOURCE_ID = /^community-[0-9a-f]{16}$/;
|
|
40
41
|
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
41
42
|
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
42
43
|
const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
|
|
@@ -189,6 +190,12 @@ function sourceId(value, label) {
|
|
|
189
190
|
return id;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
function knownDiscoverySourceId(value, label) {
|
|
194
|
+
const id = sourceId(value, label);
|
|
195
|
+
if (!SOURCE_CATALOG_IDS.has(id) && !COMMUNITY_SOURCE_ID.test(id)) throw new Error(`${label} must match a packaged or community source ID.`);
|
|
196
|
+
return id;
|
|
197
|
+
}
|
|
198
|
+
|
|
192
199
|
function integer(value, label, min, max) {
|
|
193
200
|
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
194
201
|
return value;
|
|
@@ -267,9 +274,7 @@ export function scoreJob(input, target) {
|
|
|
267
274
|
const description = string(job.description, 'job.description', 40000);
|
|
268
275
|
const source = string(job.source, 'job.source', 40).toLowerCase();
|
|
269
276
|
const discoverySource = job.discoverySource == null ? null : string(job.discoverySource, 'job.discoverySource', 40).toLowerCase();
|
|
270
|
-
if (job.discoverySourceId != null
|
|
271
|
-
throw new Error('job.discoverySourceId must match an ID in the packaged source catalog.');
|
|
272
|
-
}
|
|
277
|
+
if (job.discoverySourceId != null) knownDiscoverySourceId(job.discoverySourceId, 'job.discoverySourceId');
|
|
273
278
|
const applicationChannel = job.applicationChannel == null ? null : string(job.applicationChannel, 'job.applicationChannel', 40).toLowerCase();
|
|
274
279
|
const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
|
|
275
280
|
const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
|
|
@@ -428,8 +433,7 @@ export function validateLedgerEntry(input) {
|
|
|
428
433
|
if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
|
|
429
434
|
if (entry.discoverySource != null) normalized.discoverySource = string(entry.discoverySource, 'entry.discoverySource', 40).toLowerCase();
|
|
430
435
|
if (entry.discoverySourceId != null) {
|
|
431
|
-
normalized.discoverySourceId =
|
|
432
|
-
if (!SOURCE_CATALOG_IDS.has(normalized.discoverySourceId)) throw new Error('entry.discoverySourceId must match an ID in the packaged source catalog.');
|
|
436
|
+
normalized.discoverySourceId = knownDiscoverySourceId(entry.discoverySourceId, 'entry.discoverySourceId');
|
|
433
437
|
}
|
|
434
438
|
if (entry.applicationChannel != null) normalized.applicationChannel = string(entry.applicationChannel, 'entry.applicationChannel', 40).toLowerCase();
|
|
435
439
|
if (entry.roundId != null) normalized.roundId = string(entry.roundId, 'entry.roundId', 180);
|
|
@@ -642,7 +646,7 @@ async function sourceCatalog() {
|
|
|
642
646
|
return SOURCE_CATALOG;
|
|
643
647
|
}
|
|
644
648
|
|
|
645
|
-
async function sourcesList(filtersInput = {}, communitySources = []) {
|
|
649
|
+
export async function sourcesList(filtersInput = {}, communitySources = []) {
|
|
646
650
|
const filters = object(filtersInput, 'source filters');
|
|
647
651
|
const allowed = new Set(['regions', 'roleFamilies', 'kinds', 'requiresSession']);
|
|
648
652
|
for (const key of Object.keys(filters)) if (!allowed.has(key)) throw new Error(`Unknown source filter: ${key}.`);
|
|
@@ -651,7 +655,7 @@ async function sourcesList(filtersInput = {}, communitySources = []) {
|
|
|
651
655
|
const kinds = filters.kinds == null ? [] : terms(stringArray(filters.kinds, 'source filters.kinds'));
|
|
652
656
|
if (filters.requiresSession != null && typeof filters.requiresSession !== 'boolean') throw new Error('source filters.requiresSession must be a Boolean.');
|
|
653
657
|
const community = communitySources.map((source) => ({
|
|
654
|
-
id:
|
|
658
|
+
id: source.sourceId,
|
|
655
659
|
communitySourceId: source.sourceId,
|
|
656
660
|
name: source.name,
|
|
657
661
|
kind: source.kind,
|
|
@@ -709,6 +713,10 @@ async function sourcesPending() {
|
|
|
709
713
|
return { scope: 'local-unsent', count: pending.length, suggestions: pending };
|
|
710
714
|
}
|
|
711
715
|
|
|
716
|
+
async function communityPendingStatus() {
|
|
717
|
+
return { ...await sourcesPending(), communityJobs: await communityJobsPending() };
|
|
718
|
+
}
|
|
719
|
+
|
|
712
720
|
export async function sourcesSync(community, limit = 10) {
|
|
713
721
|
const pending = (await sourcesPending()).suggestions.slice(0, limit);
|
|
714
722
|
let shared = 0;
|
|
@@ -723,6 +731,69 @@ export async function sourcesSync(community, limit = 10) {
|
|
|
723
731
|
return { attempted, shared, remaining: (await sourcesPending()).count };
|
|
724
732
|
}
|
|
725
733
|
|
|
734
|
+
function shareableLedgerJob(entry) {
|
|
735
|
+
return normalizeCommunityJob({
|
|
736
|
+
url: entry.url,
|
|
737
|
+
company: entry.company,
|
|
738
|
+
role: entry.role,
|
|
739
|
+
applicationChannel: entry.applicationChannel ?? entry.source,
|
|
740
|
+
...(entry.discoverySource == null ? {} : { discoverySource: entry.discoverySource }),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function markCommunityJobShared(applicationId, contribution) {
|
|
745
|
+
if (!contribution.shared) return;
|
|
746
|
+
await appendPrivateEvent('community-job-contribution-receipts', {
|
|
747
|
+
applicationId,
|
|
748
|
+
jobId: contribution.jobId,
|
|
749
|
+
sharedAt: new Date().toISOString(),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function communityJobsPending() {
|
|
754
|
+
const directory = await ensureStateDir();
|
|
755
|
+
const applications = await jsonLines(join(directory, 'applications.ndjson'));
|
|
756
|
+
const receipts = await jsonLines(join(directory, 'community-job-contribution-receipts.ndjson'));
|
|
757
|
+
const shared = new Set(receipts.map((receipt) => receipt.applicationId));
|
|
758
|
+
const pending = [];
|
|
759
|
+
let unshareable = 0;
|
|
760
|
+
for (const entry of applications) {
|
|
761
|
+
if (shared.has(entry.id)) continue;
|
|
762
|
+
try {
|
|
763
|
+
pending.push({ applicationId: entry.id, job: shareableLedgerJob(entry) });
|
|
764
|
+
} catch {
|
|
765
|
+
unshareable += 1;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return { count: pending.length, unshareable, applications: pending };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export async function communityJobsSync(community, { limit = 10, applicationIds = null } = {}) {
|
|
772
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Community job sync limit must be between 1 and 100.');
|
|
773
|
+
const selected = applicationIds == null ? null : new Set(applicationIds);
|
|
774
|
+
const state = await communityJobsPending();
|
|
775
|
+
const pending = state.applications.filter((entry) => selected == null || selected.has(entry.applicationId)).slice(0, limit);
|
|
776
|
+
let attempted = 0;
|
|
777
|
+
let shared = 0;
|
|
778
|
+
for (const entry of pending) {
|
|
779
|
+
attempted += 1;
|
|
780
|
+
const contribution = await community.contributeJob(entry.job);
|
|
781
|
+
await markCommunityJobShared(entry.applicationId, contribution);
|
|
782
|
+
if (contribution.shared) shared += 1;
|
|
783
|
+
else if (contribution.reason === 'disabled' || contribution.reason === 'unavailable' || contribution.reason === 'grace') break;
|
|
784
|
+
}
|
|
785
|
+
const remaining = await communityJobsPending();
|
|
786
|
+
return { attempted, shared, remaining: remaining.count, unshareable: remaining.unshareable };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function syncAllCommunityData(community) {
|
|
790
|
+
const sources = await sourcesSync(community);
|
|
791
|
+
return {
|
|
792
|
+
...sources,
|
|
793
|
+
communityJobs: await communityJobsSync(community),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
726
797
|
async function withStateLock(name, action) {
|
|
727
798
|
const dir = await ensureStateDir();
|
|
728
799
|
const lockPath = join(dir, `.${name}.lock`);
|
|
@@ -1268,6 +1339,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1268
1339
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
1269
1340
|
const entry = validateLedgerEntry(input);
|
|
1270
1341
|
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
|
|
1342
|
+
result.communityJob = await communityJobsSync(community, { limit: 1, applicationIds: [entry.id] });
|
|
1271
1343
|
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
1272
1344
|
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
1273
1345
|
const outcome = await ledgerOutcome(await jsonStdin());
|
|
@@ -1289,23 +1361,28 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1289
1361
|
result = await roundComplete(await jsonStdin());
|
|
1290
1362
|
domainEvents.push(roundCompletedTelemetry(result));
|
|
1291
1363
|
} else if (area === 'sources' && action === 'list' && value == null) {
|
|
1292
|
-
await
|
|
1364
|
+
await syncAllCommunityData(community);
|
|
1293
1365
|
result = await sourcesList({}, await community.list());
|
|
1294
1366
|
} else if (area === 'sources' && action === 'list' && value === '--stdin') {
|
|
1295
1367
|
const filters = await jsonStdin();
|
|
1296
|
-
await
|
|
1368
|
+
await syncAllCommunityData(community);
|
|
1297
1369
|
result = await sourcesList(filters, await community.list());
|
|
1298
1370
|
}
|
|
1299
1371
|
else if (area === 'sources' && action === 'suggest' && value === '--stdin') result = await sourcesSuggest(await jsonStdin(), community);
|
|
1300
|
-
else if (area === 'sources' && action === 'pending' && value == null) result = await
|
|
1301
|
-
else if (area === 'sources' && action === 'sync' && value == null) result = await
|
|
1302
|
-
else if (area === 'sources' && action === '
|
|
1372
|
+
else if (area === 'sources' && action === 'pending' && value == null) result = await communityPendingStatus();
|
|
1373
|
+
else if (area === 'sources' && action === 'sync' && value == null) result = await syncAllCommunityData(community);
|
|
1374
|
+
else if (area === 'sources' && action === 'jobs' && value == null) result = await community.listJobs();
|
|
1375
|
+
else if (area === 'sources' && action === 'jobs' && value === '--stdin') result = await community.listJobs(await jsonStdin());
|
|
1376
|
+
else if (area === 'sources' && action === 'sharing' && ['status', 'enable', 'disable', 'reset'].includes(value)) {
|
|
1377
|
+
result = await community.configure(value);
|
|
1378
|
+
if (value === 'enable') result.communityJobs = await communityJobsSync(community);
|
|
1379
|
+
}
|
|
1303
1380
|
else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
|
|
1304
1381
|
else if (area === 'attention' && action === 'list' && value == null) result = await attentionList();
|
|
1305
1382
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1306
1383
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1307
1384
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1308
|
-
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; sources list [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1385
|
+
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1309
1386
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1310
1387
|
return result;
|
|
1311
1388
|
}
|
|
@@ -1345,6 +1422,7 @@ async function main(args) {
|
|
|
1345
1422
|
const started = Date.now();
|
|
1346
1423
|
try {
|
|
1347
1424
|
const result = await executeCommand(args, telemetry, session, community);
|
|
1425
|
+
if (!(area === 'sources' || (area === 'ledger' && action === 'add'))) await communityJobsSync(community).catch(() => null);
|
|
1348
1426
|
await telemetry.record({ event: 'command_completed', properties: { command, result: 'success', durationBucket: durationBucket(Date.now() - started) } }, session);
|
|
1349
1427
|
return print(result);
|
|
1350
1428
|
} catch (error) {
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
5
|
|
|
5
6
|
import { SKILL_VERSION } from './telemetry-client.mjs';
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createCommunityJobContributionEnvelope,
|
|
9
|
+
createSourceContributionEnvelope,
|
|
10
|
+
normalizeCommunityJob,
|
|
11
|
+
normalizeCommunitySource,
|
|
12
|
+
validateCommunityJobList,
|
|
13
|
+
validateCommunitySourceList,
|
|
14
|
+
} from './source-community-schema.mjs';
|
|
7
15
|
|
|
8
16
|
export const DEFAULT_SOURCE_COMMUNITY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL ?? process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
9
|
-
export const SOURCE_SHARING_NOTICE = 'Community
|
|
17
|
+
export const SOURCE_SHARING_NOTICE = 'Community sharing is enabled by default. Confirmed public job links are logged privately and enter maintainer review before publication, as do repeatable job boards and hiring feeds. Personal, answer, resume, score, referral, and candidate timing data are never sent. Run `sources sharing disable` to opt out.\n';
|
|
10
18
|
|
|
11
19
|
const CONFIG_FILE = 'source-sharing.json';
|
|
12
20
|
const CONFIG_LOCK_FILE = '.source-sharing.lock';
|
|
13
21
|
const CONFIG_LOCK_TIMEOUT_MS = 15_000;
|
|
14
22
|
const CONFIG_LOCK_STALE_MS = 60_000;
|
|
15
23
|
|
|
16
|
-
function defaultConfig() {
|
|
17
|
-
return { version: 1, enabled: true, disclosed: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
24
|
+
function defaultConfig({ jobSharingGraceConsumed = true } = {}) {
|
|
25
|
+
return { version: 1, enabled: true, disclosed: false, jobSharingDisclosed: false, jobSharingGraceConsumed, installationId: null, token: null, tokenExpiresAt: null };
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
function sameCredentialState(left, right) {
|
|
@@ -44,15 +52,18 @@ async function writePrivate(file, value) {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export class SourceCommunityClient {
|
|
47
|
-
constructor({ stateDir, endpoint = DEFAULT_SOURCE_COMMUNITY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_TIMEOUT_MS ?? 3000) }) {
|
|
55
|
+
constructor({ stateDir, endpoint = DEFAULT_SOURCE_COMMUNITY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_TIMEOUT_MS ?? 3000), historicalApplicationsAtStart = null }) {
|
|
48
56
|
this.stateDir = stateDir;
|
|
49
57
|
this.endpoint = endpoint.replace(/\/$/, '');
|
|
50
58
|
this.fetch = fetchFn;
|
|
51
59
|
this.stderr = stderr;
|
|
52
60
|
this.now = now;
|
|
53
61
|
this.timeoutMs = timeoutMs;
|
|
62
|
+
this.historicalApplicationsAtStart = historicalApplicationsAtStart ?? existsSync(join(stateDir, 'applications.ndjson'));
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
initialConfig() { return defaultConfig({ jobSharingGraceConsumed: !this.historicalApplicationsAtStart }); }
|
|
66
|
+
|
|
56
67
|
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
57
68
|
get configLockPath() { return join(this.stateDir, CONFIG_LOCK_FILE); }
|
|
58
69
|
|
|
@@ -64,10 +75,10 @@ export class SourceCommunityClient {
|
|
|
64
75
|
async readConfig() {
|
|
65
76
|
try {
|
|
66
77
|
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
67
|
-
return { version: 1, enabled: value.enabled !== false, disclosed: value.disclosed === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
78
|
+
return { version: 1, enabled: value.enabled !== false, disclosed: value.disclosed === true, jobSharingDisclosed: value.jobSharingDisclosed === true, jobSharingGraceConsumed: value.jobSharingGraceConsumed === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
68
79
|
} catch (error) {
|
|
69
80
|
if (error.code === 'ENOENT') return null;
|
|
70
|
-
return { version: 1, enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null };
|
|
81
|
+
return { version: 1, enabled: false, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true, installationId: null, token: null, tokenExpiresAt: null };
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
|
|
@@ -134,7 +145,7 @@ export class SourceCommunityClient {
|
|
|
134
145
|
|
|
135
146
|
async updateConfig(transform) {
|
|
136
147
|
return this.withConfigLock(async () => {
|
|
137
|
-
const current = await this.readConfig() ??
|
|
148
|
+
const current = await this.readConfig() ?? this.initialConfig();
|
|
138
149
|
const next = transform(current);
|
|
139
150
|
await this.saveConfigUnlocked(next);
|
|
140
151
|
return next;
|
|
@@ -145,7 +156,7 @@ export class SourceCommunityClient {
|
|
|
145
156
|
return this.withConfigLock(async () => {
|
|
146
157
|
const existing = await this.readConfig();
|
|
147
158
|
if (existing) return existing;
|
|
148
|
-
const config =
|
|
159
|
+
const config = this.initialConfig();
|
|
149
160
|
await this.saveConfigUnlocked(config);
|
|
150
161
|
return config;
|
|
151
162
|
});
|
|
@@ -153,19 +164,26 @@ export class SourceCommunityClient {
|
|
|
153
164
|
|
|
154
165
|
async status() {
|
|
155
166
|
const config = await this.readConfig();
|
|
156
|
-
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
167
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, jobSharingDisclosed: config?.jobSharingDisclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
157
168
|
}
|
|
158
169
|
|
|
159
170
|
async configure(action) {
|
|
160
171
|
if (action === 'status') return this.status();
|
|
172
|
+
let discloseJobSharing = false;
|
|
161
173
|
await this.updateConfig((config) => {
|
|
162
|
-
if (action === 'enable')
|
|
174
|
+
if (action === 'enable') {
|
|
175
|
+
config.enabled = true;
|
|
176
|
+
if (!config.jobSharingDisclosed) discloseJobSharing = true;
|
|
177
|
+
config.jobSharingDisclosed = true;
|
|
178
|
+
config.jobSharingGraceConsumed = true;
|
|
179
|
+
}
|
|
163
180
|
else if (action === 'disable') config.enabled = false;
|
|
164
|
-
else if (action === 'reset') Object.assign(config, { enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null });
|
|
181
|
+
else if (action === 'reset') Object.assign(config, { enabled: false, disclosed: true, jobSharingDisclosed: false, jobSharingGraceConsumed: false, installationId: null, token: null, tokenExpiresAt: null });
|
|
165
182
|
else throw new Error('Source sharing action must be status, enable, disable, or reset.');
|
|
166
183
|
config.disclosed = true;
|
|
167
184
|
return config;
|
|
168
185
|
});
|
|
186
|
+
if (discloseJobSharing) this.stderr(SOURCE_SHARING_NOTICE);
|
|
169
187
|
return this.status();
|
|
170
188
|
}
|
|
171
189
|
|
|
@@ -201,7 +219,7 @@ export class SourceCommunityClient {
|
|
|
201
219
|
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
202
220
|
if (!config.disclosed) {
|
|
203
221
|
this.stderr(SOURCE_SHARING_NOTICE);
|
|
204
|
-
config = await this.updateConfig((current) => ({ ...current, disclosed: true }));
|
|
222
|
+
config = await this.updateConfig((current) => ({ ...current, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true }));
|
|
205
223
|
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
206
224
|
}
|
|
207
225
|
config = await this.credentials(config);
|
|
@@ -240,15 +258,81 @@ export class SourceCommunityClient {
|
|
|
240
258
|
});
|
|
241
259
|
}
|
|
242
260
|
|
|
261
|
+
async contributeJob(input) {
|
|
262
|
+
const job = normalizeCommunityJob(input);
|
|
263
|
+
try {
|
|
264
|
+
let config = await this.config();
|
|
265
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
266
|
+
if (!config.jobSharingDisclosed) {
|
|
267
|
+
this.stderr(SOURCE_SHARING_NOTICE);
|
|
268
|
+
const grace = !config.jobSharingGraceConsumed;
|
|
269
|
+
config = await this.updateConfig((current) => ({ ...current, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true }));
|
|
270
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
271
|
+
if (grace) return { shared: false, reason: 'grace' };
|
|
272
|
+
} else if (!config.jobSharingGraceConsumed) {
|
|
273
|
+
config = await this.updateConfig((current) => ({ ...current, jobSharingGraceConsumed: true }));
|
|
274
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
275
|
+
return { shared: false, reason: 'grace' };
|
|
276
|
+
}
|
|
277
|
+
config = await this.credentials(config);
|
|
278
|
+
let sent = await this.sendJobContribution(config, job);
|
|
279
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
280
|
+
let response = sent.response;
|
|
281
|
+
if (response.status === 401) {
|
|
282
|
+
config = await this.updateConfig((current) => ({ ...current, tokenExpiresAt: null }));
|
|
283
|
+
config = await this.credentials(config);
|
|
284
|
+
sent = await this.sendJobContribution(config, job);
|
|
285
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
286
|
+
response = sent.response;
|
|
287
|
+
}
|
|
288
|
+
if (!response.ok) return { shared: false, reason: 'unavailable' };
|
|
289
|
+
const result = await response.json();
|
|
290
|
+
const allowed = new Set(['accepted', 'jobId', 'publicationStatus', 'contributionCount']);
|
|
291
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || Object.keys(result).some((key) => !allowed.has(key))) return { shared: false, reason: 'unavailable' };
|
|
292
|
+
if (result.accepted !== true || !/^community-job-[0-9a-f]{16}$/.test(result.jobId)) return { shared: false, reason: 'unavailable' };
|
|
293
|
+
if (!['pending', 'published', 'rejected'].includes(result.publicationStatus)) return { shared: false, reason: 'unavailable' };
|
|
294
|
+
if (!Number.isSafeInteger(result.contributionCount) || result.contributionCount < 1 || result.contributionCount > 1_000_000_000) return { shared: false, reason: 'unavailable' };
|
|
295
|
+
return { shared: true, jobId: result.jobId, publicationStatus: result.publicationStatus, contributionCount: result.contributionCount };
|
|
296
|
+
} catch {
|
|
297
|
+
return { shared: false, reason: 'unavailable' };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async sendJobContribution(config, job) {
|
|
302
|
+
return this.withConfigLock(async () => {
|
|
303
|
+
const current = await this.readConfig() ?? config;
|
|
304
|
+
if (!current.enabled) return { disabled: true };
|
|
305
|
+
const envelope = createCommunityJobContributionEnvelope({ installationId: current.installationId, token: current.token, job, skillVersion: SKILL_VERSION });
|
|
306
|
+
const response = await this.fetch(`${this.endpoint}/v1/jobs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
307
|
+
return { disabled: false, response };
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
243
311
|
async list() {
|
|
244
|
-
if (this.
|
|
312
|
+
if (this.sourceReadUnavailable) return [];
|
|
245
313
|
try {
|
|
246
314
|
const response = await this.fetch(`${this.endpoint}/v1/sources`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
247
315
|
if (!response.ok) return [];
|
|
248
316
|
return validateCommunitySourceList(await response.json());
|
|
249
317
|
} catch {
|
|
250
|
-
this.
|
|
318
|
+
this.sourceReadUnavailable = true;
|
|
251
319
|
return [];
|
|
252
320
|
}
|
|
253
321
|
}
|
|
322
|
+
|
|
323
|
+
async listJobs({ limit = 50, cursor = null } = {}) {
|
|
324
|
+
if (this.jobReadUnavailable) return { version: 1, jobs: [], nextCursor: null };
|
|
325
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Community job limit must be between 1 and 100.');
|
|
326
|
+
if (cursor !== null && (typeof cursor !== 'string' || !cursor || cursor.length > 1024)) throw new Error('Community job cursor is invalid.');
|
|
327
|
+
try {
|
|
328
|
+
const query = new URLSearchParams({ limit: String(limit) });
|
|
329
|
+
if (cursor !== null) query.set('cursor', cursor);
|
|
330
|
+
const response = await this.fetch(`${this.endpoint}/v1/jobs?${query}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
331
|
+
if (!response.ok) return { version: 1, jobs: [], nextCursor: null };
|
|
332
|
+
return validateCommunityJobList(await response.json());
|
|
333
|
+
} catch {
|
|
334
|
+
this.jobReadUnavailable = true;
|
|
335
|
+
return { version: 1, jobs: [], nextCursor: null };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
254
338
|
}
|